From 74ee3a44cf48da996e39906910c24be06f9fb512 Mon Sep 17 00:00:00 2001 From: Sergey Konstantinov Date: Tue, 6 Sep 2022 07:19:43 +0300 Subject: [PATCH] Chapter 11 refactoring begin --- src/ru/drafts/05.md | 1065 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1065 insertions(+) create mode 100644 src/ru/drafts/05.md diff --git a/src/ru/drafts/05.md b/src/ru/drafts/05.md new file mode 100644 index 0000000..c222f29 --- /dev/null +++ b/src/ru/drafts/05.md @@ -0,0 +1,1065 @@ +### Описание конечных интерфейсов + +Определив все сущности, их ответственность и отношения друг с другом, мы переходим непосредственно к разработке API: нам осталось прописать номенклатуру всех объектов, полей, методов и функций в деталях. В этой главе мы дадим сугубо практические советы, как сделать API удобным и понятным. + +Важное уточнение под номером ноль: + +##### 0. Правила — это всего лишь обобщения + +Правила не действуют безусловно и не означают, что можно не думать головой. У каждого правила есть какая-то рациональная причина его существования. Если в вашей ситуации нет причин следовать правилу — значит, следовать ему не нужно. + +Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам _необходимо_, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно. + +Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API). + +Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов. + +#### Читабельность и консистентность + +Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API сторонними разработчиками, легко читался и поддерживался. Помните, что в случае API закон больших чисел неизбежно работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, то с ростом числа потребителей API будет расти и количество неправильно понявших принципы программистов, а значит и количество плохо работающего кода, ошибки в котором будут видны конечным пользователям. + +##### Явное лучше неявного + +Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование. + +**Плохо**: +``` +// Отменяет заказ +GET /orders/cancellation +``` +Неочевидно, что достаточно просто обращения к сущности `cancellation` (что это?), тем более немодифицирующим методом `GET`, чтобы отменить заказ. + +**Хорошо**: +``` +// Отменяет заказ +POST /orders/cancel +``` + +**Плохо**: +``` +// Возвращает агрегированную статистику заказов за всё время +GET /orders/statistics +``` +Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы. + +**Хорошо**: +``` +// Возвращает агрегированную статистику заказов за указанный период +POST /v1/orders/statistics/aggregate +{ "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"` + либо + `"duration": {"unit": "ms", "value": 5000}`. + +Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты. + +Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II. + +##### Сущности должны именоваться конкретно + +Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make. + +**Плохо**: `user.get()` — неочевидно, что конкретно будет возвращено. + +**Хорошо**: `user.get_id()`. + +##### Не экономьте буквы + +В XXI веке давно уже нет нужды называть переменные покороче. + +**Плохо**: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?… + +**Хорошо**: `order.get_estimated_delivery_time()` + +**Плохо**: +``` +// возвращает положение первого вхождения в строку str2 +// любого символа из строки str2 +strpbrk (str1, str2) +``` +Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска. + +**Хорошо**: `str_search_for_characters (lookup_character_set, str)` +— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей. + +##### Тип поля должен быть ясен из его названия + +Если поле называется `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-типов. Например, объекты типа `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)`, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги». + +##### Избегайте неявного приведения типов + +Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например: + +``` +POST /v1/orders +{} +→ +{ + "contactless_delivery": true +} +``` + +Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого: + +``` +if (Type(order.contactless_delivery) == 'Boolean' && + order.contactless_delivery == false) { … } +``` + +Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`. + +В этих ситуациях универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false. + +**Хорошо** +``` +POST /v1/orders +{} +→ +{ + "force_contact_delivery": false +} +``` + +Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей. + +**Плохо**: +``` +// Создаёт пользователя +POST /users +{ … } +→ +// Пользователи создаются по умолчанию +// с указанием лимита трат в месяц +{ + … + "spending_monthly_limit_usd": "100" +} +// Для отмены лимита требуется +// указать значение null +POST /users +{ + … + "spending_monthly_limit_usd": null +} +``` + +**Хорошо** +``` +POST /users +{ + // true — у пользователя снят + // лимит трат в месяц + // false — лимит не снят + // (значение по умолчанию) + "abolish_spending_limit": false, + // Необязательное поле, имеет смысл + // только если предыдущий флаг + // имеет значение false + "spending_monthly_limit_usd": "100", + … +} +``` + +**NB**: противоречие с предыдущим советом состоит в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `abolish_spending_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь. + +##### Отсутствие результата — тоже результат + +Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой. + +**Плохо** +``` +POST /search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 404 Not Found +{ + "localized_message": + "Рядом с вами не делают лунго" +} +``` + +Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет. + +**Хорошо**: +``` +POST /search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 200 OK +{ + "results": [] +} +``` + +Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.) + +##### Ошибки должны быть информативными + +При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка: неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим 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. Do you mean 'lungo'?" + }, + { + "field": "position.latitude", + "error_type": "constraint_violation", + "constraints": { + "min": -90, + "max": 90 + }, + "message": + "'position.latitude' value must fall in [-90, 90] interval" + } + ] + } +} +``` +Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной. + +##### Соблюдайте правильный порядок ошибок + +**Во-первых**, всегда показывайте неразрешимые ошибки прежде разрешимых: +``` +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" +} +``` + +Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса. + +#### Правила разработки машиночитаемых API + +В погоне за понятностью концепций 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 +``` + +##### Пагинация, фильтрация и курсоры + +Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может. + +Любой эндпойнт, возвращающий изменяемые данные постранично, должен обеспечивать возможность эти данные перебрать. + +**Плохо**: +``` +// Возвращает указанный 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**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи: + +``` +POST /v1/records/modified/list +{ + // Опционально + "cursor" +} +→ +{ + "modified": [ + { "date", "record_id" } + ], + "cursor" +} +``` + +Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто. + +#### Техническое качество API + +Хороший API должен не просто решать проблемы разработчиков и пользователей, но и делать это максимально качественно, т.е. не содержать в себе логических и технических ошибок (и не провоцировать на них разработчика), экономить вычислительные ресурсы и вообще имплементировать лучшие практики в своей предметной области. + +##### Сохраняйте точность дробных чисел + +Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных. + +Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип. + +##### Все операции должны быть идемпотентны + +Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни. + +Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию. + +**Плохо**: +``` +// Создаёт заказ +POST /orders +``` +Повтор запроса создаст два заказа! + +**Хорошо**: +``` +// Создаёт заказ +POST /v1/orders +X-Idempotency-Token: <случайная строка> +``` +Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно. + +**Альтернатива**: +``` +// Создаёт черновик заказа +POST /v1/orders/drafts +→ +{ "draft_id" } +``` +``` +// Подтверждает черновик заказа +PUT /v1/orders/drafts/{draft_id} +{ "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), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов. + +##### Избегайте неатомарных операций + +С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это. + +**Плохо**: +``` +// Возвращает список рецептов +GET /v1/recipes +→ +{ + "recipes": [{ + "id": "lungo", + "volume": "200ml" + }, { + "id": "latte", + "volume": "300ml" + }] +} + +// Изменяет параметры +PATCH /v1/recipes +{ + "changes": [{ + "id": "lungo", + "volume": "300ml" + }, { + "id": "latte", + "volume": "-1ml" + }] +} +→ 400 Bad Request + +// Перечитываем список +GET /v1/recipes +→ +{ + "recipes": [{ + "id": "lungo", + // Это значение изменилось + "volume": "300ml" + }, { + "id": "latte", + // А это нет + "volume": "300ml" + }] +} +``` +— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом. + +Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно. + +**Лучше**: +``` +PATCH /v1/recipes +{ + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "-1ml" + }] +} +// Можно воспользоваться статусом +// «частичного успеха», если он предусмотрен +// протоколом +→ 200 OK +{ + "changes": [{ + "change_id", + "occurred_at", + "recipe_id": "lungo", + "status": "success" + }, { + "change_id", + "occurred_at", + "recipe_id": "latte", + "status": "fail", + "error" + }] +} +``` + +Здесь: + * `change_id` — уникальный идентификатор каждого атомарного изменения; + * `occurred_at` — время проведения каждого изменения; + * `error` — информация по ошибке для каждого изменения, если она возникла. + +Не лишним будет также: + * ввести в запросе `sequence_id`, чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом; + * предоставить отдельный эндпойнт `/changes-history`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось. + +Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример: + +``` +PATCH /v1/recipes +{ + "idempotency_token", + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "400ml" + }] +} +→ 200 OK +{ + "changes": [{ + … + "status": "success" + }, { + … + "status": "fail", + "error": { + "reason": "too_many_requests" + } + }] +} +``` + +Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности. + +``` +PATCH /v1/recipes +{ + "idempotency_token", + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "400ml" + }] +} +→ 200 OK +{ + "changes": [{ + … + "status": "success" + }, { + … + "status": "success", + }] +} +``` + +По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запроса отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности. + +Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений. + +На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности. + +##### Не изобретайте безопасность + +Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна. + +**Во-первых**, почти всегда процедуры, обеспечивающие безопасность той или иной операции, *уже разработаны*. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки Man-in-the-Middle, как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов. + +**Во-вторых**, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. + +##### Считайте трафик + +##### Избегайте неявных частичных обновлений + +**Плохо**: +``` +// Возвращает состояние заказа +// по его идентификатору +GET /v1/orders/123 +→ +{ + "order_id", + "delivery_address", + "client_phone_number", + "client_phone_number_ext", + "updated_at" +} +// Частично перезаписывает заказ +PATCH /v1/orders/123 +{ "delivery_address" } +→ +{ "delivery_address" } +``` +— такой подход часто практикуют для того, чтобы уменьшить объёмы запросов и ответов, плюс это позволяет дёшево реализовать совместное редактирование. Оба этих преимущества на самом деле являются мнимыми. + +**Во-первых**, экономия объёма ответа в современных условиях требуется редко. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени. + +Перерасход трафика возникает, если: + + * не предусмотрен постраничный перебор данных; + * не предусмотрены ограничения на размер значений полей; + * передаются бинарные данные (графика, аудио, видео и т.д.). + +Во всех трёх случаях передача части полей в лучшем случае замаскирует проблему, но не решит. Более оправдан следующий подход: + + * для «тяжёлых» данных сделать отдельные эндпойнты; + * ввести пагинацию и лимитирование значений полей; + * на всём остальном не пытаться экономить. + +**Во-вторых**, экономия размера ответа выйдет боком как раз при совместном редактировании: один клиент не будет видеть, какие изменения внёс другой. Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. + +**В-третьих**, этот подход может как-то работать при необходимости перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как *удалить* `client_phone_number_ext`? + +Часто в таких случаях прибегают к специальным значениям, которые означают удаление поля, например, `null`. Но, как мы разобрали выше, это плохая практика. Другой вариант — запрет необязательных полей, но это существенно усложняет дальнейшее развитие API. + +**Хорошо**: можно применить одну из двух стратегий. + +**Вариант 1**: разделение эндпойнтов. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. + +``` +// Возвращает состояние заказа +// по его идентификатору +GET /v1/orders/123 +→ +{ + "order_id", + "delivery_details": { + "address" + }, + "client_details": { + "phone_number", + "phone_number_ext" + }, + "updated_at" +} +// Полностью перезаписывает +// информацию о доставке заказа +PUT /v1/orders/123/delivery-details +{ "address" } +// Полностью перезаписывает +// информацию о клиенте +PUT /v1/orders/123/client-details +{ "phone_number" } +``` + +Теперь для удаления `client_phone_number_ext` достаточно *не* передавать его в `PUT client-details`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`order_id` и `updated_at`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `updated_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования). + +**Вариант 2**: разработать формат описания атомарных изменений. + +``` +POST /v1/order/changes +X-Idempotency-Token: <см. следующий раздел> +{ + "changes": [{ + "type": "set", + "field": "delivery_address", + "value": <новое значение> + }, { + "type": "unset", + "field": "client_phone_number_ext" + }] +} +``` + +Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и сервер впоследствии автоматически разрешает конфликты, «перебазируя» изменения. + +##### Указывайте политики кэширования + +В клиент-серверном 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 + +Помимо технологических ограничений, любой реальный API скоро столкнётся и с несовершенством окружающей действительности. Конечно, мы все хотели бы жить в мире розовых единорогов, свободном от накопления legacy, злоумышленников, национальных конфликтов и происков конкурентов. Но, к сожалению или к счастью, живём мы в реальном мире, в котором хороший API должен учитывать всё вышеперечисленное. + +##### Используйте глобально уникальные идентификаторы + +Хорошим тоном при разработке 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 по мнению официальных органов государства Б». + +**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 19 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения. + +Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`. + +И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.