1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-06-06 22:16:15 +02:00

atomicity

This commit is contained in:
Sergey Konstantinov 2023-01-06 15:55:39 +02:00
parent 10deb4f492
commit 21c878cd52
6 changed files with 323 additions and 154 deletions
src/ru/drafts/03-Раздел III. Паттерны проектирования API

@ -258,6 +258,12 @@ GET /v1/partners/{id}/offers/history⮠
Курсором в данной ситуации может представлять собой просто идентификатор последней записи (но тогда интерфейс получения новой порции данных должен будет требовать передачи всех параметров поиска, а не только курсора), а может содержать зашифрованное представление всех параметров поиска. Второе много удобнее потому, что тогда получение станицы данных через курсор полностью кэшируемо.
**NB**: в некоторых источниках перебор через курсор, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:
* подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;
* если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;
* подход с курсором не означает, что `limit`/`offset` использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида `GET /items?cursor=…`, и на запросы вида `GET /items?offset=…&limit=…`;
* наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.
В подходе с курсорами вы сможете без нарушения обратной совместимости добавлять новые фильтры и виды сортировки — при условии, конечно, что вы сможете организовать хранение данных таким образом, чтобы перебор с курсором работал однозначно.
```
@ -336,4 +342,4 @@ GET /v1/orders/created-history⮠
}
```
События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать *все* новые заказы, пусть и неоптимальным образом.
События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать *все* новые заказы, пусть и не самым оптимальным образом.

@ -83,6 +83,7 @@ GET /v1/orders/created-history⮠
* и наоборот, возможны false-negative ответы, когда сообщение было обработано, но эндпойнт почему-то вернул ошибку;
* длительное время обработки запросов — возможно, настолько длительное, что сервер сообщений просто не будет успевать их отправить;
* ошибки в реализации идемпотентости, т.е. повторная обработка одного и того же сообщения партнёром;
* размер тела сообщение может превысить лимит, выставленный на веб-сервере партнёра;
* просто недоступность эндпойнта.
Очевидно, вы никак не можете гарантировать, что партнёр не совершил какую-то из перечисленных ошибок. Но вы можете попытаться минимизировать возможный ущерб.
@ -91,9 +92,9 @@ GET /v1/orders/created-history⮠
2. Должны быть зафиксированы технические параметры:
* ключи идемпотентности;
* допустимое количество параллельных запросов к эндпойнту;
* максимальный размер сообщения в байтах и параметры мультиплексирования;
* политика перезапросов при получении ошибки;
* гарантии доставки (exactly once, at least once).
3. Должна быть реализована система мониторинга состояния партнёрских эндпойнтов:
* при появлении большого числа ошибок (таймаутов) должно срабатывать оповещение (в т.ч. оповещение партнёра о проблеме), возможно, с несколькими уровнями эскалации;
* если в очереди скапливается большое количество необработанных событий, должен существовать механизм аварийного отключения партнёра.
* если в очереди скапливается большое количество необработанных событий, должен существовать механизм деградации (ограничения количества запросов в адрес партнёра — возможно в виде срезания спроса, т.е. частичного отказа в обслуживании конечных пользователей) и полного аварийного отключения партнёра.

@ -0,0 +1,123 @@
### Варианты организации системы нотификаций
В отличие от интеграции через push-уведомления, webhook-и менее чувствительны к размеру тела сообщения, однако посылка больших сообщений в запросе — скорее, антипаттерн. Большинство веб-серверов в мире настроены отсекать слишком большие тела запросов — как и большинство средств организации очередей сообщений. Это приводит нас к необходимости сделать два технических выбора:
* содержит ли тело сообщения все данные необходимые для его обработки, или только уведомляет о факте изменения состояния;
* если второе, то содержит ли один вызов одно сообщение, или может уведомлять сразу о нескольких изменениях.
Рассмотрим на примере нашего кофейного API:
```
// Вариант 1: тело сообщения
// содержит все данные о заказе
POST /partner/webhook
Host: partners.host
{
"event_id",
"occurred_at",
"order": {
"id",
"status",
"recipe_id",
"volume",
// Все прочие детали заказа
}
}
```
```
// Вариант 2: тело сообщения
// содержит только информацию
// о самом событии
POST /partner/webhook
Host: partners.host
{
"event_id",
// Тип сообщения: нотификация
// о появлении нового заказа
"event_type": "new_order",
"occurred_at",
"order_id"
}
// При обработке сообщения,
// возможно, отложенной,
// партнёр должен обратиться
// к нашему API
GET /v1/orders/{id}
{ /* все детали заказа */ }
```
```
// Вариант 3: мы уведомляем
// партнёра, что его реакции
// ожидают три новых заказа
POST /partner/webhook
Host: partners.host
{
// Здесь может быть версия
// состояния системы или курсор
"occurred_at",
"pending_order_count":
<число новых заказов>
}
// В ответ партнёр должен вызвать
// эндпойнт получения списка заказов,
// организованный любым из способов,
// описанных в предыдущих главах
GET /v1/orders/pending
{
"orders",
"cursor"
}
```
Выбор подходящей модели зависит от предметной области и того, каким образом партнёр будет обрабатывать сообщение. В нашем конкретном случае, когда партнёр должен каждый новый заказ обработать отдельно, при этом на один заказ не может приходить больше одного-двух уведомлений, естественным выбором является вариант 1 (если тело запроса не содержит никаких непредсказуемо больших данных) или 2. Если же мы нотифицируем партнёра о каких-то часто изменяющихся параметрах системы и/или партнёра интересуют только наиболее свежие изменения, то естественным выбором будет третий подход.
Отметим, что третий (и отчасти второй) варианты естественным образом приводят нас к схеме, характерной для клиентских устройств: push-уведомление само по себе не почти содержит полезной информации и только является сигналом для внеочередного поллинга.
#### Гарантии прочтения и обработки сообщений
Если в варианте 1 (сообщение содержит в себе все релевантные данные) мы можем рассчитывать на то, что возврат кода успеха из webhook-а эквивалентен успешной обработке сообщения партнёром (что, вообще говоря, тоже не гарантировано, т.к. партнёр может использовать асинхронные схемы), то для вариантов 2 и особенно 3 это заведомо не так: для обработки сообщений необходимо выполнить дополнительные действия. В этом случае нам необходимо иметь раздельные статусы — сообщение доставлено и сообщение обработано; в идеале, второе должно вытекать из логики работы API, т.е. сигналом о том, что сообщение обработано, является какое-то действие, совершаемое партнёром. В нашем кофейном примере это может быть перевод заказа партнёром из статуса `"new"` (заказ создан пользователем) в статус `"accepted"` или `"rejected"` (кофейня партнёра приняла или отклонила заказ). Тогда полный цикл работы будет выглядеть так:
```
// Уведомляем партнёра о том,
// что его реакции
// ожидают три новых заказа
POST /partner/webhook
Host: partners.host
{
"occurred_at",
"pending_order_count":
<число новых заказов>
}
```
```
// В ответ партнёр вызывает
// эндпойнт получения списка заказов
GET /v1/orders/pending
{
"orders",
"cursor"
}
```
```
// После того, как заказы обработаны,
// партнёр уведомляет нас об
// изменениях статуса
POST /v1/orders/bulk-status-change
{
"status_changes": [{
"order_id",
"new_status": "accepted",
// Иная релевантная информация,
// например, время готовности
}, {
"order_id",
"new_status": "rejected",
"reason"
}, …]
}
```
Если такого нативного способа оповестить об успешной обработке события схема работы нашего API не предполагает, мы можем ввести эндпойнт который явно помечает сообщения прочитанными. Этот шаг, вообще говоря, необязательный (мы можем просто договориться о том, что это ответственность партнёра обрабатывать события и мы не ждём от него никаких подтверждений), но это лишает нас полезного инструмента мониторинга — что происходит на стороне партнёра, успевает ли он обрабатывать события — что в свою очередь затрудняет разработку упомянутых в предыдущей главе механизмов деградации и аварийного отключения интеграции.

@ -0,0 +1,187 @@
### Атомарность
Вернёмся теперь от webhook-ов обратно к разработке API прямого вызова. Дизайн эндпойнта `orders/bulk-status-change`, описанный в предыдущей главе, ставит перед нами интересный вопрос: что делать, если наш бэкенд часть изменений смог обработать, а часть — нет?
Правило по умолчанию очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.
**Плохо**:
```
// Партнёр уведомляет нас об
// изменениях статуса
POST /v1/orders/bulk-status-change
{
"status_changes": [{
"order_id": "1",
"new_status": "accepted",
// Иная релевантная информация,
// например, время готовности
}, {
"order_id": "2",
"new_status": "rejected",
"reason"
}]
}
500 Internal Server Error
```
```
// Партнёр вычитывает список заказов
GET /v1/orders/last?limit=2
{
"orders": [{
// Статус заказа с id="1"
// не изменился
"order_id": "1",
"status": "new"
}, {
// Но статус заказа id="2"
// был успешно изменён
"order_id": "2",
"status": "rejected"
}],
"cursor"
}
```
Почему это плохо: партнёр никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Для этого нужно разрабатывать сложную систему синхронизации, которая после получения ошибок будет пытаться восстановить состояние всех заказов.
Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.
**Лучше**:
```
// Партнёр уведомляет нас об
// изменениях статуса
POST /v1/orders/bulk-status-change
{
"status_changes": [{
"order_id": "1",
"new_status": "accepted",
// Иная релевантная информация,
// например, время готовности
}, {
"order_id": "2",
"new_status": "rejected",
"reason"
}]
}
// Можно воспользоваться статусом
// «частичного успеха»,
// если он предусмотрен протоколом
{
"changes": [{
"change_id",
"occurred_at",
"order_id": "1",
"operation_status": "success"
}, {
"change_id",
"occurred_at",
"order_id": "2",
"operation_status": "fail",
"error"
}]
}
```
Здесь:
* `change_id` — уникальный идентификатор каждого атомарного изменения;
* `occurred_at` — время проведения каждого изменения;
* `error` — информация по ошибке для каждого изменения, если она возникла.
Однако внимательный читатель может подметить, что это решение лишь уменьшает вероятность возникновения ситуации неопределённости для партнёра, но не снимает проблемы восстановления состояния после сбоя — так как сбой может произойти на сетевом уровне (или же в коде нашего эндпойнта может произойти настолько критическая ошибка, что мы не сможем сформировать ответ с разбивкой успех/неуспех). Поэтому массовые неатомарные операции должны сопровождаться эндпойнтом для восстановления состояния (возможно также серверными нотификациями)
```
GET /v1/order-status-changes/history⮠
limit=2
{
"order_status_changes": [{
"change_id",
"occurred_at",
"order_id": "1",
"operation_status": "success"
}, {
"change_id",
"occurred_at",
"order_id": "2",
"operation_status": "fail",
"error"
}],
"cursor"
}
```
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
```
// Партнёр уведомляет нас об
// изменениях статуса
POST /v1/orders/bulk-status-change
X-Idempotency-Token: <токен>
{
"status_changes": [{
"order_id": "1",
"new_status": "accepted",
}, {
"order_id": "2",
"new_status": "rejected",
}]
}
{
"changes": [{
"order_id": "1",
"operation_status": "success"
}, {
"order_id": "2",
"operation_status": "fail",
"error"
}]
}
```
Допустим, клиент не смог получить наш частично-успешный ответ и повторил запрос с тем же токеном идемпотентности.
```
// Партнёр повторно
// уведомляет нас об
// изменениях статуса
POST /v1/orders/bulk-status-change
X-Idempotency-Token: <токен>
{
"status_changes": [{
"order_id": "1",
"new_status": "accepted",
}, {
"order_id": "2",
"new_status": "rejected",
"reason"
}]
}
{
"changes": [{
"order_id": "1",
"operation_status": "success"
}, {
"order_id": "2",
"operation_status": "success",
}]
}
```
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — для чего нам вновь необходимо хранение истории изменений.
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.

@ -1,150 +0,0 @@
##### Избегайте неатомарных операций
С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.
**Плохо**:
```
// Возвращает список рецептов
api.getRecipes();
{
"recipes": [{
"id": "lungo",
"volume": "200ml"
}, {
"id": "latte",
"volume": "300ml"
}]
}
// Изменяет параметры
api.updateRecipes({
"changes": [{
"id": "lungo",
"volume": "300ml"
}, {
"id": "latte",
"volume": "-1ml"
}]
});
Bad Request
// Перечитываем список
api.getRecipes();
{
"recipes": [{
"id": "lungo",
// Это значение изменилось
"volume": "300ml"
}, {
"id": "latte",
// А это нет
"volume": "300ml"
}]
}
```
— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.
Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.
**Лучше**:
```
api.updateRecipes({
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "-1ml"
}]
});
// Можно воспользоваться статусом
// «частичного успеха»,
// если он предусмотрен протоколом
{
"changes": [{
"change_id",
"occurred_at",
"recipe_id": "lungo",
"status": "success"
}, {
"change_id",
"occurred_at",
"recipe_id": "latte",
"status": "fail",
"error"
}]
}
```
Здесь:
* `change_id` — уникальный идентификатор каждого атомарного изменения;
* `occurred_at` — время проведения каждого изменения;
* `error` — информация по ошибке для каждого изменения, если она возникла.
Не лишним будет также:
* ввести в запросе `sequence_id`, чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом;
* предоставить отдельный эндпойнт `/changes-history`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
```
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
});
{
"changes": [{
"status": "success"
}, {
"status": "fail",
"error": {
"reason":
"too_many_requests"
}
}]
}
```
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
```
api.updateRecipes({
"idempotency_token",
"changes": [{
"recipe_id": "lungo",
"volume": "300ml"
}, {
"recipe_id": "latte",
"volume": "400ml"
}]
});
{
"changes": [{
"status": "success"
}, {
"status": "success",
}]
}
```
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений.
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.

@ -1,4 +1,6 @@
##### Избегайте неявных частичных обновлений
### Частичные обновления и совместный доступ
Описанный выше пример
Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния.