1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-31 22:09:37 +02:00

Асинхронность

This commit is contained in:
Sergey Konstantinov 2022-12-21 00:51:38 +02:00
parent e2e3b9d8bb
commit 32c51e5486
42 changed files with 459 additions and 282 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,5 @@
import { resolve as pathResolve } from 'path';
import templates from './src/templates.js';
//import { init, plugins } from '../The-Book-Builder/index.js';
import { init, plugins } from '@twirl/book-builder';
import { readFileSync, writeFileSync } from 'fs';
@ -48,7 +47,8 @@ langsToBuild.forEach((lang) => {
plugins.ast.imgSrcResolve,
plugins.ast.mermaid,
plugins.ast.ref,
plugins.ast.ghTableFix
plugins.ast.ghTableFix,
plugins.ast.stat
]
},
htmlSourceValidator: {
@ -72,6 +72,16 @@ langsToBuild.forEach((lang) => {
target,
pathResolve('docs', `${l10n[lang].file}.${lang}.${target}`)
);
console.log(
`Finished lang=${lang} target=${target}\n${Object.entries({
sources: 'Sources',
references: 'references',
words: 'words',
characters: 'characters'
})
.map(([k, v]) => `${v}: ${builder.structure[k]}`)
.join(', ')}`
);
} else {
const landingHtml = templates.landing({
structure: builder.structure,

View File

@ -5,7 +5,7 @@
"author": "Sergey Konstantinov <twirl-team@yandex.ru>",
"repository": "github.com:twirl/The-API-Book",
"devDependencies": {
"@twirl/book-builder": "0.0.17",
"@twirl/book-builder": "0.0.19",
"html-docx-js": "^0.3.1",
"nodemon": "^2.0.19",
"puppeteer": "^13.1.2"

View File

@ -249,7 +249,7 @@ if (
**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
Если протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
**Хорошо**
```

View File

@ -726,7 +726,7 @@ POST /v1/run/sql
Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. `urn:order:<uuid>` (или просто `order:<uuid>`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.
Отдельное важное следствие: **не используйте инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.
Отдельное важное следствие: **не используйте инкрементальные номера как внешние идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.
**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
@ -738,6 +738,8 @@ POST /v1/run/sql
Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.
**NB**: из этого пункта вытекает достаточно очевидное правило, которое, несмотря на его очевидность, умудряются нарушить все до одного разработчики популярных API — **всегда разделяйте эндпойнты разных семейств API**. Если вы предоставляете и серверное API, и сервисы для конечных пользователей, и виджеты для встраивания в сторонние приложения — эти API должны обслужиться с разных эндпойнтов для того, чтобы вы могли вводить разные меры безопасности (скажем, API-ключи, требование логина и капчу, соответственно).
##### Не предоставляйте endpoint-ов массового получения чувствительных данных
Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация.
@ -754,7 +756,7 @@ POST /v1/run/sql
Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».
**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.
**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примерах выше сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.
Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`.

View File

@ -4,7 +4,7 @@
Если мы говорим об API, особенно если конечным потребителем этих API является разработчик (интерфейсы фреймворков, операционных систем), классические паттерны проектирования вполне к ним применимы. И действительно, многие из описанных в предыдущем разделе примеров представляют собой применение того или иного паттерна.
Однако, если мы попытаемся обобщить этот опыт на разработку API в целом, то увидим, что большинство типичных проблем дизайна API являются более высокоуровневыми не сводятся к базовым паттернам разработки ПО. Скажем, проблемы кэширования ресурсов (и инвалидации кэша) или организация пагинации классиками не покрыты.
Однако, если мы попытаемся обобщить этот опыт на разработку API в целом, то увидим, что большинство типичных проблем дизайна API являются более высокоуровневыми и не сводятся к базовым паттернам разработки ПО. Скажем, проблемы кэширования ресурсов (и инвалидации кэша) или организация пагинации классиками не покрыты.
В рамках этого раздела мы попытаемся описать те задачи проектирования API и подходы к их решению, которые представляются нам наиболее важными. Мы не претендуем здесь на то, чтобы охватить *все* проблемы и тем более — все решения, и скорее фокусируемся на описании подходов к решению типовых задач с их достоинствами и недостатками. Мы понимаем, что читатель, знакомый с классическими трудами «банды четырёх», Гради Буча и Мартина Фаулера ожидает от раздела с названием «Паттерны API» большей системности и ширины охвата, и заранее просим у него прощения.

View File

@ -1,158 +1,11 @@
### Наблюдаемость и строгая консистентность
#### Принципы решения типовых проблем проектирования API
Хотя в этом разделе мы стараемся говорить об API в целом, включая совершенно «классические» API языков программирования и операционных систем, нас всё же интересует вполне конкретное *семейство задач* и связанных с ними проблем. Рассматривая API как мост, связывающий два разных программных контекста, мы в большинстве случаев ожидаем, что стороны каньона функционируют независимо и изолированно друг от друга — причём чем крупнее контексты и сложнее каналы передачи данных, тем более независимо и изолированно они работают.
Прежде, чем излагать сами паттерны, нам нужно понять, чем же разработка API отличается от разработки обычных приложений. Ниже мы сформулируем три важных принципа, на которые будем ссылаться в последующих главах.
Что это означает на практике? Два важных следствия.
Рассматривая API как мост, связывающий два разных программных контекста, мы в большинстве случаев ожидаем, что стороны каньона функционируют независимо и изолированно друг от друга — причём чем крупнее контексты и сложнее каналы передачи данных, тем более независимо и изолированно они работают. В частности:
1. Чем более распределена и многосоставна система, чем более общий канал связи используется для коммуникации — тем более вероятны ошибки в процессе взаимодействия. В частности, в наиболее интересном нам кейсе распределённых многослойных клиент-серверных систем возникновение исключения на клиенте (потеря контекста, т.е. перезапуск приложения), на сервере (конвейер выполнения запроса выбросил исключение на каком-то шаге), в канале связи (соединение полностью или частично потеряно) или любом промежуточном агенте (например, промежуточный веб-сервер не дождался ответа бэкенда и вернул ошибку гейтвея).
1. Чем более распределена и многосоставна система, чем более общий канал связи используется для коммуникации — тем более вероятны ошибки в процессе взаимодействия. В частности, в наиболее интересном нам кейсе распределённых многослойных клиент-серверных систем возникновение исключения на клиенте (потеря контекста, т.е. перезапуск приложения), на сервере (конвейер выполнения запроса выбросил исключение на каком-то шаге), в канале связи (соединение полностью или частично потеряно) или любом промежуточном агенте (например, промежуточный веб-сервер не дождался ответа бэкенда и вернул ошибку гейтвея) — норма жизни, и все системы должны проектироваться таким образом, что **в случае возникновения исключения любого рода клиенты API должны быть способны восстановить своё состояние** и продолжить корректно работать.
2. Чем больше различных партнёров подключено к API, тем больше вероятность того, что какие-то из предусмотренных вами механизмов обеспечения корректности взаимодействия будет имплементирован неправильно. Иными словами, вы должны ожидать не только физических ошибок, связанных с состоянием сети или перегруженностью сервера, но и логических, связанных с неправильным использованием API — а в худшем случае эти ошибки ещё и могут провоцировать отказ в обслуживании других партнёров.
2. Чем больше различных партнёров подключено к API, тем больше вероятность того, что какие-то из предусмотренных вами механизмов обеспечения корректности взаимодействия будет имплементирован неправильно. Иными словами, **вы должны ожидать не только физических ошибок, связанных с состоянием сети или перегруженностью сервера, но и логических, связанных с неправильным использованием API** (и, в частности, предотвращать возможный отказ в обслуживании одних партнёров из-за ошибок в коде других партнёров).
Представим, что конечный пользователь размещает заказ на приготовление кофе через наш API. Размещённый заказ показывается сотрудникам кофейни и, после их подтверждения, возвращается обратно.
```
// Создаёт заказ
const task = api.createOrder(…);
task.on('confirmed', (order) => {
// С объектом order можно
// производить дальшейшние действия
})
```
Пока этот запрос путешествует от клиента в кофейню и обратно, многое может произойти. Например, пользователь, не дождавшись результата операции, может выгрузить приложение — или оно может быть выгружено системой. Что произойдёт далее? Пользователь перезапустит приложение и, вполне логично, партнёрский код запросит текущие заказы:
```
const oders = await api
.getOngoingOrders();
```
…и ничего не получит! Заказ ещё не подтверждён, и в списке активных отсутствует. Пользователь вполне может решить, что операция не удалась — и создать его повторно, со всеми вытекающими проблемами, весьма неприятными с продуктовой точки зрения.
Поэтому первое универсальное правило разработки API выглядит следующим образом: **клиент должен всегда иметь возможность выяснить точно текущее состояние системы**, т.е. знать, какие операции выполняются сейчас от его имени. В переданном выше примере необходимо реализовать функциональность получения висящих неподтверждённых заказов — либо обогатив функцию `getOngoingOrders`, либо, если это невозможно, через отдельный эндпойнт.
Хотя правило выше и сформулировано как универсальное, как мы понимаем, абсолютной гарантии его исполнения достичь очень сложно. Фактически, это требование — последующие чтения ресурса возвращают его состояние с учётом всех предыдущих изменений — есть требование [строгой консистентности (*strict consistency*)](https://en.wikipedia.org/wiki/Consistency_model#Strict_consistency) в отношении API.
Но, как нетрудно убедиться, одной лишь строгой консистентности нам недостаточно, чтобы избежать повторного создания заказа. Рассмотрим следующую последовательность событий.
1. Клиент отправляет запрос на создание нового заказа.
2. Из-за сетевых проблем запрос идёт до сервера очень долго, а клиент получает таймаут:
* клиент, таким образом, не знает, был ли выполнен запрос или нет.
3. Клиент запрашивает текущее состояние системы и получает пустой ответ, поскольку таймаут случился раньше, чем запрос на создание заказа дошёл до сервера:
```
const pendingOrders = await
api.getOngoingOrders() // → []
```
4. Сервер, наконец, получает запрос на создание заказа и исполняет его.
5. Клиент, не зная об этом, создаёт заказ повторно.
Поскольку действия чтения списка актуальных заказов и создания нового заказа разнесены во времени, мы не можем гарантировать, что между этими запросами состояние системы не изменилось. Если же мы хотим такую гарантию дать, нам нужно обеспечить не только консистентность, но и какую-то из [стратегий синхронизации](https://en.wikipedia.org/wiki/Synchronization_(computer_science)). Если в случае, скажем, API операционных систем или клиентских фреймворков мы можем воспользоваться предоставляемыми платформой примитивами, то в кейсе распределённых сетевых API такой примитив нам придётся разработать самостоятельно.
Существуют два основных подхода к решению этой проблемы — пессимистичный (программная реализация блокировок) и оптимистичный (версионирование ресурсов).
##### Программные блокировки
Первый подход — очевидным образом перенести стандартные примитивы синхронизации на уровень API. Например,вот так:
```
let lock;
try {
// Захватываем право
// на эксклюзивное исполнение
// операции создания заказа
lock = await api.
acquireLock(ORDER_CREATION);
// Получаем текущий список
// заказов, известных системе
const pendingOrders = await
api.getPendingOrders();
// Если нашего заказа ещё нет,
// создаём его
const task = await api
.createOrder(…)
} catch (e) {
// Обработка ошибок
} finally {
// Разблокировка
await lock.release();
}
```
Думаем, излишне уточнять, что подобного рода подход крайне редко реализуется в распределённых сетевых API, из-за комплекса связанных проблем.
1. Сама по себе блокировка — это ещё одна сущность, которую каким-то образом нужно уметь строго консистентно создавать и возвращать.
2. Поскольку клиентская часть разрабатывается сторонними партнёрами, мы не можем гарантировать, что написанный ими код корректно работает с блокировками; неизбежно в системе появятся «висящие» блокировки, а, значит, придётся предоставлять партнёрам инструменты для отслеживания и отладки возникающих проблем.
3. Необходимо разработать достаточную гранулярность блокировок, чтобы партнёры не могли влиять на работоспособность друг друга. Хорошо, если мы можем ограничить блокировку, скажем, конкретным конечным пользователем в системе партнёра; но если этого сделать не получается (например, если система авторизации общая и все партнёры имеют доступ к одному и тому же профилю пользователя), то необходимо разрабатывать ещё более комплексные системы, которые будут исправлять потенциальные ошибки в коде партнёров — например, вводить квоты на блокировки.
##### Оптимистичное управление параллелизмом
Более щадящий вариант — это реализовать [оптимистичное управление параллелизмом](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) и потребовать от клиента передавать признак того, что он располагает актуальным состоянием разделяемого ресурса.
```
// Получаем состояние
const orderState =
await api.getOrderState();
// Частью состояния является
// версия ресурса
const version =
orderState.latestVersion;
// Заказ можно создать,
// только если версия состояния
// не изменилась с момента чтения
try {
const task = await api
.createOrder(version, …);
} catch (e) {
// Если версия неверна, т.е. состояние
// было параллельно изменено
// другим клиентом, произойдёт ошибка
if (Type(e) == INCORRECT_VERSION) {
// Которую нужно как-то обработать…
}
}
```
Достоинство этого подхода — его относительная простота (если мы уже гарантируем строгую консистентность, то иметь счётчик версий не составит труда). Недостаток — необходимость в клиентском коде предусмотреть обработку ошибки несовпадения версий (которая в случае каких-то проблем на стороне клиента или сервера может потенциально привести к бесконечному циклу попыток модификации разделяемого ресурса).
**NB**: вместо версий можно использовать дату последней модификации ресурса (не забывайте сохранять её с максимально доступной точностью!) либо идентификаторы сущности (ETag).
### Слабая консистентность
Если мы можем обеспечить и сильную консистентность, и удобное управление параллелизмом — это, конечно, сделает API очень удобным для разработчика. Увы, имплементация обоих концепций сопряжена с большими техническими трудностями, главная из которых — сложность горизонтального масштабирования таких систем.
В современных микросервисных архитектурах моделью по умолчанию скорее является [слабая консистентность](https://en.wikipedia.org/wiki/Eventual_consistency) (она же «событийная консистентность», «согласованность в конечном счёте»). Если мы отражаем в API некоторый процесс реального мира — заказа кофе, в частности — то в самом деле странно пытаться добиваться большей степени целостности, чем возможна без использования API. Клиент может озвучить заказ одному бариста и, не дожидаясь подтверждения, повторить его другому бариста в соседнем заведении — получив с некоторой вероятностью два одинаковых напитка. Никто не пытается организовать для всех бариста в мире один общий гроссбух, куда они записывают заказы ради сверки, не является ли повторный заказ ошибкой — зачем же пытаться организовать такой гроссбух программно?
Рассуждение выше верное, но лукавое. Программные интерфейсы, в отличие от большинства живых бариста, обладают способностью мультиплицировать ошибки. Допустим, клиентский код написан оптимистично, без всяких моделей разрешения проблем параллелизма, и, в случае ошибки создания заказа, проверяет текущее состояние и пересоздаёт заказ, если не найдёт его. Пока система работает нормально, и время синхронизации реплик БД много меньше типичного времени сетевого таймаута и последующего перезапроса, всё работает (почти) безошибочно. Но стоит случиться проблеме синхронизации (т.е. репликам БД отстать от основного узла на значительное время), как *все* перезапросы начнут создавать новый заказ!
Если мы не можем обеспечить строгую консистентность, то мы можем хотя бы облегчить разработчику задачу написания кода — так, чтобы понизить шансы допустить критическую ошибку. Важный паттерн, который поможет в этой ситуации — это имплементация модели [«read-your-writes»](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency), а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до read-your-writes можно, если предложить клиенту самому передать токен, описывающий его последние изменения.
```
const order = await api.
createOrder(…);
const pendingOrders = await api.
getOngoingOrders({
…,
// Передаём идентификатор
// последней операции
// совершённой клиентом
lastKnownOrderId: order.id
})
```
В качестве такого токена может выступать, например:
* идентификатор или идентификаторы последних модифицирующих операций, выполненных клиентом;
* последняя известная версия ресурса, если она существует;
* последняя известная клиенту дата модификации ресурса (если таковая получена с сервера).
Получив такой токен, сервер должен проверить, что ответ (список текущих операций, который он возвращает) соответствует токену, т.е. консистентность «в конечном счёте» сошлась. Если же она не сошлась (клиент передал дату модификации / версию / идентификатор последнего заказа новее, чем известна в данном узле сети), то сервер может реализовать одну из трёх стратегий (или их произвольную комбинацию):
* запросить данные из нижележащего БД или другого хранилища повторно;
* вернуть клиенту ошибку, индицирующую необходимость повторить запрос через некоторое время;
* обратиться к основной реплике БД, если таковая имеется, либо иным образом инициировать запрос мастер-данных из хранилища.
На первый взгляд может показаться, что имплементация этой модели помогает только в случае ненадёжного канала связи или задержек репликации на сервере, но не в случае ненадёжного клиента: если приложение было перезапущено и пытается восстановить состояние, оно не располагает токеном изменений и не может гарантировать получение свежих данных. Это действительно так, но не будем забывать, что наша цель состоит всё-таки в недопущении массовых проблем.
**Во-первых**, таким подходом мы купируем автоматическое пересоздание заказа: если были утрачены токены изменений, то и сведения о последнем созданном пользователем заказе тоже скорее всего утрачены. А, значит, пользователю придётся пройти полный путь создания заказа мануально, что занимает гораздо большее время, нежели простой программный перезапрос. За это время событийная консистентность должна разрешиться, и пользователь увидит свой заказ в системе. (Если, конечно, разработчик клиентского приложения реализовал его разумно.)
**Во-вторых**, вряд ли ошибки с перезапуском приложения могут массово произойти у значительного числа клиентов. Мультипликация проблемы по сценарию, описанному в начале главы, может случиться только в случае достаточно уникального стечения обстоятельств.
**NB**: на всякий случай напомним, что выбирать подходящий подход вы можете только в случае разработки новых API. Если вы уже предоставляете эндпойнт, реализующий какую-то модель консистентности, вы не можете понизить её уровень (в частности, сменить строгую консистентность на слабую), даже если вы никогда не документировали текущее поведение явно (см. главу «О ватерлинии айсберга»). И даже замена слабой консистентности на read-your-writes мало поможет, т.к. вам придётся объяснить всем партнёрам необходимость переписать код на новую схему взаимодействия, что растянет её адаптацию на месяцы и годы.
3. Любая из частей системы может вносить непредсказуемые задержки исполнения запросов, причём достаточно высокого — секунды, десятки секунд — порядка. Даже если вы полностью контролируете среду исполнения и сеть, задержку может вносить само клиентское приложение, которое может быть просто написано неоптимальным образом или же работать на слабом или перегруженном устройстве. Если выполнение какой-то задачи через API требует последовательного исполнения цепочки вызовов, то **необходимо предусматривать механизмы синхронизации промежуточного состояния**.

View File

@ -0,0 +1,91 @@
### Стратегии синхронизации
Начнём рассмотрение паттернов проектирования API с последнего из ограничений, описанных в предыдущей главе — необходимости синхронизировать состояния. Представим, что конечный пользователь размещает заказ на приготовление кофе через наш API. Пока этот запрос путешествует от клиента в кофейню и обратно, многое может произойти; например, рассмотрим следующую последовательность событий.
1. Клиент отправляет запрос на создание нового заказа.
2. Из-за сетевых проблем запрос идёт до сервера очень долго, а клиент получает таймаут:
* клиент, таким образом, не знает, был ли выполнен запрос или нет.
3. Клиент запрашивает текущее состояние системы и получает пустой ответ, поскольку таймаут случился раньше, чем запрос на создание заказа дошёл до сервера:
```
const pendingOrders = await
api.getOngoingOrders(); // → []
```
4. Сервер, наконец, получает запрос на создание заказа и исполняет его.
5. Клиент, не зная об этом, создаёт заказ повторно.
Поскольку действия чтения списка актуальных заказов и создания нового заказа разнесены во времени, мы не можем гарантировать, что между этими запросами состояние системы не изменилось. Если же мы хотим такую гарантию дать, нам нужно обеспечить какую-то из [стратегий синхронизации](https://en.wikipedia.org/wiki/Synchronization_(computer_science)). Если в случае, скажем, API операционных систем или клиентских фреймворков мы можем воспользоваться предоставляемыми платформой примитивами, то в кейсе распределённых сетевых API такой примитив нам придётся разработать самостоятельно.
Существуют два основных подхода к решению этой проблемы — пессимистичный (программная реализация блокировок) и оптимистичный (версионирование ресурсов).
**NB**. Вообще, лучший способ избежать проблемы — не иметь её вовсе. Если ваш API идемпотентен, то никакой повторной обработки запроса не будет происходить. Однако не все операции в реальном мире идемпотентны в принципе: например, создание нового заказа такой операцией не является. Мы можем добавлять механики, предотвращающие *автоматические* перезапросы (такие как, например, генерируемый клиентом токен идемпотентности), но не можем запретить пользователю просто взять и повторно создать точно такой же заказ.
##### Программные блокировки
Первый подход — очевидным образом перенести стандартные примитивы синхронизации на уровень API. Например,вот так:
```
let lock;
try {
// Захватываем право
// на эксклюзивное исполнение
// операции создания заказа
lock = await api.
acquireLock(ORDER_CREATION);
// Получаем текущий список
// заказов, известных системе
const pendingOrders = await
api.getPendingOrders();
// Если нашего заказа ещё нет,
// создаём его
const task = await api
.createOrder(…)
} catch (e) {
// Обработка ошибок
} finally {
// Разблокировка
await lock.release();
}
```
В примере выше предполагается, что чтение состояния ресурса происходит только после получения блокировки. Думаем, излишне уточнять, что подобного рода подход крайне редко реализуется в распределённых сетевых API, из-за комплекса связанных проблем.
1. Ожидание получения блокировки вносит во взаимодействие дополнительные плохо предсказуемые и, в худшем случае, весьма длительные задержки.
2. Сама по себе блокировка — это ещё одна сущность, для работы с которой нужно иметь отдельную весьма производительную подсистему, поскольку для работы блокировок требуется ещё и обеспечить сильную консистентность в API: метод `getPendingOrders` должен вернуть актуальное состояние системы, иначе повторный заказ всё равно будет создан.
3. Поскольку клиентская часть разрабатывается сторонними партнёрами, мы не можем гарантировать, что написанный ими код корректно работает с блокировками; неизбежно в системе появятся «висящие» блокировки, а, значит, придётся предоставлять партнёрам инструменты для отслеживания и отладки возникающих проблем.
4. Необходимо разработать достаточную гранулярность блокировок, чтобы партнёры не могли влиять на работоспособность друг друга. Хорошо, если мы можем ограничить блокировку, скажем, конкретным конечным пользователем в конкретной системе партнёра; но если этого сделать не получается (например, если система авторизации общая и все партнёры имеют доступ к одному и тому же профилю пользователя), то необходимо разрабатывать ещё более комплексные системы, которые будут исправлять потенциальные ошибки в коде партнёров — например, вводить квоты на блокировки.
##### Оптимистичное управление параллелизмом
Более щадящий с точки зрения сложности имплементации вариант — это реализовать [оптимистичное управление параллелизмом](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) и потребовать от клиента передавать признак того, что он располагает актуальным состоянием разделяемого ресурса.
```
// Получаем состояние
const orderState =
await api.getOrderState();
// Частью состояния является
// версия ресурса
const version =
orderState.latestVersion;
// Заказ можно создать,
// только если версия состояния
// не изменилась с момента чтения
try {
const task = await api
.createOrder(version, …);
} catch (e) {
// Если версия неверна, т.е. состояние
// было параллельно изменено
// другим клиентом, произойдёт ошибка
if (Type(e) == INCORRECT_VERSION) {
// Которую нужно как-то обработать…
}
}
```
**NB**: Внимательный читатель может возразить нам, что необходимость имплементировать стратегии синхронизации и строгую консистентность никуда не пропала, т.к. где-то в системе должен существовать компонент, осуществляющий блокирующее чтение версии с её последующим изменением. Это не совсем так: стратегии синхронизации и строгая консистентность *пропали из публичного API*. Расстояние между клиентом, устанавливающим блокировку, и сервером, её обрабатывающим, стало намного меньше, и всё взаимодействие теперь происходит в контролируемой среде (это вообще может быть одна подсистема, если мы используем [ACID-совместимую базу данных](https://en.wikipedia.org/wiki/ACID) или вовсе держим состояние ресурса в оперативной памяти).
Вместо версий можно использовать дату последней модификации ресурса (что в целом гораздо менее надёжно ввиду неидеальной синхронизации часов в разных узлах системы; не забывайте, как минимум, сохранять дату с максимально доступной точностью!) либо идентификаторы сущности (ETag).
Достоинством оптимистичного управления параллелизмом является, таким образом, возможность «спрятать» сложную в имплементации и масштабировании часть «под капотом». Недостаток же состоит в том, что ошибки версионирования теперь являются штатным поведением, и клиентам *придётся* написать правильную работу с ними, иначе их приложение может вообще оказаться неработоспособным — пользователь будет вечно пытаться создать заказ с неактуальной версией.
**NB**. Выбор ресурса, версию которого мы требуем передать для получения доступа, очень важен. Если в нашем примере мы заведём глобальную версию всей системы, которая изменяется при поступлении любого заказа, то, очевидно, у пользователя будут околонулевые шансы успешно разместить заказ.

View File

@ -0,0 +1,103 @@
### Слабая консистентность
Описанный в предыдущей главе подход фактически представляет собой размен производительности API на «нормальный» (т.е. ожидаемый) фон ошибок при работе с ним путём изоляции компонента, отвечающего за строгую консистентность и управление параллелизмом внутри системы. Тем не менее, его пропускная способность всё равно ограничена, и снизить её мы можем единственным образом — убрав строгую консистентность из внешнего API:
```
// Получаем состояние,
// возможно, из реплики
const orderState =
await api.getOrderState();
const version =
orderState.latestVersion;
try {
// Обработчик запроса на
// создание заказа прочитает
// актуальную версию
// из мастер-данных
const task = await api
.createOrder(version, …);
} catch (e) {
}
```
Т.к. заказы создаются намного реже, нежели читаются, мы можем существенно повысить производительность системы, если откажемся от гарантии возврата всегда самого актуального состояния ресурса из операции на чтение. Версионирование же поможет нам избежать проблем: создать заказ, не получив актуальной версии, невозможно. Фактически мы пришли к модели [событийной консистентности](https://en.wikipedia.org/wiki/Consistency_model#Eventual_consistency) (т.н. «согласованность в конечном счёте»): клиент сможет выполнить свой запрос *когда-нибудь*, когда получит, наконец, актуальные данные. В самом деле, согласованность в конечном счёте — скорее норма жизни для современных микросервисных архитектур, в которой может оказаться очень сложно как раз добиться обратного, т.е. строгой консистентности.
**NB**: на всякий случай напомним, что выбирать подходящий подход вы можете только в случае разработки новых API. Если вы уже предоставляете эндпойнт, реализующий какую-то модель консистентности, вы не можете понизить её уровень (в частности, сменить строгую консистентность на слабую), даже если вы никогда не документировали текущее поведение явно (см. главу «О ватерлинии айсберга»).
Однако просто замена сильной консистентности на слабую влечёт за собой другую проблему. Да, мы можем потребовать от партнёров дождаться получения последнего актуального состояния ресурса перед внесением изменений. Но очень неочевидно (и в самом деле неудобно) требовать от партнёров быть готовыми к тому, что они должны дождаться появления в том числе и тех изменений, которые сами же внесли.
```
// Создаёт заказ
const api = await api
.createOrder(…)
// Возвращает список заказов
const pendingOrders = await api.
getOngoingOrders(); // → []
// список пуст
```
Если мы не гарантируем сильную консистентность, то второй вызов может запросто вернуть пустой результат, ведь при чтении из реплики новый заказ мог просто до неё ещё не дойти.
Важный паттерн, который поможет в этой ситуации — это имплементация модели [«read-your-writes»](https://en.wikipedia.org/wiki/Consistency_model#Read-your-writes_consistency), а именно гарантии, что клиент всегда «видит» те изменения, которые сам же и внёс. Поднять уровень слабой консистентности до read-your-writes можно, если предложить клиенту самому передать токен, описывающий его последние изменения.
```
const order = await api
.createOrder(…);
const pendingOrders = await api.
getOngoingOrders({
…,
// Передаём идентификатор
// последней операции
// совершённой клиентом
lastKnownOrderId: order.id
})
```
В качестве такого токена может выступать, например:
* идентификатор или идентификаторы последних модифицирующих операций, выполненных клиентом;
* последняя известная клиенту версия ресурса (дата изменения, ETag).
Получив такой токен, сервер должен проверить, что ответ (список текущих операций, который он возвращает) соответствует токену, т.е. консистентность «в конечном счёте» сошлась. Если же она не сошлась (клиент передал дату модификации / версию / идентификатор последнего заказа новее, чем известна в данном узле сети), то сервер может реализовать одну из трёх стратегий (или их произвольную комбинацию):
* запросить данные из нижележащего БД или другого хранилища повторно;
* вернуть клиенту ошибку, индицирующую необходимость повторить запрос через некоторое время;
* обратиться к основной реплике БД, если таковая имеется, либо иным образом инициировать запрос мастер-данных из хранилища.
Достоинством этого подхода является лучшая масштабируемость сервиса — вы можете добавлять read-only реплики и вообще перейти к событийной консистентности на бэкенде. Недостатков же здесь два:
* вам всё ещё нужно выбрать между масштабируемостью системы и постоянным фоном ошибок;
* если при несовпадении версий клиента и сервера вы обращаетесь к мастер-реплике или перезапрашиваете данные, то увеличиваете нагрузку на хранилище сложно прогнозируемым образом;
* если же вы генерируете ошибку для клиента, то в вашей системе всегда будет достаточно заметный фон таких ошибок, и, к тому же, партнёрам придётся написать клиентский код для их обработки;
* этот подход вероятностный и спасает только в части ситуаций — о чём мы расскажем в следующей главе.
Учитывая, что клиентское приложение может быть перезапущено или просто потерять токен, наиболее правильное (хотя не всегда приемлемое с точки зрения нагрузки) поведение сервера при отсутствии токена в запросе — форсировать возврат актуальных мастер-данных.
#### Риски перехода к событийной консистентности
Прежде всего, давайте зафиксируем один важный тезис: все обсуждаемые в настоящем разделе техники решения архитектурных проблем — вероятностные. В силу ненадёжности компонентов системы мы не можем разработать её так, чтобы исключительные ситуации в ходе работы с API не возникали совсем — но мы можем постараться сделать так, чтобы при типичном профиле использования системы их стало меньше.
**NB**: оговорка про «типичный профиль важна»: API предполагает вариативность сценариев его применения, и вполне может оказаться так, что кейсы использования API делятся на несколько сильно отличающихся с точки зрения толерантности к ошибкам групп (классический пример — это клиентские API, где завершения операций ждёт реальный пользователь, и его вниманием надо дорожить против серверных API, где время исполнения само по себе менее важно, но может оказаться важным, например, массовый параллелизм операций). Если такое происходит — это сильный сигнал для того, чтобы выделить API для различных типовых сценариев в отдельные продукты в семействе API, как мы описывали это в главе «Линейка сервисов API».
Вернёмся к нашему примеру с заказом кофе и предположим, что мы реализуем следующую схему:
* оптимистичное управление синхронизацией (скажем, через идентификатор последнего заказа);
* «read-your-writes»-политика чтения списка заказов;
* если токен не передан, клиент всегда получает актуальное состояние.
Тогда получить ошибку синхронизации (отправленный клиентом токен-идентификатор не совпадает с актуальным состоянием системы) можно только в одном из двух случаев:
* клиент неверно обращается с данными, т.е. шлёт устаревший токен;
* клиент создаёт заказы одновременно с двух разных экземпляров приложения, которые не разделяют между собой состояние.
В первом случае речь идёт об ошибке имплементации приложения партнёра; второй случай означает, что клиент намеренно пытается проверить систему на прочность, что вряд ли можно рассматривать как частотный кейс (либо, например, у пользователя сел телефон и он очень быстро продолжает работу с приложением с планшета — согласитесь, маловероятное развитие событий.)
Что же произойдёт, если в целях улучшения масштабируемости системы мы откажемся от третьего условия, т.е. возврата мастер-данных клиенту, не передающему токен? У нас появится третья ситуация, когда клиент получит ошибку, а именно:
* клиентское приложение потеряло часть данных (токен синхронизации), и пробует повторить последний запрос.
**NB**: важно, что перезапрос может случить и по совершенно не техническим причинам: конечному пользователю может просто надоесть ждать, он вручную перезапустит приложение и вручную создаст повторный заказ. Полагаться только на технические предохранители в этом вопросе нельзя.
Математически вероятность создания заказа выражается довольно просто: она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. (Следует, правда, отметить, что клиентское приложение может быть реализовано так, что даст вам ещё меньше времени, если оно пытается повторить несозданный заказ автоматически при запуске). Итого условие, когда вы можете относительно без проблем переходить к событийной консистентности при получении состояния приложения выглядит так: это допустимо, **если вы можете обеспечить задержку полчения актуального состояния много меньшую, чем время перезапуска приложения на целевой платформе**. Если мы говорим о современных мобильных ОС и веб-браузерах, то время перезапуска измеряется в них в секундах. (Вы можете сделать проблему менее острой, если потребуете — или хотя бы порекомендуете — партнёрам не повторять автоматически запросы на неидемпотентные операции, если токен синхронизации отсутствует. Но имейте в виду — далеко не факт, что партнёры последуют этой рекомендации.)
Однако если мы говорим не о клиентских, а о серверных приложениях, здесь ситуация совершенно иная:
* с одной стороны, серверный код, как правило, гораздо толерантнее к повышенному времени получения ответа — в отличие от живого человека, он не будет нервничать и перезапускать приложение, если спиннер крутится субъективно слишком долго;
* с другой стороны, если сервер решает повторить запрос, он сделает это моментально — задержка может составлять миллисекунды.
Таким образом, в случае серверных API не предоставлять мастер-данные на старте приложения — очень плохая идея. Даже преднамеренное внесение задержек в продолжительность старта предпочтительнее, чем риск создания повторных перезаказов в случае сетевых проблем.

View File

@ -1,136 +1,86 @@
### Асинхронность
### Асинхронность и управление временем
Мы начнём с самого базового элемента API: какую бы задачу вы ни решали, вам необходимо уметь обрабатывать программные вызовы, которые клиенты совершают посредством API — неважно, сделаны ли они в виде HTTP-запросов или вызова функции в SDK — поскольку API это *программируемый* мост, связывающий разные контексты.
Предположим, всё-таки, что стоимость реализации получения актуальных мастер-данных велика (или мы не считаем риски создания двойного заказа слишком уж значительными), и мы всё же решили её не имплементировать. На старте приложение получает *какое-то* состояние системы, возможно, не самое актуальное. Каким образом мы можем всё же снизить вероятность коллизий?
Но при реализации этого базового элемента мы сразу же сталкиваемся с различными подходами к решению одной и той же задачи, и первый возникающий вопрос — это выбор между синхронным и асинхронным подходом к реализации обработчика запроса. В случае нашего кофейного API мы можем сделать так:
Напомним, что вероятность эта равна она равна отношению периода времени, требуемого для получения актуального состояния к типичному периоду времени, за который пользователь перезапускает приложение и повторяет заказ. Повлиять на знаменатель этой дроби мы практически не можем (если только не будем преднамеренно вносить задержку инициализации API, что мы всё же считаем крайней мерой). Обратимся теперь к числителю.
Наш сценарий использования, напомним, выглядит так:
```
// Ищет подходящие предложения
POST /v1/offers/search
{ … }
→ 200 OK
{
"results"
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(…);
}
```
А можем вот так:
```
// Создаёт задание по поиску
// подходящих предложений,
// и возращает идентификатор,
// по которому можно отслеживать
// прогресс поиска
POST /v1/offers/search
{ … }
→ 202 Accepted
{
"result": "accepted",
"task_id"
}
// Возвращает текущее состояние
// операции поиска
GET /v1/tasks/{task-id}
→ 200 OK
{
"status": "pending",
"progress": "87%"
}
// либо
{
"status": "completed",
"results"
}
```
Таким образом, мы стремимся минимизировать следующий временной интервал: сетевая задержка передачи команды `createOrder` + время выполнения `createOrder` + время пропагации изменений до реплик. Первое мы вновь не контролируем (но, по счастью, мы можем надеяться на то, что сетевые задержки в пределах сессии величина плюс-минус постоянная, и, таким образом, последующий вызов `getOngoingOrders` будет задержан примерно на ту же величину); третье, скорее всего, будет обеспечиваться инфраструктурой нашего бэкенда. Поговорим теперь о втором времени.
В первом случае клиент синхронно получает результат операции. Во втором случае клиент получает только идентификатор задачи, которую он может отслеживать.
Мы видим, что, если создание заказа само по себе происходит очень долго (здесь «очень долго» = «сопоставимо со временем запуска приложения»), то все наши усилия практически бесполезны. Клиент может устать ждать исполнения вызова `createOrder`, выгрузить приложение и послать второй (и более) `createOrder`. В наших интересах сделать так, чтобы этого не происходило.
В случае SDK или фреймворков постановка проблемы чуть сложнее, поскольку вариантов исполнения здесь три:
* синхронный
```
const results = api
.searchOffers(…);
```
* асинхронный
```
const results = await api
.searchOffers(…);
// либо
// const results = api
// .searchOffers(…, (results) => …)
```
* асинхронный с очередью задач:
```
const task = api
.scheduleSearchOffersTask(…);
task.on('ready', (results) => {
})
```
Но каким образом мы реально можем улучшить это время? Ведь создание заказа *действительно* может быть длительным — нам нужно выполнить множество проверок, а то и дождаться ответа платёжного шлюза.
Однако синхронный вариант (в котором поток исполнения полностью блокируется до завершения операции) сейчас скорее является анахронизмом — почти все современные языки программирования уходят либо в сторону полной асинхронности (через паттерн `async`/`await`, корутины, легковесные потоки исполнения и т.п.). В том числе и клиент-серверное взаимодействие не является исключением: если не брать в расчёт совсем архаичные технологии типа синхронного `XMLHttpRequest`, фреймворки работы с сетью являются неблокирующими, и клиентское приложение вполне может выполнять другие задачи, в т.ч. взаимодействовать с пользователем, ожидая «синхронного» ответа на HTTP-запрос.
Поэтому здесь и далее, когда мы говорим «(а)синхронное взаимодействие», мы говорим скорее о логическом состоянии потока исполнения: либо клиент просто ждёт завершения атомарной операции, не располагая идентификатором задачи, по которому можно было бы отслеживать прогресс (сигналом завершения операции является возврат управления функцией / получение ответа от сервера), либо такой идентификатор генерируется эндпойнтом условно «мгновенно» (т.е. время создания и возврата идентификатора задачи много меньше времени её исполнения).
С точки зрения будущего развития API асинхронные интерфейсы выглядят удобнее. Они имеют несколько важных достоинств:
* можно спокойно варьировать нижележащие технологии исполнения запросов в пользу различных техник отложенного исполнения, в том числе и как ограничитель нагрузки;
* можно добавлять метаданные к операции (как в примере выше — прогресс исполнения операции в процентах);
* можно прозрачным образом организовывать очереди исполнения (возможно, с приоритетами) и отложенные задачи.
Возникает естественный вопрос: почему не сделать все операции асинхронными, хотя бы просто на всякий случай? Однако далеко не всё, что кажется удобным вам как провайдеру API, будет удобно и вашим клиентам.
Хотя формально это редко оговаривается, но по умолчанию почти всюду предполагается, что синхронные операции выполняются «быстро», а асинхронные — потенциально «долго». **Писать код, работающий с «долгими» операциями намного сложнее**: в случае пользовательских приложений это означает необходимость каким-то образом индицировать пользователю выполнение задачи в фоне (при этом пользователь может совершать в приложении другие действия, и необходимо будет реализовать UX решение на случай, если выполнение асинхронной операции завершится в тот момент, когда пользователь находится на другом экране). В случае серверных интеграций «долгие» запросы к API означают, что работающий с ними код тоже будет работать в течение длительного времени с ненулевыми шансами быть прерванным по различным причинам — а, значит, если «долгие» запросы встроены в цепочку исполнения, то необходимо организовать сохранение промежуточных результатов, чтобы в случае повторного исполнения начинать с точки обрыва, а не с начала цепочки.
Подчеркнём, что сложность имплементации кроется в самом наличии «долгих» операций — неважно, являются ли они интерфейсно асинхронными или нет; однако, если «долгая» операция при этом ещё и синхронная, клиентский код становится ещё более комплексным.
В большинстве предметных областей, однако, «долгие» операции составляют меньшинство. Поэтому всюду асинхронный интерфейс (т.е. возврат идентификаторов заданий даже для «быстрых» операций) заставляет ваших потребителей писать много ненужного и достаточно сложного кода что, во-первых, повышает порог входа и, во-вторых, приведёт к многочисленным ошибкам реализации: разработать приложение, которое корректно обрабатывает все возникающие ситуации при работе пользователя с выполняемыми в фоне задачами, крайне непросто (и, скорее всего, партнёры всё равно будут просто блокировать интерфейс, ожидая исполнения асинхронной задачи).
**NB**: иногда можно встретить решение, при котором эндпойнт имеет двойной интерфейс и может вернуть как результат, так и ссылку на исполнение задания. Хотя для вас как разработчика API он может выглядеть логично (смогли «быстро» выполнить запрос, например, получить результат из кэша — вернули ответ; не смогли — вернули ссылку на задание), для партнёров это решение на самом деле ещё хуже, чем безусловная асинхронность, поскольку заставляет поддерживать две ветки кода одновременно. Также встречается парадигма предоставления на выбор разработчику два набора эндпойнтов, синхронный и асинхронный, но по факту это просто перекладывание ответственности на партнёра.
Может показаться, что вместо неявных концепций «быстро»/«долго» было бы разумнее просто указывать в документации допустимое время исполнения запросов; однако это отнюдь не «просто» — как правило, разработчики просто не могут его гарантировать, так как [системы реального времени](https://en.wikipedia.org/wiki/Real-time_computing) имеют очень малое распространение в прикладном программировании.
Таким образом, если суммировать вышесказанное, в отношении асинхронного выполнения запросов вы можете пользоваться одной из трёх стратегий.
| Стратегия | Достоинства | Недостатки и ограничения |
|-----------|-------------|--------------------------|
| Всегда синхронные запросы | Простота разработки клиентского кода | Вам необходимо гарантировать, что все запросы будут выполнены «быстро», и у вас нет способа без нарушения обратной совместимости увеличить время исполнения запроса свыше разумного предела |
| Смешанная: «быстрые запросы» синхронные, «долгие» асинхронные | Предсказуемость с точки зрения разработки клиентского кода и возможность обогащать «долгие» запросы метаданными | Необходимость заранее решить, каким образом разделить запросы на «быстрые» и «долгие», и невозможность легко перевести запрос из одной категории в другую |
| Полная асинхронность | Гибкость реализации и полная свобода в дальнейшем развитии функциональности | Сложный и потенциально полный ошибок клиентский код |
Вы, разумеется, можете адаптировать любую из стратегий. Наша рекомендация здесь (как, впрочем, и всегда) — **используйте асинхронность там, где это уместно, и делайте это явно**:
* асинхронными должны быть те операции, которые отражают длящиеся процессы в реальном мире;
* асинхронность должна привносить дополнительную функциональность: пероставлять дополнительные метаданные и/или методы работы с длящимися операциями;
* конвенция, какие методы асинхронны, должна быть явной и читаемой из сигнатур функций;
* желательно оперировать не абстрактными «идентификаторами заданий» (тем более — не выставлять наружу особенности имплементации вашего бэкенда), а семантичными данными.
Здесь нам на помощь приходят асинхронные вызовы. Если наша цель — уменьшить число коллизий, то нам нет никакой нужды дожидаться, когда заказ будет *действительно* создан; наша цель — максимально быстро распространить по репликам знание о том, что заказ *принят к созданию*. Мы можем поступить следующим образом: создавать не заказ, а задание на создание заказа, и возвращать его идентификатор.
```
// Запускает длительный
// процесс поиска подходящих
// предложений
POST /v1/offers/search/start
X-Idempotency-Token: <токен>
{ … }
{
"result": "accepted",
// Возвращаем не «ид задания»,
// а идентификатор поисковой
// сессии
"search_session_id"
const pendingOrders = await api.
getOngoingOrders();
if (pendingOrder.length == 0) {
const task = await api
.putOrderCreationTask(…);
}
// Возвращает текущее состояние
// операции поиска
GET /v1/search-sessions/{id}
{
"status": "executing",
"progress": "87%",
"eta": "20s"
}
// Длительную операцию можно,
// например, отменить
POST /v1/search-sessions/{id}/cancel
// Здесь происходит крэш приложения,
// и те же операции выполняются
// повторно
const pendingOrders = await api.
getOngoingOrders();
// → { tasks: [task] }
```
**NB**: понимание того, что такое «быстро» и «долго» очень сильно зависит от предметной области. Для пользовательских интерфейсов задержки больше 100 миллисекунд уже считаются заметными, а больше секунды — оказывающими прямое влияние на восприятие качества приложения и как следствие на бизнес-KPI (отсылаем здесь читателя к трудам Стива Саудерса, и в частности к книге «[Even Faster Websites](https://www.amazon.com/Even-Faster-Web-Sites-Performance/dp/0596522304)»). Для серверных взаимодействий цифра существенно выше — типичные периоды, после которых наступает таймаут соединения, составляют десятки секунд, хотя предоставление эндпойнтов, обрабатывающих запрос более нескольких секунд, считается сегодня скорее плохим тоном.
Здесь мы предполагаем, что создание задания требует минимальных проверок и не ожидает исполнения каких-то длительных операций, а потому происходит много быстрее. Кроме того, саму эту операцию — создание асинхронного задания — мы можем поручить отдельному сервису абстрактных заданий в составе бэкенда, вообще никак не связанному с циклом обработки заказа. Между тем, имея функциональность создания заданий и получения списка текущих заданий, мы значительно уменьшаем «серые зоны» состояния неопределённости, когда клиент не может узнать текущее состояние сервера точно.
Таким образом, мы естественным образом приходим к паттерну организации асинхронного API через очереди заданий. Применяться он может не только для устранения периодов неопределённости, но и для решения других прикладных задач:
* организация ссылок на результаты операции и их кэширование (предполагается, что, если клиенту необходимо снова прочитать результат операции или же поделиться им с другим агентом, он может использовать для этого идентификатор задания);
* обеспечение идемпотентности операций (для этого необходимо ввести подтверждение задания, и мы фактически получим схему с черновиками операции, описанную в Главе 5);
* нативное же обеспечение устойчивости к временному всплеску нагрузки на сервис — новые задачи встают в очередь, фактически имплементируя [«маркерное ведро»](https://en.wikipedia.org/wiki/Token_bucket);
* организация взаимодействия в тех случаях, когда время исполнения операции превышает разумные значения (в случае сетевых API — типичное время срабатывания сетевых таймаутов, т.е. десятки секунд) либо является непредсказуемым.
**NB**. Мы используем здесь термин «асинхронность» логически — подразумевая отсутствие взаимных *логических* блокировок: посылающая сторона получает ответ на свой запрос сразу, не дожидаясь окончания исполнения запрошенной функциональности, и может продолжать взаимодействие с API, пока операция выполняется. При этом технически в современных системах блокировки клиента (и сервера) почти всегда не происходит и при обращении к синхронным эндпойнтам — однако логически продолжать работать с API, не дождавшись ответа на синхронный запрос, может быть чревато коллизиями подобно описанным выше.
Популярность данного паттерна также обусловлена тем, что многие современные микросервисные архитектуры «под капотом» также взаимодействуют асинхронно — либо через потоки событий, либо через асинхронную постановку заданий же. Имплементация аналогичной асинхронности во внешнем API является самым простым способом обойти возникающие проблемы (читай, те же непредсказуемые и возможно очень большие задержки выполнения операций). Доходит до того, что в некоторых API абсолютно все операции делаются асинхронными (включая чтение данных), даже если никакой необходимости в этом нет.
Мы, однако, не можем не отметить, что, несмотря на свою привлекательность, повсеместная асинхронность влечёт за собой ряд достаточно неприятных проблем.
1. Организация отдельных очередей заданий для каждого эндпойнта или каждого клиента — технически сложная задача; если же потребовать и того, и другого (отдельная очередь для каждой уникальной пары эндпойнт-клиент), то она становится близка к невозможной; как правило разные эндпойнты и/или клиенты делят одни и те же очереди (или по крайней мере одни и те же вычислительные ресурсы), что означает взаимовлияние и создание единой точки отказа: повышение нагрузки на конкретный эндпойнт или конкретным партнером приведёт к замедлению работы для всех.
2. Написание кода для партнёра становится гораздо сложнее. Дело даже не в физическом объёме кода (в конце концов, создание общего компонента взаимодействия с очередью заданий — не такая уж и сложная задача), а в том, что теперь в отношении каждого вызова разработчик должен поставить себе вопрос: что произойдёт, если его обработка займёт длительное время. Если в случае с синхронными эндпойнтами мы по умолчанию полагаем, что они отрабатывают за какое-то разумное время, меньшее, чем типичный таймаут запросов, то в случае асинхронных эндпойнтов такой гарантии у нас не просто нет — она не может быть дана.
3. Как следствие предыдущего пункта, возникает вопрос осмысленности SLA такого сервиса. Через асинхронные задачи всегда можно добиться 100% аптайма API — просто некоторые запросы будут выполнены через пару недель. Но такие гарантии пользователям вашего 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"
}]} */
```

View File

@ -0,0 +1,136 @@
### Асинхронность
Мы начнём с самого базового элемента API: какую бы задачу вы ни решали, вам необходимо уметь обрабатывать программные вызовы, которые клиенты совершают посредством API — неважно, сделаны ли они в виде HTTP-запросов или вызова функции в SDK — поскольку API это *программируемый* мост, связывающий разные контексты.
Но при реализации этого базового элемента мы сразу же сталкиваемся с различными подходами к решению одной и той же задачи, и первый возникающий вопрос — это выбор между синхронным и асинхронным подходом к реализации обработчика запроса. В случае нашего кофейного API мы можем сделать так:
```
// Ищет подходящие предложения
POST /v1/offers/search
{ … }
→ 200 OK
{
"results"
}
```
А можем вот так:
```
// Создаёт задание по поиску
// подходящих предложений,
// и возращает идентификатор,
// по которому можно отслеживать
// прогресс поиска
POST /v1/offers/search
{ … }
→ 202 Accepted
{
"result": "accepted",
"task_id"
}
// Возвращает текущее состояние
// операции поиска
GET /v1/tasks/{task-id}
→ 200 OK
{
"status": "pending",
"progress": "87%"
}
// либо
{
"status": "completed",
"results"
}
```
В первом случае клиент синхронно получает результат операции. Во втором случае клиент получает только идентификатор задачи, которую он может отслеживать.
В случае SDK или фреймворков постановка проблемы чуть сложнее, поскольку вариантов исполнения здесь три:
* синхронный
```
const results = api
.searchOffers(…);
```
* асинхронный
```
const results = await api
.searchOffers(…);
// либо
// const results = api
// .searchOffers(…, (results) => …)
```
* асинхронный с очередью задач:
```
const task = api
.scheduleSearchOffersTask(…);
task.on('ready', (results) => {
})
```
Однако синхронный вариант (в котором поток исполнения полностью блокируется до завершения операции) сейчас скорее является анахронизмом — почти все современные языки программирования уходят либо в сторону полной асинхронности (через паттерн `async`/`await`, корутины, легковесные потоки исполнения и т.п.). В том числе и клиент-серверное взаимодействие не является исключением: если не брать в расчёт совсем архаичные технологии типа синхронного `XMLHttpRequest`, фреймворки работы с сетью являются неблокирующими, и клиентское приложение вполне может выполнять другие задачи, в т.ч. взаимодействовать с пользователем, ожидая «синхронного» ответа на HTTP-запрос.
Поэтому здесь и далее, когда мы говорим «(а)синхронное взаимодействие», мы говорим скорее о логическом состоянии потока исполнения: либо клиент просто ждёт завершения атомарной операции, не располагая идентификатором задачи, по которому можно было бы отслеживать прогресс (сигналом завершения операции является возврат управления функцией / получение ответа от сервера), либо такой идентификатор генерируется эндпойнтом условно «мгновенно» (т.е. время создания и возврата идентификатора задачи много меньше времени её исполнения).
С точки зрения будущего развития API асинхронные интерфейсы выглядят удобнее. Они имеют несколько важных достоинств:
* можно спокойно варьировать нижележащие технологии исполнения запросов в пользу различных техник отложенного исполнения, в том числе и как ограничитель нагрузки;
* можно добавлять метаданные к операции (как в примере выше — прогресс исполнения операции в процентах);
* можно прозрачным образом организовывать очереди исполнения (возможно, с приоритетами) и отложенные задачи.
Возникает естественный вопрос: почему не сделать все операции асинхронными, хотя бы просто на всякий случай? Однако далеко не всё, что кажется удобным вам как провайдеру API, будет удобно и вашим клиентам.
Хотя формально это редко оговаривается, но по умолчанию почти всюду предполагается, что синхронные операции выполняются «быстро», а асинхронные — потенциально «долго». **Писать код, работающий с «долгими» операциями намного сложнее**: в случае пользовательских приложений это означает необходимость каким-то образом индицировать пользователю выполнение задачи в фоне (при этом пользователь может совершать в приложении другие действия, и необходимо будет реализовать UX решение на случай, если выполнение асинхронной операции завершится в тот момент, когда пользователь находится на другом экране). В случае серверных интеграций «долгие» запросы к API означают, что работающий с ними код тоже будет работать в течение длительного времени с ненулевыми шансами быть прерванным по различным причинам — а, значит, если «долгие» запросы встроены в цепочку исполнения, то необходимо организовать сохранение промежуточных результатов, чтобы в случае повторного исполнения начинать с точки обрыва, а не с начала цепочки.
Подчеркнём, что сложность имплементации кроется в самом наличии «долгих» операций — неважно, являются ли они интерфейсно асинхронными или нет; однако, если «долгая» операция при этом ещё и синхронная, клиентский код становится ещё более комплексным.
В большинстве предметных областей, однако, «долгие» операции составляют меньшинство. Поэтому всюду асинхронный интерфейс (т.е. возврат идентификаторов заданий даже для «быстрых» операций) заставляет ваших потребителей писать много ненужного и достаточно сложного кода что, во-первых, повышает порог входа и, во-вторых, приведёт к многочисленным ошибкам реализации: разработать приложение, которое корректно обрабатывает все возникающие ситуации при работе пользователя с выполняемыми в фоне задачами, крайне непросто (и, скорее всего, партнёры всё равно будут просто блокировать интерфейс, ожидая исполнения асинхронной задачи).
**NB**: иногда можно встретить решение, при котором эндпойнт имеет двойной интерфейс и может вернуть как результат, так и ссылку на исполнение задания. Хотя для вас как разработчика API он может выглядеть логично (смогли «быстро» выполнить запрос, например, получить результат из кэша — вернули ответ; не смогли — вернули ссылку на задание), для партнёров это решение на самом деле ещё хуже, чем безусловная асинхронность, поскольку заставляет поддерживать две ветки кода одновременно. Также встречается парадигма предоставления на выбор разработчику два набора эндпойнтов, синхронный и асинхронный, но по факту это просто перекладывание ответственности на партнёра.
Может показаться, что вместо неявных концепций «быстро»/«долго» было бы разумнее просто указывать в документации допустимое время исполнения запросов; однако это отнюдь не «просто» — как правило, разработчики просто не могут его гарантировать, так как [системы реального времени](https://en.wikipedia.org/wiki/Real-time_computing) имеют очень малое распространение в прикладном программировании.
Таким образом, если суммировать вышесказанное, в отношении асинхронного выполнения запросов вы можете пользоваться одной из трёх стратегий.
| Стратегия | Достоинства | Недостатки и ограничения |
|-----------|-------------|--------------------------|
| Всегда синхронные запросы | Простота разработки клиентского кода | Вам необходимо гарантировать, что все запросы будут выполнены «быстро», и у вас нет способа без нарушения обратной совместимости увеличить время исполнения запроса свыше разумного предела |
| Смешанная: «быстрые запросы» синхронные, «долгие» асинхронные | Предсказуемость с точки зрения разработки клиентского кода и возможность обогащать «долгие» запросы метаданными | Необходимость заранее решить, каким образом разделить запросы на «быстрые» и «долгие», и невозможность легко перевести запрос из одной категории в другую |
| Полная асинхронность | Гибкость реализации и полная свобода в дальнейшем развитии функциональности | Сложный и потенциально полный ошибок клиентский код |
Вы, разумеется, можете адаптировать любую из стратегий. Наша рекомендация здесь (как, впрочем, и всегда) — **используйте асинхронность там, где это уместно, и делайте это явно**:
* асинхронными должны быть те операции, которые отражают длящиеся процессы в реальном мире;
* асинхронность должна привносить дополнительную функциональность: пероставлять дополнительные метаданные и/или методы работы с длящимися операциями;
* конвенция, какие методы асинхронны, должна быть явной и читаемой из сигнатур функций;
* желательно оперировать не абстрактными «идентификаторами заданий» (тем более — не выставлять наружу особенности имплементации вашего бэкенда), а семантичными данными.
```
// Запускает длительный
// процесс поиска подходящих
// предложений
POST /v1/offers/search/start
X-Idempotency-Token: <токен>
{ … }
{
"result": "accepted",
// Возвращаем не «ид задания»,
// а идентификатор поисковой
// сессии
"search_session_id"
}
// Возвращает текущее состояние
// операции поиска
GET /v1/search-sessions/{id}
{
"status": "executing",
"progress": "87%",
"eta": "20s"
}
// Длительную операцию можно,
// например, отменить
POST /v1/search-sessions/{id}/cancel
```
**NB**: понимание того, что такое «быстро» и «долго» очень сильно зависит от предметной области. Для пользовательских интерфейсов задержки больше 100 миллисекунд уже считаются заметными, а больше секунды — оказывающими прямое влияние на восприятие качества приложения и как следствие на бизнес-KPI (отсылаем здесь читателя к трудам Стива Саудерса, и в частности к книге «[Even Faster Websites](https://www.amazon.com/Even-Faster-Web-Sites-Performance/dp/0596522304)»). Для серверных взаимодействий цифра существенно выше — типичные периоды, после которых наступает таймаут соединения, составляют десятки секунд, хотя предоставление эндпойнтов, обрабатывающих запрос более нескольких секунд, считается сегодня скорее плохим тоном.