Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Это главы 24 и 25 моей книги «API». v2 будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Раздел «Паттерны API» на этом завершён. Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.
Глава 24. Частичные обновления
Описанный в предыдущей главе пример со списком операций, который может быть выполнен частично, естественным образом подводит нас к следующей проблеме дизайна API. Что, если изменение не является атомарной идемпотентной операцией (как изменение статуса заказа), а представляет собой низкоуровневую перезапись нескольких полей объекта? Рассмотрим следующий пример.
// Создаёт заказ из двух напитков
POST /v1/orders/
X-Idempotency-Token: <токен>
{
"delivery_address",
"items": [{
"recipe": "lungo"
}, {
"recipe": "latte",
"milk_type": "oat"
}]
}
→
{ "order_id" }
// Частично перезаписывает заказ,
// обновляет объём второго напитка
PATCH /v1/orders/{id}
{
"items": [
// `null` показывает, что
// параметры первого напитка
// менять не надо
null,
// список изменений свойств
// второго напитка
{"volume": "800ml"}
]
}
→
{ /* изменения приняты */ }
Эта сигнатура плоха сама по себе, поскольку её читабельность сомнительна. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (delivery_address
, milk_type
) — они будут сброшены в значения по умолчанию или останутся неизменными?
Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция {"items":[null, {…}]}
означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
Частичные изменения состояния ресурсов — одна из самых частых задач, которые решает разработчик API, и, увы, одна из самых сложных. Попытки обойтись малой кровью и упростить имплементацию зачастую приводят к очень большим проблемам в будущем.
Простое решение состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
повышенные размеры запросов и, как следствие, расход трафика;
необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства, поскольку клиенты всегда посылают полное состояние объекта, известное им, и переписывают изменения друг друга, поскольку о них не знают.
Во избежание перечисленных проблем разработчики, как правило, реализуют некоторое наивное решение:
клиент передаёт только те поля, которые изменились;
для сброса значения поля в значение по умолчанию или пропуска/удаления элементов массивов используются специально оговоренные значения.
Если обратиться к примеру выше, наивный подход выглядит примерно так:
// Частично перезаписывает заказ:
// * сбрасывает адрес доставки
// в значение по умолчанию
// * не изменяет первый напиток
// * удаляет второй напиток
PATCH /v1/orders/{id}
{
// Специальное значение №1:
// обнулить поле
"delivery_address": null
"items": [
// Специальное значение №2:
// не выполнять никаких
// операций с объектом
{},
// Специальное значение №3:
// удалить объект
false
]
}
Предполагается, что:
повышенного расхода трафика можно избежать, передавая только изменившиеся поля и заменяя пропускаемые элементы специальными значениями (
{}
в нашем случае);события изменения значения поля также будут генерироваться только по тем полям и объектам, которые переданы в запросе;
если два клиента делают одновременный запрос, но изменяют различные поля, конфликта доступа не происходит, и оба изменения применяются.
Все эти соображения, однако, на поверку оказываются мнимыми:
причины увеличенного расхода трафика (слишком частый поллинг, отсутствие пагинации и/или ограничений на размеры полей) мы разбирали в главе «Описание конечных интерфейсов», и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
кроме того, часто в рамках той же концепции экономят и на исходящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга, что ещё больше повышает вероятность получить совершенно неожиданные результаты.
Более консистентное решение: разделить эндпойнт на несколько идемпотентных суб-эндпойнтов, имеющих независимые идентификаторы и/или адреса (чего обычно достаточно для обеспечения транзитивности операций). Этот подход также хорошо согласуется с принципом декомпозиции, который мы рассматривали в предыдущем главе «Разграничение областей ответственности».
// Создаёт заказ из двух напитков
POST /v1/orders/
{
"parameters": {
"delivery_address"
},
"items": [{
"recipe": "lungo"
}, {
"recipe": "latte",
"milk_type": "oats"
}]
}
→
{
"order_id",
"created_at",
"parameters": {
"delivery_address"
},
"items": [
{ "item_id", "status"},
{ "item_id", "status"}
]
}
// Изменяет параметры,
// относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters
{ "delivery_address" }
→
{ "delivery_address" }
// Частично перезаписывает заказ
// обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id}
{
// Все поля передаются, даже если
// изменилось только какое-то одно
"recipe", "volume", "milk_type"
}
→
{ "recipe", "volume", "milk_type" }
// Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id}
Теперь для удаления volume
достаточно не передавать его в PUT items/{item_id}
. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.
Этот подход также позволяет отделить неизменяемые и вычисляемые поля (created_at
и status
) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить created_at
?).
Применения этого паттерна, как правило, достаточно для большинства API, манипулирующих сложносоставленными сущностями, однако и недостатки у него тоже есть: высокие требования к качеству проектирования декомпозиции (иначе велик шанс, что стройное API развалится при дальнейшем расширении функциональности) и необходимость совершать множество запросов для изменения всей сущности целиком (из чего вытекает необходимость создания функциональности для внесения массовых изменений, нежелательность которой мы обсуждали в предыдущей главе).
NB: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и тогда потребуется выпускать новые интерфейсы работы с данными. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).
Разрешение конфликтов совместного редактирования
Идеи организации изменения состояния ресурса через независимые атомарные идемпотентные операции выглядит достаточно привлекательно и с точки зрения разрешения конфликтов доступа. Так как составляющие ресурса перезаписываются целиком, результатом записи будет именно то, что пользователь видел своими глазами на экране своего устройства, даже если при этом он смотрел на неактуальную версию. Однако этот подход очень мало помогает нам, если мы действительно обеспечить максимально гранулярное изменение данных, как, например, это сделано в онлайн-сервисах совместной работы с документами или системах контроля версий (поскольку для этого нам придётся сделать столь же гранулярные эндпойнты, т.е. буквально адресовать каждый символ документа по отдельности).
Для «настоящего» совместного редактирования необходимо будет разработать отдельный формат описания изменений, который позволит:
иметь максимально возможную гранулярность (т.е. одна операция соответствует одному действию клиента);
реализовать политику разрешения конфликтов.
В нашем случае мы можем пойти, например, вот таким путём:
POST /v1/order/changes
X-Idempotency-Token: <токен>
{
// Какую ревизию ресурса
// видел пользователь, когда
// выполнял изменения
"known_revision",
"changes": [{
"type": "set",
"field": "delivery_address",
"value": <новое значение>
}, {
"type": "unset_item_field",
"item_id",
"field": "volume"
}],
…
}
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает возможные конфликты, основываясь на истории ревизий.
NB: один из подходов к этой задаче — разработка такой номенклатуры операций над данными (например, CRDT), в которой любые действия транзитивны (т.е. конечное состояние системы не зависит от того, в каком порядке они были применены). Мы, однако, склонны считать такой подход применимым только к весьма ограниченным предметным областям — поскольку в реальной жизни нетранзитивные действия находятся почти всегда. Если один пользователь ввёл в документ новый текст, а другой пользователь удалил документ — никакого разумного (т.е. удовлетворительного с точки зрения обоих акторов) способа автоматического разрешения конфликта здесь нет, необходимо явно спросить пользователей, что бы они хотели сделать с возникшим конфликтом.
Глава 25. Деградация и предсказуемость
В предыдущих главах мы много говорили о том, что фон ошибок — не только неизбежное зло в любой достаточно большой системе, но и, зачастую, осознанное решение, которое позволяет сделать систему более масштабируемой и предсказуемой.
Зададим себе, однако, вопрос: а что значит «более предсказуемая» система? Для нас как для вендора API это достаточно просто: процент ошибок (в разбивке по типам) достаточно стабилен, и им можно пользоваться как индикатором возникающих технических проблем (если он растёт) и как KPI для технических улучшений и рефакторингов (если он падает).
Но вот для разработчиков-партнёров понятие «предсказуемость поведения API» имеет совершенно другой смысл: насколько хорошо и полно они в своём коде могут покрыть различные сценарии использования API и потенциальные проблемы — или, иными словами, насколько явно из документации и номенклатуры методов и ошибок API становится ясно, какие типовые ошибки могут возникнуть и как с ними работать.
Чем, например, оптимистичное управление параллелизмом (см. главу «Стратегии синхронизации») лучше блокировок с точки зрения партнёра? Тем, что, получив ошибку несовпадения ревизий, разработчик понимает, какой код он должен написать: обновить состояние и попробовать ещё раз (в простейшем случае — показав новое состояние пользователю и предложив ему решить, что делать дальше). Если же разработчик пытается захватить lock и не может сделать этого в течение разумного времени, то… а что он может полезного сделать? Попробовать ещё раз — но результат ведь, скорее всего, не изменится. Показать пользователю… что? Бесконечный спиннер? Попросить пользователя принять какое решение — сдаться или ещё подождать?
При проектировании поведения вашего API исключительно важно представить себя на месте разработчика и попытаться понять, какой код он должен написать для разрешения возникающих ситуаций (включая сетевые таймауты и/или частичную недоступность вашего бэкенда). В этой книге приведено множество частных советов, как поступать в той или иной ситуации, но они, конечно, покрывают только типовые сценарии. О нетиповых вам придётся подумать самостоятельно.
Несколько общих советов, которые могут вам пригодиться:
если вы можете включить в саму ошибку рекомендации, как с ней бороться — сделайте это не раздумывая (имейте в виду, что таких рекомендаций должно быть две — для пользователя, который увидит ошибку в приложении, и для разработчика, который будет разбирать логи);
если ошибки в каком-то эндпойнте некритичны для основной функциональности интеграции, очень желательно описать этот факт в документации (потому что разработчик может просто не догадаться обернуть соответствующий вызов в try-catch), а лучше — привести примеры, каким значением/поведением по умолчанию следует воспользоваться в случае получения ошибки;
не забывайте, что, какую бы стройную и всеобъемлющую систему ошибок вы ни выстроили, почти в любой среде разработчик может получить ошибку транспортного уровня или таймаут выполнения, а, значит, оказаться в ситуации, когда восстанавливать состояние надо, а «подсказки» от бэкенда недоступны; должна существовать какая-то достаточно очевидная последовательность действий «по умолчанию» для восстановления работы интеграции из любой точки;
наконец, при введении новых ошибок не забывайте о старых клиентах, которые про эти новые типы ошибок не знают; «реакция по умолчанию» на неизвестные ошибки должна в том числе покрывать и эти новые сценарии.
В идеальном мире для «правильной деградации» клиентов желательно иметь мета-API, позволяющее определить статус доступности для эндпойнтов основного API — тогда партнёры смогут, например, автоматически включать fallback-и, если какая-то функциональность не работает. (В реальном мире, увы, если на уровне сервиса наблюдаются масштабные проблемы, то обычно и API статуса доступности оказывается ими затронуто.)