You've already forked The-API-Book
mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-07-12 22:50:21 +02:00
Chapter 7 rewritten finished
This commit is contained in:
BIN
docs/API.ru.epub
BIN
docs/API.ru.epub
Binary file not shown.
944
docs/API.ru.html
944
docs/API.ru.html
File diff suppressed because it is too large
Load Diff
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -525,6 +525,52 @@ GET /v1/users/{id}/orders
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
##### Указывайте время жизни ресурсов и политики кэширования
|
||||||
|
|
||||||
|
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Желательно в такой ситуации вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
|
||||||
|
|
||||||
|
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать повтором запроса?
|
||||||
|
|
||||||
|
**Плохо**:
|
||||||
|
```
|
||||||
|
// Возвращает цену лунго в кафе,
|
||||||
|
// ближайшем к указанной точке
|
||||||
|
GET /v1/price?recipe=lungo
|
||||||
|
&longitude={longitude}&latitude={latitude}
|
||||||
|
→
|
||||||
|
{ "currency_code", "price" }
|
||||||
|
```
|
||||||
|
Возникает два вопроса:
|
||||||
|
* в течение какого времени эта цена действительна?
|
||||||
|
* на каком расстоянии от указанной точки цена всё ещё действительна?
|
||||||
|
|
||||||
|
**Хорошо**:
|
||||||
|
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.
|
||||||
|
|
||||||
|
```
|
||||||
|
// Возвращает предложение: за какую сумму
|
||||||
|
// наш сервис готов приготовить лунго
|
||||||
|
GET /v1/price?recipe=lungo
|
||||||
|
&longitude={longitude}&latitude={latitude}
|
||||||
|
→
|
||||||
|
{
|
||||||
|
"offer": {
|
||||||
|
"id",
|
||||||
|
"currency_code",
|
||||||
|
"price",
|
||||||
|
"conditions": {
|
||||||
|
// До какого времени валидно предложение
|
||||||
|
"valid_until",
|
||||||
|
// Где валидно предложение:
|
||||||
|
// * город
|
||||||
|
// * географический объект
|
||||||
|
// * …
|
||||||
|
"valid_within"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
##### Пагинация, фильтрация и курсоры
|
##### Пагинация, фильтрация и курсоры
|
||||||
|
|
||||||
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
|
Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может.
|
||||||
@ -927,115 +973,102 @@ PATCH /v1/recipes
|
|||||||
|
|
||||||
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
|
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
|
||||||
|
|
||||||
##### Указывайте политики кэширования
|
|
||||||
|
|
||||||
В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование результатов операции на клиенте является стандартным действием.
|
|
||||||
|
|
||||||
Желательно в такой ситуации внести ясность; каким образом можно кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
|
|
||||||
|
|
||||||
**Плохо**:
|
|
||||||
```
|
|
||||||
// Возвращает цену лунго в кафе,
|
|
||||||
// ближайшем к указанной точке
|
|
||||||
GET /v1/price?recipe=lungo
|
|
||||||
&longitude={longitude}&latitude={latitude}
|
|
||||||
→
|
|
||||||
{ "currency_code", "price" }
|
|
||||||
```
|
|
||||||
Возникает два вопроса:
|
|
||||||
* в течение какого времени эта цена действительна?
|
|
||||||
* на каком расстоянии от указанной точки цена всё ещё действительна?
|
|
||||||
|
|
||||||
**Хорошо**:
|
|
||||||
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так:
|
|
||||||
```
|
|
||||||
// Возвращает предложение: за какую сумму
|
|
||||||
// наш сервис готов приготовить лунго
|
|
||||||
GET /v1/price?recipe=lungo
|
|
||||||
&longitude={longitude}&latitude={latitude}
|
|
||||||
→
|
|
||||||
{
|
|
||||||
"offer": {
|
|
||||||
"id",
|
|
||||||
"currency_code",
|
|
||||||
"price",
|
|
||||||
"conditions": {
|
|
||||||
// До какого времени валидно предложение
|
|
||||||
"valid_until",
|
|
||||||
// Где валидно предложение:
|
|
||||||
// * город
|
|
||||||
// * географический объект
|
|
||||||
// * …
|
|
||||||
"valid_within"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
##### Избегайте неявных частичных обновлений
|
##### Избегайте неявных частичных обновлений
|
||||||
|
|
||||||
|
Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния.
|
||||||
|
|
||||||
**Плохо**:
|
**Плохо**:
|
||||||
```
|
```
|
||||||
// Возвращает состояние заказа
|
// Создаёт заказ из двух напитков
|
||||||
// по его идентификатору
|
POST /v1/orders/
|
||||||
GET /v1/orders/123
|
|
||||||
→
|
|
||||||
{
|
{
|
||||||
"order_id",
|
|
||||||
"delivery_address",
|
"delivery_address",
|
||||||
"client_phone_number",
|
"items": [{
|
||||||
"client_phone_number_ext",
|
"recipe": "lungo",
|
||||||
"updated_at"
|
}, {
|
||||||
|
"recipe": "latte",
|
||||||
|
"milk_type": "oats"
|
||||||
|
}]
|
||||||
}
|
}
|
||||||
// Частично перезаписывает заказ
|
|
||||||
PATCH /v1/orders/123
|
|
||||||
{ "delivery_address" }
|
|
||||||
→
|
→
|
||||||
{ "delivery_address" }
|
{ "order_id" }
|
||||||
|
```
|
||||||
|
```
|
||||||
|
// Частично перезаписывает заказ
|
||||||
|
// обновляет объём второго напитка
|
||||||
|
PATCH /v1/orders/{id}
|
||||||
|
{"items": [null, {
|
||||||
|
"volume": "800ml"
|
||||||
|
}]}
|
||||||
|
→
|
||||||
|
{ /* изменения приняты */ }
|
||||||
```
|
```
|
||||||
— такой подход часто практикуют для того, чтобы уменьшить объёмы запросов и ответов, плюс это позволяет дёшево реализовать совместное редактирование. Оба этих преимущества на самом деле являются мнимыми.
|
|
||||||
|
|
||||||
**Во-первых**, экономия объёма ответа в таком формате бессмысленна. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени. Реальные причины слишком высокого расхода трафика мы описали выше.
|
Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? Ну и самое неприятное — какой бы вариант вы ни выбрали, это только начало проблем.
|
||||||
|
|
||||||
**Во-вторых**, экономия размера ответа выйдет боком как раз при совместном редактировании: один клиент не будет видеть, какие изменения внёс другой. Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.
|
Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
|
||||||
|
|
||||||
**В-третьих**, этот подход может как-то работать при необходимости перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как *удалить* `client_phone_number_ext`?
|
**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта и полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
|
||||||
|
* повышенные размеры запросов и, как следствие, расход трафика;
|
||||||
|
* необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
|
||||||
|
* невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства.
|
||||||
|
|
||||||
Часто в таких случаях прибегают к специальным значениям, которые означают удаление поля, например, `null`. Но, как мы разобрали выше, это плохая практика. Другой вариант — запрет необязательных полей, но это существенно усложняет дальнейшее развитие API.
|
Все эти соображения, однако, на поверку оказываются мнимыми:
|
||||||
|
* причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
|
||||||
|
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент — что не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; более того, существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиент может ошибиться или просто полениться правильно вычислить изменившиеся поля;
|
||||||
|
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
|
||||||
|
* кроме того, часто в рамках той же экономии ответ на операцию частичного обновления пуст; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
|
||||||
|
|
||||||
**Хорошо**: можно применить одну из двух стратегий.
|
**Лучше**: разделить эндпойнты. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
|
||||||
|
|
||||||
**Вариант 1**: разделение эндпойнтов. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
// Возвращает состояние заказа
|
```
|
||||||
// по его идентификатору
|
// Создаёт заказ из двух напитков
|
||||||
GET /v1/orders/123
|
POST /v1/orders/
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"delivery_address"
|
||||||
|
}
|
||||||
|
"items": [{
|
||||||
|
"recipe": "lungo",
|
||||||
|
}, {
|
||||||
|
"recipe": "latte",
|
||||||
|
"milk_type": "oats"
|
||||||
|
}]
|
||||||
|
}
|
||||||
→
|
→
|
||||||
{
|
{
|
||||||
"order_id",
|
"order_id",
|
||||||
"delivery_details": {
|
"created_at",
|
||||||
"address"
|
"parameters": {
|
||||||
},
|
"delivery_address"
|
||||||
"client_details": {
|
|
||||||
"phone_number",
|
|
||||||
"phone_number_ext"
|
|
||||||
},
|
|
||||||
"updated_at"
|
|
||||||
}
|
}
|
||||||
// Полностью перезаписывает
|
"items": [
|
||||||
// информацию о доставке заказа
|
{"item_id", "status"},
|
||||||
PUT /v1/orders/123/delivery-details
|
{"item_id", "status"}
|
||||||
{ "address" }
|
]
|
||||||
// Полностью перезаписывает
|
}
|
||||||
// информацию о клиенте
|
```
|
||||||
PUT /v1/orders/123/client-details
|
```
|
||||||
{ "phone_number" }
|
// Изменяет параметры заказа
|
||||||
|
PUT /v1/orders/{id}/parameters
|
||||||
|
{"delivery_address"}
|
||||||
|
→
|
||||||
|
{"delivery_address"}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
// Частично перезаписывает заказ
|
||||||
|
// обновляет объём второго напитка
|
||||||
|
PUT /v1/orders/{id}/items/{item_id}
|
||||||
|
{"recipe", "volume", "milk_type"}
|
||||||
|
→
|
||||||
|
{"recipe", "volume", "milk_type"}
|
||||||
|
```
|
||||||
```
|
```
|
||||||
|
|
||||||
Теперь для удаления `client_phone_number_ext` достаточно *не* передавать его в `PUT client-details`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`order_id` и `updated_at`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `updated_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования).
|
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования), а не только изменённые суб-объекты.
|
||||||
|
|
||||||
**Вариант 2**: разработать формат описания атомарных изменений.
|
**Ещё лучше**: разработать формат описания атомарных изменений.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/order/changes
|
POST /v1/order/changes
|
||||||
@ -1046,9 +1079,11 @@ X-Idempotency-Token: <см. следующий раздел>
|
|||||||
"field": "delivery_address",
|
"field": "delivery_address",
|
||||||
"value": <новое значение>
|
"value": <новое значение>
|
||||||
}, {
|
}, {
|
||||||
"type": "unset",
|
"type": "unset_item_field",
|
||||||
"field": "client_phone_number_ext"
|
"item_id",
|
||||||
}]
|
"field": "volume"
|
||||||
|
}],
|
||||||
|
…
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -1068,11 +1103,11 @@ X-Idempotency-Token: <см. следующий раздел>
|
|||||||
|
|
||||||
**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
|
**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо.
|
||||||
|
|
||||||
##### Предусмотрите ограничения
|
##### Предусмотрите ограничения доступа
|
||||||
|
|
||||||
С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.
|
С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений.
|
||||||
|
|
||||||
Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет такая необходимость.
|
Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость.
|
||||||
|
|
||||||
Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.
|
Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно.
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user