1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-05-19 21:33:04 +02:00

sync strategies

This commit is contained in:
Sergey Konstantinov 2023-04-23 22:32:08 +03:00
parent 473a50dbd3
commit 22cc6692b5
3 changed files with 185 additions and 26 deletions

View File

@ -1 +1,92 @@
### Synchronization Strategies
### [Synchronization Strategies][api-patterns-sync-strategies]
Let's proceed to the technical problems that API developers face. We begin with the last one described in the introductory chapter: the necessity to synchronize states. Let us imagine that a user creates a request to order coffee through our API. While this request travels from the client to the coffee house and back, many things might happen. Consider the following chain of events:
1. The client sends the order creation request
2. Because of network issues, the request propagates to the server very slowly, and the client gets a timeout;
* therefore, the client does not know whether the query was served or not.
3. The client requests the current state of the system and gets an empty response as the initial request still hasn't reached the server:
```
const pendingOrders = await
api.getOngoingOrders(); // → []
```
4. The server finally gets the initial request for creating an order and serves it.
5. The client, being unaware of this, tries to create an order anew.
As the operations of reading the list of ongoing orders and of creating a new order happen at different moments of time, we can't guarantee that the system state hasn't changed in between. If we do want to have this guarantee, we must implement some [synchronization strategy](https://en.wikipedia.org/wiki/Synchronization_(computer_science)). In the case of, let's say, operating system APIs or client frameworks we might rely on the primitives provided by the platform. But in the case of distributed client-server APIs, we would need to implement such a primitive of our own.
There are two main approaches to solving this problem: the pessimistic one (implementing locks in the API) and the optimistic one (resource versioning).
**NB**: generally speaking, the best approach to tackling an issue is not having the issue at all. Let's say, if your API is idempotent, the duplicating calls are not a problem. However, in the real world, not every operation is idempotent; for example, creating new orders is not. We might add mechanisms to prevent *automatic* retries (such as client-generated idempotency tokens) but we can't forbid users from just creating a second identical order.
#### API Locks
The first approach is to literally implement standard synchronization primitives at the API level. Like this, for example:
```
let lock;
try {
// Capture the exclusive
// reight to create new orders
lock = await api.
acquireLock(ORDER_CREATION);
// Get the list of current orders
// known to the system
const pendingOrders = await
api.getPendingOrders();
// If our order is absent,
// create it
if (pendingOrders.length == 0) {
const order = await api
.createOrder(…)
}
} catch (e) {
// Deal with errors
} finally {
// Unblock the resource
await lock.release();
}
```
Rather unsurprisingly, this approach sees very rare use in distributed client-server APIs because of the plethora of related problems:
1. Waiting for acquiring a lock introduces new latencies to the interaction that are hardly predictable and might potentially be quite significant.
2. The lock itself is one more entity that constitutes a subsystem of its own, and quite a demanding one as strong consistency is required for implementing locks: the `getPendingOrders` function must return the up-to-date state of the system otherwise the duplicate order will be anyway created.
3. As it's partners who develop client code, we can't guarantee it works with locks always correctly. Inevitably, “lost” locks will occur in the system, and that means we need to provide some tools to partners so they can find the problem and debug it.
4. A certain granularity of locks is to be developed so that partners can't affect each other. We are lucky if there are natural boundaries for a lock — for example, if it's limited to a specific user in the specific partner's system. If we are not so lucky (let's say all partners share the same user profile), we will have to develop even more complex systems to deal with potential errors in the partners' code — for example, introduce locking quotas.
#### Optimistic Concurrency Control
A less implementation-heavy approach is to develop an [optimistic concurrency control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) system, i.e., to require clients to pass a flag proving they know the actual state of a shared resource.
```
// Retrieve the state
const orderState =
await api.getOrderState();
// The version is a part
// of the state of the resource
const version =
orderState.latestVersion;
// An order might only be created
// if the resource version hasn't
// changed since the last read
try {
const task = await api
.createOrder(version, …);
} catch (e) {
// If the version is wrong, i.e.,
// another client changed the
// resource state, an error occurs
if (Type(e) == INCORRECT_VERSION) {
// Which should be handled…
}
}
```
**NB**: an attentive reader might note that the necessity to implement some synchronization strategy and strongly consistent reading has not disappeared: there must be a component in the system that performs a locking read of the resource version and its subsequent change. It's not entirely true as synchronization strategies and strongly consistent reading have disappeared *from the public API*. The distance between the client that sets the lock and the server that processes it became much smaller, and the entire interaction now happens in a controllable environment. It might be a single subsystem in a form of [an ACID-compatible database](https://en.wikipedia.org/wiki/ACID) or even an in-memory solution.
Instead of a version, the date of the last modification of the resource might be used (which is much less reliable as clocks are not ideally synchronized across different system nodes; at least save it with the maximum possible precision!) or entity identifiers (ETags).
The advantage of optimistic concurrency control is therefore the possibility to hide under the hood the complexity of implementing locking mechanisms. The disadvantage is that the versioning errors are no longer exceptions — it's now a regular behavior of the system. Furthermore, client developers *must* implement working with them otherwise the application might render inoperable as users will be infinitely creating an order with the wrong version.
**NB**: which resource to select for making versioning is extremely important. If in our example we create a global system version that is incremented after any order comes, users' chances to successfully create an order will be close to zero.

View File

@ -1 +1,93 @@
### Стратегии синхронизации
### [Стратегии синхронизации][api-patterns-sync-strategies]
Перейдём теперь к техническим проблемам, стоящим перед разработчикам 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();
// Если нашего заказа ещё нет,
// создаём его
if (pendingOrders.length == 0) {
const order = 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

@ -1,24 +0,0 @@
### Аутентификация партнёров и авторизация вызовов API
Прежде, чем мы перейдём к обсуждению технических проблем и их решений, мы не можем не остановиться на важном вопросе авторизации вызовов API и аутентификации осуществляющих вызов клиентов (*AA* — Authorization & Authentication). Исходя из всё того же принципа мультипликатора («API умножает как возможности, так и проблемы») организация AA — одна из самых насущных проблем провайдера API, особенно публичного. Тем удивительнее тот факт, что в настоящий момент не существует стандартного подхода к ней — почти каждый крупный сервис разрабатывает какой-то свой интерфейс для решения этих задач, причём зачастую достаточно архаичный.
Если отвлечься от технических деталей имплементации (в отношении которых мы ещё раз настоятельно рекомендуем не изобретать велосипед и использовать стандартные подходы и протоколы безопасности), то, по большому счёту, есть два основных способа авторизовать выполнение некоторой операции через API:
* завести в системе специальный тип аккаунта «робот» и выполнять операции от имени робота;
* авторизовать вызывающую систему (бэкенд или тип клиента) как единое целое (обычно для этого используются ключи, подписи или сертификаты).
Разница между двумя подходами заключается в гранулярности доступа:
* если клиент API выполняет запросы от имени пользователя системы, то и выполнять он может только те операции, которые разрешены конкретному пользователю;
* если клиент API авторизуется ключом, то он может выполнять запросы фактически от имени любого пользователя.
Первая система, таким образом, является более гранулярной (робот может быть «виртуальным сотрудником» организации, то есть иметь доступ только к ограниченному набору данных) и вообще является естественным выбором для тех API, которые являются дополнением к существующему сервису для конечных пользователей (и, таким образом, иогут использовать уже существующие системы AA). Недостатки же этого способа вытекают из его достоинств:
1. Необходимо организовать какой-то процесс безопасной авторизации пользователя-робота (например, через получение для него токенов реальным пользователем из веб-интерфейса), поскольку стандартная логин-парольная схема логина (тем более двухфакторная) слаба применима к клиенту API.
2. Необходимо сделать для пользователей-роботов исключения из почти всех систем безопасности:
* роботы выполняют намного больше запросов, чем обычные люди, и могут делать это в параллель (в том числе с разных IP-адресов, расположенных в разных дата-центрах);
* роботы не принимают куки и не могут решить капчу;
* робота нельзя профилактически разлогинить и/или инвалидировать его токен (это чревато простоем бизнеса партнёра), поэтому для роботов часто приходится изобретать токены с большим временем жизни и/или процедуру «подновления» токена.
3. Наконец, вы столкнётесь с очень большими проблемами, если вам всё-таки понадобится дать роботу возможность выполнять операцию от имени другого пользователя (поскольку такую возможность придётся тогда либо выдать и обычным пользователям, либо каким-то образом скрыть её и разрешить только роботам).
Если же API не предоставляется как сервис для конечных пользователей, авторизация клиентов через API-ключи более проста в имплементации. При этой схеме предполагается, что, если запрос подписан правильно (передан правильный API-ключ или сертификат, валидна цифровая подпись), то передающий клиент может выполнять любые операции. Здесь можно добиться гранулярности уровня эндпойнта (т.е. партнёр может выставить для ключа набор эндпойнтов, которые можно с ним вызывать), но более гранулярные системы (когда ключу выставляются ещё и ограничения на уровне бизнес-сущностей) уже намного сложнее в разработке и применяются редко.
Обе схемы, в общем-то, можно свести друг к другу (если разрешить роботным пользователям выполнять операции от имени любых других пользователей, мы фактически получим авторизацию по ключу; если создать по API-ключу какой-то ограниченный сегмент данных в рамках которого выполняются запросы, то фактически мы получим систему аккаунтов пользователей), и иногда можно встретить гибридные схемы (когда запрос авторизуется и API-ключом, и токеном пользователя). В рамках этой главы мы скорее хотели дать общее представление, в чём принципиальное различие двух «чистых» подходов, нежели сделать обзор всех возможных схем.