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

proofreading

This commit is contained in:
Sergey Konstantinov 2023-05-06 21:02:49 +03:00
parent fb1ee4b3e5
commit 34a7f598a5
6 changed files with 194 additions and 538 deletions

View File

@ -8,7 +8,7 @@ In the previous chapter, we concluded that the hierarchy of abstractions in our
We are now to define each entity's responsibility area: what's the reasoning for keeping this entity within our API boundaries? What operations are applicable to the entity directly (and which are delegated to other objects)? In fact, we are to apply the “why”-principle to every single API entity.
To do so, we must iterate all over the API and formulate in subject area terms what every object is. Let us remind that the abstraction levels concept implies that each level is some interim subject area per se; a step we take in the journey from describing a task in terms belonging to the first connected context (“a lungo ordered by a user”) terms belonging to the second connected context (“a command performed by a coffee machine”).
To do so, we must iterate all over the API and formulate in subject area terms what every object is. Let us remind that the abstraction levels concept implies that each level is some interim subject area per se; a step we take in the journey from describing a task in terms belonging to the first connected context (“a lungo ordered by a user”) to terms belonging to the second connected context (“a command performed by a coffee machine”).
As for our fictional example, it would look as follows.
1. User-level entities.
@ -17,35 +17,35 @@ As for our fictional example, it would look as follows.
* checked for its status
* retrieved
* canceled.
* A `recipe` describes an “ideal model” of some coffee beverage type, i.e., its customer properties. A `recipe` is an immutable entity for us, which means we could only read it.
* A `recipe` describes an “ideal model” of a coffee beverage type, i.e., its customer properties. A `recipe` is an immutable entity that can only be read.
* A `coffee-machine` is a model of a real-world device. We must be able to retrieve the coffee machine's geographical location and the options it supports from this model (which will be discussed below).
2. Program execution control-level entities.
* A `program` describes a general execution plan for a coffee machine. Programs could only be read.
* The `programs/matcher` entity is capable of coupling a `recipe` and a `program`, which in fact means retrieving a dataset needed to prepare a specific recipe on a specific coffee machine.
* A `program` describes a general execution plan for a coffee machine. Programs can only be read.
* The `programs/matcher` entity couples a `recipe` and a `program`, which in fact means retrieving a dataset needed to prepare a specific recipe on a specific coffee machine.
* The `programs/run` entity describes a single fact of running a program on a coffee machine. A `run` might be:
* initialized (created)
* checked for its status
* canceled.
3. Runtime-level entities.
* A `runtime` describes a specific execution data context, i.e., the state of each variable. A `runtime` might be:
* A `runtime` describes a specific execution data context, i.e., the state of each variable. A `runtime` can be:
* initialized (created)
* checked for its status
* terminated.
If we look closely at the entities, we may notice that each entity turns out to be a composite. For example, a `program` will operate high-level data (`recipe` and `coffee-machine`), enhancing them with its subject area terms (`program_run_id` for instance). This is totally fine: connecting contexts is what APIs do.
If we look closely at the entities, we may notice that each entity turns out to be a composite. For example, a `program` operates high-level data (`recipe` and `coffee-machine`), enhancing them with its subject area terms (`program_run_id` for instance). This is totally fine as connecting contexts is what APIs do.
#### Use Case Scenarios
At this point, when our API is in general clearly outlined and drafted, we must put ourselves into the developer's shoes and try writing code. Our task is to look at the entity nomenclature and make some guesses regarding their future usage.
At this point, when our API is in general clearly outlined and drafted, we must put ourselves in the developer's shoes and try writing code. Our task is to look at the entity nomenclature and make some guesses regarding their future usage.
So, let us imagine we've got a task to write an app for ordering a coffee, based on our API. What code would we write?
So, let us imagine we've got a task to write an app for ordering coffee based on our API. What code would we write?
Obviously, the first step is offering a choice to a user, to make them point out what they want. And this very first step reveals that our API is quite inconvenient. There are no methods allowing for choosing something. Developers have to implement these steps:
Obviously, the first step is to offer a choice to the user, to make them point out what they want. And this very first step reveals that our API is quite inconvenient. There are no methods allowing for choosing something. Developers have to implement these steps:
* retrieve all possible recipes from the `GET /v1/recipes` endpoint;
* retrieve a list of all available coffee machines from the `GET /v1/coffee-machines` endpoint;
* write a code that traverses all this data.
* write code that traverses all this data.
If we try writing pseudocode, we will get something like that:
If we try writing pseudocode, we will get something like this:
```
// Retrieve all possible recipes
@ -106,14 +106,14 @@ POST /v1/offers/search
```
Here:
* an `offer` a marketing bid: on what conditions a user could have the requested coffee beverage (if specified in the request), or some kind of a marketing offer — prices for the most popular or interesting products (if no specific preference was set);
* a `place` — a spot (café, restaurant, street vending machine) where the coffee machine is located; we never introduced this entity before, but it's quite obvious that users need more convenient guidance to find a proper coffee machine than just geographical coordinates.
* An `offer` is a marketing bid: on what conditions a user could have the requested coffee beverage (if specified in the request), or some kind of marketing offer — prices for the most popular or interesting products (if no specific preference was set).
* A `place` is a spot (café, restaurant, street vending machine) where the coffee machine is located. We never introduced this entity before, but it's quite obvious that users need more convenient guidance to find a proper coffee machine than just geographical coordinates.
**NB**. We could have enriched the existing `/coffee-machines` endpoint instead of adding a new one. This decision, however, looks less semantically viable: coupling different modes of listing entities in one interface, by relevance and by order, is usually a bad idea because these two types of rankings imply different features and usage scenarios. Furthermore, enriching the search with “offers” pulls this functionality out of the `coffee-machines` namespace: the fact of getting offers to prepare specific beverages in specific conditions is a key feature to users, with specifying the coffee machine being just a part of an offer. And users actually rarely care about coffee machine models.
**NB**: we could have enriched the existing `/coffee-machines` endpoint instead of adding a new one. Although this decision looks less semantically viable, coupling different modes of listing entities in one interface, by relevance and by order, is usually a bad idea because these two types of rankings imply different features and usage scenarios. Furthermore, enriching the search with “offers” pulls this functionality out of the `coffee-machines` namespace: the fact of getting offers to prepare specific beverages in specific conditions is a key feature for users, with specifying the coffee machine being just a part of an offer. In reality, users rarely care about coffee machine models.
**NB**. Actually, having `coffee_machine_id` in the interface is to some extent violating the abstraction separation principle. It should be organized in a more complex way: coffee shops shall somehow map the incoming orders against available coffee machines, and only the type of the coffee machine (if a coffee shop really operates several of them) is something meaningful in the context of the order creation. However, we make it deliberately simplified by making a coffee machine selectable in the API to keep our API example readable enough.
**NB**: having `coffee_machine_id` in the interface is to some extent violating the abstraction separation principle. It should be organized in a more complex way: coffee shops should somehow map incoming orders against available coffee machines, and only the type of the coffee machine (if a coffee shop really operates several of them) is something meaningful in the context of order creation. However, we deliberately simplified our study by making a coffee machine selectable in the API to keep our API example readable.
Coming back to the code developers are writing, it would now look like that:
Coming back to the code developers write, it would now look like that:
```
// Searching for offers
@ -125,16 +125,17 @@ app.display(offers);
#### Helpers
Methods similar to the newly invented `offers/search` one are called *helpers*. The purpose they exist is to generalize known API usage scenarios and facilitate implementing them. By “facilitating” we mean not only reducing wordiness (getting rid of “boilerplates”) but also helping developers to avoid common problems and mistakes.
Methods similar to the newly invented `offers/search` one are called helpers. The purpose of their existence is to generalize known API usage scenarios and facilitate their implementation. By “facilitating,” we mean not only reducing wordiness (getting rid of “boilerplates”) but also helping developers avoid common problems and mistakes.
For instance, let's consider the problem of the monetary value of an order. Our search function returns some “offers” with prices. However, the price is volatile; coffee could cost less during “happy hours,” for example. Developers could make a mistake three times while implementing this functionality:
For instance, let's consider the order price question. Our search function returns some “offers” with prices. But “price” is volatile; coffee could cost less during “happy hours,” for example. Developers could make a mistake thrice while implementing this functionality:
* cache search results on a client device for too long (as a result, the price will always be outdated);
* contrary to the previous, call the search endpoint excessively just to actualize prices, thus overloading the network and the API servers;
* create an order with an invalid price (therefore deceiving a user, displaying one sum, and debiting another).
* contrary to the previous point, call the search endpoint excessively just to actualize prices, thus overloading the network and the API servers;
* create an order with an invalid price (thereby deceiving a user, displaying one sum, and debiting another).
To solve the third problem we could demand including the displayed price in the order creation request, and return an error if it differs from the actual one. (In fact, any API working with money *shall* do so.) But it isn't helping with the first two problems and deteriorates the user experience. Displaying the actual price is always a much more convenient behavior than displaying errors upon pressing the “place an order” button.
To solve the third problem we could demand that the displayed price be included in the order creation request and return an error if it differs from the actual one. (In fact, any API working with money *must* do so.) However, this solution does not help with the first two problems, and also deteriorates the user experience. Displaying the actual price is always a much more convenient behavior than displaying errors upon pressing the “place an order” button.
One solution is to provide a special identifier to an offer. This identifier must be specified in an order creation request.
One solution is to provide a special identifier to an offer. This identifier must be specified in an order creation request:
```
{
@ -156,13 +157,13 @@ One solution is to provide a special identifier to an offer. This identifier mus
"cursor"
}
```
By doing so we're not only helping developers to grasp the concept of getting the relevant price but also solving a UX task of telling users about “happy hours.”
By doing so we're not only helping developers grasp the concept of getting the relevant price but also solving a UX task of informing users about “happy hours.”
As an alternative, we could split endpoints: one for searching, and one for obtaining offers. This second endpoint would only be needed to actualize prices if needed.
As an alternative, we could split the endpoints: one for searching, and one for obtaining offers. The second endpoint would only be needed to actualize prices if necessary.
#### Error Handling
And one more step towards making developers' life easier: how an “invalid price” error would look like?
And one more step towards making developers' lives easier: what would an “invalid price” error look like?
```
POST /v1/orders
@ -171,19 +172,18 @@ POST /v1/orders
{ "message": "Invalid price" }
```
Formally speaking, this error response is enough: users get the “Invalid price” message, and they have to repeat the order. But from the UX point of view that would be a horrible decision: the user hasn't made any mistakes, and this message isn't helpful at all.
Formally speaking, this error response is sufficient: users get the “Invalid price” message, and they have to repeat the order. But from a UX point of view, this would be a terrible decision: the user hasn't made any mistakes, and this message isn't helpful at all.
The main rule of error interfaces in the APIs is that an error response must help a client to understand *what to do with this error*. An error response content must address the following questions:
The main rule of error interfaces in APIs is that an error response must help a client understand *what to do with the error*. An error response's content must address the following questions:
1. Which party is the problem's source: the client or the server?
HTTP APIs traditionally employ the `4xx` status codes to indicate client problems and `5xx` to indicate server problems (with the exception of the `404` code, which is an uncertainty status).
2. If the error is caused by a server, is there any sense to repeat the request? If yes, then when?
3. If the error is caused by a client, is it resolvable, or not?
The invalid price error is resolvable: a client could obtain a new price offer and create a new order with it. But if the error occurred because of a mistake in the client code, then eliminating the cause is impossible, and there is no need to make the user push the “place an order” button again: this request will never succeed.
**NB**: here and throughout we indicate resolvable problems with the `409 Conflict` code, and unresolvable ones with the `400 Bad Request` code.
4. If the error is resolvable then what's the kind of problem? Obviously, a client couldn't resolve a problem it's unaware of. For every resolvable problem, developers must *write some code* (reobtaining the offer in our case), so there must be a list of possible error reasons and the corresponding field in the error response.
5. If the same kind of errors arise because of different parameters being invalid then which parameter value is wrong exactly?
6. Finally, if some parameter value is unacceptable then what values are acceptable?
1. Which party is the source of the problem: the client or the server? For example, HTTP APIs traditionally employ the `4xx` status codes to indicate client problems and `5xx` to indicate server problems (with the exception of the `404` code, which is an uncertainty status).
2. If the error is caused by the server, is there any sense in repeating the request? If yes, then when?
3. If the error is caused by the client, is it resolvable or not?
For example, the invalid price error is resolvable: a client could obtain a new price offer and create a new order with it. But if the error occurred because of a mistake in the client code, then eliminating the cause is impossible, and there is no need to make the user press the “place an order” button again: this request will never succeed.
**NB**: here and throughout we indicate resolvable problems with the `409 Conflict` code and unresolvable ones with the `400 Bad Request` code.
4. If the error is resolvable then what kind of problem is it? Obviously, application engineers couldn't resolve a problem they are unaware of. For every resolvable problem, developers must *write some code* (re-obtaining the offer in our case), so there must be a list of possible error reasons and the corresponding fields in the error response to tell one problem from another.
5. If passing invalid values in different parameters arises the same kind of error, then how to learn which parameter value is wrong exactly?
6. Finally, if some parameter value is unacceptable, then what values are acceptable?
In our case, the price mismatch error should look like this:
@ -193,7 +193,7 @@ In our case, the price mismatch error should look like this:
// Error kind
"reason": "offer_invalid",
"localized_message":
"Something goes wrong.⮠
"Something went wrong.⮠
Try restarting the app."
"details": {
// What's wrong exactly?
@ -205,29 +205,29 @@ In our case, the price mismatch error should look like this:
}
```
After getting this error, a client is to check the error's kind (“some problem with the offer”), check the specific error reason (“order lifetime expired”), and send an offer retrieving request again. If the `checks_failed` field indicated another error reason (for example, the offer isn't bound to the specified user), client actions would be different (re-authorize the user, then get a new offer). If there was no error handler for this specific reason, a client should show the `localized_message` to the user, and invoke the standard error recovery procedure.
After receiving this error, a client should check the error's kind (“some problem with the offer”) and the specific error reason (“order lifetime expired”), and send the offer retrieval request again. If the `checks_failed` field indicated a different error reason (for example, the offer isn't bound to the specified user), client actions would be different (re-authorize the user, then get a new offer). If there was no error handler for this specific reason, a client should show the `localized_message` to the user and invoke the standard error recovery procedure.
It is also worth mentioning that unresolvable errors are useless to a user at the time when the error occurs (since the client couldn't react meaningfully to unknown errors), but it doesn't mean that providing extended error data is excessive. A developer will read it while fixing the issue in their code.
It is also worth mentioning that unresolvable errors are useless to a user at the time of the error occurrence (since the client couldn't react meaningfully to unknown errors). Still, providing extended error data is not excessive as a developer will read it while fixing the issue in their code.
#### Decomposing Interfaces. The “7±2” Rule
Out of our own API development experience, we can tell without any doubt that the greatest final interface design mistake (and the greatest developers' pain accordingly) is the excessive overloading of entities' interfaces with fields, methods, events, parameters, and other attributes.
From our own API development experience, we can tell without a doubt that the greatest final interface design mistake (and the greatest developer's pain accordingly) is the excessive overloading of entities' interfaces with fields, methods, events, parameters, and other attributes.
Meanwhile, there is the “Golden Rule” of interface design (applicable not only to APIs but almost to anything): humans could comfortably keep 7±2 entities in short-term memory. Manipulating a larger number of chunks complicates things for most humans. The rule is also known as the [Miller's law](https://en.wikipedia.org/wiki/Working_memory#Capacity).
Meanwhile, there is the “Golden Rule” of interface design (applicable not only to APIs but almost to anything): humans can comfortably keep 7±2 entities in short-term memory. Manipulating a larger number of chunks complicates things for most humans. The rule is also known as [Miller's Law](https://en.wikipedia.org/wiki/Working_memory#Capacity).
The only possible method of overcoming this law is decomposition. Entities should be grouped under a single designation at every concept level of the API, so developers are never to operate more than a reasonable amount of entities (let's say, ten) at a time.
The only possible method of overcoming this law is decomposition. Entities should be grouped under a single designation at every concept level of the API so that developers never have to operate on more than a reasonable amount of entities (let's say, ten) at a time.
Let's take a look at the coffee machine search function response in our API. To ensure an adequate UX of the app, quite bulky datasets are required:
```
{
"results": [{
// Coffee machine data
"coffee_machine_id",
"coffee_machine_type":
"drip_coffee_maker",
"coffee_machine_type",
"coffee_machine_brand",
// Place data
"place_name": "The Chamomile",
// Coordinates of a place
"place_location_latitude",
"place_location_longitude",
"place_open_now",
@ -236,38 +236,41 @@ Let's take a look at the coffee machine search function response in our API. To
"walking_distance",
"walking_time",
// How to find the place
"place_location_tip",
"location_tip",
// Offers
"offers": [{
"recipe": "lungo",
"recipe_name":
"Our brand new Lungo®™",
// Recipe data
"recipe",
"recipe_name",
"recipe_description",
"volume": "800ml",
// Order parameters
"volume",
// Offer data
"offer_id",
"offer_valid_until",
"price": "19.00",
"localized_price":
"Just $19 for a large coffee cup",
"price": "19.00",
"currency_code": "USD",
"estimated_waiting_time": "20s"
"currency_code",
"estimated_waiting_time"
}, …]
}, …]
}
```
This approach is regretfully quite usual and could be found in almost every API. As we see, the number of entities' fields exceeds recommended seven, and even nine. Fields are mixed into one single list, grouped by a common prefix.
This approach is regretfully quite common and could be found in almost every API. Fields are mixed into one single list and often prefixed to indicate the related ones.
In this situation, we are to split this structure into data domains: which fields are logically related to a single subject area. In our case we may identify at least 7 data clusters:
In this situation, we need to split this structure into data domains by grouping fields that are logically related to a single subject area. In our case, we may identify at least 7 data clusters:
* data regarding the place where the coffee machine is located;
* properties of the coffee machine itself;
* route data;
* recipe data;
* recipe options specific to the particular place;
* offer data;
* data regarding the place where the coffee machine is located
* properties of the coffee machine itself
* route data
* recipe data
* order options
* offer data
* pricing data.
Let's try to group it together:
Let's group them together:
```
{
@ -275,7 +278,9 @@ Let's try to group it together:
// Place data
"place": { "name", "location" },
// Coffee machine properties
"coffee-machine": { "id", "brand", "type" },
"coffee-machine": {
"id", "brand", "type"
},
// Route data
"route": {
"distance",
@ -289,7 +294,7 @@ Let's try to group it together:
"name",
"description"
},
// Recipe specific options
// Order options
"options":
{ "volume" },
// Offer metadata
@ -311,4 +316,4 @@ Such a decomposed API is much easier to read than a long list of different attri
It is important to say that readability is achieved not only by merely grouping the entities. Decomposing must be performed in such a manner that a developer, while reading the interface, instantly understands, “Here is the place description of no interest to me right now, no need to traverse deeper.” If the data fields needed to complete some action are scattered all over different composites, the readability doesn't improve and even degrades.
Proper decomposition also helps with extending and evolving an API. We'll discuss the subject in Section II.
Proper decomposition also helps with extending and evolving an API. We'll discuss the subject in Section III.

View File

@ -1 +1 @@
### Bidirectional Data Flows. Push and Poll Models
### [Bidirectional Data Flows. Push and Poll Models][api-patterns-push-vs-poll]

View File

@ -214,43 +214,41 @@ POST /v1/orders
Бороться с этим законом можно только одним способом: декомпозицией. На каждом уровне работы с вашим API нужно стремиться логически группировать сущности под одним именем там, где это возможно и таким образом, чтобы разработчику никогда не приходилось оперировать более чем 10 сущностями одновременно.
Рассмотрим простой пример: что должна возвращать функция поиска подходящей кофемашины. Для обеспечения хорошего UX приложения необходимо передать довольно значительные объёмы информации.
```
{
"results": [{
// Данные кофемашины
"coffee_machine_id",
// Тип кофемашины
"coffee_machine_type":
"drip_coffee_maker",
// Марка кофемашины
"coffee_machine_type",
"coffee_machine_brand",
// Название заведения
"place_name": "Кафе «Ромашка»",
// Координаты
// Данные кафе
"place_name": "The Chamomile",
"place_location_latitude",
"place_location_longitude",
// Флаг «открыто сейчас»
"place_open_now",
// Часы работы
"working_hours",
// Сколько идти: время и расстояние
// Как добраться
"walking_distance",
"walking_time",
// Как найти заведение и кофемашину
"place_location_tip",
// Как найти нужное место
"location_tip",
// Предложения
"offers": [{
"recipe": "lungo",
"recipe_name":
"Наш фирменный лунго®™",
// Данные рецепта
"recipe",
"recipe_name",
"recipe_description",
"volume": "800ml",
// Параметры заказа
"volume",
// Данные предложения
"offer_id",
"offer_valid_until",
"localized_price":
"Большая чашка⮠
всего за 19 баксов",
"price": "19.00",
"currency_code": "USD",
"estimated_waiting_time": "20s"
"localized_price":
"Just $19 for a large coffee cup",
"currency_code",
"estimated_waiting_time"
}, …]
}, …]
}
@ -263,7 +261,7 @@ POST /v1/orders
* данные о самой кофемашине;
* данные о пути до точки;
* данные о рецепте;
* особенности рецепта в конкретном заведении;
* опции приготовления заказа;
* данные о предложении;
* данные о цене.
@ -288,9 +286,7 @@ POST /v1/orders
// Рецепт
"recipe":
{ "id", "name", "description" },
// Данные относительно того,
// как рецепт готовят
// на конкретной кофемашине
// Опции заказа
"options":
{ "volume" },
// Метаданные предложения
@ -311,4 +307,4 @@ POST /v1/orders
Важно, что читабельность достигается не просто снижением количества сущностей на одном уровне. Декомпозиция должна производиться таким образом, чтобы разработчик при чтении интерфейса сразу понимал: так, вот здесь находится описание заведения, оно мне пока неинтересно и углубляться в эту ветку я пока не буду. Если перемешать данные, которые нужны в моменте одновременно для выполнения действия по разным композитам — это только ухудшит читабельность, а не улучшит.
Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чём мы поговорим в разделе II.
Дополнительно правильная декомпозиция поможет нам в решении задачи расширения и развития API, о чём мы поговорим в разделе III.

View File

@ -1 +1,101 @@
### [Двунаправленные потоки данных. Push и poll-модели][api-patterns-push-vs-poll]
### [Двунаправленные потоки данных. Push и poll-модели][api-patterns-push-vs-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, которые использовали бы эти технологии. Главными причинами такого положения дел нам видятся:
* бо́льшая критичность корректной и своевременной обработки событий, и отсюда повышенные требования к гарантиям их доставки;
* широкий выбор готовых компонентов для организации сервера webhook-ов (поскольку, фактически, это просто обычный веб-сервер);
* возможность описать такое взаимодействие спецификацией и использовать кодогенерацию.
Предположим, что в нашем кофейном примере партнёр располагает некоторым бэкендом, готовым принимать оповещения о новых заказах, поступивших в его кофейни. Решение этой задачи декомпозируется на несколько шагов:
##### Договоренность о контракте
В зависимости от важности партнёра для вашего бизнеса здесь возможны разные варианты:
* производитель 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. Если в общем случае качество работы API зависит в первую очередь от самого разработчика API, то в случае обратных вызовов всё в точности наоборот: качество работы интеграции напрямую зависит от того, как код webhook-эндпойнта написан партнёром. Мы можем столкнуться здесь с самыми различными видами проблем в партнёрском коде:
* false-positive ответы, когда сообщение не было обработано, но сервер партнёра тем не менее ошибочно вернул код успеха;
* и наоборот, возможны false-negative ответы, когда сообщение было обработано, но эндпойнт почему-то вернул ошибку;
* длительное время обработки запросов — возможно, настолько длительное, что сервер сообщений просто не будет успевать их отправить;
* ошибки в реализации идемпотентости, т.е. повторная обработка одного и того же сообщения партнёром может приводить к ошибкам или некорректности данных в системе партнёра;
* размер тела сообщение может превысить лимит, выставленный на веб-сервере партнёра;
* наконец, эндпойнт может быть просто недоступен по множеству разных причин, он проблем в дата-центре, где расположены сервера, до банальной человеческой ошибки при смене URL webhook-а.
Очевидно, вы никак не можете гарантировать, что партнёр не совершил какую-то из перечисленных ошибок. Но вы можете попытаться минимизировать возможный ущерб:
1. Состояние системы должно быть восстановимо. Даже если партнёр неправильно обработал сообщения, всегда должна быть возможность реабилитироваться и получить список последних событий и/или полное состояние системы, чтобы исправить случившиеся ошибки.
2. Помогите партнёру написать правильный код, зафиксировав в документации неочевидные моменты, с которыми могут быть незнакомы неопытные разработчики:
* ключи идемпотентности каждой операции;
* гарантии доставки (exactly once, at least once);
* будет ли сервер генерировать параллельные запросы к webhook-у;
* размеры полей и сообщений в байтах;
* политика перезапросов при получении ошибки.
3. Должна быть реализована система мониторинга состояния партнёрских эндпойнтов:
* при появлении большого числа ошибок (таймаутов) должно срабатывать оповещение (в т.ч. оповещение партнёра о проблеме), возможно, с несколькими уровнями эскалации;
* если в очереди скапливается большое количество необработанных событий, должен существовать механизм деградации (ограничения количества запросов в адрес партнёра — возможно в виде срезания спроса, т.е. частичного отказа в обслуживании конечных пользователей) и полного аварийного отключения партнёра.

View File

@ -1,345 +0,0 @@
### Списки и организация доступа к ним
В предыдущей главе мы пришли вот к такому интерфейсу, позволяющему минимизировать коллизии при создании заказов:
```
const pendingOrders = await api.
getOngoingOrders();
{ orders: [{
order_id: <идентификатор задания>,
status: "new"
}]}
```
Внимательный читатель может подметить, что этот интерфейс нарушает нашу же рекомендацию, данную в главе «Описание конечных интерфейсов»: количество возвращаемых данных в любом ответе должно быть ограничено, но в нашем интерфейсе отсутствуют какие-либо лимиты. Эта проблема существовала и в предыдущих версиях этого эндпойнта, но отказ от синхронного создания заказа её усугубил: операция создания задания должна работать максимально быстро, и, следовательно, почти все проверки лимитов мы должны проводить асинхронно — а значит, клиент потенциально может создать очень много заданий.
Исправить эту проблему достаточно просто — можно ввести лимит записей и параметры фильтрации и сортировки, например так:
```
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=<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"
}
```
Курсором в данной ситуации может представлять собой просто идентификатор последней записи (но тогда интерфейс получения новой порции данных должен будет требовать передачи всех параметров поиска, а не только курсора), а может содержать зашифрованное представление всех параметров поиска. Второе много удобнее потому, что тогда получение станицы данных через курсор полностью кэшируемо.
**NB**: в некоторых источниках перебор через курсор, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что:
* подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом;
* если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы;
* подход с курсором не означает, что `limit`/`offset` использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида `GET /items?cursor=…`, и на запросы вида `GET /items?offset=…&limit=…`;
* наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц.
В подходе с курсорами вы сможете без нарушения обратной совместимости добавлять новые фильтры и виды сортировки — при условии, конечно, что вы сможете организовать хранение данных таким образом, чтобы перебор с курсором работал однозначно.
```
// Инициализируем поиск
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"
}, …]
}
```
События иммутабельны, и их список только пополняется, следовательно, организовать перебор этого списка вполне возможно. Да, событие — это не то же самое, что и сам заказ: к моменту прочтения партнёром события, заказ уже давно может изменить статус. Но, тем не менее, мы предоставили возможность перебрать *все* новые заказы, пусть и не самым оптимальным образом.

View File

@ -1,100 +0,0 @@
### Двунаправленные потоки данных. 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. Должна быть реализована система мониторинга состояния партнёрских эндпойнтов:
* при появлении большого числа ошибок (таймаутов) должно срабатывать оповещение (в т.ч. оповещение партнёра о проблеме), возможно, с несколькими уровнями эскалации;
* если в очереди скапливается большое количество необработанных событий, должен существовать механизм деградации (ограничения количества запросов в адрес партнёра — возможно в виде срезания спроса, т.е. частичного отказа в обслуживании конечных пользователей) и полного аварийного отключения партнёра.