diff --git a/src/ru/clean-copy/01-Введение/06.md b/src/ru/clean-copy/01-Введение/06.md index 81be3b8..c0c271c 100644 --- a/src/ru/clean-copy/01-Введение/06.md +++ b/src/ru/clean-copy/01-Введение/06.md @@ -27,7 +27,7 @@ Cache-Control: no-cache ``` Её следует читать так: - * выполняется POST-запрос к ресурсу `/v1/bucket/{id}/some-resource`, где `{id}` заменяется на некоторый идентификатор `bucket`-а (при отсутствии уточнений подстановки вида `{something}` следует относить к ближайшему термину слева); + * клиент выполняет POST-запрос к ресурсу `/v1/bucket/{id}/some-resource`, где `{id}` заменяется на некоторый идентификатор `bucket`-а (при отсутствии уточнений подстановки вида `{something}` следует относить к ближайшему термину слева); * запрос сопровождается (помимо стандартных заголовков, которые мы опускаем) дополнительным заголовком `X-Idempotency-Token`; * фразы в угловых скобках (`<токен идемпотентности>`) описывают семантику значения сущности (поля, заголовка, параметра); * в качестве тела запроса передаётся JSON, содержащий поле `some_parameter` со значением `value` и ещё какие-то поля, которые для краткости опущены (что показано многоточием); @@ -35,9 +35,11 @@ Cache-Control: no-cache * в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание; * телом ответа является JSON, состоящий из единственного поля `error_message`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке. +Здесь термин «клиент» означает «приложение, установленное на устройстве пользователя, использующее рассматриваемое API». Приложение может быть как нативным, так и веб-приложением. Термины «агент» и «юзер-агент» являются синонимами термина «клиент». + Ответ (частично или целиком) и тело запроса могут быть опущены, если в контексте обсуждаемого вопроса их содержание не имеют значения. -Для упрощения возможна сокращенная запись вида: `POST /v1/bucket/{id}/some-resource` `{…,"some_parameter",…}` → `{ "operation_id" }`; тело запроса и/или ответа может опускаться аналогично полной записи. +Возможна сокращённая запись вида: `POST /v1/bucket/{id}/some-resource` `{…,"some_parameter",…}` → `{ "operation_id" }`; тело запроса и/или ответа может опускаться аналогично полной записи. Чтобы сослаться на это описание будут использоваться выражения типа «метод `POST /v1/bucket/{id}/some-resource`» или, для простоты, «метод `some-resource`» или «метод `bucket/some-resource`» (если никаких других `some-resource` в контексте главы не упоминается и перепутать не с чем). diff --git a/src/ru/clean-copy/02-I. Проектирование API/01.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/01.md similarity index 100% rename from src/ru/clean-copy/02-I. Проектирование API/01.md rename to src/ru/clean-copy/02-Раздел I. Проектирование API/01.md diff --git a/src/ru/clean-copy/02-I. Проектирование API/02.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/02.md similarity index 100% rename from src/ru/clean-copy/02-I. Проектирование API/02.md rename to src/ru/clean-copy/02-Раздел I. Проектирование API/02.md diff --git a/src/ru/clean-copy/02-I. Проектирование API/03.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/03.md similarity index 97% rename from src/ru/clean-copy/02-I. Проектирование API/03.md rename to src/ru/clean-copy/02-Раздел I. Проектирование API/03.md index 8677d97..f059c1d 100644 --- a/src/ru/clean-copy/02-I. Проектирование API/03.md +++ b/src/ru/clean-copy/02-Раздел I. Проектирование API/03.md @@ -7,10 +7,10 @@ Вспомним, что программный продукт - это средство связи контекстов, средство преобразования терминов и операций одной предметной области в другую. Чем дальше друг от друга эти области отстоят - тем большее число промежуточных передаточных звеньев нам придётся ввести. Вернёмся к нашему примеру с кофейнями. Какие уровни сущностей мы видим? 1. Мы готовим с помощью нашего API *заказ* — один или несколько стаканов кофе — и взымаем за это плату. - 2. Каждый стакан кофе приготовлен по определённому рецепту, что подразумевает наличие разных ингредиентов и последовательности выполнения шагов приготовления. - 3. Напиток готовится на конкретной физической кофе-машине, располагающейся в какой-то точке пространства. + 2. Каждый стакан кофе приготовлен по определённому *рецепту*, что подразумевает наличие разных ингредиентов и последовательности выполнения шагов приготовления. + 3. Напиток готовится на конкретной физической *кофе-машине*, располагающейся в какой-то точке пространства. -Каждый из этих уровней задаёт некоторый срез нашего API, с которым будет работать потребитель. Выделяя иерархию абстракций мы прежде всего стремимся снизить связность различных сущностей нашего API. Это позволит нам добиться нескольких целей: +Каждый из этих уровней задаёт некоторый срез нашего API, с которым будет работать потребитель. Выделяя иерархию абстракций мы прежде всего стремимся снизить связность различных сущностей нашего API. Это позволит нам добиться нескольких целей. 1. Упрощение работы разработчика и легкость обучения: в каждый момент времени разработчику достаточно будет оперировать только теми сущностями, которые нужны для решения его задачи; и наоборот, плохо выстроенная изоляция приводит к тому, что разработчику нужно держать в голове множество концепций, не имеющих прямого отношения к решаемой задаче. @@ -28,14 +28,15 @@ // размещает на указанной кофе-машине // заказ на приготовление лунго // и возвращает идентификатор заказа - POST /v1/coffee-machines/orders?machine_id={id} + POST /v1/coffee-machines/orders { + "coffee_machine_id", "recipe": "lungo" } ``` ``` // возвращает состояние заказа - GET /v1/orders?order_id={id} + GET /v1/orders/{id} ``` И зададимся вопросом, каким образом разработчик определит, что заказ клиента готов. Допустим, мы сделаем так: добавим в рецепт лунго эталонный объём, а в состояние заказа — количество уже налитого кофе. Тогда разработчику нужно будет проверить совпадение этих двух цифр, чтобы убедиться, что кофе готов. @@ -50,8 +51,9 @@ Вариант 2: мы модифицируем интерфейс, объявляя объём кофе, указанный в рецепте, значением по умолчанию; при размещении заказа мы разрешаем указать объём, отличный от эталонного: ``` -POST /v1/coffee-machines/orders?machine_id={id} +POST /v1/coffee-machines/orders { + "coffee_machine_id", "recipe":"lungo", "volume":"800ml" } @@ -327,7 +329,7 @@ POST /v1/runtimes Вернёмся к нашему примеру. Каким образом будет работать операция получения статуса заказа? Для получения статуса будет выполнена следующая цепочка вызовов: * пользователь вызовет метод `GET /v1/orders`; - * обработчик `orders` выполнит операции своего уровня ответственности (проверку авторизации, в частности), найдёт идентификатор `program_run_id` и обратится к API программ `GET /v1/programs/{id}/runs/{program_run_id}`; + * обработчик `orders` выполнит операции своего уровня ответственности (проверку авторизации, в частности), найдёт идентификатор `program_run_id` и обратится к API программ `runs/{program_run_id}`; * обработчик `runs` в свою очередь выполнит операции своего уровня (в частности, проверит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения: * либо вызовет `GET /execution/status` физического API кофе-машины, получит объём кофе и сличит с эталонным; * либо обратится к `GET /v1/runtimes/{runtime_id}`, получит `state.status` и преобразует его к статусу заказа; @@ -346,7 +348,7 @@ POST /v1/runtimes * обработчик метода произведёт операции в своей зоне ответственности: * проверит авторизацию; * решит денежные вопросы — нужно ли делать рефанд; - * найдёт идентификатор `program_run_id` и обратится к `POST /v1/programs/{id}/runs/{program_run_id}/cancel`; + * найдёт идентификатор `program_run_id` и обратится к обработчику `runs/{program_run_id}/cancel`; * обработчик `runs/cancel` произведёт операции своего уровня (в частности, установит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения: * либо вызовет `POST /execution/cancel` физического API кофе-машины; * либо вызовет `POST /v1/runtimes/{id}/terminate`; diff --git a/src/ru/clean-copy/02-I. Проектирование API/04.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/04.md similarity index 100% rename from src/ru/clean-copy/02-I. Проектирование API/04.md rename to src/ru/clean-copy/02-Раздел I. Проектирование API/04.md diff --git a/src/ru/clean-copy/02-I. Проектирование API/05.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md similarity index 86% rename from src/ru/clean-copy/02-I. Проектирование API/05.md rename to src/ru/clean-copy/02-Раздел I. Проектирование API/05.md index 075d067..51ab9ae 100644 --- a/src/ru/clean-copy/02-I. Проектирование API/05.md +++ b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md @@ -74,7 +74,7 @@ POST /v1/orders/statistics/aggregate Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты. -Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II. +Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II. #### 3. Сохраняйте точность дробных чисел @@ -187,7 +187,7 @@ GET /comments/{id} "content" } ``` -— хотя операция будто бы выполнена успешна, клиенту необходимо сделать дополнительный запрос, чтобы понять необходимость решения капчи. Между вызовами `POST /comments` и `GET /comments/{id}` клиент находится в состоянии кота Шрёдингера: непонятно, опубликован комментарий или нет, и как отразить это пользователю. +— хотя операция будто бы выполнена успешно, клиенту необходимо сделать дополнительный запрос, чтобы понять необходимость решения капчи. Между вызовами `POST /comments` и `GET /comments/{id}` клиент находится в состоянии кота Шрёдингера: непонятно, опубликован комментарий или нет, и как отразить это пользователю. **Хорошо**: ``` @@ -206,7 +206,7 @@ GET /v1/comments/{id} … } ``` -Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа невелик) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. +Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. #### 9. Идемпотентность @@ -227,7 +227,7 @@ POST /orders POST /v1/orders X-Idempotency-Token: <случайная строка> ``` -Клиент на своей стороне запоминает X-Idempotency-Token, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно. +Клиент на своей стороне запоминает `X-Idempotency-Token`, и, в случае автоматического повторного перезапроса, обязан его сохранить. Сервер на своей стороне проверяет токен и, если заказ с таким токеном уже существует для этого клиента, не даёт создать заказ повторно. **Альтернатива**: ``` @@ -263,7 +263,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude} * на каком расстоянии от указанной точки цена всё ещё действительна? **Хорошо**: -Для указания времени жизни кэша можно пользоваться стандатрными средствами протокола, например, заголовком Cache-Control. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так: +Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком `Cache-Control`. В ситуации, когда кэш нужен и по временной, и по пространственной координате следует поступить примерно так: ``` // Возвращает предложение: за какую сумму // наш сервис готов приготовить лунго @@ -298,20 +298,20 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude} // Возвращает указанный limit записей, // отсортированных по дате создания // начиная с записи с номером offset -GET /records?limit=10&offset=100 +GET /v1/records?limit=10&offset=100 ``` На первый взгляд это самый что ни на есть стандартный способ организации пагинации в API. Однако зададим себе три вопроса: 1. Каким образом клиент узнает о появлении новых записей в начале списка? - Легко заметить, что клиент может только попытаться повторить первый запрос и сличить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает limit? Представим себе ситуацию: + Легко заметить, что клиент может только попытаться повторить первый запрос и сличить идентификаторы с запомненным началом списка. Но что делать, если добавленное количество записей превышает `limit`? Представим себе ситуацию: * клиент обрабатывает записи в порядке поступления; * произошла какая-то проблема, и накопилось большое количество необработанных записей; - * клиент запрашивает новые записи (offset=0), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем limit; - * клиент вынужден продолжить перебирать записи (увеличивая offset), пока не доберётся до последней известной ему; всё это время клиент простаивает; + * клиент запрашивает новые записи (`offset=0`), однако не находит на первой странице известных идентификаторов — новых записей накопилось больше, чем `limit`; + * клиент вынужден продолжить перебирать записи (увеличивая `offset`), пока не доберётся до последней известной ему; всё это время клиент простаивает; * таким образом может сложиться ситуация, когда клиент вообще никогда не обработает всю очередь, т.к. будет занят беспорядочным линейным перебором. 2. Что произойдёт, если при переборе списка одна из записей в уже перебранной части будет удалена? Произойдёт следующее: клиент пропустит одну запись и никогда не сможет об этом узнать. 3. Какие параметры кэширования мы можем выставить на этот эндпойнт? - Никакие: повторяя запрос с теми же limit-offset, мы каждый раз получаем новый набор записей. + Никакие: повторяя запрос с теми же `limit`-`offset`, мы каждый раз получаем новый набор записей. **Хорошо**: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок которых фиксирован. Например, вот так: ``` @@ -319,16 +319,41 @@ GET /records?limit=10&offset=100 // отсортированных по дате создания, // начиная с первой записи, созданной позднее, // чем запись с указанным id -GET /records?older_than={record_id}&limit=10 +GET /v1/records?older_than={record_id}&limit=10 // Возвращает указанный limit записей, // отсортированных по дате создания, // начиная с первой записи, созданной раньше, // чем запись с указанным id -GET /records?newer_than={record_id}&limit=10 +GET /v1/records?newer_than={record_id}&limit=10 ``` При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор. Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей. -Другой вариант организации таких списков — возврат курсора `cursor`, который используется вместо `record_id`, что делает интерфейсы универсальнее. +Другой вариант организации таких списков — возврат курсора `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` в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее. + +Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один. **Плохо**: ``` @@ -437,7 +462,7 @@ POST /v1/coffee-machines/search Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должно себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б». -**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки невозможно. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятно для разработчика — что, скорее всего, означает «на английском языке», т.к. английский де факто является стандартом в мире разработки программного обеспечения. +**Важно**: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение `localized_message` адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение `details.checks_failed[].message` написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де факто является стандартом в мире разработки программного обеспечения. Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс `localized_`.