diff --git a/README.md b/README.md index e2bde2b..b3581e6 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,30 @@ -# [Read ‘The API’ Book by Sergey Konstantinov](https://twirl.github.io/The-API-Book) -# [Читать книгу ‘The API’ Сергея Константинова](https://twirl.github.io/The-API-Book/index.ru.html) +# Read [‘The API’ Book by Sergey Konstantinov](https://twirl.github.io/The-API-Book) in English +# Читать [книгу ‘The API’ Сергея Константинова](https://twirl.github.io/The-API-Book/index.ru.html) по-русски This is the working repository for ‘The API’ book written by Sergey Konstantinov ([email](mailto:twirl-team@yandex.ru), [Linkedin profile](https://linkedin.com/in/twirl)). ## Current State and the Roadmap -Right now all three section (‘The API Design’, ‘The Backwards Compatibility’, and ‘The API Product’) are finished. So the book is basically ready, I'm working on some cosmetics: +Right now all three section (‘The API Design’, ‘The Backwards Compatibility’, and ‘The API Product’) are finished. So the book is basically ready. However, after some beta-testing I understood there were several important problems. + 1. 'Describing final interfaces' chapter is way too overloaded; many concepts explained there deserve a separate chapter, and being minimized to fit the format, they arise a lot of controversy. + 2. Though I've tried to avoid talking about any specific paradigm (REST in particular), it's often being read as such, thus igniting discussions on whether the samples are proper REST. - * adding readable schemes where it's appropriate; - * refactoring the ‘Describing Final Interfaces’ chapters; - * rephrasing and expanding the chapters on versioning and identifying users. +So the current plan is: + 1. To split Chapter 11 into a full Section III (work title: 'API Patterns') comprising: + * defining API-first approach in a technical sense; + * the review of API-describing paradigms (OpenAPI/REST, GraphQL, GRPC, JSON-RPC, SOAP); + * working with default values, backwards compatibility-wise; + * (a)synchronous interaction; + * strong and weak consistency; + * push and poll models; + * machine-readable APIs: iterable lists, cursors, observability; + * an amount of traffic and data compression; + * API errors: resolvability, reduction to defaults; + * degrading properly. + 2. To compile Section IV ‘HTTP API & JSON’ from current drafts + HTTP general knowledge + codestyle. + 3. Maybe, try to compile Section V ‘SDK’ (much harder as there are very few drafts). -I also have more distant plans on adding two more subsections to Section I. - * Section Ia ‘JSON HTTP APIs’: - * the REST myth; - * following HTTP spec, including those parts where you should not follow the spec; - * best practices; - * Section Ib ‘SDK Design’ covering more tricky issues of having proving UI alongside the API (no specific plan right now) +Also, the book still lacks the readable schemes which I'm still planning to plot with mermaid. ## Translation diff --git a/src/en/clean-copy/02-Section I. The API Design/05.md b/src/en/clean-copy/02-Section I. The API Design/05.md index c699994..756e701 100644 --- a/src/en/clean-copy/02-Section I. The API Design/05.md +++ b/src/en/clean-copy/02-Section I. The API Design/05.md @@ -25,30 +25,33 @@ Entity name must explicitly tell what it does and what side effects to expect wh **Bad**: ``` // Cancels an order -GET /orders/cancellation +order.canceled = true; ``` -It's quite a surprise that accessing the `cancellation` resource (what is it?) with the non-modifying `GET` method actually cancels an order. +It's unobvious that a state field might be set, and that this operation will cancel the order. **Better**: ``` // Cancels an order -POST /orders/cancel +order.cancel(); ``` **Bad**: ``` // Returns aggregated statistics // since the beginning of time -GET /orders/statistics +orders.getStats() ``` Even if the operation is non-modifying but computationally expensive, you should explicitly indicate that, especially if clients got charged for computational resource usage. Even more so, default values must not be set in a manner leading to maximum resource consumption. **Better**: ``` -// Returns aggregated statistics +// Calculates and returns +// aggregated statistics // for a specified period of time -POST /v1/orders/statistics/aggregate -{ "begin_date", "end_date" } +orders.calculateAggregatedStats({ + begin_date, + end_date +}); ``` **Try to design function signatures to be absolutely transparent about what the function does, what arguments it takes, and what's the result**. While reading a code working with your API, it must be easy to understand what it does without reading docs. @@ -76,6 +79,8 @@ So *always* specify exactly which standard is applied. Exceptions are possible i or `"duration": "5000ms"` or + `"iso_duration": "PT5S"` + or `"duration": {"unit": "ms", "value": 5000}`. One particular implication of this rule is that money sums must *always* be accompanied by a currency code. @@ -210,20 +215,24 @@ GET /coffee-machines/{id}/stocks This advice is opposite to the previous one, ironically. When developing APIs you frequently need to add a new optional field with a non-empty default value. For example: ``` -POST /v1/orders -{} -→ -{ "contactless_delivery": true } +const orderParams = { + contactless_delivery: false +}; +const order = api.createOrder( + orderParams +); ``` -This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (field isn't set). They have to write something like: +This new `contactless_delivery` option isn't required, but its default value is `true`. A question arises: how developers should discern explicit intention to abolish the option (`false`) from knowing not it exists (the field isn't set). They have to write something like: ``` -if (Type( - order.contactless_delivery - ) == 'Boolean' && - order.contactless_delivery == false) { - … +if ( + Type( + orderParams.contactless_delivery + ) == 'Boolean' && + orderParams + .contactless_delivery == false) { + … } ``` @@ -235,10 +244,12 @@ If the protocol does not support resetting to default values as a first-class ci **Better** ``` -POST /v1/orders -{} -→ -{ "force_contact_delivery": false } +const orderParams = { + force_contact_delivery: true +}; +const order = api.createOrder( + orderParams +); ``` If a non-Boolean field with specially treated value absence is to be introduced, then introduce two fields. @@ -733,11 +744,9 @@ Since the produced view is immutable, access to it might be organized in any for **Option two**: guarantee a strict records order, for example, by introducing a concept of record change events: ``` -POST /v1/records/modified/list -{ - // Optional - "cursor" -} +// `cursor` is optional +GET /v1/records/modified/list⮠ + ?[cursor={cursor}] → { "modified": [ @@ -791,7 +800,8 @@ POST /v1/orders/drafts ``` ``` // Confirms the draft -PUT /v1/orders/drafts/{draft_id} +PUT /v1/orders/drafts⮠ + /{draft_id}/confirmation { "confirmed": true } ``` @@ -852,7 +862,7 @@ There is a common problem with implementing the changes list approach: what to d **Bad**: ``` // Returns a list of recipes -GET /v1/recipes +api.getRecipes(); → { "recipes": [{ @@ -864,8 +874,7 @@ GET /v1/recipes }] } // Changes recipes' parameters -PATCH /v1/recipes -{ +api.updateRecipes({ "changes": [{ "id": "lungo", "volume": "300ml" @@ -873,10 +882,10 @@ PATCH /v1/recipes "id": "latte", "volume": "-1ml" }] -} -→ 400 Bad Request +}); +→ Bad Request // Re-reading the list -GET /v1/recipes +api.getRecipes(); → { "recipes": [{ @@ -896,8 +905,7 @@ If you can't guarantee the atomicity of an operation, you should elaborate in de **Better**: ``` -PATCH /v1/recipes -{ +api.updateRecipes({ "changes": [{ "recipe_id": "lungo", "volume": "300ml" @@ -905,11 +913,11 @@ PATCH /v1/recipes "recipe_id": "latte", "volume": "-1ml" }] -} +}); // You may actually return // a ‘partial success’ status // if the protocol allows it -→ 200 OK +→ { "changes": [{ "change_id", @@ -938,8 +946,7 @@ Might be of use: Non-atomic changes are undesirable because they erode the idempotency concept. Let's take a look at the example: ``` -PATCH /v1/recipes -{ +api.updateRecipes({ "idempotency_token", "changes": [{ "recipe_id": "lungo", @@ -948,8 +955,8 @@ PATCH /v1/recipes "recipe_id": "latte", "volume": "400ml" }] -} -→ 200 OK +}); +→ { "changes": [{ … @@ -968,8 +975,7 @@ PATCH /v1/recipes Imagine the client failed to get a response because of a network error, and it repeats the request: ``` -PATCH /v1/recipes -{ +api.updateRecipes({ "idempotency_token", "changes": [{ "recipe_id": "lungo", @@ -978,8 +984,8 @@ PATCH /v1/recipes "recipe_id": "latte", "volume": "400ml" }] -} -→ 200 OK +}); +→ { "changes": [{ … diff --git a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md index 76721c6..1d81974 100644 --- a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md +++ b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md @@ -25,30 +25,32 @@ **Плохо**: ``` // Отменяет заказ -GET /orders/cancellation +order.canceled = true; ``` -Неочевидно, что достаточно просто обращения к сущности `cancellation` (что это?), тем более немодифицирующим методом `GET`, чтобы отменить заказ. +Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ. **Хорошо**: ``` // Отменяет заказ -POST /orders/cancel +order.cancel(); ``` **Плохо**: ``` // Возвращает агрегированную // статистику заказов за всё время -GET /orders/statistics +orders.getStats() ``` Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы. **Хорошо**: ``` -// Возвращает агрегированную +// Вычисляет и возвращает агрегированную // статистику заказов за указанный период -POST /v1/orders/statistics/aggregate -{ "begin_date", "end_date" } +orders.calculateAggregatedStats({ + begin_date: <начало периода> + end_date: <конец_периода> +}); ``` **Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию. @@ -72,7 +74,9 @@ POST /v1/orders/statistics/aggregate **Хорошо**: `"duration_ms": 5000` либо - `"duration": "5000ms"` + `"duration": "5000ms"` + либо + `"iso_duration": "PT5S"` либо ``` "duration": { @@ -220,19 +224,23 @@ GET /coffee-machines/{id}/stocks Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например: ``` -POST /v1/orders -{ … } -→ -{ "contactless_delivery": true } +const orderParams = { + contactless_delivery: false +}; +const order = api.createOrder( + orderParams +); ``` Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого: ``` -if (Type( - order.contactless_delivery - ) == 'Boolean' && - order.contactless_delivery == false) { +if ( + Type( + orderParams.contactless_delivery + ) == 'Boolean' && + orderParams + .contactless_delivery == false) { … } ``` @@ -245,10 +253,12 @@ if (Type( **Хорошо** ``` -POST /v1/orders -{} -→ -{ "force_contact_delivery": false } +const orderParams = { + force_contact_delivery: true +}; +const order = api.createOrder( + orderParams +); ``` Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей. @@ -741,11 +751,9 @@ GET /v1/record-views/{id}⮠ **Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи: ``` -POST /v1/records/modified/list -{ - // Опционально - "cursor" -} +// Курсор опционален +GET /v1/records/modified/list⮠ + ?[cursor={cursor}] → { "modified": [ @@ -799,7 +807,8 @@ POST /v1/orders/drafts ``` ``` // Подтверждает черновик заказа -PUT /v1/orders/drafts/{draft_id} +PUT /v1/orders/drafts⮠ + /{draft_id}/confirmation { "confirmed": true } ``` Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности. @@ -859,7 +868,7 @@ X-Idempotency-Token: <токен> **Плохо**: ``` // Возвращает список рецептов -GET /v1/recipes +api.getRecipes(); → { "recipes": [{ @@ -872,8 +881,7 @@ GET /v1/recipes } // Изменяет параметры -PATCH /v1/recipes -{ +api.updateRecipes({ "changes": [{ "id": "lungo", "volume": "300ml" @@ -881,11 +889,12 @@ PATCH /v1/recipes "id": "latte", "volume": "-1ml" }] -} -→ 400 Bad Request +}); +→ +Bad Request // Перечитываем список -GET /v1/recipes +api.getRecipes(); → { "recipes": [{ @@ -905,8 +914,7 @@ GET /v1/recipes **Лучше**: ``` -PATCH /v1/recipes -{ +api.updateRecipes({ "changes": [{ "recipe_id": "lungo", "volume": "300ml" @@ -914,11 +922,11 @@ PATCH /v1/recipes "recipe_id": "latte", "volume": "-1ml" }] -} +}); // Можно воспользоваться статусом // «частичного успеха», // если он предусмотрен протоколом -→ 200 OK +→ { "changes": [{ "change_id", @@ -947,8 +955,7 @@ PATCH /v1/recipes Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример: ``` -PATCH /v1/recipes -{ +api.updateRecipes({ "idempotency_token", "changes": [{ "recipe_id": "lungo", @@ -957,8 +964,8 @@ PATCH /v1/recipes "recipe_id": "latte", "volume": "400ml" }] -} -→ 200 OK +}); +→ { "changes": [{ … @@ -977,8 +984,7 @@ PATCH /v1/recipes Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности. ``` -PATCH /v1/recipes -{ +api.updateRecipes({ "idempotency_token", "changes": [{ "recipe_id": "lungo", @@ -987,8 +993,8 @@ PATCH /v1/recipes "recipe_id": "latte", "volume": "400ml" }] -} -→ 200 OK +}); +→ { "changes": [{ … diff --git a/src/ru/drafts/02-Раздел I. Проектирование API/05.md b/src/ru/drafts/02-Раздел I. Проектирование API/05.md new file mode 100644 index 0000000..a05fdfc --- /dev/null +++ b/src/ru/drafts/02-Раздел I. Проектирование API/05.md @@ -0,0 +1,675 @@ +### Описание конечных интерфейсов + +Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным. + +Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API. + +**NB**: примеры, приведённые в этой главе, прежде всего иллюстрируют проблемы консистентности и читабельности, возникающие при разработке API. Мы не ставим здесь цели дать рекомендации по разработке REST API (такого рода советы будут даны в разделе III) или стандартных библиотек языков программирования — важен не конкретный синтаксис, а общая идея. + +Важное уточнение под номером ноль: + +##### 0. Правила не должны применяться бездумно + +Правило — это просто кратко сформулированное обобщение опыта. Они не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно. + +Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам _необходимо_, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно. + +Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API). + +Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов. + +##### Явное лучше неявного + +Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование. + +**Плохо**: +``` +// Отменяет заказ +order.canceled = true; +``` +Неочевидно, что поле состояния можно перезаписывать, и что это действие отменяет заказ. + +**Хорошо**: +``` +// Отменяет заказ +order.cancel(); +``` + +**Плохо**: +``` +// Возвращает агрегированную +// статистику заказов за всё время +orders.getStats() +``` +Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы. + +**Хорошо**: +``` +// Вычисляет и возвращает агрегированную +// статистику заказов за указанный период +orders.calculateAggregatedStats({ + begin_date: <начало периода> + end_date: <конец_периода> +}); +``` + +**Стремитесь к тому, чтобы из сигнатуры функции было абсолютно ясно, что она делает, что принимает на вход и что возвращает**. Вообще, при прочтении кода, работающего с вашим API, должно быть сразу понятно, что, собственно, он делает — без подглядывания в документацию. + +Два важных следствия: + +**1.1.** Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за `GET`. + +**1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных. + +##### Указывайте использованные стандарты + +К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя». Поэтому *всегда* указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе. + +**Плохо**: `"date": "11/12/2020"` — существует огромное количество стандартов записи дат, плюс из этой записи невозможно даже понять, что здесь число, а что месяц. + +**Хорошо**: `"iso_date": "2020-11-12"`. + +**Плохо**: `"duration": 5000` — пять тысяч чего? + +**Хорошо**: + `"duration_ms": 5000` + либо + `"duration": "5000ms"` + либо + `"iso_duration": "PT5S"` + либо +``` +"duration": { + "unit": "ms", + "value": 5000 +} +``` + +Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты. + +Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II. + +##### Сущности должны именоваться конкретно + +Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make. + +**Плохо**: `user.get()` — неочевидно, что конкретно будет возвращено. + +**Хорошо**: `user.get_id()`. + +##### Не экономьте буквы + +В XXI веке давно уже нет нужды называть переменные покороче. + +**Плохо**: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?… + +**Хорошо**: +``` +order + .get_estimated_delivery_time() +``` + +**Плохо**: +``` +// возвращает положение +// первого вхождения в строку str1 +// любого символа из строки str2 +strpbrk (str1, str2) +``` +Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска. + +**Хорошо**: +``` +str_search_for_characters( + str, + lookup_character_set +) +``` +— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей. + +**NB**: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола. + +##### Тип поля должен быть ясен из его названия + +Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`. + +То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — `objects`, `children`; если это невозможно (термин неисчисляем), следует добавить префикс или постфикс, не оставляющий сомнений. + +**Плохо**: `GET /news` — неясно, будет ли получена какая-то конкретная новость или массив новостей. + +**Хорошо**: `GET /news-list`. + +Аналогично, если ожидается булево значение, то это должно быть очевидно из названия, т.е. именование должно описывать некоторое качественное состояние, например, `is_ready`, `open_now`. + +**Плохо**: `"task.status": true` — неочевидно, что статус бинарен, к тому же такой API будет нерасширяемым. + +**Хорошо**: `"task.is_finished": true`. + +Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа `Date`, и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`. + +Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания. + +**Плохо**: +``` +// Возвращает список +// встроенных функций кофемашины +GET /coffee-machines/{id}/functions +``` +Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует). + +**Хорошо**: +``` +GET /v1/coffee-machines/{id}⮠ + /builtin-functions-list +``` + +##### Подобные сущности должны называться подобно и вести себя подобным образом + +**Плохо**: `begin_transition` / `stop_transition` +— `begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации. + +**Хорошо**: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`. + +**Плохо**: +``` +// Находит первую позицию строки `needle` +// внутри строки `haystack` +strpos(haystack, needle) +``` +``` +// Находит и заменяет +// все вхождения строки `needle` +// внутри строки `haystack` +// на строку `replace` +str_replace(needle, replace, haystack) +``` +Здесь нарушены сразу несколько правил: + * написание неконсистентно в части знака подчёркивания; + * близкие по смыслу методы имеют разный порядок аргументов `needle`/`haystack`; + * первый из методов находит только первое вхождение строки `needle`, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций. + +Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю. + +##### Избегайте двойных отрицаний + +**Плохо**: `"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)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги». + +##### Отсутствие результата — тоже результат + +Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой. + +**Плохо** +``` +POST /v1/coffee-machines/search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 404 Not Found +{ + "localized_message": + "Рядом с вами не делают лунго" +} +``` + +Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет. + +**Хорошо**: +``` +POST /v1/coffee-machines/search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 200 OK +{ + "results": [] +} +``` + +Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.) + +##### Валидируйте корректность операции + +В ситуации выбора: указать на ошибку или молча её проглотить — разработчик API должен всегда выбирать первый вариант. + +**Плохо**: +``` +POST /v1/coffee-machines/search +{ + "recipes": ["lngo"] + // Положение пользователя не задано +} +→ +{ + "results": [ + // Результаты для какой-то + // локации по умолчанию + ] +} +``` + +Результатом подобных политик «тихого» исправления ошибок становятся абсурдные ситуации типа «null island» — [самой посещаемой точки в мире](https://www.sciencealert.com/welcome-to-null-island-the-most-visited-place-that-doesn-t-exist). Чем популярнее API, тем больше шансов, что партнеры просто не обратят внимания на такие пограничные ситуации. + +**Хорошо** +``` +POST /v1/coffee-machines/search +{ + "recipes": ["lngo"] + // Положение пользователя не задано +} +→ 400 Bad Request +{ + // описание ошибки + // см. следующее правило +} +``` + +Желательно не только обращать внимание партнёров на ошибки, но и проактивно предупреждать их о поведении, возможно похожем на ошибку: + +``` +POST /v1/coffee-machines/search +{ + "recipes": ["lngo"] + "position": { + "latitude": 0, + "longitude": 0 + }, + "force_convact_delivery": true +} +→ +{ + "results": [], + "warnings": [{ + "type": "suspicious_coordinates", + "message": "Position [0, 0]⮠ + is probably a mistake" + }, { + "type": "unknown_field", + "message": "unknown field:⮠ + `force_convact_delivery`. Did you⮠ + mean `force_contact_delivery`?" + }] +} +``` + +Однако следует отметить, что далеко не во все интерфейсы можно удобно уложить дополнительно возврат предупреждений. В такой ситуации можно ввести дополнительный режим отладки или строгий режим, в котором уровень предупреждений эскалируется: + +``` +POST /v1/coffee-machines/search⮠ + strict_mode=true +{ + "recipes": ["lngo"] + "position": { + "latitude": 0, + "longitude": 0 + } +} +→ 404 Bad Request +{ + "errors": [{ + "type": "suspicious_coordinates", + "message": "Position [0, 0]⮠ + is probably a mistake" + }], + … +} +``` + +Если всё-таки координаты [0, 0] не ошибка, то можно дополнительно задавать игнорируемые ошибки для конкретной операции: + +``` +POST /v1/coffee-machines/search⮠ + strict_mode=true⮠ + disable_errors=suspicious_coordinates +``` + +##### Ошибки должны быть информативными + +Недостаточно просто валидировать ввод — необходимо ещё и уметь правильно описать, в чём состоит проблема. В ходе работы над интеграцией партнёры неизбежно будут допускать детские ошибки. Чем понятнее тексты сообщений, возвращаемых вашим API, тем меньше времени разработчик потратит на отладку, и тем приятнее работать с таким API. + +**Плохо**: +``` +POST /v1/coffee-machines/search +{ + "recipes": ["lngo"], + "position": { + "latitude": 110, + "longitude": 55 + } +} +→ 400 Bad Request +{} +``` +— да, конечно, допущенные ошибки (опечатка в `"lngo"` и неправильные координаты) очевидны. Но раз наш сервер всё равно их проверяет, почему не вернуть описание ошибок в читаемом виде? + +**Хорошо**: +``` +{ + "reason": "wrong_parameter_value", + "localized_message": + "Что-то пошло не так.⮠ + Обратитесь к разработчику приложения." + "details": { + "checks_failed": [ + { + "field": "recipe", + "error_type": "wrong_value", + "message": + "Value 'lngo' unknown.⮠ + Did you mean 'lungo'?" + }, + { + "field": "position.latitude", + "error_type": "constraint_violation", + "constraints": { + "min": -90, + "max": 90 + }, + "message": + "'position.latitude' value⮠ + must fall within⮠ + the [-90, 90] interval" + } + ] + } +} +``` +Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной. + +##### Декларируйте технические ограничения явно + +У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам. + +Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в 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" + } + } +} +``` + +##### Сохраняйте точность дробных чисел + +Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных. + +Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип. + +Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат `00:20` формату `0.333333…`), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления. + +##### Все операции должны быть идемпотентны + +Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни. + +Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию. + +**Плохо**: +``` +// Создаёт заказ +POST /orders +``` +Повтор запроса создаст два заказа! + +**Хорошо**: +``` +// Создаёт заказ +POST /v1/orders +X-Idempotency-Token: <случайная строка> +``` +Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно. + +**Альтернатива**: +``` +// Создаёт черновик заказа +POST /v1/orders/drafts +→ +{ "draft_id" } +``` +``` +// Подтверждает черновик заказа +PUT /v1/orders/drafts⮠ + /{draft_id}/confirmation +{ "confirmed": true } +``` +Создание черновика заказа — необязывающая операция, которая не приводит ни к каким последствиям, поэтому допустимо создавать черновики без токена идемпотентности. +Операция подтверждения заказа — уже естественным образом идемпотентна, для неё `draft_id` играет роль ключа идемпотентности. + +Также стоит упомянуть, что добавление токенов идемпотентности к эндпойнтам, которые и так изначально идемпотентны, имеет определённый смысл, так как токен помогает различить две ситуации: + * клиент не получил ответ из-за сетевых проблем и пытается повторить запрос; + * клиент ошибся, пытаясь применить конфликтующие изменения. + +Рассмотрим следующий пример: представим, что у нас есть ресурс с общим доступом, контролируемым посредством номера ревизии, и клиент пытается его обновить. +``` +POST /resource/updates +{ + "resource_revision": 123 + "updates" +} +``` + +Сервер извлекает актуальный номер ревизии и обнаруживает, что он равен 124. Как ответить правильно? Можно просто вернуть `409 Conflict`, но тогда клиент будет вынужден попытаться выяснить причину конфликта и как-то решить его, потенциально запутав пользователя. К тому же, фрагментировать алгоритмы разрешения конфликтов, разрешая каждому клиенту реализовать какой-то свой — плохая идея. + +Сервер мог бы попытаться сравнить значения поля `updates`, предполагая, что одинаковые значения означают перезапрос, но это предположение будет опасно неверным (например, если ресурс представляет собой счётчик, то последовательные запросы с идентичным телом нормальны). + +Добавление токена идемпотентности (явного в виде случайной строки или неявного в виде черновиков) решает эту проблему +``` +POST /resource/updates +X-Idempotency-Token: <токен> +{ + "resource_revision": 123 + "updates" +} +→ 201 Created +``` +— сервер обнаружил, что ревизия 123 была создана с тем же токеном идемпотентности, а значит клиент просто повторяет запрос. + +Или: +``` +POST /resource/updates +X-Idempotency-Token: <токен> +{ + "resource_revision": 123 + "updates" +} +→ 409 Conflict +``` +— сервер обнаружил, что ревизия 123 была создана с другим токеном, значит имеет место быть конфликт общего доступа к ресурсу. + +Более того, добавление токена идемпотентности не только решает эту проблему, но и позволяет в будущем сделать продвинутые оптимизации. Если сервер обнаруживает конфликт общего доступа, он может попытаться решить его, «перебазировав» обновление, как это делают современные системы контроля версий, и вернуть `200 OK` вместо `409 Conflict`. Эта логика существенно улучшает пользовательский опыт и при этом полностью обратно совместима и предотвращает фрагментацию кода разрешения конфликтов. + +Но имейте в виду: клиенты часто ошибаются при имплементации логики токенов идемпотентности. Две проблемы проявляются постоянно: + * нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции; + * клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов. + +##### Не изобретайте безопасность + +Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна. + +**Во-первых**, почти всегда процедуры, обеспечивающие безопасность той или иной операции, *уже разработаны*. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки [Man-in-the-Middle](https://en.wikipedia.org/wiki/Man-in-the-middle_attack), как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов. + +**Во-вторых**, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен [атаке по времени](https://en.wikipedia.org/wiki/Timing_attack), а веб-сервер — [атаке с разделением запросов](https://capec.mitre.org/data/definitions/105.html). + +Отдельно уточним: любые API должны предоставляться строго по протоколу TLS версии не ниже 1.2 (лучше 1.3). + +##### Помогайте партнёрам не изобретать безопасность + +Не менее важно не только обеспечивать безопасность API как такового, но и предоставить партнёрам такие интерфейсы, которые минимизируют возможные проблемы с безопасностью на их стороне. + +**Плохо**: +``` +// Позволяет партнёру задать +// описание для своего напитка +PUT /v1/partner-api/{partner-id}⮠ + /recipes/lungo/info +"" +``` +``` +// возвращает описание +GET /v1/partner-api/{partner-id}⮠ + /recipes/lungo/info +→ +"" +``` + +Подобный интерфейс является прямым способом соорудить хранимую XSS, которым потенциально может воспользоваться злоумышленник. Да, это ответственность самого партнёра — не допускать сохранения подобного ввода. Но большие цифры по-прежнему работают против вас: всегда найдутся начинающие разработчики, которые не знают об этом виде уязвимости или не подумали о нём. В худшем случае существование таких хранимых уязвимостей может затронуть не только конкретного партнёра, но и вообще всех пользователей API. + +В таких ситуациях мы рекомендуем, во-первых, всегда валидировать вводимые через API данные, и, во-вторых, ограничивать радиус взрыва так, чтобы через уязвимости в коде одного партнёра нельзя было затронуть других партнёров. В случае, если функциональность небезопасного ввода всё же нужна, необходимо предупреждать о рисках максимально явно. + +**Луче** (но не идеально): +``` +// Позволяет партнёру задать +// потенциально небезопасное +// описание для своего напитка +PUT /v1/partner-api/{partner-id}⮠ + /recipes/lungo/info +X-Dangerously-Disable-Sanitizing: true +"" +``` +``` +// возвращает потенциально +// небезопасное описание +GET /v1/partner-api/{partner-id}⮠ + /recipes/lungo/info +X-Dangerously-Allow-Raw-Value: true +→ +"" +``` + +В частности, если вы позволяете посредством API выполнять какие-то текстовые скрипты, всегда предпочитайте безопасный ввод небезопасному. + +**Плохо** +``` +POST /v1/run/sql +{ + // Передаёт готовый запрос целиком + "query": "INSERT INTO data (name)⮠ + VALUES ('Robert');⮠ + DROP TABLE students;--')" +} +``` +**Лучше** +``` +POST /v1/run/sql +{ + // Передаёт шаблон запроса + "query": "INSERT INTO data (name)⮠ + VALUES (?)", + // и параметры для подстановки + values: [ + "Robert');⮠ + DROP TABLE students;--" + ] +} +``` + +Во втором случае вы сможете централизованно экранировать небезопасный ввод и избежать тем самым SQL-инъекции. Напомним повторно, что делать это необходимо с помощью state-of-the-art инструментов, а не самописных регулярных выражений. + +##### Используйте глобально уникальные идентификаторы + +Хорошим тоном при разработке API будет использование для идентификаторов сущностей глобально уникальных строк, либо семантичных (например, "lungo" для видов напитков), либо случайных (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random))). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором. + +Мы вообще склонны порекомендовать использование идентификаторов в urn-подобном формате, т.е. `urn:order:` (или просто `order:`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности, в таком случае неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования. + +Отдельное важное следствие: **не используйте инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений. + +**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо. + +##### Предусмотрите ограничения доступа + +С ростом популярности API вам неизбежно придётся внедрять технические средства защиты от недобросовестного использования — такие, как показ капчи, расстановка приманок-honeypot-ов, возврат ошибок вида «слишком много запросов», постановка прокси-защиты от DDoS перед эндпойнтами и так далее. Всё это невозможно сделать, если вы не предусмотрели такой возможности изначально, а именно — не ввели соответствующей номенклатуры ошибок и предупреждений. + +Вы не обязаны с самого начала такие ошибки действительно генерировать — но вы можете предусмотреть их на будущее. Например, вы можете описать ошибку `429 Too Many Requests` или перенаправление на показ капчи, но не имплементировать возврат таких ответов, пока не возникнет в этом необходимость. + +Отдельно необходимо уточнить, что в тех случаях, когда через API можно совершать платежи, ввод дополнительных факторов аутентификации пользователя (через TOTP, SMS или технологии типа 3D-Secure) должен быть предусмотрен обязательно. + +##### Не предоставляйте endpoint-ов массового получения чувствительных данных + +Если через API возможно получение персональных данных, номер банковских карт, переписки пользователей и прочей информации, раскрытие которой нанесёт большой ущерб пользователям, партнёрам и/или вам — методов массового получения таких данных в API быть не должно, или, по крайней мере, на них должны быть ограничения на частоту запросов, размер страницы данных, а в идеале ещё и многофакторная аутентификация. + +Часто разумной практикой является предоставление таких массовых выгрузок по запросу, т.е. фактически в обход API. + +##### Локализация и интернационализация + +Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет. + +Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны. + +Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма. + +Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б». + +**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения. + +Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`. + +И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой. diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Observability.md b/src/ru/drafts/03-Раздел II. Паттерны API/Observability.md new file mode 100644 index 0000000..d62fd0f --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Observability.md @@ -0,0 +1,83 @@ +#### Правила разработки машиночитаемых интерфейсов + +В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации. + +##### Состояние системы должно быть понятно клиенту + +Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус. + +**Плохо**: +``` +// Создаёт заказ и возвращает его id +POST /v1/orders +{ … } +→ +{ "order_id" } +``` +``` +// Возвращает заказ по его id +GET /v1/orders/{id} +// Заказ ещё не подтверждён +// и ожидает проверки +→ 404 Not Found +``` +— хотя операция будто бы выполнена успешно, клиенту необходимо самостоятельно запомнить идентификатор заказа и периодически проверять состояние `GET /v1/orders/{id}`. Этот паттерн плох сам по себе, но ещё и усугубляется двумя обстоятельствами: + + * клиент может потерять идентификатор, если произошёл системный сбой в момент между отправкой запроса и получением ответа или было повреждено (очищено) системное хранилище данных приложения; + * потребитель не может воспользоваться другим устройством; фактически, знание о сделанном заказе привязано к конкретному юзер-агенту. + +В обоих случаях потребитель может решить, что заказ по какой-то причине не создался — и сделать повторный заказ со всеми вытекающими отсюда проблемами. + +**Хорошо**: +``` +// Создаёт заказ и возвращает его +POST /v1/orders +{ <параметры заказа> } +→ +{ + "order_id", + // Заказ создаётся в явном статусе + // «идёт проверка» + "status": "checking", + … +} +``` +``` +// Возвращает заказ по его id +GET /v1/orders/{id} +→ +{ "order_id", "status" … } +``` +``` +// Возвращает все заказы пользователя +// во всех статусах +GET /v1/users/{id}/orders +``` + +Это правило также распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема. + +**Плохо**: `{ "error": "email malformed" }` +— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю + +**Хорошо**: +``` +{ + // Машиночитаемый статус + "status": "validation_failed", + // Массив описания проблем; + // если пользовательский ввод + // некорректен в нескольких + // аспектах, пользователь сможет + // исправить их все + "failed_checks": [ + { + "field: "email", + "error_type": "malformed", + // Локализованное + // человекочитаемое + // сообщение + "message": "email malformed" + } + ] +} +``` diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Атомарность.md b/src/ru/drafts/03-Раздел II. Паттерны API/Атомарность.md new file mode 100644 index 0000000..6ee442c --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Атомарность.md @@ -0,0 +1,150 @@ +##### Избегайте неатомарных операций + +С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это. + +**Плохо**: +``` +// Возвращает список рецептов +api.getRecipes(); +→ +{ + "recipes": [{ + "id": "lungo", + "volume": "200ml" + }, { + "id": "latte", + "volume": "300ml" + }] +} + +// Изменяет параметры +api.updateRecipes({ + "changes": [{ + "id": "lungo", + "volume": "300ml" + }, { + "id": "latte", + "volume": "-1ml" + }] +}); +→ +Bad Request + +// Перечитываем список +api.getRecipes(); +→ +{ + "recipes": [{ + "id": "lungo", + // Это значение изменилось + "volume": "300ml" + }, { + "id": "latte", + // А это нет + "volume": "300ml" + }] +} +``` +— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом. + +Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно. + +**Лучше**: +``` +api.updateRecipes({ + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "-1ml" + }] +}); +// Можно воспользоваться статусом +// «частичного успеха», +// если он предусмотрен протоколом +→ +{ + "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`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось. + +Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример: + +``` +api.updateRecipes({ + "idempotency_token", + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "400ml" + }] +}); +→ +{ + "changes": [{ + … + "status": "success" + }, { + … + "status": "fail", + "error": { + "reason": + "too_many_requests" + } + }] +} +``` + +Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности. + +``` +api.updateRecipes({ + "idempotency_token", + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "400ml" + }] +}); +→ +{ + "changes": [{ + … + "status": "success" + }, { + … + "status": "success", + }] +} +``` + +По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности. + +Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений. + +На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности. diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Ошибки.md b/src/ru/drafts/03-Раздел II. Паттерны API/Ошибки.md new file mode 100644 index 0000000..b70ba3e --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Ошибки.md @@ -0,0 +1,105 @@ +##### Соблюдайте правильный порядок ошибок + +**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых: +``` +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" +} +``` + +Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса. diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Пагинация.md b/src/ru/drafts/03-Раздел II. Паттерны API/Пагинация.md new file mode 100644 index 0000000..fe5b69c --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Пагинация.md @@ -0,0 +1,134 @@ +##### Пагинация, фильтрация и курсоры + +Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может. + +Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать. + +**Плохо**: +``` +// Возвращает указанный limit записей, +// отсортированных по дате создания +// начиная с записи с номером offset +GET /v1/records?limit=10&offset=100 +``` +На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса. + 1. Каким образом клиент узнает о появлении новых записей в начале списка? + Легко заметить, что клиент может только попытаться повторить первый запрос и сверить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает `limit`? Представим себе ситуацию: + * клиент обрабатывает записи в порядке поступления; + * произошла какая-то проблема, и накопилось большое количество необработанных записей; + * клиент запрашивает новые записи (`offset=0`), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем `limit`; + * клиент вынужден продолжить перебирать записи (увеличивая `offset`) до тех пор, пока не доберётся до последней известной ему; всё это время клиент простаивает; + * таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором. + 2. Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена? + Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать. + 3. Какие параметры кэширования мы можем выставить на этот эндпойнт? + Никакие: повторяя запрос с теми же `limit`-`offset`, мы каждый раз получаем новый набор записей. + +**Хорошо**: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок сортировки по которому фиксирован. Например, вот так: +``` +// Возвращает указанный limit записей, +// отсортированных по дате создания, +// начиная с первой записи, +// созданной позднее, +// чем запись с указанным id +GET /v1/records⮠ + ?older_than={record_id}&limit=10 +// Возвращает указанный limit записей, +// отсортированных по дате создания, +// начиная с первой записи, +// созданной раньше, +// чем запись с указанным id +GET /v1/records⮠ + ?newer_than={record_id}&limit=10 +``` + +При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор. +Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей. +Другой вариант организации таких списков — возврат курсора `cursor`, который используется вместо `record_id`, что делает интерфейсы более универсальными. + +``` +// Первый запрос данных +POST /v1/records/list +{ + // Какие-то дополнительные + // параметры фильтрации + "filter": { + "category": "some_category", + "created_date": { + "older_than": "2020-12-07" + } + } +} +→ +{ "cursor" } +``` + +``` +// Последующие запросы +GET /v1/records?cursor=<курсор> +{ "records", "cursor" } +``` + +Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. `filter` в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее. + +Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один. + +**NB**: в некоторых источниках такой подход, напротив, не рекомендуется по следующей причине: пользователю невозможно показать список страниц и дать возможность выбрать произвольную. Здесь следует отметить, что: + * подобный кейс — список страниц и выбор страниц — существует только для пользовательских интерфейсов; представить себе API, в котором действительно требуется доступ к случайным страницам данных мы можем с очень большим трудом; + * если же мы всё-таки говорим об API приложения, которое содержит элемент управления с постраничной навигацией, то наиболее правильный подход — подготавливать данные для этого элемента управления на стороне сервера, в т.ч. генерировать ссылки на страницы; + * подход с курсором не означает, что `limit`/`offset` использовать нельзя — ничто не мешает сделать двойной интерфейс, который будет отвечать и на запросы вида `GET /items?cursor=…`, и на запросы вида `GET /items?offset=…&limit=…`; + * наконец, если возникает необходимость предоставлять доступ к произвольной странице в пользовательском интерфейсе, то следует задать себе вопрос, какая проблема тем самым решается; вероятнее всего с помощью этой функциональности пользователь что-то ищет: определенный элемент списка или может быть позицию, на которой он закончил работу со списком в прошлый раз; возможно, для этих задач следует предоставить более удобные элементы управления, нежели перебор страниц. + +**Плохо**: +``` +// Возвращает указанный limit записей, +// отсортированных по полю sort_by +// в порядке sort_order, +// начиная с записи с номером offset +GET /records?sort_by=date_modified⮠ + &sort_order=desc&limit=10&offset=100 +``` + +Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям. + +**Хорошо**: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов. + +**Вариант 1**: фиксировать порядок в момент обработки запроса; т.е. сервер формирует полный список и сохраняет его в неизменяемом виде: + +``` +// Создаёт представление по указанным параметрам +POST /v1/record-views +{ + sort_by: [{ + "field": "date_modified", + "order": "desc" + }] +} +→ +{ "id", "cursor" } +``` + +``` +// Позволяет получить часть представления +GET /v1/record-views/{id}⮠ + ?cursor={cursor} +``` + +Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком). + +**Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи: + +``` +// Курсор опционален +GET /v1/records/modified/list⮠ + ?[cursor={cursor}] +→ +{ + "modified": [ + { "date", "record_id" } + ], + "cursor" +} +``` + +Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто. diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Совместный доступ.md b/src/ru/drafts/03-Раздел II. Паттерны API/Совместный доступ.md new file mode 100644 index 0000000..1a3d93a --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Совместный доступ.md @@ -0,0 +1,137 @@ +##### Избегайте неявных частичных обновлений + +Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния. + +**Плохо**: + +``` +// Создаёт заказ из двух напитков +POST /v1/orders/ +{ + "delivery_address", + "items": [{ + "recipe": "lungo", + }, { + "recipe": "latte", + "milk_type": "oats" + }] +} +→ +{ "order_id" } +``` + +``` +// Частично перезаписывает заказ +// обновляет объём второго напитка +PATCH /v1/orders/{id} +{ + "items": [null, { + "volume": "800ml" + }] +} +→ +{ /* изменения приняты */ } +``` + +Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? + +Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{"items":[null, {…}]}` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию? + +**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам: + * повышенные размеры запросов и, как следствие, расход трафика; + * необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения; + * невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства. + +Все эти соображения, однако, на поверку оказываются мнимыми: + * причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт); + * концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент; + * это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; + * существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля; + * наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны); + * кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга. + +**Лучше**: разделить эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. + +``` +// Создаёт заказ из двух напитков +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`?). + +Также в ответах операций `PUT` можно возвращать объект заказа целиком, а не перезаписываемый суб-ресурс (однако следует использовать какую-то конвенцию именования). + +**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке). + +**Ещё лучше**: разработать формат описания атомарных изменений. + +``` +POST /v1/order/changes +X-Idempotency-Token: <токен идемпотентности> +{ + "changes": [{ + "type": "set", + "field": "delivery_address", + "value": <новое значение> + }, { + "type": "unset_item_field", + "item_id", + "field": "volume" + }], + … +} +``` + +Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения. diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Трафик.md b/src/ru/drafts/03-Раздел II. Паттерны API/Трафик.md new file mode 100644 index 0000000..a96d81a --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Трафик.md @@ -0,0 +1,23 @@ +##### Считайте трафик + +В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей. + +Три основные причины раздувания объёма трафика достаточно очевидны: + * не предусмотрен постраничный перебор данных; + * не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.); + * клиент слишком часто запрашивает данные и/или слишком мало их кэширует. + +Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций: + + * не злоупотребляйте асинхронными интерфейсами; + * с одной стороны, они позволяют нивелировать многие технические проблемы с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных; + * с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений; + + * объявляйте явную политику перезапросов (например, посредством заголовка `Retry-After`); + * да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK); + + * если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы); + + * если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по размеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных. + +Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл. \ No newline at end of file diff --git a/src/ru/drafts/03-Раздел II. Паттерны API/Хелперы, обратная совместимость, работа с умолчаниями.md b/src/ru/drafts/03-Раздел II. Паттерны API/Хелперы, обратная совместимость, работа с умолчаниями.md new file mode 100644 index 0000000..95f3458 --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Паттерны API/Хелперы, обратная совместимость, работа с умолчаниями.md @@ -0,0 +1,84 @@ +##### Избегайте неявного приведения типов + +Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например: + +``` +const orderParams = { + contactless_delivery: false +}; +const order = api.createOrder( + orderParams +); +``` + +Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого: + +``` +if ( + Type( + orderParams.contactless_delivery + ) == 'Boolean' && + orderParams + .contactless_delivery == false) { + … +} +``` + +Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`. + +**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть. + +Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false. + +**Хорошо** +``` +const orderParams = { + force_contact_delivery: true +}; +const order = api.createOrder( + orderParams +); +``` + +Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей. + +**Плохо**: +``` +// Создаёт пользователя +POST /v1/users +{ … } +→ +// Пользователи создаются по умолчанию +// с указанием лимита трат в месяц +{ + "spending_monthly_limit_usd": "100", + … +} +// Для отмены лимита требуется +// указать значение null +PUT /v1/users/{id} +{ + "spending_monthly_limit_usd": null, + … +} +``` + +**Хорошо** +``` +POST /v1/users +{ + // true — у пользователя снят + // лимит трат в месяц + // false — лимит не снят + // (значение по умолчанию) + "abolish_spending_limit": false, + // Необязательное поле, имеет смысл + // только если предыдущий флаг + // имеет значение false + "spending_monthly_limit_usd": "100", + … +} +``` + +**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь. + diff --git a/src/ru/drafts/План.md b/src/ru/drafts/План.md index 96c33aa..b5ccc4d 100644 --- a/src/ru/drafts/План.md +++ b/src/ru/drafts/План.md @@ -1,43 +1,43 @@ -1. Зачем предоставлять API - * возможности интеграции с основным сервисом - * непрофильные сервисы - * концепция API-first -2. Выгоды наличия API и виды монетизации - * API к монетизируемым сервисам (+ Affiliate API) - * on-premise - * freemium-модели - * лимиты - * ограничения на типы использования - * техническая поддержка - * премиум-функциональность - * маркетплейсы - * рекламные модели - * сбор данных / UGC - * контакт с брендом - * терраформирование -3. Прямая и обратная API-пирамида Маслоу -4. Виды API - * API - * SDK - * виджеты - * embedded (включая картинки) -5. Бизнес-аудитория, её интересы и места обитания - * SLA - * безопасность - * идентификация пользователей, ведение статистики, защита публичных ключей - * контакты и оповещения -6. Разработчики, их интересы и места обитания - * новички vs продвинутые - * OpenSource -7. Документация - * референс - * tutorial - * how-to - * примеры - * песочница -8. Поддержка пользователей - * форумы - * поддержка разработчиков - * работа с комьюнити - * обратная связь -9. Продуктовое управление разработкой API \ No newline at end of file +Раздел I + * описание конечных советов — оставить только кодстайл +Раздел II + * API-first подход + * выбор поддерживаемых стандартов + * существующие стандарты (описания) взаимодействия через API + * JSON / OpenAPI + * XML-RPC / WSDL + * JSON-RPC + * GraphQL + * GRPC + * клиентские библиотеки + * хелперы, обратная совместимость, работа с умолчаниями + * синхронное и асинхронное взаимодействие + * push- и poll-модели + * сильная и слабая консистентность + * машиночитаемое API + * observability + * перебор списков, курсоры + * объёмы передаваемых данных + * сжатие + * кэширование + * ошибки + * разрешимость + * сведение к умолчанию + * ошибка для пользователя vs ошибка для разработчика + * деградация + * мониторинг состояния +Раздел III + * о терминологии + * введение в HTTP + * REST, определение и реальность + * плюсы и минусы разработки HTTP API + * проблема статус-кодов + * трактовка методов + * CRUD + * кодстайл + * domain/path/query/body +Раздел IV + * постановка проблемы + * реюз кода vs реюз поведения + * трехстороннее взаимодействие + * асинхронность, разделяемые ресурсы \ No newline at end of file