mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-06-06 22:16:15 +02:00
консистентность
This commit is contained in:
parent
c2f1901461
commit
e2e3b9d8bb
15
src/ru/drafts/01-Введение/01.md
Normal file
15
src/ru/drafts/01-Введение/01.md
Normal file
@ -0,0 +1,15 @@
|
||||
### Об этой книге
|
||||
|
||||
Книга, которую вы держите в руках, посвящена разработке API как отдельной инженерной задаче. Хотя многое из того, о чём мы будем говорить, применимо к написанию любых программ, наша цель состояла прежде всего в описании тех проблем и подходов к их решению, которые характерны именно для разработки API.
|
||||
|
||||
Мы ожидаем, что читатель обладает навыками разработки программного обеспечения, и не даём подробных определений и объяснений в отношении понятий, которыми разработчик должен по нашему мнению владеть. Без такого рода навыков читать последнюю главу вам будет достаточно некомфортно (а остальные главы — скорее всего, невозможно) — за что мы искренне просим прощения, но не видим другого способа написать эту книгу, не раздув её объём ещё в три раза.
|
||||
|
||||
Настоящая книга состоит из введения и шести больших разделов. Первые два из них («Проектирование API» и «Обратная совместимость») являются полностью абстрактными и не привязанными ни к каким конкретным технологиям — мы рассчитываем, что они окажутся полезными тем читателям, которые хотят выстроить системное понимание того, что такое архитектура API и как разрабатывать сложные иерархии интерфейсов. Предложенный подход, как мы надеемся, помогает выстроить API сверху вниз, от сырой идеи до конкретной реализации.
|
||||
|
||||
Третий раздел («Паттерны проектирования API») является более практическим и содержит описания типичных проблем, возникающих при разработке API, и предлагает типовые решения для них.
|
||||
|
||||
Четвёртый и пятый раздел посвящены вполне конкретным технологиям — разработке HTTP API («REST») и SDK (в основном речь пойдёт о библиотеках визуальных компонент).
|
||||
|
||||
Наконец, шестой раздел, наименее технический из всех, посвящён API как продукту и фокусируется на всех не-разработческих аспектах существования API: изучение рынка, позиционирование сервиса, взаимодействие с потребителями, KPI команды и так далее. Мы настаиваем здесь, что последний раздел одинаково важен и для менеджеров, и для программистов, поскольку технические продукты можно развивать только в тесном взаимодействии продуктовой и технической команд.
|
||||
|
||||
На этом переходим к делу.
|
@ -1,11 +0,0 @@
|
||||
### О паттернах проектирования в контексте API
|
||||
|
||||
Термин [«паттерны»](https://en.wikipedia.org/wiki/Software_design_pattern#History) применительно к разработке программного обеспечения был введён Кентом Бэком и Уордом Каннингемом в 1987 году, и популяризирован «бандой четырёх» (Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес) в их книге «Приёмы объектно-ориентированного проектирования. Паттерны проектирования», изданной в 1994 году. Согласно общепринятому определению, паттерны программирования — «повторяемая архитектурная конструкция, представляющая собой решение проблемы проектирования в рамках некоторого часто возникающего контекста».
|
||||
|
||||
Если мы говорим об API, особенно если их конечным потребителем является разработчик (API фреймворков, операционных систем), классические паттерны проектирования вполне к ним применимы. И действительно, многие описанные в предыдущем разделе примеры представляют собой применение того или иного паттерна.
|
||||
|
||||
Однако, если мы подойдём с другой стороны и попытаемся поговорить о частых проблемах проектирования API, то увидим, что многие из них не сводятся к базовым паттернам разработки ПО. Скажем, проблемы кэширования ресурсов (и инвалидации кэша) или организация асинхронного взаимодействия классиками не покрыты.
|
||||
|
||||
В рамках этого раздела мы попытаемся описать те задачи проектирования API и подходы к их решению, которые представляются нам наиболее важными. Мы понимаем, что читатель, знакомый с классическими трудами «банды четырёх», Гради Буча и Мартина Фаулера ожидает от раздела с названием «Паттерны API» большей глубины и ширины охвата, и заранее просим у него прощения.
|
||||
|
||||
**NB**: первый из подобных паттернов — это API-first подход к разработке ПО, который мы описали во введении.
|
@ -1,25 +0,0 @@
|
||||
### API-first подход с технической точки зрения
|
||||
|
||||
Первый и самый важный паттерн разработки API — это концепция «API-first», т.е. собственно парадигма разработки ПО, в которой главной составляющей является разработка API.
|
||||
|
||||
Следует, однако, различать API-first подход в продуктовом и техническом смысле. Первое означает, что при разработке некоторого сервиса сначала как первый (и иногда единственный) шаг разрабатывается API к нему, и мы обсудим этот подход в разделе «API как продукт».
|
||||
|
||||
Если же мы говорим об API-first подходе в техническом смысле, то мы имеем в виду следующее: **контракт, т.е. обязательство связывать программные контексты, предшествует реализации и определяет её**. Конкретнее, речь идёт о двух принципах:
|
||||
* контракт разрабатывается и фиксируется в виде спецификации до того, как функциональность непосредственно реализована;
|
||||
* если обнаруживается несоответствие контракта и его имплементации, изменения вносятся в имплементацию, а не в контракт.
|
||||
|
||||
Оба этих принципа фактически утверждают приоритет интересов партнёра-разработчика API:
|
||||
* первый принцип позволяет партнёру разрабатывать код по формальной спецификации, не требуя согласования с провайдером API;
|
||||
* появляется возможность использовать генерацию кода по спецификации, что может существенно упрощать и автоматизировать разработку;
|
||||
* партнёрский код может быть написан вообще в отсутствие доступа к API;
|
||||
* второй принцип позволяет не требовать изменений в коде партнёра, если обнаружен ошибка в реализации API.
|
||||
|
||||
Таким образом, API-first подход — это некоторая гарантия для ваших потребителей. Но, как легко заметить, работает эта гарантия только в условиях качественного дизайна API: если в фазе разработки спецификации были допущены неисправимые ошибки, то второй принцип придётся нарушить.
|
||||
|
||||
#### Обратная совместимость на уровне спецификаций
|
||||
|
||||
Другая возникающая проблема при адаптации API-first подхода — появление гранулярности понятия «обратная совместимость», поскольку теперь в системе помимо двух связываемых контекстов появляется ещё спецификация и кодогенерация по ней. Становится возможным нарушить обратную совместимость, не нарушая спецификации (например, изменив строгую консистентность на слабую) — и, напротив, несовместимо изменить спецификацию, не затронув никак существующие интеграции (например, изменив `additionalProperties` с `false` на `true` в OpenAPI).
|
||||
|
||||
Вообще вопрос того, являются ли две версии спецификации обратно совместимыми — относится скорее к серой зоне, поскольку в самих стандартах спецификаций такое понятие не определено. Из общих соображений, утверждение «изменение спецификации является обратно-совместимым» тождественно утверждению «любой клиентский код, написанный или сгенерированный по этой спецификации, продолжит работать функционально корректно после релиза сервера, соответствующего обновлённой версии спецификации», однако в практическом смысле следовать этому определению достаточно тяжело. Изучить поведение всех мыслимых генераторов кода по спецификациям крайне трудоёмко (в частности, очень сложно предсказать, переживёт ли сгенерированный код упомянутое выше изменение `additionaProperties` на `true` с последующей передачей дополнительных полей).
|
||||
|
||||
Мы здесь склонны советовать придерживаться разумного подхода, а именно — не использовать потенциально проблемные с точки зрения обратной совместимости возможности (включая упомянутый `additionalProperties: false`) и при оценке совместимости изменений исходить из соображения, что сгенерированный по спецификации код ведёт себя так же, как и написанный вручную. В случае же неразрешимых сомнений вам не остаётся ничего другого, кроме как перебрать все имеющиеся кодогенераторы и проверить работоспособность их выдачи.
|
@ -0,0 +1,11 @@
|
||||
### О паттернах проектирования в контексте API
|
||||
|
||||
Термин [«паттерны»](https://en.wikipedia.org/wiki/Software_design_pattern#History) применительно к разработке программного обеспечения был введён Кентом Бэком и Уордом Каннингемом в 1987 году, и популяризирован «бандой четырёх» (Эрих Гамма, Ричард Хелм, Ральф Джонсон и Джон Влиссидес) в их книге «Приёмы объектно-ориентированного проектирования. Паттерны проектирования», изданной в 1994 году. Согласно общепринятому определению, паттерны программирования — «повторяемая архитектурная конструкция, представляющая собой решение проблемы проектирования в рамках некоторого часто возникающего контекста».
|
||||
|
||||
Если мы говорим об API, особенно если конечным потребителем этих API является разработчик (интерфейсы фреймворков, операционных систем), классические паттерны проектирования вполне к ним применимы. И действительно, многие из описанных в предыдущем разделе примеров представляют собой применение того или иного паттерна.
|
||||
|
||||
Однако, если мы попытаемся обобщить этот опыт на разработку API в целом, то увидим, что большинство типичных проблем дизайна API являются более высокоуровневыми не сводятся к базовым паттернам разработки ПО. Скажем, проблемы кэширования ресурсов (и инвалидации кэша) или организация пагинации классиками не покрыты.
|
||||
|
||||
В рамках этого раздела мы попытаемся описать те задачи проектирования API и подходы к их решению, которые представляются нам наиболее важными. Мы не претендуем здесь на то, чтобы охватить *все* проблемы и тем более — все решения, и скорее фокусируемся на описании подходов к решению типовых задач с их достоинствами и недостатками. Мы понимаем, что читатель, знакомый с классическими трудами «банды четырёх», Гради Буча и Мартина Фаулера ожидает от раздела с названием «Паттерны API» большей системности и ширины охвата, и заранее просим у него прощения.
|
||||
|
||||
**NB**: первый из подобных паттернов — это API-first подход к разработке ПО, который мы описали во введении.
|
158
src/ru/drafts/03-Раздел III. Паттерны проектирования API/02.md
Normal file
158
src/ru/drafts/03-Раздел III. Паттерны проектирования API/02.md
Normal file
@ -0,0 +1,158 @@
|
||||
### Наблюдаемость и строгая консистентность
|
||||
|
||||
Хотя в этом разделе мы стараемся говорить об API в целом, включая совершенно «классические» API языков программирования и операционных систем, нас всё же интересует вполне конкретное *семейство задач* и связанных с ними проблем. Рассматривая API как мост, связывающий два разных программных контекста, мы в большинстве случаев ожидаем, что стороны каньона функционируют независимо и изолированно друг от друга — причём чем крупнее контексты и сложнее каналы передачи данных, тем более независимо и изолированно они работают.
|
||||
|
||||
Что это означает на практике? Два важных следствия.
|
||||
|
||||
1. Чем более распределена и многосоставна система, чем более общий канал связи используется для коммуникации — тем более вероятны ошибки в процессе взаимодействия. В частности, в наиболее интересном нам кейсе распределённых многослойных клиент-серверных систем возникновение исключения на клиенте (потеря контекста, т.е. перезапуск приложения), на сервере (конвейер выполнения запроса выбросил исключение на каком-то шаге), в канале связи (соединение полностью или частично потеряно) или любом промежуточном агенте (например, промежуточный веб-сервер не дождался ответа бэкенда и вернул ошибку гейтвея).
|
||||
|
||||
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 мало поможет, т.к. вам придётся объяснить всем партнёрам необходимость переписать код на новую схему взаимодействия, что растянет её адаптацию на месяцы и годы.
|
136
src/ru/drafts/03-Раздел III. Паттерны проектирования API/05.md
Normal file
136
src/ru/drafts/03-Раздел III. Паттерны проектирования API/05.md
Normal 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)»). Для серверных взаимодействий цифра существенно выше — типичные периоды, после которых наступает таймаут соединения, составляют десятки секунд, хотя предоставление эндпойнтов, обрабатывающих запрос более нескольких секунд, считается сегодня скорее плохим тоном.
|
Loading…
x
Reference in New Issue
Block a user