You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-08-10 21:51:42 +02:00
пагинация
This commit is contained in:
@@ -227,6 +227,14 @@ GET /coffee-machines/{id}/stocks
|
||||
|
||||
То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.
|
||||
|
||||
##### Любые запросы должны быть лимитированы
|
||||
|
||||
Ограничения должны быть не только на размеры полей, но и на размеры списков или агрегируемых интервалов.
|
||||
|
||||
**Плохо**: `getOrders()` — что, если пользователь совершил миллион заказов?
|
||||
|
||||
**Хорошо**: `getOrders({ limit, parameters })` — должно существовать ограничение сверху на размер обрабатываемых и возвращаемых данных и, соответственно, возможность уточнить запрос, если партнёру всё-таки требуется большее количество данных, чем разрешено обрабатывать в одном запросе.
|
||||
|
||||
##### Отсутствие результата — тоже результат
|
||||
|
||||
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
|
||||
|
@@ -49,13 +49,17 @@ const pendingOrders = await api.
|
||||
|
||||
Здесь мы предполагаем, что создание задания требует минимальных проверок и не ожидает исполнения каких-то длительных операций, а потому происходит много быстрее. Кроме того, саму эту операцию — создание асинхронного задания — мы можем поручить отдельному сервису абстрактных заданий в составе бэкенда, вообще никак не связанному с циклом обработки заказа. Между тем, имея функциональность создания заданий и получения списка текущих заданий, мы значительно уменьшаем «серые зоны» состояния неопределённости, когда клиент не может узнать текущее состояние сервера точно.
|
||||
|
||||
Таким образом, мы естественным образом приходим к паттерну организации асинхронного API через очереди заданий. Применяться он может не только для устранения периодов неопределённости, но и для решения других прикладных задач:
|
||||
Таким образом, мы естественным образом приходим к паттерну организации асинхронного API через очереди заданий. Мы используем здесь термин «асинхронность» логически — подразумевая отсутствие взаимных *логических* блокировок: посылающая сторона получает ответ на свой запрос сразу, не дожидаясь окончания исполнения запрошенной функциональности, и может продолжать взаимодействие с API, пока операция выполняется. При этом технически в современных системах блокировки клиента (и сервера) почти всегда не происходит и при обращении к синхронным эндпойнтам — однако логически продолжать работать с API, не дождавшись ответа на синхронный запрос, может быть чревато коллизиями подобно описанным выше.
|
||||
|
||||
Асинхронный подход может применяться не только для устранения коллизий и неопределённости, но и для решения других прикладных задач:
|
||||
* организация ссылок на результаты операции и их кэширование (предполагается, что, если клиенту необходимо снова прочитать результат операции или же поделиться им с другим агентом, он может использовать для этого идентификатор задания);
|
||||
* обеспечение идемпотентности операций (для этого необходимо ввести подтверждение задания, и мы фактически получим схему с черновиками операции, описанную в Главе 5);
|
||||
* нативное же обеспечение устойчивости к временному всплеску нагрузки на сервис — новые задачи встают в очередь, фактически имплементируя [«маркерное ведро»](https://en.wikipedia.org/wiki/Token_bucket);
|
||||
* нативное же обеспечение устойчивости к временному всплеску нагрузки на сервис — новые задачи встают в очередь (возможно, приоритизированную), фактически имплементируя [«маркерное ведро»](https://en.wikipedia.org/wiki/Token_bucket);
|
||||
* организация взаимодействия в тех случаях, когда время исполнения операции превышает разумные значения (в случае сетевых API — типичное время срабатывания сетевых таймаутов, т.е. десятки секунд) либо является непредсказуемым.
|
||||
|
||||
**NB**. Мы используем здесь термин «асинхронность» логически — подразумевая отсутствие взаимных *логических* блокировок: посылающая сторона получает ответ на свой запрос сразу, не дожидаясь окончания исполнения запрошенной функциональности, и может продолжать взаимодействие с API, пока операция выполняется. При этом технически в современных системах блокировки клиента (и сервера) почти всегда не происходит и при обращении к синхронным эндпойнтам — однако логически продолжать работать с API, не дождавшись ответа на синхронный запрос, может быть чревато коллизиями подобно описанным выше.
|
||||
Кроме того, асихнронное взаимодействие удобнее с точки зрения развития API в будущем: устройство системы, обрабатывающей такие запросы, может меняться в сторону усложнения и удлинения конвейера исполнения задачи, в то время как синхронным функциям придётся укладываться в разумные временные рамки, чтобы оставаться синхронными — что, конечно, ограничивает возможности рефакторинга внутренних механик.
|
||||
|
||||
**NB**: иногда можно встретить решение, при котором эндпойнт имеет двойной интерфейс и может вернуть как результат, так и ссылку на исполнение задания. Хотя для вас как разработчика API он может выглядеть логично (смогли «быстро» выполнить запрос, например, получить результат из кэша — вернули ответ; не смогли — вернули ссылку на задание), для партнёров это решение на самом деле ещё хуже, чем безусловная асинхронность, поскольку заставляет поддерживать две ветки кода одновременно. Также встречается парадигма предоставления на выбор разработчику два набора эндпойнтов, синхронный и асинхронный, но по факту это просто перекладывание ответственности на партнёра.
|
||||
|
||||
Популярность данного паттерна также обусловлена тем, что многие современные микросервисные архитектуры «под капотом» также взаимодействуют асинхронно — либо через потоки событий, либо через асинхронную постановку заданий же. Имплементация аналогичной асинхронности во внешнем API является самым простым способом обойти возникающие проблемы (читай, те же непредсказуемые и возможно очень большие задержки выполнения операций). Доходит до того, что в некоторых API абсолютно все операции делаются асинхронными (включая чтение данных), даже если никакой необходимости в этом нет.
|
||||
|
||||
@@ -63,7 +67,7 @@ const pendingOrders = await api.
|
||||
|
||||
1. Организация отдельных очередей заданий для каждого эндпойнта или каждого клиента — технически сложная задача; если же потребовать и того, и другого (отдельная очередь для каждой уникальной пары эндпойнт-клиент), то она становится близка к невозможной; как правило разные эндпойнты и/или клиенты делят одни и те же очереди (или по крайней мере одни и те же вычислительные ресурсы), что означает взаимовлияние и создание единой точки отказа: повышение нагрузки на конкретный эндпойнт или конкретным партнером приведёт к замедлению работы для всех.
|
||||
2. Написание кода для партнёра становится гораздо сложнее. Дело даже не в физическом объёме кода (в конце концов, создание общего компонента взаимодействия с очередью заданий — не такая уж и сложная задача), а в том, что теперь в отношении каждого вызова разработчик должен поставить себе вопрос: что произойдёт, если его обработка займёт длительное время. Если в случае с синхронными эндпойнтами мы по умолчанию полагаем, что они отрабатывают за какое-то разумное время, меньшее, чем типичный таймаут запросов, то в случае асинхронных эндпойнтов такой гарантии у нас не просто нет — она не может быть дана.
|
||||
3. Как следствие предыдущего пункта, возникает вопрос осмысленности SLA такого сервиса. Через асинхронные задачи всегда можно добиться 100% аптайма API — просто некоторые запросы будут выполнены через пару недель. Но такие гарантии пользователям вашего API, разумеется, совершенно не нужны: их пользователи обычно хотят выполнить задачу сейчас, а не через две недели.
|
||||
3. Как следствие предыдущего пункта, возникает вопрос осмысленности SLA такого сервиса. Через асинхронные задачи легко можно поднять аптайм API до 100% — просто некоторые запросы будут выполнены через пару недель. Но такие гарантии пользователям вашего API, разумеется, совершенно не нужны: их пользователи обычно хотят выполнить задачу сейчас, а не через две недели.
|
||||
|
||||
Поэтому, при всей привлекательности идеи, мы всё же склонны рекомендовать ограничиться асинхронными интерфейсами только там, где они действительно критически важны (как в примере выше, где они снижают вероятность коллизий), и при этом иметь отдельные очереди для каждого кейса. Идеальное решение с очередями — то, которое вписано в бизнес-логику и вообще не выглядит очередью. Например, ничто не мешает нам объявить состояние «задание на создание заказа принято и ожидает исполнения» просто отдельным статусом заказа, а его идентификатор сделать идентификатором будущего заказа:
|
||||
|
||||
@@ -84,3 +88,5 @@ const pendingOrders = await api.
|
||||
status: "new"
|
||||
}]} */
|
||||
```
|
||||
|
||||
**NB**: отметим также, что в формате асинхронного взаимодействия можно передавать не только бинарный статус (выполнено задание или нет), но и прогресс выполнения, если это возможно.
|
@@ -1,136 +1,339 @@
|
||||
### Асинхронность
|
||||
### Списки и организация доступа к ним
|
||||
|
||||
Мы начнём с самого базового элемента API: какую бы задачу вы ни решали, вам необходимо уметь обрабатывать программные вызовы, которые клиенты совершают посредством API — неважно, сделаны ли они в виде HTTP-запросов или вызова функции в SDK — поскольку API это *программируемый* мост, связывающий разные контексты.
|
||||
|
||||
Но при реализации этого базового элемента мы сразу же сталкиваемся с различными подходами к решению одной и той же задачи, и первый возникающий вопрос — это выбор между синхронным и асинхронным подходом к реализации обработчика запроса. В случае нашего кофейного API мы можем сделать так:
|
||||
В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:
|
||||
|
||||
```
|
||||
// Ищет подходящие предложения
|
||||
POST /v1/offers/search
|
||||
{ … }
|
||||
→ 200 OK
|
||||
{
|
||||
"results"
|
||||
const pendingOrders = await api.
|
||||
getOngoingOrders();
|
||||
→
|
||||
{ orders: [{
|
||||
order_id: <идентификатор задания>,
|
||||
status: "new"
|
||||
}]}
|
||||
```
|
||||
|
||||
Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество данных в ответе ничем не ограничено. Эта проблема присутствовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубляет: так как почти все проверки лимитов желательно делать асинхронно, количество заданий на создание заказа почти ничем не ограничено, и их легко может быть создано очень большое количество.
|
||||
|
||||
Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:
|
||||
|
||||
```
|
||||
api.getOngoingOrders({
|
||||
// необязательное, но имеющее
|
||||
// значение по умолчанию
|
||||
"limit": 100,
|
||||
"parameters": {
|
||||
"order_by": [{
|
||||
"field": "created_iso_time",
|
||||
"direction": "DESC"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
А можем вот так:
|
||||
```
|
||||
// Создаёт задание по поиску
|
||||
// подходящих предложений,
|
||||
// и возращает идентификатор,
|
||||
// по которому можно отслеживать
|
||||
// прогресс поиска
|
||||
POST /v1/offers/search
|
||||
{ … }
|
||||
→ 202 Accepted
|
||||
{
|
||||
"result": "accepted",
|
||||
"task_id"
|
||||
}
|
||||
// Возвращает текущее состояние
|
||||
// операции поиска
|
||||
GET /v1/tasks/{task-id}
|
||||
→ 200 OK
|
||||
{
|
||||
"status": "pending",
|
||||
"progress": "87%"
|
||||
}
|
||||
// либо
|
||||
{
|
||||
"status": "completed",
|
||||
"results"
|
||||
}
|
||||
```
|
||||
|
||||
В первом случае клиент синхронно получает результат операции. Во втором случае клиент получает только идентификатор задачи, которую он может отслеживать.
|
||||
|
||||
В случае SDK или фреймворков постановка проблемы чуть сложнее, поскольку вариантов исполнения здесь три:
|
||||
* синхронный
|
||||
```
|
||||
const results = api
|
||||
.searchOffers(…);
|
||||
```
|
||||
* асинхронный
|
||||
```
|
||||
const results = await api
|
||||
.searchOffers(…);
|
||||
// либо
|
||||
// const results = api
|
||||
// .searchOffers(…, (results) => …)
|
||||
```
|
||||
* асинхронный с очередью задач:
|
||||
```
|
||||
const task = api
|
||||
.scheduleSearchOffersTask(…);
|
||||
task.on('ready', (results) => {
|
||||
…
|
||||
})
|
||||
…
|
||||
```
|
||||
|
||||
Однако синхронный вариант (в котором поток исполнения полностью блокируется до завершения операции) сейчас скорее является анахронизмом — почти все современные языки программирования уходят либо в сторону полной асинхронности (через паттерн `async`/`await`, корутины, легковесные потоки исполнения и т.п.). В том числе и клиент-серверное взаимодействие не является исключением: если не брать в расчёт совсем архаичные технологии типа синхронного `XMLHttpRequest`, фреймворки работы с сетью являются неблокирующими, и клиентское приложение вполне может выполнять другие задачи, в т.ч. взаимодействовать с пользователем, ожидая «синхронного» ответа на HTTP-запрос.
|
||||
Однако введение лимита ставит другой вопрос: если всё же количество записей, которые нужно выбрать, превышает лимит, каким образом клиент должен их выбрать?
|
||||
|
||||
Поэтому здесь и далее, когда мы говорим «(а)синхронное взаимодействие», мы говорим скорее о логическом состоянии потока исполнения: либо клиент просто ждёт завершения атомарной операции, не располагая идентификатором задачи, по которому можно было бы отслеживать прогресс (сигналом завершения операции является возврат управления функцией / получение ответа от сервера), либо такой идентификатор генерируется эндпойнтом условно «мгновенно» (т.е. время создания и возврата идентификатора задачи много меньше времени её исполнения).
|
||||
|
||||
С точки зрения будущего развития API асинхронные интерфейсы выглядят удобнее. Они имеют несколько важных достоинств:
|
||||
* можно спокойно варьировать нижележащие технологии исполнения запросов в пользу различных техник отложенного исполнения, в том числе и как ограничитель нагрузки;
|
||||
* можно добавлять метаданные к операции (как в примере выше — прогресс исполнения операции в процентах);
|
||||
* можно прозрачным образом организовывать очереди исполнения (возможно, с приоритетами) и отложенные задачи.
|
||||
|
||||
Возникает естественный вопрос: почему не сделать все операции асинхронными, хотя бы просто на всякий случай? Однако далеко не всё, что кажется удобным вам как провайдеру API, будет удобно и вашим клиентам.
|
||||
|
||||
Хотя формально это редко оговаривается, но по умолчанию почти всюду предполагается, что синхронные операции выполняются «быстро», а асинхронные — потенциально «долго». **Писать код, работающий с «долгими» операциями намного сложнее**: в случае пользовательских приложений это означает необходимость каким-то образом индицировать пользователю выполнение задачи в фоне (при этом пользователь может совершать в приложении другие действия, и необходимо будет реализовать UX решение на случай, если выполнение асинхронной операции завершится в тот момент, когда пользователь находится на другом экране). В случае серверных интеграций «долгие» запросы к API означают, что работающий с ними код тоже будет работать в течение длительного времени с ненулевыми шансами быть прерванным по различным причинам — а, значит, если «долгие» запросы встроены в цепочку исполнения, то необходимо организовать сохранение промежуточных результатов, чтобы в случае повторного исполнения начинать с точки обрыва, а не с начала цепочки.
|
||||
|
||||
Подчеркнём, что сложность имплементации кроется в самом наличии «долгих» операций — неважно, являются ли они интерфейсно асинхронными или нет; однако, если «долгая» операция при этом ещё и синхронная, клиентский код становится ещё более комплексным.
|
||||
|
||||
В большинстве предметных областей, однако, «долгие» операции составляют меньшинство. Поэтому всюду асинхронный интерфейс (т.е. возврат идентификаторов заданий даже для «быстрых» операций) заставляет ваших потребителей писать много ненужного и достаточно сложного кода что, во-первых, повышает порог входа и, во-вторых, приведёт к многочисленным ошибкам реализации: разработать приложение, которое корректно обрабатывает все возникающие ситуации при работе пользователя с выполняемыми в фоне задачами, крайне непросто (и, скорее всего, партнёры всё равно будут просто блокировать интерфейс, ожидая исполнения асинхронной задачи).
|
||||
|
||||
**NB**: иногда можно встретить решение, при котором эндпойнт имеет двойной интерфейс и может вернуть как результат, так и ссылку на исполнение задания. Хотя для вас как разработчика API он может выглядеть логично (смогли «быстро» выполнить запрос, например, получить результат из кэша — вернули ответ; не смогли — вернули ссылку на задание), для партнёров это решение на самом деле ещё хуже, чем безусловная асинхронность, поскольку заставляет поддерживать две ветки кода одновременно. Также встречается парадигма предоставления на выбор разработчику два набора эндпойнтов, синхронный и асинхронный, но по факту это просто перекладывание ответственности на партнёра.
|
||||
|
||||
Может показаться, что вместо неявных концепций «быстро»/«долго» было бы разумнее просто указывать в документации допустимое время исполнения запросов; однако это отнюдь не «просто» — как правило, разработчики просто не могут его гарантировать, так как [системы реального времени](https://en.wikipedia.org/wiki/Real-time_computing) имеют очень малое распространение в прикладном программировании.
|
||||
|
||||
Таким образом, если суммировать вышесказанное, в отношении асинхронного выполнения запросов вы можете пользоваться одной из трёх стратегий.
|
||||
|
||||
| Стратегия | Достоинства | Недостатки и ограничения |
|
||||
|-----------|-------------|--------------------------|
|
||||
| Всегда синхронные запросы | Простота разработки клиентского кода | Вам необходимо гарантировать, что все запросы будут выполнены «быстро», и у вас нет способа без нарушения обратной совместимости увеличить время исполнения запроса свыше разумного предела |
|
||||
| Смешанная: «быстрые запросы» синхронные, «долгие» асинхронные | Предсказуемость с точки зрения разработки клиентского кода и возможность обогащать «долгие» запросы метаданными | Необходимость заранее решить, каким образом разделить запросы на «быстрые» и «долгие», и невозможность легко перевести запрос из одной категории в другую |
|
||||
| Полная асинхронность | Гибкость реализации и полная свобода в дальнейшем развитии функциональности | Сложный и потенциально полный ошибок клиентский код |
|
||||
|
||||
Вы, разумеется, можете адаптировать любую из стратегий. Наша рекомендация здесь (как, впрочем, и всегда) — **используйте асинхронность там, где это уместно, и делайте это явно**:
|
||||
* асинхронными должны быть те операции, которые отражают длящиеся процессы в реальном мире;
|
||||
* асинхронность должна привносить дополнительную функциональность: пероставлять дополнительные метаданные и/или методы работы с длящимися операциями;
|
||||
* конвенция, какие методы асинхронны, должна быть явной и читаемой из сигнатур функций;
|
||||
* желательно оперировать не абстрактными «идентификаторами заданий» (тем более — не выставлять наружу особенности имплементации вашего бэкенда), а семантичными данными.
|
||||
Стандартный подход к этой проблеме — введение параметра `offset` или номера страницы данных:
|
||||
|
||||
```
|
||||
// Запускает длительный
|
||||
// процесс поиска подходящих
|
||||
// предложений
|
||||
POST /v1/offers/search/start
|
||||
X-Idempotency-Token: <токен>
|
||||
{ … }
|
||||
api.getOngoingOrders({
|
||||
// необязательное, но имеющее
|
||||
// значение по умолчанию
|
||||
"limit": 100,
|
||||
// По умолчанию — 0
|
||||
"offset": 100
|
||||
"parameters"
|
||||
});
|
||||
```
|
||||
|
||||
Однако, как нетрудно заметить, в нашем случае этот подход приведёт к новым проблемам. Пусть для простоты в системе от имени пользователя выполняется три заказа:
|
||||
|
||||
```
|
||||
[{
|
||||
"id": 3,
|
||||
"created_iso_time": "2022-12-22T15:35",
|
||||
"status": "new"
|
||||
}, {
|
||||
"id": 2,
|
||||
"created_iso_time": "2022-12-22T15:34",
|
||||
"status": "new"
|
||||
}, {
|
||||
"id": 1,
|
||||
"created_iso_time": "2022-12-22T15:33",
|
||||
"status": "new"
|
||||
}]
|
||||
```
|
||||
|
||||
Приложение партнёра запросило первую страницу заказов:
|
||||
|
||||
```
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"parameters": {
|
||||
"order_by": [{
|
||||
"field": "created_iso_time",
|
||||
"direction": "DESC"
|
||||
}]
|
||||
}
|
||||
})
|
||||
→
|
||||
{
|
||||
"result": "accepted",
|
||||
// Возвращаем не «ид задания»,
|
||||
// а идентификатор поисковой
|
||||
// сессии
|
||||
"search_session_id"
|
||||
"orders": [{
|
||||
"id": 3, …
|
||||
}, {
|
||||
"id": 2, …
|
||||
}]
|
||||
}
|
||||
// Возвращает текущее состояние
|
||||
// операции поиска
|
||||
GET /v1/search-sessions/{id}
|
||||
→
|
||||
{
|
||||
"status": "executing",
|
||||
"progress": "87%",
|
||||
"eta": "20s"
|
||||
}
|
||||
// Длительную операцию можно,
|
||||
// например, отменить
|
||||
POST /v1/search-sessions/{id}/cancel
|
||||
```
|
||||
|
||||
**NB**: понимание того, что такое «быстро» и «долго» очень сильно зависит от предметной области. Для пользовательских интерфейсов задержки больше 100 миллисекунд уже считаются заметными, а больше секунды — оказывающими прямое влияние на восприятие качества приложения и как следствие на бизнес-KPI (отсылаем здесь читателя к трудам Стива Саудерса, и в частности к книге «[Even Faster Websites](https://www.amazon.com/Even-Faster-Web-Sites-Performance/dp/0596522304)»). Для серверных взаимодействий цифра существенно выше — типичные периоды, после которых наступает таймаут соединения, составляют десятки секунд, хотя предоставление эндпойнтов, обрабатывающих запрос более нескольких секунд, считается сегодня скорее плохим тоном.
|
||||
Теперь приложение запрашивает вторую страницу `"limit": 2, "offset": 2` и ожидает получить заказ `"id": 1`. Предположим, однако, что за время, прошедшее с момента первого запроса, в системе появился новый заказ с `"id": 4`.
|
||||
|
||||
```
|
||||
[{
|
||||
"id": 4,
|
||||
"created_iso_time": "2022-12-22T15:36",
|
||||
"status": "new"
|
||||
}, {
|
||||
"id": 3,
|
||||
"created_iso_time": "2022-12-22T15:35",
|
||||
"status": "new"
|
||||
}, {
|
||||
"id": 2,
|
||||
"created_iso_time": "2022-12-22T15:34",
|
||||
"status": "ready"
|
||||
}, {
|
||||
"id": 1,
|
||||
"created_iso_time": "2022-12-22T15:33",
|
||||
"status": "new"
|
||||
}]
|
||||
```
|
||||
|
||||
Тогда, запросив вторую страницу заказов, вместо одного заказа `"id": 1`, приложение партнёра получит повторно ещё и заказ `"id": 2`:
|
||||
|
||||
```
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"offset": 2
|
||||
"parameters"
|
||||
})
|
||||
→
|
||||
{
|
||||
"orders": [{
|
||||
"id": 2, …
|
||||
}, {
|
||||
"id": 1, …
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Следует отметить, что такие перестановки крайне неудобны даже для пользовательского интерфейса — если,допустим, предположить, что заказы запрашивает бухгалтер партнёра, чтобы рассчитать выплаты, то и он легко может просто не заметить, что какой-то заказ посчитан дважды. Однако в случае *программной* интеграции ситуация становится намного сложнее: у разработчика приложения *нет никакой возможности* понять, что происходит с его перебором, и переберёт ли он *все* заказы вообще хоть когда-нибудь.
|
||||
|
||||
Отметим теперь, что ситуацию легко можно сделать гораздо более запутанной. Например, если заказы мы отсортируем заказы по статусу:
|
||||
|
||||
```
|
||||
api.getOrders({
|
||||
"limit": 2,
|
||||
"parameters": {
|
||||
"order_by": [{
|
||||
"field": "status",
|
||||
"direction": "DESC"
|
||||
}, {
|
||||
"field": "created_iso_time",
|
||||
"direction": "DESC"
|
||||
}]
|
||||
}
|
||||
})
|
||||
→
|
||||
{
|
||||
"orders": [{
|
||||
"id": 3,
|
||||
"status": "new"
|
||||
}, {
|
||||
"id": 2,
|
||||
"status": "new"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Предположим, что в интервале между запросами первой и второй страницы заказ `"id": 1` изменил свой статус, и, соответственно, свое положение в списке, став самым первым. Тогда, запросив вторую страницу, приложение партнёра получит (повторно) только заказ с `"id": 2`, а заказ `"id": 1` попросту вообще пропустит, и вновь не будет располагать вообще никаким способом узнать об этом пропуске.
|
||||
|
||||
Повторимся, такой подход плохо работает для визуальных интерфейсов, но в программных интерфейсах он практически гарантированно приведёт к ошибкам. **API должно предоставлять способы перебора больших списков, которые гарантируют клиенту получение полного и целостного набора данных**.
|
||||
|
||||
Если не вдаваться в детали имплементации, то можно выделить три основных паттерна организации такого перебора — в зависимости от того, как сами по себе организованы данные.
|
||||
|
||||
#### Иммутабельные списки
|
||||
|
||||
Проще всего организовать доступ, конечно, если список в принципе не может измениться, т.е. все данные в нём фиксированы. Тогда даже схема с `limit`/`offset` прекрасно работает и не требует дополнительных ухищрений. К сожалению, в реальных предметных областях встречается редко.
|
||||
|
||||
#### Пополняемые списки, иммутабельные данные
|
||||
|
||||
Более распространённый случай — когда не меняются данные в списке, но появляются новые элементы. Чаще всего речь идёт об очередях событий — например, новых сообщений или уведомлений. Представим, что в нашем кофейном API есть эндпойнт для партнёра для получения истории офферов:
|
||||
|
||||
```
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
limit=<limit>
|
||||
→
|
||||
{
|
||||
"offer_history": [{
|
||||
// Идентификатор элемента
|
||||
// списка
|
||||
"id",
|
||||
// Идентификатор пользователя,
|
||||
// получившего оффер
|
||||
"user_id",
|
||||
// Время и дата поиска
|
||||
"occurred_at",
|
||||
// Установленные пользователем
|
||||
// параметры поиска предложений
|
||||
"search_parameters",
|
||||
// Офферы, которые пользователь
|
||||
// увидел
|
||||
"offers"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Данные в списке по своей природе неизменны — они отражают уже случившийся факт: пользователь искал предложения, и увидел вот такой их список. Но новые элементы списка постоянно возникают, причём вполне могут возникать большими сериями, если пользователь сделал несколько поисков подряд.
|
||||
|
||||
Партнёр может использовать эти данные, например, для реализации двух сценариев:
|
||||
1. Анализ запросов (скажем, выяснить в реальном времени, где наблюдается значительное превышение спроса над предложением).
|
||||
2. Индивидуальные предложения пользователям (скажем, партнёр может отправить пользователю пуш-уведомление с предложением скидки).
|
||||
|
||||
Для этих сценариев нам необходимо предоставить партнёру две операции со списками:
|
||||
1. Перебор списка в глубину (т.е. получение всех запросов за последний час).
|
||||
2. Обновление списка (т.е. получение всех новых элементов с момента последнего запроса).
|
||||
|
||||
Оба сценария покрываются `limit`/`offset`-схемой, но требуют значительных усилий при написании кода, так как партнёру в обоих случаях нужно как-то ориентироваться, на сколько элементов очередь событий сдвинулась с момента последнего запроса. Отдельно отметим, что использование `limit`/`offset`-подхода приводит к невозможности кэширования ответов — повторные запросы с той же парой `limit`/`offset` могут возвращать совершенно разные результаты.
|
||||
|
||||
Решить эту проблему мы можем, если будем ориентироваться не на позицию элемента в списке (которая может меняться), а на какие-то другие признаки. Нам важно здесь следующее условие: по этому признаку мы можем однозначно определить, какие элементы списка «более новые» по отношению к нему (т.е. имеют меньшие индексы), а какие «более старые».
|
||||
|
||||
Если хранилище данных, в котором находятся элементы списка, позволяет использовать монотонно растущие идентификаторы (что на практике означает два условия: (1) база данных поддерживает автоинкрементные первичные ключи, (2) вставка данных осуществляется блокирующим образом), то идентификатор элемента в списке является максимально удобным способом организовать перебор:
|
||||
|
||||
```
|
||||
// Получить записи более старые,
|
||||
// чем запись с указанным id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
older_than=<item_id>&limit=<limit>
|
||||
// Получить записи новее,
|
||||
// чем запись с указанным id
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
newer_than=<item_id>&limit=<limit>
|
||||
```
|
||||
|
||||
Первый формат запроса позволяет решить задачу (1), т.е. перебрать нужно количество записей в истории запросов; второй формат — задачу (2), т.е. получить все элементы списка, появившиеся позднее последнего известного. Важно, что первый запрос при этом ещё и кэшируемый.
|
||||
|
||||
Другим способом организации такого перебора может быть дата создания записи, но этот способ чуть сложнее в имплементации:
|
||||
* дата создания двух записей может полностью совпадать, особенно если записи могут генерироваться программно; в худшем случае может получиться так, что в один момент времени было создано больше записей, чем максимальный лимит их извлечения, и тогда часть записей вообще нельзя будет перебрать;
|
||||
* если хранилище данных поддерживает распределённую запись, то может оказаться, что более новая запись имеет чуть меньшую дату создания, нежели предыдущая известная (поскольку часы на разных виртуальных машинах могут идти чуть по-разному), т.е. нарушится требование монотонности по признаку даты — фактически, необходимо либо выбирать архитектуру без распределённой записи, либо описывать эту неконсистентность в документации (что ещё больше усложнит партнёрский код).
|
||||
|
||||
Недостатками такой организации является, во-первых, необходимость раскрыть внутренние детали имплементации (наличие монотонных id, которые выставляются во внешнем API) и, во-вторых, невозможность введения сортировки по произвольному полю — порядок списка чётко фиксирован. Оба этих недостатка, однако, достаточно легко обходятся, если мы введём понятие *курсора*:
|
||||
|
||||
```
|
||||
// Инициализируем поиск
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
search
|
||||
{
|
||||
"order_by": [{
|
||||
"field": "created",
|
||||
"direction": "DESC"
|
||||
}]
|
||||
}
|
||||
→
|
||||
{
|
||||
"cursor": "TmluZSBQcmluY2VzIGluIEFtYmVy"
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
// Получение порции данных
|
||||
GET /v1/partners/{id}/offers/history⮠
|
||||
cursor=aGVsbG8gdGhlcmU=&limit=100
|
||||
→
|
||||
{
|
||||
"items": […],
|
||||
// Указатель на получение следующей
|
||||
// порции данных
|
||||
"cursor": "R3VucyBvZiBBdmFsb24"
|
||||
}
|
||||
```
|
||||
|
||||
Курсором в данной ситуации может представлять собой просто идентификатор последней записи (но тогда интерфейс получения новой порции данных должен будет требовать передачи всех параметров поиска, а не только курсора), а может содержать зашифрованное представление всех параметров поиска. Второе много удобнее потому, что тогда получение станицы данных через курсор полностью кэшируемо.
|
||||
|
||||
В подходе с курсорами вы сможете без нарушения обратной совместимости добавлять новые фильтры и виды сортировки — при условии, конечно, что вы сможете организовать хранение данных таким образом, чтобы перебор с курсором работал однозначно.
|
||||
|
||||
```
|
||||
// Инициализируем поиск
|
||||
POST /v1/partners/{id}/offers/history⮠
|
||||
search
|
||||
{
|
||||
// Добавим фильтр по виду кофе
|
||||
"filter": {
|
||||
"recipe": "americano"
|
||||
},
|
||||
// добавим новую сортировку
|
||||
// по удалённости от указанной
|
||||
// географической точки
|
||||
"order_by": [{
|
||||
"mode": "distance",
|
||||
"location": [-86.2, 39.8]
|
||||
}]
|
||||
}
|
||||
→
|
||||
{
|
||||
"items": […],
|
||||
"cursor":
|
||||
"Q29mZmVlIGFuZCBDb250ZW1wbGF0aW9u"
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: вы можете представлять курсор в незашифрованном виде; но фактически тогда это будет означать, что вы теперь обязаны поддерживать формат курсора, даже если никогда его не документировали. Лучше возвращать курсоры зашифрованными или хотя бы в таком виде, который не вызывал бы желания его раскодировать и поэкспериментировать с параметрами.
|
||||
|
||||
Небольшое примечание: признаком окончания перебора часто выступает отсутствие курсора на последней странице с данными; мы бы рекомендовали так не делать (т.е. всё же возвращать курсор, указывающий на пустой список), поскольку это позволит добавить функциональность динамической вставки данных в конец списка.
|
||||
|
||||
#### Общий сценарий
|
||||
|
||||
Увы, далеко не всегда данные организованы таким образом, чтобы из них можно было составить иммутабельные списки. Например, в указанном выше примере поиска текущих заказов мы никак не можем представить постраничный список заказов, находящихся сейчас в статусе «исполняется» — просто потому, что заказы переходят в другие статусы и в реальном времени пропадают из списка. Для таких сложных случаев нам нужно в первую очередь ориентироваться на *сценарии использования* данных.
|
||||
|
||||
Бывает так, что задачу можно *свести* к иммутабельному списку, если по запросу создавать какой-то слепок запрошенных данных. Во многих случаях работа с таким срезом данных по состоянию на определённую дату более удобна и для партнёров, поскольку снимает необходимость учитывать текущие изменения. Часто такой подход работает с «холодными» хранилищами, которые по запросу выгружают какой-то подмассив данных в «горячее» хранилище.
|
||||
|
||||
```
|
||||
POST /v1/orders/archive/retrieve
|
||||
{
|
||||
"created_iso_date": {
|
||||
"from": "1980-01-01",
|
||||
"to": "1990-01-01"
|
||||
}
|
||||
}
|
||||
→
|
||||
{
|
||||
"task_id": <идентификатор
|
||||
задания на выгрузку данных>
|
||||
}
|
||||
```
|
||||
|
||||
Недостаток такого подхода понятен — он требует дополнительных (и зачастую немалых) затрат на создание и хранение слепка, а потому требует и отдельной тарификации. Кроме того, проблема-то сама по себе никуда не делась: мы перенесли её из публичного API на уровень реализации нашего бэкенда, но нам всё ещё нужно каким-то образом перебрать массив данных и сформировать консистентный слепок.
|
||||
|
||||
Обратный подход к организации такого перебора — это принципиально не предоставлять больше одной страницы данных. Т.е. партнёр может запросить только «последние» в каком-то смысле записи. Такой подход обычно применяется в одном из трёх случаев:
|
||||
* если эндпойнт представляет собой поисковый алгоритм, который выбирает наиболее релевантные данные — как мы все отлично знаем, вторая страница поисковой выдачи уже никому не нужна;
|
||||
* если эндпойнт нужен для того, чтобы *изменить* данные — например, сервис партнёра достаёт все заказы в статусе `"new"` и переводит в статус «принято к исполнению»; тогда пагинация на самом деле и не нужна;
|
||||
* наконец, если через эндпойнт предоставляются только «горячие» необработанные данные, а к обработанным данным доступ предоставляется уже через стандартные интерфейсы.
|
||||
|
||||
Если ни один из описанных вариантов не подходит по тем или иным причинам, единственный способ организации доступа — это изменение предметной области. Если мы не можем консистентно упорядочить элементы списка, нам нужно найти какой-то другой срез тех же данных, который мы *можем* упорядочить. Например, в нашем случае доступа к новым заказам мы можем упорядочить *список событий* создания нового заказа:
|
||||
|
||||
```
|
||||
// Получить все события создания
|
||||
// заказа, более старые,
|
||||
// чем запись с указанным id
|
||||
GET /v1/orders/created-history⮠
|
||||
older_than=<item_id>&limit=<limit>
|
||||
→
|
||||
{
|
||||
"orders_created_events": [{
|
||||
"id": <идентификатор события>,
|
||||
"occured_at",
|
||||
// Идентификатор заказа
|
||||
"order_id"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать *все* новые заказы, пусть и неоптимальным образом.
|
Reference in New Issue
Block a user