1
0
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:
Sergey Konstantinov 2021-01-03 16:23:43 +03:00
parent 0a0c9cf0b9
commit 9f97cb4838
3 changed files with 523 additions and 48 deletions

View File

@ -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`), даже если на текущем этапе нужды в локализации нет.

View File

@ -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">

View 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 операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем такой сложности было бы затруднительно.