1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-08-10 21:51:42 +02:00

push and poll models

This commit is contained in:
Sergey Konstantinov
2023-01-03 23:50:20 +02:00
parent 087f0a25f0
commit 10deb4f492
2 changed files with 106 additions and 7 deletions

View File

@@ -12,7 +12,7 @@ const pendingOrders = await api.
}]}
```
Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество данных в ответе ничем не ограничено. Эта проблема присутствовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубляет: так как почти все проверки лимитов желательно делать асинхронно, количество заданий на создание заказа почти ничем не ограничено, и их легко может быть создано очень большое количество.
Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество возвращаемых данных в любом ответе должно быть ограничено, но в нашем интерфейсе отсутствуют какие-либо лимиты. Эта проблема существовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубил: операция создания задания должна работать максимально быстро, и, следовательно, почти все проверки лимитов мы должны проводить асинхронно — а значит, клиент потенциально может создать очень много заданий.
Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:
@@ -30,7 +30,7 @@ api.getOngoingOrders({
})
```
Однако введение лимита ставит другой вопрос: если всё же количество записей, которые нужно выбрать, превышает лимит, каким образом клиент должен их выбрать?
Однако введение лимита ставит другой вопрос: если всё же количество записей, которые нужно выбрать, превышает лимит, каким образом клиент должен получить к ним доступ?
Стандартный подход к этой проблеме — введение параметра `offset` или номера страницы данных:
@@ -63,7 +63,7 @@ api.getOngoingOrders({
}]
```
Приложение партнёра запросило первую страницу заказов:
Приложение партнёра запросило первую страницу списка заказов:
```
api.getOrders({
@@ -107,7 +107,7 @@ api.getOrders({
}]
```
Тогда, запросив вторую страницу заказов, вместо одного заказа `"id": 1`, приложение партнёра получит повторно ещё и заказ `"id": 2`:
Тогда, запросив вторую страницу заказов, вместо одного заказа `"id": 1`, приложение партнёра получит повторно заказ `"id": 2`:
```
api.getOrders({
@@ -125,9 +125,9 @@ api.getOrders({
}
```
Следует отметить, что такие перестановки крайне неудобны даже для пользовательского интерфейса — если,допустим, предположить, что заказы запрашивает бухгалтер партнёра, чтобы рассчитать выплаты, то и он легко может просто не заметить, что какой-то заказ посчитан дважды. Однако в случае *программной* интеграции ситуация становится намного сложнее: у разработчика приложения *нет никакой возможности* понять, что происходит с его перебором, и переберёт ли он *все* заказы вообще хоть когда-нибудь.
Такие перестановки крайне неудобны и для пользовательских интерфейсов — если, допустим, предположить, что заказы запрашивает бухгалтер партнёра, чтобы рассчитать выплаты, то и он легко может просто не заметить, что какой-то заказ посчитан дважды. Однако в случае *программной* интеграции ситуация становится намного сложнее: разработчику приложения нужно написать достаточно неочевидный код (сохраняющий состояние уже полученных страниц данных), чтобы провести такой перебор корректно.
Отметим теперь, что ситуацию легко можно сделать гораздо более запутанной. Например, если заказы мы отсортируем заказы по статусу:
Отметим теперь, что ситуацию легко можно сделать гораздо более запутанной. Например, если мы добавим сортировку не только по дате создания, но и по статусу заказа:
```
api.getOrders({
@@ -332,7 +332,7 @@ GET /v1/orders/created-history⮠
"occured_at",
// Идентификатор заказа
"order_id"
}]
}, …]
}
```

View File

@@ -0,0 +1,99 @@
### Двунаправленные потоки данных. Push и poll-модели
В предыдущей главе мы рассмотрели следующий кейс: партнёр получает информацию о новых событиях, произошедших в системе, периодически опрашивая эндпойнт, поддерживающий отдачу упорядоченных списков:
```
GET /v1/orders/created-history⮠
older_than=<item_id>&limit=<limit>
{
"orders_created_events": [{
"id",
"occured_at",
"order_id"
}, …]
}
```
Подобный паттерн (известный как [*поллинг*](https://en.wikipedia.org/wiki/Polling_(computer_science))) — наиболее часто встречающийся способ организации двунаправленной связи в API, когда партнёру требуется не только отправлять какие-то данные на сервер, но и получать оповещения от сервера об изменении какого-то состояния.
При всей простоте, поллинг всегда заставляет искать компромисс между отзывчивостью, производительностью и пропускной способностью системы:
* чем длиннее интервал между последовательными запросами, тем больше будет задержка между изменением состояния на сервере и получением информации об этом на клиенте, и тем потенциально большим будет объём данных, которые необходимо будет передать за одну итерацию;
* с другой стороны, чем этот интервал короче, чем большее количество запросов будет совершаться зря, т.к. никаких изменений в системе за прошедшее время не произошло.
Иными словами, поллинг всегда создаёт какой-то фоновый трафик в системе, но никогда не гарантирует максимальной отзывчивости. Иногда эту проблему решают с помощью «долгого поллинга» ([long polling](https://en.wikipedia.org/wiki/Push_technology#Long_polling)) — т.е. целенаправленно замедляют отдачу сервером ответа на длительное (секунды, десятки секунд) время — однако мы не рекомендуем использовать этот подход в современных системах из-за связанных технических проблем (в частности, в условиях ненадёжной сети у клиента нет способа понять, что соединение на самом деле потеряно, и нужно отправить новый запрос, а не ожидать ответа на текущий).
Если оказывается, что обычного поллинга для решения пользовательских задач недостаточно, то можно перейти к обратной модели (*push*): сервер *сам* сообщает клиенту, что в системе произошли изменения.
Хотя и проблема, и способы её решения выглядят похоже, в настоящий момент применяются совершенно разные технологии для доставки сообщений от бэкенда к бэкенду, и от бэкенда к клиентскому устройству.
#### Доставка сообщений на клиентское устройство
Поскольку разнообразные мобильные платформы сейчас составляют значительную долю всех клиентских устройств, на технологии взаимного обмена данных между сервером и конечным пользователем накладываются значительные ограничения с точки зрения экономии заряда батареи (и отчасти трафика). Многие производители платформ и устройств следят за потребляемыми приложением ресурсами, и могут отправлять приложение в фон или вовсе закрывать открытые соединения. В такой ситуации частый поллинг стоит применять только в активных фазах работы приложения (т.е. когда пользователь непосредственно взаимодействует с UI) либо если приложение работает в контролируемой среде (например, используется сотрудниками компании-партнера непосредственно в работе, и может быть добавлено в системные исключения).
Альтернатив поллингу на данный момент можно предложить две.
##### Дуплексные соединения
Самый очевидный вариант — использование технологий, позволяющих передавать по одному соединению сообщения в обе стороны. Наиболее известной из таких технологий является [WebSockets](https://websockets.spec.whatwg.org/). Иногда для организации полнодуплексного соединения применяется [Server Push, предусмотренный протоколом HTTP/2](https://datatracker.ietf.org/doc/html/rfc7540#section-8.2), однако надо отметить, что формально спецификация не предусматривает такого использования.
Несмотря на то, что идея в целом выглядит достаточно простой и привлекательной, в реальности её использование довольно ограничено. Поддержки инициирования *сервером* отправки сообщения обратно на клиент практически нет в популярных протоколах и фреймворках (gRPC поддерживает потоки сообщений с сервера, но их всё равно должен инициировать клиент; использование потоков для пересылки сообщений по мере их возникновения — фактически то же самое использование HTTP/2 Server Push в обход спецификации), и существующие стандарты спецификаций API также не поддерживают такой обмен данными. WebSockets является низкоуровневым протоколом, и формат взаимодействия придётся разработать самостоятельно.
К тому же дуплексные соединения по-прежнему страдают от ненадёжной сети и требуют дополнительных ухищрений для того, чтобы отличить сетевую проблему от отсутствия новых сообщений. Всё это приводит к тому, что данная технология используется в основном веб-приложениями.
##### Сторонние сервисы отправки push-уведомлений
Одна из неприятных особенностей технологии типа long polling / WebSockets — необходимость поддерживать открытое соединение между клиентом и сервером, что для мобильных приложений может быть проблемой с точки зрения производительности и энергопотребления. Один из вариантов решения этой проблемы — делегирование отправки уведомлений стороннему сервису (самым популярным выбором на сегодня является Firebase Cloud Messaging от Google), который в свою очередь доставит уведомление через встроенные механизмы платформы. Использование встроенных в платформу механизмов получения уведомлений снимает с разработчика головную боль по написанию кода, поддерживающего открытое соединение, и снижает риски неполучения сообщения. Недостатками third-party серверов сообщений является необходимость платить за них и ограничения на размер сообщения.
Независимо от выбранной технологии, отправка сообщений с сервера на устройство конечного пользователя страдает от одной большой проблемы: в результате всех описанных ограничений, процент успешной доставки уведомлений никогда не равен 100; потери сообщений могут достигать десятков процентов. С учётом ограничений на размер контента, **скорее правильно говорить не о push-модели, а о комбинированной**: приложение продолжает периодически опрашивать сервер, а пуши являются триггером для внеочередного запроса.
#### Доставка сообщений backend-to-backend
В отличие от клиентских приложений, серверные API практически безрезультативно используют единственный подход — отдельный канал связи для обратных вызовов. При интеграции партнёр указывает URL своего собственного сервера обработки сообщений, и сервер API вызывает этот эндпойнт (также называемый ‘webhook’) для оповещения о произошедшем событии. Хотя long polling, Web Sockets и HTTP/2 Push тоже вполне применимы для backend-2-backend взаимодействия, мы сходу затрудняемся назвать примеры популярных API, которые использовали бы эти технологии. Главными причинами такого положения дел нам видятся:
* бо́льшая критичность корректной и своевременной обработки событий, и отсюда повышенные требования к гарантиям их доставки;
* возможность покрыть такое взаимодействие строгой спецификацией.
Предположим, что в нашем кофейном примере партнёр располагает некоторым бэкендом, готовым принимать оповещения о новых заказах, поступивших в его кофейни. Решение этой задачи декомпозируется на несколько шагов:
##### Договоренность о контракте
В зависимости от важности партнёра для вашего бизнеса здесь возможны разные варианты:
* производитель API может реализовать возможность вызова webhook-а в формате, предложенном партнёром;
* наоборот, партнёр должен разработать эндпойнт в стандартном формате, предлагаемом производителем API;
* любой промежуточный вариант.
Важно, что в любом случае должен существовать формальный контракт (очень желательно — в виде спецификации) на формат запроса к эндпойнту-webhook-у и формат его ответа, а также, что важно, возникающие ошибки.
##### Договорённость о методах авторизации
Так как webhook-и представляют собой отдельный канал взаимодействия, для него придётся разработать отдельный способ авторизации. Мы повторяем здесь настоятельную рекомендацию не изобретать безопасность и использовать существующие стандартные механизмы, например, [mTLS](https://en.wikipedia.org/wiki/Mutual_authentication#mTLS).
##### API для задания адреса webhook-а
Так как callback-эндпойнт контролируется партнёром, жёстко зафиксировать его обычно не представляется возможным — должен существовать API (возможно, в виде кабинета партнёра) для задания URL webhook-а (и публичных ключей авторизации).
**Важно**. К операции задания адреса callback-а нужно подходить с максимально возможной серьёзностью, поскольку, получив доступ к такой функциональности, злоумышленник может совершить множество весьма неприятных атак:
* если указать в качестве приёмника сторонний URL, можно получить доступ к потоку всех заказов партнёра и при этом вызвать перебои в его работе; очень желательно требовать второй фактор авторизации для подтверждения этой операции;
* если указать в качестве webhook-а URL интранет-сервисов компании-провайдера API, можно осуществить [SSRF-атаку](https://en.wikipedia.org/wiki/SSRF) на инфраструктуру самой компании.
#### Типичные проблемы интеграций с обратными вызовами
Двунаправленные интеграции (и клиентские, и серверные — хотя последние в большей степени) несут в себе очень неприятные риски для разработчика API. Если в случае прямых вызовов корректность работы системы зависит от того, как хороша она написана, то в случае обратных вызовов всё в точности наоборот: качество работы интеграции напрямую зависит от того, как код webhook-эндпойнта написан партнёром. Мы можем столкнуться здесь с самыми различными видами проблем в партнёрском коде:
* false-positive ответы, когда сообщение не было обработано, но сервер партнёра тем не менее ошибочно вернул код успеха;
* и наоборот, возможны false-negative ответы, когда сообщение было обработано, но эндпойнт почему-то вернул ошибку;
* длительное время обработки запросов — возможно, настолько длительное, что сервер сообщений просто не будет успевать их отправить;
* ошибки в реализации идемпотентости, т.е. повторная обработка одного и того же сообщения партнёром;
* просто недоступность эндпойнта.
Очевидно, вы никак не можете гарантировать, что партнёр не совершил какую-то из перечисленных ошибок. Но вы можете попытаться минимизировать возможный ущерб.
1. Состояние системы должно быть восстановимо. Даже если партнёр неправильно обработал сообщения, всегда должна быть возможность реабилитироваться и получить список последних событий (либо полное состояние) системы, чтобы исправить случившиеся ошибки.
2. Должны быть зафиксированы технические параметры:
* ключи идемпотентности;
* допустимое количество параллельных запросов к эндпойнту;
* политика перезапросов при получении ошибки;
* гарантии доставки (exactly once, at least once).
3. Должна быть реализована система мониторинга состояния партнёрских эндпойнтов:
* при появлении большого числа ошибок (таймаутов) должно срабатывать оповещение (в т.ч. оповещение партнёра о проблеме), возможно, с несколькими уровнями эскалации;
* если в очереди скапливается большое количество необработанных событий, должен существовать механизм аварийного отключения партнёра.