From 9f97cb48382c739bd24f5da5ce832ef70b4afe2c Mon Sep 17 00:00:00 2001 From: Sergey Konstantinov Date: Sun, 3 Jan 2021 16:23:43 +0300 Subject: [PATCH] Chapter 11 extended --- .../02-Раздел I. Проектирование API/05.md | 555 ++++++++++++++++-- src/ru/clean-copy/intro.html | 2 +- .../03.md | 14 + 3 files changed, 523 insertions(+), 48 deletions(-) create mode 100644 src/ru/drafts/03-Раздел II. Обратная совместимость/03.md diff --git a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md index fe9dc2d..ca2ed44 100644 --- a/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md +++ b/src/ru/clean-copy/02-Раздел I. Проектирование API/05.md @@ -14,7 +14,7 @@ Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов `set_entity` / `get_entity` в пользу одного метода `entity` с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов. -##### 1. Явное лучше неявного +##### Явное лучше неявного Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование. @@ -53,7 +53,7 @@ POST /v1/orders/statistics/aggregate **1.2.** Если в номенклатуре вашего API есть как синхронные операции, так и асинхронные, то (а)синхронность должна быть очевидна из сигнатур, **либо** должна существовать конвенция именования, позволяющая отличать синхронные операции от асинхронных. -##### 2. Указывайте использованные стандарты +##### Указывайте использованные стандарты К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «с какого дня начинается неделя», что уж говорить о каких-то более сложных стандартах. @@ -76,13 +76,13 @@ POST /v1/orders/statistics/aggregate Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II. -##### 3. Сохраняйте точность дробных чисел +##### Сохраняйте точность дробных чисел Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных. Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип. -##### 4. Сущности должны именоваться конкретно +##### Сущности должны именоваться конкретно Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make. @@ -90,7 +90,7 @@ POST /v1/orders/statistics/aggregate **Хорошо**: `user.get_id()`. -##### 5. Не экономьте буквы +##### Не экономьте буквы В XXI веке давно уже нет нужды называть переменные покороче. @@ -109,7 +109,7 @@ strpbrk (str1, str2) **Хорошо**: `str_search_for_characters (lookup_character_set, str)` — однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей. -##### 6. Тип поля должен быть ясен из его названия +##### Тип поля должен быть ясен из его названия Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`. @@ -125,7 +125,7 @@ strpbrk (str1, str2) **Хорошо**: `"task.is_finished": true`. -Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at`, etc) или `_date`. +Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`. Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс, чтобы избежать непонимания. @@ -138,7 +138,7 @@ GET /coffee-machines/{id}/functions **Хорошо**: `GET /v1/coffee-machines/{id}/builtin-functions-list` -##### 7. Подобные сущности должны называться подобно и вести себя подобным образом +##### Подобные сущности должны называться подобно и вести себя подобным образом **Плохо**: `begin_transition` / `stop_transition` — `begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации. @@ -163,56 +163,231 @@ str_replace(needle, replace, haystack) Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю. -##### 8. Клиент всегда должен знать полное состояние системы +##### Используйте глобально уникальные идентификаторы + +Хороший тон при разработке API — использовать для идентификаторов сущностей глобально уникальные строки, либо семантичные (например, "lungo" для видов напитков), либо случайные (например [UUID-4](https://en.wikipedia.org/wiki/Universally_unique_identifier#Version_4_(random)). Это может чрезвычайно пригодиться, если вдруг придётся объединять данные из нескольких источников под одним идентификатором. + +Мы вообще склонны порекомендовать использовать идентификаторы в urn-подобном формате, т.е. `urn:order:` (или просто `order:`), это сильно помогает с отладкой legacy-систем, где по историческим причинам есть несколько разных идентификаторов для одной и той же сущности — тогда неймспейсы в urn помогут быстро понять, что это за идентификатор и нет ли здесь ошибки использования. + +Отдельное важное следствие: **не используете инкрементальные номера как идентификаторы**. Помимо вышесказанного, это плохо ещё и тем, что ваши конкуренты легко смогут подсчитать, сколько у вас в системе каких сущностей и тем самым вычислить, например, точное количество заказов за каждый день наблюдений. + +**NB**: в этой книге часто используются короткие идентификаторы типа "123" в примерах — это для удобства чтения на маленьких экранах, повторять эту практику в реальном API не надо. + +##### Клиент всегда должен знать полное состояние системы Правило можно ещё сформулировать так: не заставляйте клиент гадать. **Плохо**: ``` -// Создаёт комментарий и возвращает его id -POST /comments -{ "content" } +// Создаёт заказ и возвращает его id +POST /v1/orders +{ … } → -{ "comment_id" } +{ "order_id" } ``` ``` -// Возвращает комментарий по его id -GET /comments/{id} -→ -{ - // Комментарий не опубликован - // и ждёт прохождения капчи - "published": false, - "action_required": "solve_captcha", - "content" -} +// Возвращает заказ по его id +GET /v1/orders/{id} +// Заказ ещё не подтверждён +// и ожидает проверки +→ 404 Not Found ``` -— хотя операция будто бы выполнена успешно, клиенту необходимо сделать дополнительный запрос, чтобы понять необходимость решения капчи. Между вызовами `POST /comments` и `GET /comments/{id}` клиент находится в состоянии кота Шрёдингера: непонятно, опубликован комментарий или нет, и как отразить это пользователю. +— хотя операция будто бы выполнена успешно, клиенту необходимо самостоятельно запомнить идентификатор заказа и периодически проверять состояние `GET /v1/orders/{id}`. Этот паттерн плох сам по себе, но ещё и усугубляется двумя обстоятельствами: + + * клиент может потерять идентификатор, если произошёл системный сбой в момент между отправкой запроса и получением ответа или было повреждено (очищено) системное хранилище данных приложения; + * потребитель не может воспользоваться другим устройством; фактически, знание о сделанном заказе привязано к конкретному юзер-агенту. + +В обоих случаях потребитель может решить, что заказ по какой-то причине не создался — и сделать повторный заказ со всеми вытекающими отсюда проблемами. **Хорошо**: ``` -// Создаёт комментарий и возвращает его -POST /v1/comments -{ "content" } +// Создаёт заказ и возвращает его +POST /v1/orders +{ <параметры заказа> } → -{ "comment_id", "published", "action_required", "content" } -``` -``` -// Возвращает комментарий по его id -GET /v1/comments/{id} -→ -{ /* в точности тот же формат, - что и в ответе POST /comments */ - … +{ + "order_id", + // Заказ создаётся в явном статусе + // «идёт проверка» + "status": "checking", + … } ``` -Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. +``` +// Возвращает заказ по его id +GET /v1/orders/{id} +→ +{ "order_id", "status" … } +``` -То же соображение применимо и к значениям по умолчанию. Не заставляйте клиент гадать эти значения, или хуже — хардкодить их. Возвращайте заполненные значения необязательных полей в ответе операции создания (перезаписи) сущности. +##### Избегайте двойных отрицаний -##### 9. Идемпотентность +**Плохо**: `"dont_call_me": false` +— люди в целом плохо считывают двойные отрицания. Это провоцирует ошибки. -Все операции должны быть идемпотентны. Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни. +**Лучше**: `"prohibit_calling": true` или `"avoid_calling": true` +— читается лучше, хотя обольщаться все равно не следует. Насколько это возможно откажитесь от семантически двойных отрицаний, даже если вы придумали «негативное» слово без явной приставки «не». + +Стоит также отметить, что в использовании [законов де Моргана](https://ru.wikipedia.org/wiki/%D0%97%D0%B0%D0%BA%D0%BE%D0%BD%D1%8B_%D0%B4%D0%B5_%D0%9C%D0%BE%D1%80%D0%B3%D0%B0%D0%BD%D0%B0) ошибиться ещё проще, чем в двойных отрицаниях. Предположим, что у вас есть два флага: + +``` +GET /coffee-machines/{id}/stocks +→ +{ + "has_beans": true, + "has_cup": true +} +``` + +Условие «кофе можно приготовить» будет выглядеть как `has_beans && has_cup` — есть и зерно, и стакан. Однако, если по какой-то причине в ответе будут отрицания тех же флагов: + +``` +{ + "beans_absence": false, + "cup_absence": false +} +``` +— то разработчику потребуется вычислить флаг `!beans_absence && !cup_absence` ⇔ `!(beans_absence || cup_absence)`, а вот этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги». + +##### Избегайте неявного приведения типов + +Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например: + +``` +POST /v1/orders +{} +→ +{ + "contactless_delivery": true +} +``` + +Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого: + +``` +if (Type(order.contactless_delivery) == 'Boolean' && + order.contactless_delivery == false) { … } +``` + +Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`. + +В этих ситуациях универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false; если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, — следует ввести пару полей. + +**Плохо**: +``` +// Задаёт лимит количества +// одновременных заказов +// на пользователя, числом +// либо null — лимита нет +PUT /users/{id}/order-limit +{} +→ +{ "order_limit": null } +``` + +**Хорошо** +``` +// Задаёт флаг наличия/отсутствия +// лимита заказов, и его значение +// при наличии +PUT /users/{id}/order-limit +{ + // true — у пользователя задан + // лимит одновременных заказов + // false — лимита нет + // (значение по умолчанию) + "has_specific_limit": true, + "limit": 5 +} +``` + +**NB**: противоречие с предыдущим советом в том, что мы специально ввели отрицающий флаг («нет лимита»), который по правилу двойных отрицаний пришлось переименовать в `has_specific_limit`. Хотя это и хорошее название для отрицательного флага, семантика его довольно неочевидна, разработчикам придётся как минимум покопаться в документации. Таков путь. + +##### Избегайте частичных обновлений + +**Плохо**: +``` +// Возвращает состояние заказа +// по его идентификатору +GET /v1/orders/123 +→ +{ + "order_id", + "delivery_address", + "client_phone_number", + "client_phone_number_ext", + "updated_at" +} +// Частично перезаписывает заказ +PATCH /v1/orders/123 +{ "delivery_address" } +→ +{ "delivery_address" } +``` +— такой подход часто практикуют для того, чтобы уменьшить объёмы запросов и ответов, плюс это позволяет дёшево реализовать совместное редактирование. Оба этих преимущества на самом деле являются мнимыми. + +Во-первых, экономия объёма ответа в современных условиях требуется крайне редко. Максимальные размеры сетевых пакетов (MTU, Maximum Transmission Unit) в настоящее время составляют более килобайта; пытаться экономить на размере ответа, пока он не превышает килобайт — попросту бессмысленная трата времени. Да и в целом, скорее более оправдан следующий подход: для тяжёлых данных следует сделать отдельный эндпойнт, а на всём остальном не пытаться экономить. + +Во-вторых, экономия размера ответа как раз сыграет злую шутку при совместном редактировании: один клиент не будет видеть, какие изменения внёс другой. Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа не оказывает значительного влияния на производительность) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение. + +В-третьих, этот подход может как-то работать при необходимость перезаписать поле. Но что делать, если поле требуется сбросить к значению по умолчанию? Например, как *удалить* `client_phone_number_ext`? + +Часто в таких случаях прибегают к специальным значениям, которые означают удаление поля, например, null. Но, как мы разобрали выше, это плохая практика. Другой вариант — запрет необязательных полей, но это существенно усложняет дальнейшее развитие API. + +**Хорошо**: можно применить одну из двух стратегий. + +**Вариант 1**: разделение эндпойнтов. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе. + +``` +// Возвращает состояние заказа +// по его идентификатору +GET /v1/orders/123 +→ +{ + "order_id", + "delivery_details": { + "address" + }, + "client_details": { + "phone_number", + "phone_number_ext" + }, + "updated_at" +} +// Полностью перезаписывает +// информацию о доставке заказа +PUT /v1/orders/123/delivery-details +{ "address" } +// Полностью перезаписывает +// информацию о клиенте +PUT /v1/orders/123/client-details +{ "phone_number" } +``` + +Теперь для удаления `client_phone_number_ext` достаточно *не* передавать его в `PUT client-details`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`order_id` и `updated_at`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `updated_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования). + +**Вариант 2**: разработать формат описания атомарных изменений. + +``` +POST /v1/order/changes +X-Idempotency-Token: <см. следующий раздел> +{ + "changes": [{ + "type": "set", + "field": "delivery_address", + "value": <новое значение> + }, { + "type": "unset", + "field": "client_phone_number_ext" + }] +} +``` + +Этот подход существенно сложнее в имплементации, но является единственным возможным вариантом реализации совместного редактирования, поскольку он явно отражает, что в действительности делал пользовать с представлением объекта. Имея данные в таком формате возможно организовать и оффлайн-редактирование, когда пользовательские изменения накапливаются и потом сервер автоматически разрешает конфликты, «перебазируя» изменения. + +##### Все операции должны быть идемпотентны + +Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни. Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию. @@ -293,7 +468,160 @@ X-Idempotency-Token: <токен> * нельзя полагаться на то, что клиенты генерируют честные случайные токены — они могут иметь одинаковый seed рандомизатора или просто использовать слабый алгоритм или источник энтропии; при проверке токенов нужны слабые ограничения: уникальность токена должна проверяться не глобально, а только применительно к конкретному пользователю и конкретной операции; * клиенты склонны неправильно понимать концепцию — или генерировать новый токен на каждый перезапрос (что на самом деле неопасно, в худшем случае деградирует UX), или, напротив, использовать один токен для разнородных запросов (а вот это опасно и может привести к катастрофически последствиям; ещё одна причина имплементировать совет из предыдущего пункта!); поэтому рекомендуется написать хорошую документацию и/или клиентскую библиотеку для перезапросов. -##### 10. Кэширование +##### Избегайте неатомарных операций + +С применением массива изменений часто возникает вопрос: что делать, если часть изменений удалось применить, а часть — нет? Здесь правило очень простое: если вы можете обеспечить атомарность, т.е. выполнить либо все изменения сразу, либо ни одно из них — сделайте это. + +**Плохо**: +``` +// Возвращает список рецептов +GET /v1/recipes +→ +{ + "recipes": [{ + "id": "lungo", + "volume": "200ml" + }, { + "id": "latte", + "volume": "300ml" + }] +} + +// Изменяет параметры +PATCH /v1/recipes +{ + "changes": [{ + "id": "lungo", + "volume": "300ml" + }, { + "id": "latte", + "volume": "-1ml" + }] +} +→ 400 Bad Request + +// Перечитываем список +GET /v1/recipes +→ +{ + "recipes": [{ + "id": "lungo", + // Это значение изменилось + "volume": "300ml" + }, { + "id": "latte", + // А это нет + "volume": "300ml" + }] +} +``` +— клиент никак не может узнать, что операция, которую он посчитал ошибочной, на самом деле частично применена. Даже если индицировать это в ответе, у клиента нет способа понять — значение объёма лунго изменилось вследствие запроса, или это конкурирующее изменение, выполненное другим клиентом. + +Если способа обеспечить атомарность выполнения операции нет, следует очень хорошо подумать над её обработкой. Следует предоставить способ получения статуса каждого изменения отдельно. + +**Лучше**: +``` +PATCH /v1/recipes +{ + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "-1ml" + }] +} +// Можно воспользоваться статусом +// «частичного успеха», если он предусмотрен +// протоколом +→ 200 OK +{ + "changes": [{ + "change_id", + "occurred_at", + "recipe_id": "lungo", + "status": "success" + }, { + "change_id", + "occurred_at", + "recipe_id": "latte", + "status": "fail", + "error" + }] +} +``` + +Здесь: + * `change_id` — уникальный идентификатор каждого атомарного изменения; + * `occurred_at` — время проведения каждого изменения + * `error` — информация по ошибке для каждого изменения, если она возникла. + +Не лишним будет также: + * введение `sequence_id` в запросе, чтобы гарантировать порядок исполнения операций и соотнесение порядка статусов изменений в ответе с запросом; + * предоставить отдельный эндпойнт `/changes-history`, чтобы клиент мог получить информацию о выполненных изменениях, если во время обработки запроса произошла сетевая ошибка или приложение перезагрузилось. + +Неатомарные изменения нежелательны ещё и потому, что вносят неопределённость в понятие идемпотентности, даже если каждое вложенное изменение идемпотентно. Рассмотрим такой пример: + +``` +PATCH /v1/recipes +{ + "idempotency_token", + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "400ml" + }] +} +→ 200 OK +{ + "changes": [{ + … + "status": "success" + }, { + … + "status": "fail", + "error": { + "reason": "too_many_requests" + } + }] +} +``` + +Допустим, клиент не смог получить ответ и повторил запрос с тем же токеном идемпотентности. + +``` +PATCH /v1/recipes +{ + "idempotency_token", + "changes": [{ + "recipe_id": "lungo", + "volume": "300ml" + }, { + "recipe_id": "latte", + "volume": "400ml" + }] +} +→ 200 OK +{ + "changes": [{ + … + "status": "success" + }, { + … + "status": "success", + }] +} +``` + +По сути, для клиента всё произошло ожидаемым образом: изменения были внесены, и последний полученный ответ всегда корректен. Однако по сути состояние ресурса после первого запросе отличалось от состояния ресурса после второго запроса, что противоречит самому определению идемпотентности. + +Более корректно было бы при получении повторного запроса с тем же токеном ничего не делать и возвращать ту же разбивку ошибок, что была дана на первый запрос — но для этого придётся её каким-то образом хранить в истории изменений. + +На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности. + +##### Указывайте политики кэширования В клиент-серверном API, как правило, сеть и ресурс сервера не бесконечны, поэтому кэширование на клиенте результатов операции является стандартным действием. @@ -303,7 +631,8 @@ X-Idempotency-Token: <токен> ``` // Возвращает цену лунго в кафе, // ближайшем к указанной точке -GET /price?recipe=lungo&longitude={longitude}&latitude={latitude} +GET /v1/price?recipe=lungo + &longitude={longitude}&latitude={latitude} → { "currency_code", "price" } ``` @@ -316,7 +645,8 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude} ``` // Возвращает предложение: за какую сумму // наш сервис готов приготовить лунго -GET /price?recipe=lungo&longitude={longitude}&latitude={latitude} +GET /v1/price?recipe=lungo + &longitude={longitude}&latitude={latitude} → { "offer": { @@ -336,7 +666,7 @@ GET /price?recipe=lungo&longitude={longitude}&latitude={latitude} } ``` -##### 11. Пагинация, фильтрация и курсоры +##### Пагинация, фильтрация и курсоры Любой эндпойнт, возвращающий массивы данных, должен содержать пагинацию. Никаких исключений в этом правиле быть не может. @@ -434,7 +764,7 @@ POST /v1/record-views GET /v1/record-views/{id}?cursor={cursor} ``` -Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offest, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком). +Т.к. созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков может получиться так, что порядок будет нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком). **Вариант 2**: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи: @@ -455,7 +785,7 @@ POST /v1/records/modified/list Недостатком этой схемы является необходимость заводить отдельное индексированное хранилище событий, а также появление множества событий для одной записи, если данные меняются часто. -##### 12. Ошибки должны быть информативными +##### Ошибки должны быть информативными При написании кода разработчик неизбежно столкнётся с ошибками, в том числе самого примитивного толка — неправильный тип параметра или неверное значение. Чем понятнее ошибки, возвращаемые вашим API, тем меньше времени разработчик потратит на борьбу с ними, и тем приятнее работать с таким API. @@ -504,7 +834,138 @@ POST /v1/coffee-machines/search ``` Также хорошей практикой является указание всех допущенных ошибок, а не только первой найденной. -##### 13. Локализация и интернационализация +##### Соблюдайте правильный порядок ошибок + +Во-первых, всегда показывайте неразрешимые ошибки прежде разрешимых: +``` +POST /v1/orders +{ + "recipe": "lngo", + "offer" +} +→ 409 Conflict +{ + "reason": "offer_expired" +} +// Повторный запрос +// с новым `offer` +POST /v1/orders +{ + "recipe": "lngo", + "offer" +} +→ 400 Bad Request +{ + "reason": "recipe_unknown" +} +``` +— какой был смысл получать новый `offer`, если заказ все равно не может быть создан? + +Во-вторых, соблюдайте такой порядок разрешимых ошибок, который приводит к наименьшему раздражению пользователя и разработчика. В частности, следует начинать с более значимых ошибок, решение которых требует более глобальных изменений. + +**Плохо**: +``` +POST /v1/orders +{ + "items": [{ "item_id": "123", "price": "0.10" }] +} +→ +409 Conflict +{ + "reason": "price_changed", + "details": [{ "item_id": "123", "actual_price": "0.20" }] +} +// Повторный запрос +// с актуальной ценой +POST /v1/orders +{ + "items": [{ "item_id": "123", "price": "0.20" }] +} +→ +409 Conflict +{ + "reason": "order_limit_exceeded", + "localized_message": "Лимит заказов превышен" +} +``` +— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать все равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз. + +В-третьих, постройте таблицу: разрешение какой ошибки может привести к появлению другой, иначе вы можете показать одну и ту же ошибку несколько раз, а то и вовсе зациклить разрешение ошибок. + +``` +// Создаём заказ с платной доставкой +POST /v1/orders +{ + "items": 3, + "item_price": "3000.00" + "currency_code": "MNT", + "delivery_fee": "1000.00", + "total": "10000.00" +} +→ 409 Conflict +// Ошибка: доставка становится бесплатной +// при стоимости заказа от 9000 тугриков +{ + "reason": "delivery_is_free" +} + +// Создаём заказ с бесплатной доставкой +POST /v1/orders +{ + "items": 3, + "item_price": "3000.00" + "currency_code": "MNT", + "delivery_fee": "0.00", + "total": "9000.00" +} +→ 409 Conflict +// Ошибка: минимальная сумма заказа +// 10000 тугриков +{ + "reason": "below_minimal_sum", + "currency_code": "MNT", + "minimal_sum": "10000.00" +} +``` + +Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчета (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса. + +##### Отсутствие результата — тоже результат + +Если сервер корректно обработал вопрос и никакой внештатной ситуации не возникло — следовательно, это не ошибка. К сожалению, весьма распространён антипаттерн, когда отсутствие результата считается ошибкой. + +**Плохо** +``` +POST /search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 404 Not Found +{ + "localized_message": + "Рядом с вами не делают лунго" +} +``` + +Статусы `4xx` означают, что клиент допустил ошибку; однако в данном случае никакой ошибки сделано не было, ни пользователем, ни разработчиком: клиент же не может знать заранее, готовят здесь лунго или нет. + +**Хорошо**: +``` +POST /search +{ + "query": "lungo", + "location": <положение пользователя> +} +→ 200 OK +{ + "results": [] +} +``` + +Это правило вообще можно упростить до следующего: если результатом операции является массив данных, то пустота этого массива — не ошибка, а штатный ответ. (Если, конечно, он допустим по смыслу; пустой массив координат, например, является ошибкой.) + +##### Локализация и интернационализация Все эндпойнты должны принимать на вход языковые параметры (например, в виде заголовка `Accept-Language`), даже если на текущем этапе нужды в локализации нет. diff --git a/src/ru/clean-copy/intro.html b/src/ru/clean-copy/intro.html index 16de1f2..52bf0ce 100644 --- a/src/ru/clean-copy/intro.html +++ b/src/ru/clean-copy/intro.html @@ -1,4 +1,4 @@ -

Сергей Константинов
API

+

Сергей Константинов
API

diff --git a/src/ru/drafts/03-Раздел II. Обратная совместимость/03.md b/src/ru/drafts/03-Раздел II. Обратная совместимость/03.md new file mode 100644 index 0000000..52d8719 --- /dev/null +++ b/src/ru/drafts/03-Раздел II. Обратная совместимость/03.md @@ -0,0 +1,14 @@ +### Интерфейсы как универсальный паттерн + +Как мы указали в предыдущей главе, основные причины внесения изменений в API — развитие самого API (добавление новой функциональности) и эволюция платформ (клиентской, серверной и предметной) — следует предусматривать ещё на этапе проектирования. Может показаться, что совет этот полезен примерно так же, как и сократовское определение человека — очень конкретен, и столь же бесполезен — но это не так. Методология, позволяющая получить устойчивое к изменениям API, существует и вполне конкретна: это применение концепции «интерфейса» ко всем уровням абстракции. + +На практике это означает следующая: нам необходимо рассмотреть каждую сущность нашего API и выделить её абстрактную модель, т.е. разделить все поля и методы сущности на две группы: те, которые абсолютно необходимы для корректного цикла работы API, и те, которые мы можем назвать «деталями имплементации». Первые образуют *интерфейс* сущности: если заменить одну конкретную реализацию этого интерфейса на другую, API будет продолжать работать. + +**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных» и «контрактное программирование», но это методологически неверно: разработка API в принципе представляет собой контрактное программирование, при этом большинство клиент-серверных архитектур подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL, но «интерфейс» нам кажется более понятным. + +Мы будем использовать термин «интерфейс» как обобщение понятия «абстрактный тип данных» и «контракт». «Интерфейс» — это некоторое абстрактное подмножество абстрактного типа данных, «метаконтракт». Интерфейсы мы будем обозначать с помощью префикса `I`, например: `Recipe` — это модель данных «рецепт», а `IRecipe` — это интерфейс рецепта: «минимальная» модель данных и операций над ними, которая достаточна для корректной работы API. Объект `Recipe` таким образом имплементирует интерфейс `IRecipe`. + +Попробуем применить этот (дважды) абстрактный концепт к нашему кофейному API. Представьте, что на этапе разработки архитектуры бизнес выдвинул следующее требование: мы не только предоставляем доступ к оборудованию партнеров, но и предлагаем партнерам наше ПО (т.е. в данном случае API), чтобы они могли строить поверх него свои собственные приложения. + +**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; в реальном мире такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реалтайме применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем такой сложности было бы затруднительно. +