mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-03-17 20:42:26 +02:00
Chapter 11 extended
This commit is contained in:
parent
0a0c9cf0b9
commit
9f97cb4838
@ -14,7 +14,7 @@
|
||||
|
||||
Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.
|
||||
|
||||
##### 1. Явное лучше неявного
|
||||
##### Явное лучше неявного
|
||||
|
||||
Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование.
|
||||
|
||||
@ -53,7 +53,7 @@ POST /v1/orders/statistics/aggregate
|
||||
|
||||
**1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных.
|
||||
|
||||
##### 2. Указывайте использованные стандарты
|
||||
##### Указывайте использованные стандарты
|
||||
|
||||
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах.
|
||||
|
||||
@ -76,13 +76,13 @@ POST /v1/orders/statistics/aggregate
|
||||
|
||||
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
|
||||
|
||||
##### 3. Сохраняйте точность дробных чисел
|
||||
##### Сохраняйте точность дробных чисел
|
||||
|
||||
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
|
||||
|
||||
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
|
||||
|
||||
##### 4. Сущности должны именоваться конкретно
|
||||
##### Сущности должны именоваться конкретно
|
||||
|
||||
Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.
|
||||
|
||||
@ -90,7 +90,7 @@ POST /v1/orders/statistics/aggregate
|
||||
|
||||
**Хорошо**: `user.get_id()`.
|
||||
|
||||
##### 5. Не экономьте буквы
|
||||
##### Не экономьте буквы
|
||||
|
||||
В XXI веке давно уже нет нужды называть переменные покороче.
|
||||
|
||||
@ -109,7 +109,7 @@ strpbrk (str1, str2)
|
||||
**Хорошо**: `str_search_for_characters (lookup_character_set, str)`
|
||||
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
|
||||
|
||||
##### 6. Тип поля должен быть ясен из его названия
|
||||
##### Тип поля должен быть ясен из его названия
|
||||
|
||||
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`.
|
||||
|
||||
@ -125,7 +125,7 @@ strpbrk (str1, str2)
|
||||
|
||||
**Хорошо**: `"task.is_finished": true`.
|
||||
|
||||
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at`, etc) или `_date`.
|
||||
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`.
|
||||
|
||||
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс, чтобы избежать непонимания.
|
||||
|
||||
@ -138,7 +138,7 @@ GET /coffee-machines/{id}/functions
|
||||
|
||||
**Хорошо**: `GET /v1/coffee-machines/{id}/builtin-functions-list`
|
||||
|
||||
##### 7. Подобные сущности должны называться подобно и вести себя подобным образом
|
||||
##### Подобные сущности должны называться подобно и вести себя подобным образом
|
||||
|
||||
**Плохо**: `begin_transition` / `stop_transition`
|
||||
— `begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации.
|
||||
@ -163,56 +163,231 @@ str_replace(needle, replace, haystack)
|
||||
|
||||
Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.
|
||||
|
||||
##### 8. Клиент всегда должен знать полное состояние системы
|
||||
##### Используйте глобально уникальные идентификаторы
|
||||
|
||||
Хороший тон при разработке API — использовать для идентификаторов сущностей глобально уникальные строки, либо семантичные (например, "lungo" для видов напитков), либо случайные (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random)). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором.
|
||||
|
||||
Мы вообще склонны порекомендовать использовать идентификаторы в urn-подобном формате, т.е. `urn:order:<uuid>` (или просто `order:<uuid>`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности — тогда неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования.
|
||||
|
||||
Отдельное важное следствие: **не используете инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений.
|
||||
|
||||
**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
|
||||
|
||||
##### Клиент всегда должен знать полное состояние системы
|
||||
|
||||
Правило можно ещё сформулировать так: не заставляйте клиент гадать.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Создаёт комментарий и возвращает его id
|
||||
POST /comments
|
||||
{ "content" }
|
||||
// Создаёт заказ и возвращает его id
|
||||
POST /v1/orders
|
||||
{ … }
|
||||
→
|
||||
{ "comment_id" }
|
||||
{ "order_id" }
|
||||
```
|
||||
```
|
||||
// Возвращает комментарий по его id
|
||||
GET /comments/{id}
|
||||
→
|
||||
{
|
||||
// Комментарий не опубликован
|
||||
// и ждёт прохождения капчи
|
||||
"published": false,
|
||||
"action_required": "solve_captcha",
|
||||
"content"
|
||||
}
|
||||
// Возвращает заказ по его id
|
||||
GET /v1/orders/{id}
|
||||
// Заказ ещё не подтверждён
|
||||
// и ожидает проверки
|
||||
→ 404 Not Found
|
||||
```
|
||||
— хотя операция будто бы выполнена успешно, клиенту необходимо сделать дополнительный запрос, чтобы понять необходимость решения капчи. Между вызовами `POST /comments` и `GET /comments/{id}` клиент находится в состоянии кота Шрёдингера: непонятно, опубликован комментарий или нет, и как отразить это пользователю.
|
||||
— хотя операция будто бы выполнена успешно, клиенту необходимо самостоятельно запомнить идентификатор заказа и периодически проверять состояние `GET /v1/orders/{id}`. Этот паттерн плох сам по себе, но ещё и усугубляется двумя обстоятельствами:
|
||||
|
||||
* клиент может потерять идентификатор, если произошёл системный сбой в момент между отправкой запроса и получением ответа или было повреждено (очищено) системное хранилище данных приложения;
|
||||
* потребитель не может воспользоваться другим устройством; фактически, знание о сделанном заказе привязано к конкретному юзер-агенту.
|
||||
|
||||
В обоих случаях потребитель может решить, что заказ по какой-то причине не создался — и сделать повторный заказ со всеми вытекающими отсюда проблемами.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
// Создаёт комментарий и возвращает его
|
||||
POST /v1/comments
|
||||
{ "content" }
|
||||
// Создаёт заказ и возвращает его
|
||||
POST /v1/orders
|
||||
{ <параметры заказа> }
|
||||
→
|
||||
{ "comment_id", "published", "action_required", "content" }
|
||||
```
|
||||
```
|
||||
// Возвращает комментарий по его id
|
||||
GET /v1/comments/{id}
|
||||
→
|
||||
{ /* в точности тот же формат,
|
||||
что и в ответе POST /comments */
|
||||
…
|
||||
{
|
||||
"order_id",
|
||||
// Заказ создаётся в явном статусе
|
||||
// «идёт проверка»
|
||||
"status": "checking",
|
||||
…
|
||||
}
|
||||
```
|
||||
Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.
|
||||
```
|
||||
// Возвращает заказ по его id
|
||||
GET /v1/orders/{id}
|
||||
→
|
||||
{ "order_id", "status" … }
|
||||
```
|
||||
|
||||
То же соображение применимо и к значениям по умолчанию. Не заставляйте клиент гадать эти значения, или хуже — хардкодить их. Возвращайте заполненные значения необязательных полей в ответе операции создания (перезаписи) сущности.
|
||||
##### Избегайте двойных отрицаний
|
||||
|
||||
##### 9. Идемпотентность
|
||||
**Плохо**: `"dont_call_me": false`
|
||||
— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки.
|
||||
|
||||
Все операции должны быть идемпотентны. Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
|
||||
**Лучше**: `"prohibit_calling": true` или `"avoid_calling": true`
|
||||
— читается лучше, хотя обольщаться все равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не».
|
||||
|
||||
Стоит также отметить, что в использовании [законов де Моргана](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага:
|
||||
|
||||
```
|
||||
GET /coffee-machines/{id}/stocks
|
||||
→
|
||||
{
|
||||
"has_beans": true,
|
||||
"has_cup": true
|
||||
}
|
||||
```
|
||||
|
||||
Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов:
|
||||
|
||||
```
|
||||
{
|
||||
"beans_absence": false,
|
||||
"cup_absence": false
|
||||
}
|
||||
```
|
||||
— то разработчику потребуется вычислить флаг `!beans_absence && !cup_absence` ⇔ `!(beans_absence || cup_absence)`, а вот этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».
|
||||
|
||||
##### Избегайте неявного приведения типов
|
||||
|
||||
Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:
|
||||
|
||||
```
|
||||
POST /v1/orders
|
||||
{}
|
||||
→
|
||||
{
|
||||
"contactless_delivery": true
|
||||
}
|
||||
```
|
||||
|
||||
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
|
||||
|
||||
```
|
||||
if (Type(order.contactless_delivery) == 'Boolean' &&
|
||||
order.contactless_delivery == false) { … }
|
||||
```
|
||||
|
||||
Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`.
|
||||
|
||||
В этих ситуациях универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false; если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, — следует ввести пару полей.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Задаёт лимит количества
|
||||
// одновременных заказов
|
||||
// на пользователя, числом
|
||||
// либо null — лимита нет
|
||||
PUT /users/{id}/order-limit
|
||||
{}
|
||||
→
|
||||
{ "order_limit": null }
|
||||
```
|
||||
|
||||
**Хорошо**
|
||||
```
|
||||
// Задаёт флаг наличия/отсутствия
|
||||
// лимита заказов, и его значение
|
||||
// при наличии
|
||||
PUT /users/{id}/order-limit
|
||||
{
|
||||
// true — у пользователя задан
|
||||
// лимит одновременных заказов
|
||||
// false — лимита нет
|
||||
// (значение по умолчанию)
|
||||
"has_specific_limit": true,
|
||||
"limit": 5
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: противоречие с предыдущим советом в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `has_specific_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь.
|
||||
|
||||
##### Избегайте частичных обновлений
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает состояние заказа
|
||||
// по его идентификатору
|
||||
GET /v1/orders/123
|
||||
→
|
||||
{
|
||||
"order_id",
|
||||
"delivery_address",
|
||||
"client_phone_number",
|
||||
"client_phone_number_ext",
|
||||
"updated_at"
|
||||
}
|
||||
// Частично перезаписывает заказ
|
||||
PATCH /v1/orders/123
|
||||
{ "delivery_address" }
|
||||
→
|
||||
{ "delivery_address" }
|
||||
```
|
||||
— такой подход часто практикуют для того, чтобы уменьшить объёмы запросов и ответов, плюс это позволяет дёшево реализовать совместное редактирование. Оба этих преимущества на самом деле являются мнимыми.
|
||||
|
||||
Во-первых, экономия объёма ответа в современных условиях требуется крайне редко. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени. Да и в целом, скорее более оправдан следующий подход: для тяжёлых данных следует сделать отдельный эндпойнт, а на всём остальном не пытаться экономить.
|
||||
|
||||
Во-вторых, экономия размера ответа как раз сыграет злую шутку при совместном редактировании: один клиент не будет видеть, какие изменения внёс другой. Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.
|
||||
|
||||
В-третьих, этот подход может как-то работать при необходимость перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как *удалить* `client_phone_number_ext`?
|
||||
|
||||
Часто в таких случаях прибегают к специальным значениям, которые означают удаление поля, например, null. Но, как мы разобрали выше, это плохая практика. Другой вариант — запрет необязательных полей, но это существенно усложняет дальнейшее развитие API.
|
||||
|
||||
**Хорошо**: можно применить одну из двух стратегий.
|
||||
|
||||
**Вариант 1**: разделение эндпойнтов. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
|
||||
|
||||
```
|
||||
// Возвращает состояние заказа
|
||||
// по его идентификатору
|
||||
GET /v1/orders/123
|
||||
→
|
||||
{
|
||||
"order_id",
|
||||
"delivery_details": {
|
||||
"address"
|
||||
},
|
||||
"client_details": {
|
||||
"phone_number",
|
||||
"phone_number_ext"
|
||||
},
|
||||
"updated_at"
|
||||
}
|
||||
// Полностью перезаписывает
|
||||
// информацию о доставке заказа
|
||||
PUT /v1/orders/123/delivery-details
|
||||
{ "address" }
|
||||
// Полностью перезаписывает
|
||||
// информацию о клиенте
|
||||
PUT /v1/orders/123/client-details
|
||||
{ "phone_number" }
|
||||
```
|
||||
|
||||
Теперь для удаления `client_phone_number_ext` достаточно *не* передавать его в `PUT client-details`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`order_id` и `updated_at`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `updated_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования).
|
||||
|
||||
**Вариант 2**: разработать формат описания атомарных изменений.
|
||||
|
||||
```
|
||||
POST /v1/order/changes
|
||||
X-Idempotency-Token: <см. следующий раздел>
|
||||
{
|
||||
"changes": [{
|
||||
"type": "set",
|
||||
"field": "delivery_address",
|
||||
"value": <новое значение>
|
||||
}, {
|
||||
"type": "unset",
|
||||
"field": "client_phone_number_ext"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и потом сервер автоматически разрешает конфликты, «перебазируя» изменения.
|
||||
|
||||
##### Все операции должны быть идемпотентны
|
||||
|
||||
Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
|
||||
|
||||
Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.
|
||||
|
||||
@ -293,7 +468,160 @@ X-Idempotency-Token: <токен>
|
||||
* нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции;
|
||||
* клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов.
|
||||
|
||||
##### 10. Кэширование
|
||||
##### Избегайте неатомарных операций
|
||||
|
||||
С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
// Возвращает список рецептов
|
||||
GET /v1/recipes
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
"id": "lungo",
|
||||
"volume": "200ml"
|
||||
}, {
|
||||
"id": "latte",
|
||||
"volume": "300ml"
|
||||
}]
|
||||
}
|
||||
|
||||
// Изменяет параметры
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
"changes": [{
|
||||
"id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
}
|
||||
→ 400 Bad Request
|
||||
|
||||
// Перечитываем список
|
||||
GET /v1/recipes
|
||||
→
|
||||
{
|
||||
"recipes": [{
|
||||
"id": "lungo",
|
||||
// Это значение изменилось
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"id": "latte",
|
||||
// А это нет
|
||||
"volume": "300ml"
|
||||
}]
|
||||
}
|
||||
```
|
||||
— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом.
|
||||
|
||||
Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно.
|
||||
|
||||
**Лучше**:
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"recipe_id": "latte",
|
||||
"volume": "-1ml"
|
||||
}]
|
||||
}
|
||||
// Можно воспользоваться статусом
|
||||
// «частичного успеха», если он предусмотрен
|
||||
// протоколом
|
||||
→ 200 OK
|
||||
{
|
||||
"changes": [{
|
||||
"change_id",
|
||||
"occurred_at",
|
||||
"recipe_id": "lungo",
|
||||
"status": "success"
|
||||
}, {
|
||||
"change_id",
|
||||
"occurred_at",
|
||||
"recipe_id": "latte",
|
||||
"status": "fail",
|
||||
"error"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Здесь:
|
||||
* `change_id` — уникальный идентификатор каждого атомарного изменения;
|
||||
* `occurred_at` — время проведения каждого изменения
|
||||
* `error` — информация по ошибке для каждого изменения, если она возникла.
|
||||
|
||||
Не лишним будет также:
|
||||
* введение `sequence_id` в запросе, чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом;
|
||||
* предоставить отдельный эндпойнт `/changes-history`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось.
|
||||
|
||||
Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример:
|
||||
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
}
|
||||
→ 200 OK
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
"status": "success"
|
||||
}, {
|
||||
…
|
||||
"status": "fail",
|
||||
"error": {
|
||||
"reason": "too_many_requests"
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности.
|
||||
|
||||
```
|
||||
PATCH /v1/recipes
|
||||
{
|
||||
"idempotency_token",
|
||||
"changes": [{
|
||||
"recipe_id": "lungo",
|
||||
"volume": "300ml"
|
||||
}, {
|
||||
"recipe_id": "latte",
|
||||
"volume": "400ml"
|
||||
}]
|
||||
}
|
||||
→ 200 OK
|
||||
{
|
||||
"changes": [{
|
||||
…
|
||||
"status": "success"
|
||||
}, {
|
||||
…
|
||||
"status": "success",
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запросе отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности.
|
||||
|
||||
Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений.
|
||||
|
||||
На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.
|
||||
|
||||
##### Указывайте политики кэширования
|
||||
|
||||
В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование на клиенте результатов операции является стандартным действием.
|
||||
|
||||
@ -303,7 +631,8 @@ X-Idempotency-Token: <токен>
|
||||
```
|
||||
// Возвращает цену лунго в кафе,
|
||||
// ближайшем к указанной точке
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
GET /v1/price?recipe=lungo
|
||||
&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{ "currency_code", "price" }
|
||||
```
|
||||
@ -316,7 +645,8 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
```
|
||||
// Возвращает предложение: за какую сумму
|
||||
// наш сервис готов приготовить лунго
|
||||
GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
GET /v1/price?recipe=lungo
|
||||
&longitude={longitude}&latitude={latitude}
|
||||
→
|
||||
{
|
||||
"offer": {
|
||||
@ -336,7 +666,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude}
|
||||
}
|
||||
```
|
||||
|
||||
##### 11. Пагинация, фильтрация и курсоры
|
||||
##### Пагинация, фильтрация и курсоры
|
||||
|
||||
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
|
||||
|
||||
@ -434,7 +764,7 @@ POST /v1/record-views
|
||||
GET /v1/record-views/{id}?cursor={cursor}
|
||||
```
|
||||
|
||||
Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
|
||||
Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
|
||||
|
||||
**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:
|
||||
|
||||
@ -455,7 +785,7 @@ POST /v1/records/modified/list
|
||||
|
||||
Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто.
|
||||
|
||||
##### 12. Ошибки должны быть информативными
|
||||
##### Ошибки должны быть информативными
|
||||
|
||||
При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка — неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API.
|
||||
|
||||
@ -504,7 +834,138 @@ POST /v1/coffee-machines/search
|
||||
```
|
||||
Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной.
|
||||
|
||||
##### 13. Локализация и интернационализация
|
||||
##### Соблюдайте правильный порядок ошибок
|
||||
|
||||
Во-первых, всегда показывайте неразрешимые ошибки прежде разрешимых:
|
||||
```
|
||||
POST /v1/orders
|
||||
{
|
||||
"recipe": "lngo",
|
||||
"offer"
|
||||
}
|
||||
→ 409 Conflict
|
||||
{
|
||||
"reason": "offer_expired"
|
||||
}
|
||||
// Повторный запрос
|
||||
// с новым `offer`
|
||||
POST /v1/orders
|
||||
{
|
||||
"recipe": "lngo",
|
||||
"offer"
|
||||
}
|
||||
→ 400 Bad Request
|
||||
{
|
||||
"reason": "recipe_unknown"
|
||||
}
|
||||
```
|
||||
— какой был смысл получать новый `offer`, если заказ все равно не может быть создан?
|
||||
|
||||
Во-вторых, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений.
|
||||
|
||||
**Плохо**:
|
||||
```
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": [{ "item_id": "123", "price": "0.10" }]
|
||||
}
|
||||
→
|
||||
409 Conflict
|
||||
{
|
||||
"reason": "price_changed",
|
||||
"details": [{ "item_id": "123", "actual_price": "0.20" }]
|
||||
}
|
||||
// Повторный запрос
|
||||
// с актуальной ценой
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": [{ "item_id": "123", "price": "0.20" }]
|
||||
}
|
||||
→
|
||||
409 Conflict
|
||||
{
|
||||
"reason": "order_limit_exceeded",
|
||||
"localized_message": "Лимит заказов превышен"
|
||||
}
|
||||
```
|
||||
— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать все равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.
|
||||
|
||||
В-третьих, постройте таблицу: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок.
|
||||
|
||||
```
|
||||
// Создаём заказ с платной доставкой
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": 3,
|
||||
"item_price": "3000.00"
|
||||
"currency_code": "MNT",
|
||||
"delivery_fee": "1000.00",
|
||||
"total": "10000.00"
|
||||
}
|
||||
→ 409 Conflict
|
||||
// Ошибка: доставка становится бесплатной
|
||||
// при стоимости заказа от 9000 тугриков
|
||||
{
|
||||
"reason": "delivery_is_free"
|
||||
}
|
||||
|
||||
// Создаём заказ с бесплатной доставкой
|
||||
POST /v1/orders
|
||||
{
|
||||
"items": 3,
|
||||
"item_price": "3000.00"
|
||||
"currency_code": "MNT",
|
||||
"delivery_fee": "0.00",
|
||||
"total": "9000.00"
|
||||
}
|
||||
→ 409 Conflict
|
||||
// Ошибка: минимальная сумма заказа
|
||||
// 10000 тугриков
|
||||
{
|
||||
"reason": "below_minimal_sum",
|
||||
"currency_code": "MNT",
|
||||
"minimal_sum": "10000.00"
|
||||
}
|
||||
```
|
||||
|
||||
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчета (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.
|
||||
|
||||
##### Отсутствие результата — тоже результат
|
||||
|
||||
Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой.
|
||||
|
||||
**Плохо**
|
||||
```
|
||||
POST /search
|
||||
{
|
||||
"query": "lungo",
|
||||
"location": <положение пользователя>
|
||||
}
|
||||
→ 404 Not Found
|
||||
{
|
||||
"localized_message":
|
||||
"Рядом с вами не делают лунго"
|
||||
}
|
||||
```
|
||||
|
||||
Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было, ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет.
|
||||
|
||||
**Хорошо**:
|
||||
```
|
||||
POST /search
|
||||
{
|
||||
"query": "lungo",
|
||||
"location": <положение пользователя>
|
||||
}
|
||||
→ 200 OK
|
||||
{
|
||||
"results": []
|
||||
}
|
||||
```
|
||||
|
||||
Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.)
|
||||
|
||||
##### Локализация и интернационализация
|
||||
|
||||
Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет.
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
<h1>Сергей Константинов<br />API</h1>
|
||||
<div class="cover"><h1>Сергей Константинов<br />API</h1></div>
|
||||
|
||||
<img class="cc-by-nc-img" src="https://i.creativecommons.org/l/by-nc/4.0/88x31.png" />
|
||||
<p class="cc-by-nc">
|
||||
|
14
src/ru/drafts/03-Раздел II. Обратная совместимость/03.md
Normal file
14
src/ru/drafts/03-Раздел II. Обратная совместимость/03.md
Normal file
@ -0,0 +1,14 @@
|
||||
### Интерфейсы как универсальный паттерн
|
||||
|
||||
Как мы указали в предыдущей главе, основные причины внесения изменений в API — развитие самого API (добавление новой функциональности) и эволюция платформ (клиентской, серверной и предметной) — следует предусматривать ещё на этапе проектирования. Может показаться, что совет этот полезен примерно так же, как и сократовское определение человека — очень конкретен, и столь же бесполезен — но это не так. Методология, позволяющая получить устойчивое к изменениям API, существует и вполне конкретна: это применение концепции «интерфейса» ко всем уровням абстракции.
|
||||
|
||||
На практике это означает следующая: нам необходимо рассмотреть каждую сущность нашего API и выделить её абстрактную модель, т.е. разделить все поля и методы сущности на две группы: те, которые абсолютно необходимы для корректного цикла работы API, и те, которые мы можем назвать «деталями имплементации». Первые образуют *интерфейс* сущности: если заменить одну конкретную реализацию этого интерфейса на другую, API будет продолжать работать.
|
||||
|
||||
**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных» и «контрактное программирование», но это методологически неверно: разработка API в принципе представляет собой контрактное программирование, при этом большинство клиент-серверных архитектур подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL, но «интерфейс» нам кажется более понятным.
|
||||
|
||||
Мы будем использовать термин «интерфейс» как обобщение понятия «абстрактный тип данных» и «контракт». «Интерфейс» — это некоторое абстрактное подмножество абстрактного типа данных, «метаконтракт». Интерфейсы мы будем обозначать с помощью префикса `I`, например: `Recipe` — это модель данных «рецепт», а `IRecipe` — это интерфейс рецепта: «минимальная» модель данных и операций над ними, которая достаточна для корректной работы API. Объект `Recipe` таким образом имплементирует интерфейс `IRecipe`.
|
||||
|
||||
Попробуем применить этот (дважды) абстрактный концепт к нашему кофейному API. Представьте, что на этапе разработки архитектуры бизнес выдвинул следующее требование: мы не только предоставляем доступ к оборудованию партнеров, но и предлагаем партнерам наше ПО (т.е. в данном случае API), чтобы они могли строить поверх него свои собственные приложения.
|
||||
|
||||
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; в реальном мире такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реалтайме применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем такой сложности было бы затруднительно.
|
||||
|
Loading…
x
Reference in New Issue
Block a user