1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-08-10 21:51:42 +02:00
This commit is contained in:
Sergey Konstantinov
2023-05-06 17:38:53 +03:00
parent 25ffa2dbea
commit 46e4e6b3fa
3 changed files with 703 additions and 94 deletions

View File

@@ -1 +1,350 @@
### Lists and Accessing Them
### [Lists and Accessing Them][api-patterns-lists]
In the previous chapter, we concluded with the following interface that allows minimizing collisions while creating orders:
```
const pendingOrders = await api.
getOngoingOrders();
{ orders: [{
order_id: <task identifier>,
status: "new"
}, …]}
```
However, an attentive reader might notice that this interface violates the recommendation we previously gave in the [“Describing Final Interfaces”](#api-design-describing-interfaces) chapter: the returned data volume must be limited, but there are no restrictions in our design. This problem was already present in the previous versions of the endpoint, but abolishing asynchronous order creation makes it much worse. The task creation operation must work as quickly as possible, and therefore, almost all limit checks are to be executed asynchronously. As a result, a client might easily create a large number of ongoing tasks which would potentially inflate the size of the `getOngoingOrders` response.
**NB**: having *no limit at all* on order task creation is unwise, and there must be some (involving as lightweight checks as possible). Let us, however, focus on the response size issue in this chapter.
Fixing this problem is rather simple: we might introduce a limit for the items returned in the response, and allow passing filtering and sorting parameters, like this:
```
api.getOngoingOrders({
// The `limit` parameter
// is optional, but there is
// a reasonable default value
"limit": 100,
"parameters": {
"order_by": [{
"field": "created_iso_time",
"direction": "desc"
}]
}
})
```
However, introducing limits leads to another issue: if the number of items to return is higher than the limit, how would clients access them?
The standard approach is to add an `offset` parameter or a page number:
```
api.getOngoingOrders({
// The `limit` parameter
// is optional, but there is
// a reasonable default value
"limit": 100,
// The default value is 0
"offset": 100,
"parameters"
})
```
With this approach, however, other problems arise. Let us imagine three orders are being processed on behalf of the user:
```
[{
"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"
}]
```
A partner application requested the first page of the list:
```
api.getOrders({
"limit": 2,
"parameters": {
"order_by": [{
"field": "created_iso_time",
"direction": "desc"
}]
}
})
{
"orders": [{
"id": 3, …
}, {
"id": 2, …
}]
}
```
Then the application requests the second page (`"limit": 2, "offset": 2`) and expects to retrieve the order with `"id": 1`. However, during the interval between the requests, another order, with `"id": 4`, happened.
```
[{
"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"
}]
```
Then upon requesting the second page of the order list, instead of getting exactly one order with `"id": 1`, the application will get the `"id": 2` order once again:
```
api.getOrders({
"limit": 2,
"offset": 2
"parameters"
})
{
"orders": [{
"id": 2, …
}, {
"id": 1, …
}]
}
```
These permutations are rather inconvenient in user interfaces (if let's say, the partner's accountant is requesting orders to calculate fees, they might easily overlook the duplicate identifiers and process one order twice). But in the case of *programmable* integrations, the situation becomes even more complicated: the application developer needs to write rather unobvious code (which preserves the information regarding which pages were already processed) to carry out this enumeration correctly.
The problem might easily become even more sophisticated. For example, if we add sorting by two fields, creation date and order status:
```
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"
}]
}
```
Imagine, that in between requesting the first and the second pages, the `"id": 1` order changed its status and moved to the top of the list. Upon requesting the second page, the partner application will only receive the `"id": 2` order (for the second time) and miss the `"id": 1` completely — and there is no method to learn this fact!
Let us reiterate: this approach works poorly with visual interfaces, but with program ones, it inevitably leads to mistakes. **An API must provide methods of traversing large lists that guarantee clients can retrieve the full and consistent dataset**.
If we don't go into implementation details, we can identify three main patterns of realizing such traversing, depending on how the data itself is organized.
#### Immutable Lists
The easiest case is with immutable lists, i.e., when the set of items never changes. The `limit`/`offset` scheme then works perfectly and no additional tricks are needed. Unfortunately, this rarely happens in real subject areas.
#### Additive Lists, Immutable Data
The case of a list with immutable items and the operation of adding new ones is more typical. Most notably, we talk about event queues containing, for example, new messages or notifications. Let's imagine there is an endpoint in our coffee API that allows partners to retrieve the history of offers:
```
GET /v1/partners/{id}/offers/history⮠
limit=<limit>
{
"offer_history": [{
// A list item identifier
"id",
// An identifier of the user
// that got the offer
"user_id",
// Date and time of the search
"occurred_at",
// The search parameter values
// set by the user
"search_parameters",
// The offers that the user got
"offers"
}]
}
```
The data returned from this endpoint is naturally immutable because it reflects a completed action: a user searched for offers and received a response. However, new items are continuously added to the list, potentially in large chunks, as users might make multiple searches in succession.
Partners can utilize this data to implement various features, such as:
1. Real-time user behavior analysis (e.g., sending push notifications with discount codes to encourage users to convert offers to orders)
2. Statistical analysis (e.g., calculating conversion rates per hour).
To enable these scenarios, we need to expose through the API two operations with the offer history:
1. For the first task, the real-time fetching of new offers that were made since the last request.
2. For the second task, traversing the list, i.e., retrieving all queries until some condition is reached (possibly, the end of the list).
Both scenarios are covered with the `limit`/`offset` approach but require significant effort to write code properly as partners need to somehow align their requests with the rate of incoming queries. Additionally, note that using the `limit`/`offset` scheme makes caching impossible as repeating requests with the same `limit`/`offset` values will emit different results.
To solve this issue, we need to rely not on an attribute that constantly changes (such as the item position in the list) but on other anchors. The important rule is that this attribute must provide the possibility to unambiguously tell which list elements are “newer” compared to the given one (i.e., precede it in the list) and which are “older”.
If the data storage we use for keeping list items offers the possibility of using monotonically increased identifiers (which practically means two things: (1) the DB supports auto-incremental columns and (2) there are insert locks that guarantee inserts are performed sequentially), then using the monotonous identifier is the most convenient way of organizing list traversal:
```
// Retrieve the records that precede
// the one with the given id
GET /v1/partners/{id}/offers/history⮠
newer_than=<item_id>&limit=<limit>
// Retrieve the records that follow
// the one with the given id
GET /v1/partners/{id}/offers/history⮠
older_than=<item_id>&limit=<limit>
```
The first request format allows for implementing the first scenario, i.e., retrieving the fresh portion of the data. Conversely, the second format makes it possible to consistently iterate over the data to fulfill the second scenario. Importantly, the second request is cacheable as the tail of the list never changes.
**NB**: in the [“Describing Final Interfaces”](#api-design-describing-interfaces) chapter we recommended avoiding exposing incremental identifiers in publicly accessible APIs. Note that the scheme described above might be augmented to comply with this rule by exposing some arbitrary secondary identifiers. The requirement is that these identifiers might be unequivocally converted into monotonous ones.
Another possible anchor to rely on is the record creation date. However, this approach is harder to implement for the following reasons:
* Creation dates for two records might be identical, especially if the records are mass-generated programmatically. In the worst-case scenario, it might happen that at some specific moment, more records were created than one request page contains making it impossible to traverse them.
* If the storage supports parallel writing to several nodes, the most recently created record might have a slightly earlier creation date than the second-recent one because clocks on different nodes might tick slightly differently, and it is challenging to achieve even microsecond-precision coherence[[1]](https://www.yugabyte.com/blog/evolving-clock-sync-for-distributed-databases/). This breaks the monotonicity invariant, which makes it poorly fit for use in public APIs. If there is no other choice but relying on such storage, one of two evils is to be chosen:
* Introducing artificial delays, i.e., returning only items created earlier than N seconds ago, selecting this N to be certainly less than the clock irregularity. This technique also works in the case of asynchronously populated lists. Keep in mind, however, that this solution is probabilistic, and wrong data will be served to clients in case of backend synchronization problems.
* Describe the instability of ordering list items in the docs (and thus make partners responsible for solving arising issues).
Often, the interfaces of traversing data through stating boundaries are generalized by introducing the concept of a “cursor”:
```
// Initiate list traversal
POST /v1/partners/{id}/offers/history⮠
search
{
"order_by": [{
"field": "created",
"direction": "desc"
}]
}
{
"cursor": "TmluZSBQcmluY2VzIGluIEFtYmVy"
}
```
```
// Get the next data chunk
GET /v1/partners/{id}/offers/history⮠
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy⮠
&limit=100
{
"items": […],
// Pointer to the next data chunk
"cursor": "R3VucyBvZiBBdmFsb24"
}
```
A *cursor* might be just an encoded identifier of the last record or it might comprise all the searching parameters. One advantage of using cursors instead of exposing raw monotonous fields is the possibility to change the underlying technology. For example, you might switch from using an auto-incremental key to using the date of the last known record's creation without breaking backward compatibility. (That's why cursors are usually opaque strings: providing readable cursors would mean that you now have to maintain the cursor format even if you never documented it. It's better to return cursors encrypted or at least coded in a form that will not arise the desire to decode it and experiment with parameters.)
The cursor-based approach also allows adding new filters and sorting directions in a backward-compatible manner — provided you organize the data in a way that cursor-based traversal will continue working.
```
// Initialize list traversal
POST /v1/partners/{id}/offers/history⮠
search
{
// Add a filter by the recipe
"filter": {
"recipe": "americano"
},
// Add a new sorting mode
// by the distance from some
// location
"order_by": [{
"mode": "distance",
"location": [-86.2, 39.8]
}]
}
{
"items": […],
"cursor":
"Q29mZmVlIGFuZCBDb250ZW1wbGF0aW9u"
}
```
A small footnote: sometimes, the absence of the next-page cursor in the response is used as a flag to signal that iterating is over and there are no more elements in the list. However, we would rather recommend not using this practice and always returning a cursor even if it points to an empty page. This approach allows for adding the functionality of dynamically inserting new items at the end of the list.
**NB**: in some articles, organizing list traversals through monotonous identifiers / creation dates / cursors is not recommended because it is impossible to show a page selection to the end user and allow them to choose the desired result page. However, we should consider the following:
* This case, of showing a pager and selecting a page, makes sense for end-user interfaces only. It's unlikely that an API would require access to random data pages.
* If we talk about the internal API for an application that provides the UI control element with a pager, the proper approach is to prepare the data for this control element on the server side, including generating links to pages.
* The boundary-based approach doesn't mean that using `limit`/`offset` parameters is prohibited. It is quite possible to have a double interface that would respond to both `GET /items?cursor=…` and `GET /items?offset=…&limit=…` queries.
* Finally, if the need to have access to an arbitrary data page in the UI exists, we need to ask ourselves a question: what is the user's problem that we're solving with this UI? Most likely, users *are searching* for something, such as a specific list item or where they were the last time they worked with the list. Specific UI control elements to help them will be likely more convenient than a pager.
#### The General Case
Unfortunately, it is not universally possible to organize the data in a way that would not require mutable lists. For example, we cannot paginate the list of ongoing orders consistently because orders change their status and randomly enter and leave this list. In these general scenarios, we need to focus on the *use cases* for accessing the data.
Sometimes, the task can be *reduced* to an immutable list if we create a snapshot of the data. In many cases, it is actually more convenient for partners to work with a snapshot that is current for a specific date as it eliminates the necessity of taking ongoing changes into account. This approach works well with accessing “cold” data storage by downloading chunks of data and putting them into “hot” storage upon request.
```
POST /v1/orders/archive/retrieve
{
"created_iso_date": {
"from": "1980-01-01",
"to": "1990-01-01"
}
}
{
"task_id": <an identifier of
a task to retrieve the data>
}
```
The disadvantage of this approach is also clear: it requires additional (sometimes quite considerable) computational resources to create and store a snapshot (and therefore requires a separate tariff). And we actually haven't solved the problem: though we don't expose the real-time traversal functionality in public APIs, we still need to implement it internally to be able to make a snapshot.
The inverse approach to the problem is to never provide more than one page of data, meaning that partners can only access the “newest” data chunk. This technique is viable in one of three cases:
* If the endpoint features a search algorithm that fetches the most relevant data. As we are well aware, nobody needs a second search result page.
* If the endpoint is needed to *modify* data. For example, the partner's service retrieves all “new” orders to transit them into the “accepted” status; then pagination is not needed at all as with each request the partner is *removing* items from the top of the list.
* The important case for such modifications is marking the received data as “read”.
* finally, if the endpoint is needed to access only real-time “raw” data while the processed and classified data are available through other interfaces.
If none of the approaches above works, our only solution is changing the subject area itself. If we can't consistently enumerate list elements, we need to find a facet of the same data that we *can* enumerate. In our example with the ongoing orders we might make an ordered list of the *events* of creating new orders:
```
// Retrieve all the events older
// than the one with the given id
GET /v1/orders/created-history⮠
older_than=<item_id>&limit=<limit>
{
"orders_created_events": [{
"id": <event id>,
"occured_at",
"order_id"
}, …]
}
```
Events themselves and the order of their occurrence are immutable. Therefore, it's possible to organize traversing the list. It is important to note that the order creation event is not the order itself: when a partner reads an event, the order might have already changed its status. However, accessing *all* new orders is ultimately doable, although not in the most efficient manner.
**NB**: in the code samples above, we omitted passing metadata for responses, such as the number of items in the list, the `has_more_items` flag, etc. Although this metadata is not mandatory (i.e., clients will learn the list size when they retrieve it fully), having it makes working with the API more convenient for developers. Therefore we recommend adding it to responses.

View File

@@ -1 +1,353 @@
### Списки и организация доступа к ним
### [Списки и организация доступа к ним][api-patterns-lists]
В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:
```
const pendingOrders = await api.
getOngoingOrders();
{ orders: [{
order_id: <идентификатор задания>,
status: "new"
}, …]}
```
Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество возвращаемых данных в любом ответе должно быть ограничено, но в нашем интерфейсе отсутствуют какие-либо лимиты. Эта проблема существовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубил: операция создания задания должна работать максимально быстро, и, следовательно, почти все проверки лимитов мы должны проводить асинхронно — а значит, клиент потенциально может создать очень много заданий, что может многократно увеличить размер ответа функции `getOngoingOrders`.
**NB**: конечно, не иметь *вообще никакого* ограничения на создание заданий — не самое мудрое решение; какие-то легковесные проверки лимитов должны быть в API. Тем не менее, в рамках этой главы мы фокусируемся именно на проблеме размера ответа сервера.
Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:
```
api.getOngoingOrders({
// необязательное, но имеющее
// значение по умолчанию
"limit": 100,
"parameters": {
"order_by": [{
"field": "created_iso_time",
"direction": "desc"
}]
}
})
```
Однако введение лимита ставит другой вопрос: если всё же количество записей, которые нужно выбрать, превышает лимит, каким образом клиент должен получить к ним доступ?
Стандартный подход к этой проблеме — введение параметра `offset` или номера страницы данных:
```
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"
}]
}
})
{
"orders": [{
"id": 3, …
}, {
"id": 2, …
}]
}
```
Теперь приложение запрашивает вторую страницу `"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=<лимит>
{
"offer_history": [{
// Идентификатор элемента
// списка
"id",
// Идентификатор пользователя,
// получившего оффер
"user_id",
// Время и дата поиска
"occurred_at",
// Установленные пользователем
// параметры поиска предложений
"search_parameters",
// Офферы, которые пользователь
// увидел
"offers"
}]
}
```
Данные в списке по своей природе неизменны — они отражают уже случившийся факт: пользователь искал предложения, и увидел вот такой их список. Но новые элементы списка постоянно возникают, причём вполне могут возникать большими сериями, если пользователь сделал несколько поисков подряд.
Партнёр может использовать эти данные, например, для реализации двух сценариев:
1. Анализ поведения пользователей в реальном времени (скажем, партнёр может отправить пользователю пуш-уведомление с предложением скидки тем пользователям, которые искали).
1. Построение статистического отчёта (скажем, подсчёт конверсии по часам).
Для этих сценариев нам необходимо предоставить партнёру две операции со списками:
1. Для первой задачи, получение в реальном всех новых элементов с момента последнего запроса.
2. Для второй задачи, перебор списка, т.е. получение всех запросов за указанный временной интервал.
Оба сценария покрываются `limit`/`offset`-схемой, но требуют значительных усилий при написании кода, так как партнёру в обоих случаях нужно как-то ориентироваться, на сколько элементов очередь событий сдвинулась с момента последнего запроса. Отдельно отметим, что использование `limit`/`offset`-подхода приводит к невозможности кэширования ответов — повторные запросы с той же парой `limit`/`offset` могут возвращать совершенно разные результаты.
Решить эту проблему мы можем, если будем ориентироваться не на позицию элемента в списке (которая может меняться), а на какие-то другие признаки. Нам важно здесь следующее условие: по этому признаку мы можем однозначно определить, какие элементы списка «более новые» по отношению к нему (т.е. имеют меньшие индексы), а какие «более старые».
Если хранилище данных, в котором находятся элементы списка, позволяет использовать монотонно растущие идентификаторы (что на практике означает два условия: (1) база данных поддерживает автоинкрементные колонки, (2) вставка данных осуществляется блокирующим образом), то идентификатор элемента в списке является максимально удобным способом организовать перебор:
```
// Получить записи новее,
// чем запись с указанным id
GET /v1/partners/{id}/offers/history⮠
newer_than=<item_id>&limit=<limit>
// Получить записи более старые,
// чем запись с указанным id
GET /v1/partners/{id}/offers/history⮠
older_than=<item_id>&limit=<limit>
```
Первый формат запроса позволяет решить задачу (1), т.е. получить все элементы списка, появившиеся позднее последнего известного; второй формат — задачу (2), т.е. перебрать нужно количество записей в истории запросов. Важно, что первый запрос при этом ещё и кэшируемый.
**NB**: отметим, что в главе [«Описание конечных интерфейсов»](#api-design-describing-interfaces) мы давали рекомендацию не давать доступ во внешнем API к инкрементальным id. Однако, схема этого и не требует: внешние идентификаторы могут быть произвольными (не обязательно монотонными) — достаточно, чтобы они однозначно конвертировались во внутренние монотонные идентификаторы.
Другим способом организации такого перебора может быть дата создания записи, но этот способ чуть сложнее в имплементации:
* дата создания двух записей может полностью совпадать, особенно если записи могут массово генерироваться программно; в худшем случае может получиться так, что в один момент времени было создано больше записей, чем максимальный лимит их извлечения, и тогда часть записей вообще нельзя будет перебрать;
* если хранилище данных поддерживает распределённую запись, то может оказаться, что более новая запись имеет чуть меньшую дату создания, нежели предыдущая известная (поскольку часы на разных виртуальных машинах могут идти чуть по-разному, и добиться хотя бы микросекундной точности крайне сложно[[1]](https://www.yugabyte.com/blog/evolving-clock-sync-for-distributed-databases/)), т.е. нарушится требование монотонности по признаку даты; если использование такого хранилища не имеет альтернативы, необходимо выбрать одно из двух зол:
* внести рукотворные задержки, т.е. возвращать в API только элементы, созданные более чем N секунд назад — так, чтобы N было заведомо больше неравномерности хода часов (эта техника может использоваться и в тех случаях, когда список формируется асинхронно) — однако надо иметь в виду, что это решение вероятностное и всегда есть шанс отдачи неверных данных в случае проблем с синхронизацией на бэкенде;
* описать нестабильность порядка новых элементов списка в документации и переложить решение этой проблемы на партнёров.
Часто подобные интерфейсы перебора данных (путём указания граничного значения) обобщают через введение понятия *курсор*:
```
// Инициализируем поиск
POST /v1/partners/{id}/offers/history⮠
search
{
"order_by": [{
"field": "created",
"direction": "desc"
}]
}
{
"cursor": "TmluZSBQcmluY2VzIGluIEFtYmVy"
}
```
```
// Получение порции данных
GET /v1/partners/{id}/offers/history⮠
?cursor=TmluZSBQcmluY2VzIGluIEFtYmVy⮠
&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**: в некоторых источниках перебор через идентификаторы / даты создания / курсор, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:
* подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;
* если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;
* подход с курсором не означает, что `limit`/`offset` использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида `GET /items?cursor=…`, и на запросы вида `GET /items?offset=…&limit=…`;
* наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.
#### Общий сценарий
Увы, далеко не всегда данные организованы таким образом, чтобы из них можно было составить иммутабельные списки. Например, в указанном выше примере поиска текущих заказов мы никак не можем представить постраничный список заказов, находящихся сейчас в статусе «исполняется» — просто потому, что заказы переходят в другие статусы и в реальном времени пропадают из списка. Для таких сложных случаев нам нужно в первую очередь ориентироваться на *сценарии использования* данных.
Бывает так, что задачу можно *свести* к иммутабельному списку, если по запросу создавать какой-то слепок запрошенных данных. Во многих случаях работа с таким срезом данных по состоянию на определённую дату более удобна и для партнёров, поскольку снимает необходимость учитывать текущие изменения. Часто такой подход работает с «холодными» хранилищами, которые по запросу выгружают какой-то подмассив данных в «горячее» хранилище.
```
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"
}, …]
}
```
События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать *все* новые заказы, пусть и не самым оптимальным образом.
**NB**: в вышеприведённых фрагментах кода мы опустили метаданные ответа — такие как общее число элементов в списке, флаг типа `has_more_items` для индикации необходимости продолжить перебор и т.д. Хотя эти метаданные необязательны (клиент узнает размер списка, когда переберёт его полностью), их наличие повышает удобство работы с API для разработчиков, и мы рекомендуем их добавлять.

View File

@@ -1,92 +0,0 @@
### Асинхронность и управление временем
Предположим, всё-таки, что стоимость реализации получения актуальных мастер-данных велика (или мы не считаем риски создания двойного заказа слишком уж значительными), и мы всё же решили её не имплементировать. На старте приложение получает *какое-то* состояние системы, возможно, не самое актуальное. Каким образом мы можем всё же снизить вероятность коллизий?
Напомним, что вероятность эта равна она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. Повлиять на знаменатель этой дроби мы практически не можем (если только не будем преднамеренно вносить задержку инициализации API, что мы всё же считаем крайней мерой). Обратимся теперь к числителю.
Наш сценарий использования, напомним, выглядит так:
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
const order = await api
.createOrder(…);
}
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders(); // → []
if (pendingOrder.length == 0) {
const order = await api
.createOrder(…);
}
```
Таким образом, мы стремимся минимизировать следующий временной интервал: сетевая задержка передачи команды `createOrder` + время выполнения `createOrder` + время пропагации изменений до реплик. Первое мы вновь не контролируем (но, по счастью, мы можем надеяться на то, что сетевые задержки в пределах сессии величина плюс-минус постоянная, и, таким образом, последующий вызов `getOngoingOrders` будет задержан примерно на ту же величину); третье, скорее всего, будет обеспечиваться инфраструктурой нашего бэкенда. Поговорим теперь о втором времени.
Мы видим, что, если создание заказа само по себе происходит очень долго (здесь «очень долго» = «сопоставимо со временем запуска приложения»), то все наши усилия практически бесполезны. Клиент может устать ждать исполнения вызова `createOrder`, выгрузить приложение и послать второй (и более) `createOrder`. В наших интересах сделать так, чтобы этого не происходило.
Но каким образом мы реально можем улучшить это время? Ведь создание заказа *действительно* может быть длительным — нам нужно выполнить множество проверок, а то и дождаться ответа платёжного шлюза.
Здесь нам на помощь приходят асинхронные вызовы. Если наша цель — уменьшить число коллизий, то нам нет никакой нужды дожидаться, когда заказ будет *действительно* создан; наша цель — максимально быстро распространить по репликам знание о том, что заказ *принят к созданию*. Мы можем поступить следующим образом: создавать не заказ, а задание на создание заказа, и возвращать его идентификатор.
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
const task = await api
.putOrderCreationTask(…);
}
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders();
// → { tasks: [task] }
```
Здесь мы предполагаем, что создание задания требует минимальных проверок и не ожидает исполнения каких-то длительных операций, а потому происходит много быстрее. Кроме того, саму эту операцию — создание асинхронного задания — мы можем поручить отдельному сервису абстрактных заданий в составе бэкенда, вообще никак не связанному с циклом обработки заказа. Между тем, имея функциональность создания заданий и получения списка текущих заданий, мы значительно уменьшаем «серые зоны» состояния неопределённости, когда клиент не может узнать текущее состояние сервера точно.
Таким образом, мы естественным образом приходим к паттерну организации асинхронного API через очереди заданий. Мы используем здесь термин «асинхронность» логически — подразумевая отсутствие взаимных *логических* блокировок: посылающая сторона получает ответ на свой запрос сразу, не дожидаясь окончания исполнения запрошенной функциональности, и может продолжать взаимодействие с API, пока операция выполняется. При этом технически в современных системах блокировки клиента (и сервера) почти всегда не происходит и при обращении к синхронным эндпойнтам — однако логически продолжать работать с API, не дождавшись ответа на синхронный запрос, может быть чревато коллизиями подобно описанным выше.
Асинхронный подход может применяться не только для устранения коллизий и неопределённости, но и для решения других прикладных задач:
* организация ссылок на результаты операции и их кэширование (предполагается, что, если клиенту необходимо снова прочитать результат операции или же поделиться им с другим агентом, он может использовать для этого идентификатор задания);
* обеспечение идемпотентности операций (для этого необходимо ввести подтверждение задания, и мы фактически получим схему с черновиками операции, описанную в Главе 5);
* нативное же обеспечение устойчивости к временному всплеску нагрузки на сервис — новые задачи встают в очередь (возможно, приоритизированную), фактически имплементируя [«маркерное ведро»](https://en.wikipedia.org/wiki/Token_bucket);
* организация взаимодействия в тех случаях, когда время исполнения операции превышает разумные значения (в случае сетевых API — типичное время срабатывания сетевых таймаутов, т.е. десятки секунд) либо является непредсказуемым.
Кроме того, асихнронное взаимодействие удобнее с точки зрения развития API в будущем: устройство системы, обрабатывающей такие запросы, может меняться в сторону усложнения и удлинения конвейера исполнения задачи, в то время как синхронным функциям придётся укладываться в разумные временные рамки, чтобы оставаться синхронными — что, конечно, ограничивает возможности рефакторинга внутренних механик.
**NB**: иногда можно встретить решение, при котором эндпойнт имеет двойной интерфейс и может вернуть как результат, так и ссылку на исполнение задания. Хотя для вас как разработчика API он может выглядеть логично (смогли «быстро» выполнить запрос, например, получить результат из кэша — вернули ответ; не смогли — вернули ссылку на задание), для партнёров это решение на самом деле ещё хуже, чем безусловная асинхронность, поскольку заставляет поддерживать две ветки кода одновременно. Также встречается парадигма предоставления на выбор разработчику два набора эндпойнтов, синхронный и асинхронный, но по факту это просто перекладывание ответственности на партнёра.
Популярность данного паттерна также обусловлена тем, что многие современные микросервисные архитектуры «под капотом» также взаимодействуют асинхронно — либо через потоки событий, либо через асинхронную постановку заданий же. Имплементация аналогичной асинхронности во внешнем API является самым простым способом обойти возникающие проблемы (читай, те же непредсказуемые и возможно очень большие задержки выполнения операций). Доходит до того, что в некоторых API абсолютно все операции делаются асинхронными (включая чтение данных), даже если никакой необходимости в этом нет.
Мы, однако, не можем не отметить, что, несмотря на свою привлекательность, повсеместная асинхронность влечёт за собой ряд достаточно неприятных проблем.
1. Организация отдельных очередей заданий для каждого эндпойнта или каждого клиента — технически сложная задача; если же потребовать и того, и другого (отдельная очередь для каждой уникальной пары эндпойнт-клиент), то она становится близка к невозможной; как правило разные эндпойнты и/или клиенты делят одни и те же очереди (или по крайней мере одни и те же вычислительные ресурсы), что означает взаимовлияние и создание единой точки отказа: повышение нагрузки на конкретный эндпойнт или конкретным партнером приведёт к замедлению работы для всех.
2. Написание кода для партнёра становится гораздо сложнее. Дело даже не в физическом объёме кода (в конце концов, создание общего компонента взаимодействия с очередью заданий — не такая уж и сложная задача), а в том, что теперь в отношении каждого вызова разработчик должен поставить себе вопрос: что произойдёт, если его обработка займёт длительное время. Если в случае с синхронными эндпойнтами мы по умолчанию полагаем, что они отрабатывают за какое-то разумное время, меньшее, чем типичный таймаут запросов, то в случае асинхронных эндпойнтов такой гарантии у нас не просто нет — она не может быть дана.
3. Как следствие предыдущего пункта, возникает вопрос осмысленности SLA такого сервиса. Через асинхронные задачи легко можно поднять аптайм API до 100% — просто некоторые запросы будут выполнены через пару недель. Но такие гарантии пользователям вашего API, разумеется, совершенно не нужны: их пользователи обычно хотят выполнить задачу сейчас, а не через две недели.
Поэтому, при всей привлекательности идеи, мы всё же склонны рекомендовать ограничиться асинхронными интерфейсами только там, где они действительно критически важны (как в примере выше, где они снижают вероятность коллизий), и при этом иметь отдельные очереди для каждого кейса. Идеальное решение с очередями — то, которое вписано в бизнес-логику и вообще не выглядит очередью. Например, ничто не мешает нам объявить состояние «задание на создание заказа принято и ожидает исполнения» просто отдельным статусом заказа, а его идентификатор сделать идентификатором будущего заказа:
```
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
const task = await api
.putOrderCreationTask(…);
}
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders();
/* → { orders: [{
order_id: <идентификатор задания>,
status: "new"
}]} */
```
**NB**: отметим также, что в формате асинхронного взаимодействия можно передавать не только бинарный статус (выполнено задание или нет), но и прогресс выполнения, если это возможно.