1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-08-10 21:51:42 +02:00

Chapter 11 refactoring finished

This commit is contained in:
Sergey Konstantinov
2022-09-18 17:25:10 +03:00
parent 165332c92e
commit 409d776ac2
7 changed files with 1085 additions and 727 deletions

View File

@@ -108,13 +108,6 @@ body {
text-align: left;
}
body,
h6 {
font-family: local-serif, serif;
font-size: 14pt;
text-align: justify;
}
.cc-by-nc-img {
display: block;
float: left;
@@ -135,7 +128,8 @@ pre {
}
code {
white-space: nowrap;
hyphens: manual;
font-size: 80%;
}
.img-wrapper img {
@@ -153,7 +147,6 @@ pre {
box-sizing: border-box;
page-break-inside: avoid;
overflow-x: auto;
font-size: 80%;
}
img:not(.cc-by-nc-img) {
@@ -188,6 +181,14 @@ h5 {
page-break-after: avoid;
}
body,
h5,
h6 {
font-family: local-serif, serif;
font-size: 14pt;
text-align: justify;
}
h6 {
font-size: 80%;
color: darkgray;
@@ -215,11 +216,14 @@ h3 {
font-size: 140%;
}
h4,
h5 {
h4 {
font-size: 120%;
}
h5 {
font-size: 110%;
}
.annotation {
text-align: justify;
}
@@ -537,8 +541,9 @@ ul.references li p a.back-anchor {
}
pre {
margin: 0;
margin: 0.2em 0;
padding: 0.2em;
font-size: 80%;
}
ul,
@@ -649,7 +654,8 @@ ul.references li p a.back-anchor {
<p>Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо <code>GET /v1/orders</code> вполне может быть вызов метода <code>orders.get()</code>, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.</p>
<p>Рассмотрим следующую запись:</p>
<pre><code>// Описание метода
POST /v1/bucket/{id}/some-resource
POST /v1/bucket/{id}/some-resource
/{resource_id}
X-Idempotency-Token: &#x3C;токен идемпотентности>
{
@@ -662,7 +668,10 @@ Cache-Control: no-cache
{
/* А это многострочный
комментарий */
"error_message"
"error_message":
"Длинное сообщение,⮠
которое приходится⮠
разбивать на строки"
}
</code></pre>
<p>Её следует читать так:</p>
@@ -673,7 +682,8 @@ Cache-Control: no-cache
<li>в качестве тела запроса передаётся JSON, содержащий поле <code>some_parameter</code> со значением <code>value</code> и ещё какие-то поля, которые для краткости опущены (что показано многоточием);</li>
<li>в ответ (индицируется стрелкой <code></code>) сервер возвращает статус <code>404 Not Found</code>; статус может быть опущен (отсутствие статуса следует трактовать как <code>200 OK</code>);</li>
<li>в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание;</li>
<li>телом ответа является JSON, состоящий из единственного поля <code>error_message</code>; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке.</li>
<li>телом ответа является JSON, состоящий из единственного поля <code>error_message</code>; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке</li>
<li>если какой-то токен оказывается слишком длинным, мы будем переносить его на следующую строку, используя символ <code></code> для индикации переноса.</li>
</ul>
<p>Здесь термин «клиент» означает «приложение, установленное на устройстве пользователя, использующее рассматриваемый API». Приложение может быть как нативным, так и веб-приложением. Термины «агент» и «юзер-агент» являются синонимами термина «клиент».</p>
<p>Ответ (частично или целиком) и тело запроса могут быть опущены, если в контексте обсуждаемого вопроса их содержание не имеет значения.</p>
@@ -1420,10 +1430,10 @@ app.display(offers);
<p>Например, требование консистентности номенклатуры существует затем, чтобы разработчик тратил меньше времени на чтение документации; если вам <em>необходимо</em>, чтобы разработчик обязательно прочитал документацию по какому-то методу, вполне разумно сделать его сигнатуру нарочито неконсистентно.</p>
<p>Это соображение применимо ко всем принципам ниже. Если из-за следования правилам у вас получается неудобный, громоздкий, неочевидный API — это повод пересмотреть правила (или API).</p>
<p>Важно понимать, что вы вольны вводить свои собственные конвенции. Например, в некоторых фреймворках сознательно отказываются от парных методов <code>set_entity</code> / <code>get_entity</code> в пользу одного метода <code>entity</code> с опциональным параметром. Важно только проявить последовательность в её применении — если такая конвенция вводится, то абсолютно все методы API должны иметь подобную полиморфную сигнатуру, или по крайней мере должен существовать принцип именования, отличающий такие комбинированные методы от обычных вызовов.</p>
<h4>Читабельность и консистентность</h4>
<p>Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API сторонними разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число разработчиков по мере роста популярности API.</p>
<h4>Обеспечение читабельности и консистентности</h4>
<p>Важнейшая задача разработчика API — добиться того, чтобы код, написанный поверх API другими разработчиками, легко читался и поддерживался. Помните, что закон больших чисел работает против вас: если какую-то концепцию или сигнатуру вызова можно понять неправильно, значит, её неизбежно будет понимать неправильно всё большее число партнеров по мере роста популярности API.</p>
<h5><a href="#chapter-11-paragraph-1" id="chapter-11-paragraph-1" class="anchor">1. Явное лучше неявного</a></h5>
<p>Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование.</p>
<p>Из названия любой сущности должно быть очевидно, что она делает, и к каким побочным эффектам может привести её использование.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Отменяет заказ
GET /orders/cancellation
@@ -1434,12 +1444,14 @@ GET /orders/cancellation
POST /orders/cancel
</code></pre>
<p><strong>Плохо</strong>:</p>
<pre><code>// Возвращает агрегированную статистику заказов за всё время
<pre><code>// Возвращает агрегированную
// статистику заказов за всё время
GET /orders/statistics
</code></pre>
<p>Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>// Возвращает агрегированную статистику заказов за указанный период
<pre><code>// Возвращает агрегированную
// статистику заказов за указанный период
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }
</code></pre>
@@ -1456,10 +1468,14 @@ POST /v1/orders/statistics/aggregate
<code>"duration_ms": 5000</code><br>
либо<br>
<code>"duration": "5000ms"</code><br>
либо<br>
<code>"duration": {"unit": "ms", "value": 5000}</code>.</p>
либо</p>
<pre><code>"duration": {
"unit": "ms",
"value": 5000
}
</code></pre>
<p>Отдельное следствие из этого правила — денежные величины <em>всегда</em> должны сопровождаться указанием кода валюты.</p>
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.</p>
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.</p>
<h5><a href="#chapter-11-paragraph-3" id="chapter-11-paragraph-3" class="anchor">3. Сущности должны именоваться конкретно</a></h5>
<p>Избегайте одиночных слов-«амёб» без определённой семантики, таких как get, apply, make.</p>
<p><strong>Плохо</strong>: <code>user.get()</code> — неочевидно, что конкретно будет возвращено.</p>
@@ -1467,15 +1483,25 @@ POST /v1/orders/statistics/aggregate
<h5><a href="#chapter-11-paragraph-4" id="chapter-11-paragraph-4" class="anchor">4. Не экономьте буквы</a></h5>
<p>В XXI веке давно уже нет нужды называть переменные покороче.</p>
<p><strong>Плохо</strong>: <code>order.time()</code> — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…</p>
<p><strong>Хорошо</strong>: <code>order.get_estimated_delivery_time()</code></p>
<p><strong>Хорошо</strong>:</p>
<pre><code>order
.get_estimated_delivery_time()
</code></pre>
<p><strong>Плохо</strong>:</p>
<pre><code>// возвращает положение первого вхождения в строку str2
<pre><code>// возвращает положение
// первого вхождения в строку str1
// любого символа из строки str2
strpbrk (str1, str2)
</code></pre>
<p>Возможно, автору этого API казалось, что аббревиатура <code>pbrk</code> что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк <code>str1</code>, <code>str2</code> является набором символов для поиска.</p>
<p><strong>Хорошо</strong>: <code>str_search_for_characters (lookup_character_set, str)</code><br>
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение <code>string</code> до <code>str</code> выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>str_search_for_characters(
lookup_character_set,
str
)
</code></pre>
<p>— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение <code>string</code> до <code>str</code> выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.</p>
<p><strong>NB</strong>: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола.</p>
<h5><a href="#chapter-11-paragraph-5" id="chapter-11-paragraph-5" class="anchor">5. Тип поля должен быть ясен из его названия</a></h5>
<p>Если поле называется <code>recipe</code> — мы ожидаем, что его значением является сущность типа <code>Recipe</code>. Если поле называется <code>recipe_id</code> — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности <code>Recipe</code>.</p>
<p>То же касается и примитивных типов. Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — <code>objects</code>, <code>children</code>; если это невозможно (термин неисчисляем), следует добавить префикс или постфикс, не оставляющий сомнений.</p>
@@ -1484,14 +1510,18 @@ strpbrk (str1, str2)
<p>Аналогично, если ожидается булево значение, то это должно быть очевидно из названия, т.е. именование должно описывать некоторое качественное состояние, например, <code>is_ready</code>, <code>open_now</code>.</p>
<p><strong>Плохо</strong>: <code>"task.status": true</code> — неочевидно, что статус бинарен, к тому же такой API будет нерасширяемым.</p>
<p><strong>Хорошо</strong>: <code>"task.is_finished": true</code>.</p>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, объекты типа <code>Date</code>, если таковые имеются, разумно индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code> и т.д.) или <code>_date</code>.</p>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа <code>Date</code>, и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code> и т.д.) или <code>_date</code>.</p>
<p>Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Возвращает список встроенных функций кофемашины
<pre><code>// Возвращает список
// встроенных функций кофемашины
GET /coffee-machines/{id}/functions
</code></pre>
<p>Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).</p>
<p><strong>Хорошо</strong>: <code>GET /v1/coffee-machines/{id}/builtin-functions-list</code></p>
<p><strong>Хорошо</strong>:</p>
<pre><code>GET /v1/coffee-machines/{id}⮠
/builtin-functions-list
</code></pre>
<h5><a href="#chapter-11-paragraph-6" id="chapter-11-paragraph-6" class="anchor">6. Подобные сущности должны называться подобно и вести себя подобным образом</a></h5>
<p><strong>Плохо</strong>: <code>begin_transition</code> / <code>stop_transition</code><br>
<code>begin</code> и <code>stop</code> — непарные термины; разработчик будет вынужден рыться в документации.</p>
@@ -1501,8 +1531,10 @@ GET /coffee-machines/{id}/functions
// внутри строки `haystack`
strpos(haystack, needle)
</code></pre>
<pre><code>// Находит и заменяет все вхождения строки `needle`
// внутри строки `haystack` на строку `replace`
<pre><code>// Находит и заменяет
// все вхождения строки `needle`
// внутри строки `haystack`
// на строку `replace`
str_replace(needle, replace, haystack)
</code></pre>
<p>Здесь нарушены сразу несколько правил:</p>
@@ -1531,30 +1563,30 @@ str_replace(needle, replace, haystack)
"cup_absence": false
}
</code></pre>
<p>— то разработчику потребуется вычислить флаг <code>!beans_absence &#x26;&#x26; !cup_absence</code> <code>!(beans_absence || cup_absence)</code>, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».</p>
<p>— то разработчику потребуется вычислить флаг <code>!beans_absence &#x26;&#x26; !cup_absence</code>, что эквивалентно <code>!(beans_absence || cup_absence)</code>, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».</p>
<h5><a href="#chapter-11-paragraph-8" id="chapter-11-paragraph-8" class="anchor">8. Избегайте неявного приведения типов</a></h5>
<p>Этот совет парадоксально противоположен предыдущему. Часто при разработке API возникает ситуация, когда добавляется новое необязательное поле с непустым значением по умолчанию. Например:</p>
<pre><code>POST /v1/orders
{}
{}
{
"contactless_delivery": true
}
{ "contactless_delivery": true }
</code></pre>
<p>Новая опция <code>contactless_delivery</code> является необязательной, однако её значение по умолчанию — <code>true</code>. Возникает вопрос, каким образом разработчик должен отличить явное <em>нежелание</em> пользоваться опцией (<code>false</code>) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:</p>
<pre><code>if (Type(order.contactless_delivery) == 'Boolean' &#x26;&#x26;
order.contactless_delivery == false) { … }
<pre><code>if (Type(
order.contactless_delivery
) == 'Boolean' &#x26;&#x26;
order.contactless_delivery == false) {
}
</code></pre>
<p>Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа <code>null</code> или <code>-1</code>.</p>
<p><strong>NB</strong>. Это замечание не распространяется на те случае, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция <code>NULL</code>, и значения полей по умолчанию, и поддержка операций вида <code>UPDATE … SET field = DEFAULT</code> (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил <code>UPDATE … DEFAULT</code>), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.</p>
<p><strong>NB</strong>. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция <code>NULL</code>, и значения полей по умолчанию, и поддержка операций вида <code>UPDATE … SET field = DEFAULT</code> (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил <code>UPDATE … DEFAULT</code>), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.</p>
<p>Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.</p>
<p><strong>Хорошо</strong></p>
<pre><code>POST /v1/orders
{}
{
"force_contact_delivery": false
}
{ "force_contact_delivery": false }
</code></pre>
<p>Если же требуется ввести небулево поле, отсутствие которого трактуется специальным образом, то следует ввести пару полей.</p>
<p><strong>Плохо</strong>:</p>
@@ -1638,14 +1670,16 @@ POST /users
<pre><code>{
"reason": "wrong_parameter_value",
"localized_message":
"Что-то пошло не так. Обратитесь к разработчику приложения."
"Что-то пошло не так.
Обратитесь к разработчику приложения."
"details": {
"checks_failed": [
{
"field": "recipe",
"error_type": "wrong_value",
"message":
"Value 'lngo' unknown. Do you mean 'lungo'?"
"Value 'lngo' unknown.
Did you mean 'lungo'?"
},
{
"field": "position.latitude",
@@ -1655,7 +1689,9 @@ POST /users
"max": 90
},
"message":
"'position.latitude' value must fall in [-90, 90] interval"
"'position.latitude' value
must fall within⮠
the [-90, 90] interval"
}
]
}
@@ -1690,25 +1726,35 @@ POST /v1/orders
<p><strong>Плохо</strong>:</p>
<pre><code>POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.10" }]
"items": [{
"item_id": "123",
"price": "0.10"
}]
}
409 Conflict
{
"reason": "price_changed",
"details": [{ "item_id": "123", "actual_price": "0.20" }]
"details": [{
"item_id": "123",
"actual_price": "0.20"
}]
}
// Повторный запрос
// с актуальной ценой
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.20" }]
"items": [{
"item_id": "123",
"price": "0.20"
}]
}
409 Conflict
{
"reason": "order_limit_exceeded",
"localized_message": "Лимит заказов превышен"
"localized_message":
"Лимит заказов превышен"
}
</code></pre>
<p>— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.</p>
@@ -1748,8 +1794,8 @@ POST /v1/orders
}
</code></pre>
<p>Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.</p>
<h4>Правила разработки машиночитаемых API</h4>
<p>В погоне за понятностью концепций API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.</p>
<h4>Правила разработки машиночитаемых интерфейсов</h4>
<p>В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.</p>
<h5><a href="#chapter-11-paragraph-12" id="chapter-11-paragraph-12" class="anchor">12. Состояние системы должно быть понятно клиенту</a></h5>
<p>Часто можно встретить интерфейсы, в которых клиент не обладает полнотой знаний о том, что происходит в системе от его имени — например, какие операции сейчас выполняются и каков их статус.</p>
<p><strong>Плохо</strong>:</p>
@@ -1793,34 +1839,39 @@ GET /v1/orders/{id}
// во всех статусах
GET /v1/users/{id}/orders
</code></pre>
<p>Это правило так же распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема.</p>
<p><strong>Плохо</strong>: <code>{"error": "email malformed"}</code>
— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю
<strong>Хорошо</strong>:</p>
<p>Это правило также распространяется и на ошибки, в первую очередь, клиентские. Если ошибку можно исправить, информация об этом должна быть машиночитаема.</p>
<p><strong>Плохо</strong>: <code>{ "error": "email malformed" }</code>
— единственное, что может с этой ошибкой сделать разработчик — показать её пользователю</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>{
// Machine-readable
// Машиночитаемый статус
"status": "validation_failed",
// An array; if there are several
// errors, the user might correct
// them all at once
// Массив описания проблем;
// если пользовательский ввод
// некорректен в нескольких
// аспектах, пользователь сможет
// исправить их все
"failed_checks": [
{
"field: "email",
"error_type": "malformed",
// Localized human-readable message
// Локализованное
// человекочитаемое
// сообщение
"message": "email malformed"
}
]
}
</code></pre>
<h5><a href="#chapter-11-paragraph-13" id="chapter-11-paragraph-13" class="anchor">13. Указывайте время жизни ресурсов и политики кэширования</a></h5>
<p>В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Желательно в такой ситуации вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.</p>
<p>Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать повтором запроса?</p>
<p>В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.</p>
<p>Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?</p>
<p><strong>Плохо</strong>:</p>
<pre><code>// Возвращает цену лунго в кафе,
// ближайшем к указанной точке
GET /v1/price?recipe=lungo
&#x26;longitude={longitude}&#x26;latitude={latitude}
GET /v1/price?recipe=lungo­⮠
&#x26;longitude={longitude}
­&#x26;latitude={latitude}
{ "currency_code", "price" }
</code></pre>
@@ -1833,8 +1884,9 @@ GET /v1/price?recipe=lungo
Для указания времени жизни кэша можно пользоваться стандартными средствами протокола, например, заголовком <code>Cache-Control</code>. В ситуации, когда кэш существует не только во временном измерении (как, например, в нашем примере добавляется пространственное измерение), вам придётся разработать свой формат описания параметров кэширования.</p>
<pre><code>// Возвращает предложение: за какую сумму
// наш сервис готов приготовить лунго
GET /v1/price?recipe=lungo
&#x26;longitude={longitude}&#x26;latitude={latitude}
GET /v1/price?recipe=lungo
&#x26;longitude={longitude}
&#x26;latitude={latitude}
{
"offer": {
@@ -1842,7 +1894,8 @@ GET /v1/price?recipe=lungo
"currency_code",
"price",
"conditions": {
// До какого времени валидно предложение
// До какого времени
// валидно предложение
"valid_until",
// Где валидно предложение:
// * город
@@ -1882,14 +1935,18 @@ GET /v1/records?limit=10&#x26;offset=100
<p><strong>Хорошо</strong>: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок сортировки по которому фиксирован. Например, вот так:</p>
<pre><code>// Возвращает указанный limit записей,
// отсортированных по дате создания,
// начиная с первой записи, созданной позднее,
// начиная с первой записи,
// созданной позднее,
// чем запись с указанным id
GET /v1/records?older_than={record_id}&#x26;limit=10
GET /v1/records
?older_than={record_id}&#x26;limit=10
// Возвращает указанный limit записей,
// отсортированных по дате создания,
// начиная с первой записи, созданной раньше,
// начиная с первой записи,
// созданной раньше,
// чем запись с указанным id
GET /v1/records?newer_than={record_id}&#x26;limit=10
GET /v1/records
?newer_than={record_id}&#x26;limit=10
</code></pre>
<p>При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.<br>
@@ -1897,7 +1954,8 @@ GET /v1/records?newer_than={record_id}&#x26;limit=10
<pre><code>// Первый запрос данных
POST /v1/records/list
{
// Какие-то дополнительные параметры фильтрации
// Какие-то дополнительные
// параметры фильтрации
"filter": {
"category": "some_category",
"created_date": {
@@ -1906,12 +1964,10 @@ POST /v1/records/list
}
}
{
"cursor"
}
{ "cursor" }
</code></pre>
<pre><code>// Последующие запросы
GET /v1/records?cursor=&#x3C;значение курсора>
GET /v1/records?cursor=&#x3C;курсор>
{ "records", "cursor" }
</code></pre>
<p>Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. <code>filter</code> в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее.</p>
@@ -1928,7 +1984,8 @@ GET /v1/records?cursor=&#x3C;значение курсора>
// отсортированных по полю sort_by
// в порядке sort_order,
// начиная с записи с номером offset
GET /records?sort_by=date_modified&#x26;sort_order=desc&#x26;limit=10&#x26;offset=100
GET /records?sort_by=date_modified
&#x26;sort_order=desc&#x26;limit=10&#x26;offset=100
</code></pre>
<p>Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям.</p>
<p><strong>Хорошо</strong>: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.</p>
@@ -1936,15 +1993,17 @@ GET /records?sort_by=date_modified&#x26;sort_order=desc&#x26;limit=10&#x26;offse
<pre><code>// Создаёт представление по указанным параметрам
POST /v1/record-views
{
sort_by: [
{ "field": "date_modified", "order": "desc" }
]
sort_by: [{
"field": "date_modified",
"order": "desc"
}]
}
{ "id", "cursor" }
</code></pre>
<pre><code>// Позволяет получить часть представления
GET /v1/record-views/{id}?cursor={cursor}
GET /v1/record-views/{id}
?cursor={cursor}
</code></pre>
<p>Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).</p>
<p><strong>Вариант 2</strong>: гарантировать строгий неизменяемый порядок записей, например, путём введения понятия события изменения записи:</p>
@@ -1967,6 +2026,7 @@ GET /v1/record-views/{id}?cursor={cursor}
<h5><a href="#chapter-11-paragraph-15" id="chapter-11-paragraph-15" class="anchor">15. Сохраняйте точность дробных чисел</a></h5>
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.</p>
<p>Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.</p>
<p>Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат <code>00:20</code> формату <code>0.333333…</code>), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.</p>
<h5><a href="#chapter-11-paragraph-16" id="chapter-11-paragraph-16" class="anchor">16. Все операции должны быть идемпотентны</a></h5>
<p>Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.</p>
<p>Там, где идемпотентность не может быть обеспечена естественным образом, необходимо добавить явный параметр — ключ идемпотентности или ревизию.</p>
@@ -2091,8 +2151,8 @@ GET /v1/recipes
}]
}
// Можно воспользоваться статусом
// «частичного успеха», если он предусмотрен
// протоколом
// «частичного успеха»,
// если он предусмотрен протоколом
→ 200 OK
{
"changes": [{
@@ -2141,7 +2201,8 @@ GET /v1/recipes
"status": "fail",
"error": {
"reason": "too_many_requests"
"reason":
"too_many_requests"
}
}]
}
@@ -2174,27 +2235,43 @@ GET /v1/recipes
<p>На всякий случай уточним, что вложенные операции должны быть сами по себе идемпотентны. Если же это не так, то следует сгенерировать внутренние ключи идемпотентности на каждую вложенную операцию в отдельности.</p>
<h5><a href="#chapter-11-paragraph-18" id="chapter-11-paragraph-18" class="anchor">18. Не изобретайте безопасность</a></h5>
<p>Если бы автору этой книги давали доллар каждый раз, когда ему приходилось бы имплементировать кем-то придуманный дополнительный протокол безопасности — он бы давно уже был на заслуженной пенсии. Любовь разработчиков API к подписыванию параметры запросов или сложным схемам обмена паролей на токены столь же несомненна, сколько и бессмысленна.</p>
<p><strong>Во-первых</strong>, почти всегда процедуры, обеспечивающие безопасность той или иной операции, <em>уже разработаны</em>. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки Man-in-the-Middle, как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.</p>
<p><strong>Во-первых</strong>, почти всегда процедуры, обеспечивающие безопасность той или иной операции, <em>уже разработаны</em>. Нет никакой нужды придумывать их заново, просто имплементируйте какой-то из существующих протоколов. Никакие самописные алгоритмы проверки сигнатур запросов не обеспечат вам того же уровня защиты от атаки <a href="https://en.wikipedia.org/wiki/Man-in-the-middle_attack">Man-in-the-Middle</a>, как соединение по протоколу TLS с взаимной проверкой сигнатур сертификатов.</p>
<p><strong>Во-вторых</strong>, чрезвычайно самонадеянно (и опасно) считать, что вы разбираетесь в вопросах безопасности. Новые вектора атаки появляются каждый день, и быть в курсе всех актуальных проблем — это само по себе работа на полный рабочий день. Если же вы полный рабочий день занимаетесь чем-то другим, спроектированная вами система защиты наверняка будет содержать уязвимости, о которых вы просто никогда не слышали — например, ваш алгоритм проверки паролей может быть подвержен <a href="https://en.wikipedia.org/wiki/Timing_attack">атаке по времени</a>, а веб-сервер — <a href="https://capec.mitre.org/data/definitions/105.html">атаке с разделением запросов</a>.</p>
<h5><a href="#chapter-11-paragraph-19" id="chapter-11-paragraph-19" class="anchor">19. Декларируйте технические ограничения явно</a></h5>
<p>У любого поля в вашем API есть ограничения на допустимые значения: размеры текстовых полей, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.</p>
<p>У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.</p>
<p>Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.</p>
<p>То же соображение применимо и к квотам: партнёры должны иметь доступ к информации о том, какую долю доступных ресурсов они выбрали, и ошибки в случае превышения квоты должны быть информативными.</p>
<h5><a href="#chapter-11-paragraph-20" id="chapter-11-paragraph-20" class="anchor">20. Считайте трафик</a></h5>
<p>В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей, и API способно выступать мультипликатором ошибок и в этом вопросе.</p>
<p>В современном мире такой ресурс, как объём пропущенного трафика, считать уже почти не принято — считается, что Интернет всюду практически безлимитен. Однако он всё-таки не абсолютно безлимитен: всегда можно спроектировать систему так, что объём трафика окажется некомфортным даже и для современных сетей.</p>
<p>Три основные причины раздувания объёма трафика достаточно очевидны:</p>
<ul>
<li>не предусмотрен постраничный перебор данных;</li>
<li>не предусмотрены ограничения на размер значений полей и/или передаются большие бинарные данные (графика, аудио, видео и т.д.);</li>
<li>клиент слишком часто запрашивает данные и/или слишком мало их кэширует.</li>
</ul>
<p>Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то вторая проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:</p>
<p>Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:</p>
<ul>
<li>не злоупотребляйте асинхронными интерфейсами; с одной стороны, они позволяют избежать многих технических проблем с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость (если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных); но, с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым;</li>
<li>объявляйте явную политику перезапросов (например, посредством заголовка <code>Retry-After</code>); да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);</li>
<li>если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы) моделью;</li>
<li>если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по объёмы превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это как минимум позволит задавать различные политики кэширования для разных данных.</li>
<li>
<p>не злоупотребляйте асинхронными интерфейсами;</p>
<ul>
<li>с одной стороны, они позволяют нивелировать многие технических проблем с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных;</li>
<li>с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений;</li>
</ul>
<p>Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.</p>
</li>
<li>
<p>объявляйте явную политику перезапросов (например, посредством заголовка <code>Retry-After</code>);</p>
<ul>
<li>да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);</li>
</ul>
</li>
<li>
<p>если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы);</p>
</li>
<li>
<p>если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по разумеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных.</p>
</li>
</ul>
<p>Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.</p>
<h5><a href="#chapter-11-paragraph-21" id="chapter-11-paragraph-21" class="anchor">21. Избегайте неявных частичных обновлений</a></h5>
<p>Один из самых частых антипаттернов в разработке API — попытка сэкономить на подробном описании изменения состояния.</p>
<p><strong>Плохо</strong>:</p>
@@ -2215,15 +2292,17 @@ POST /v1/orders/
<pre><code>// Частично перезаписывает заказ
// обновляет объём второго напитка
PATCH /v1/orders/{id}
{"items": [null, {
"volume": "800ml"
}]}
{
"items": [null, {
"volume": "800ml"
}]
}
{ /* изменения приняты */ }
</code></pre>
<p>Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (<code>delivery_address</code>, <code>milk_type</code>) — они будут сброшены в значения по умолчанию или останутся неизменными? Ну и самое неприятное — какой бы вариант вы ни выбрали, это только начало проблем.</p>
<p>Допустим, мы договорились, что конструкция <code>{"items":[null, {…}]}</code> означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?</p>
<p><strong>Простое решение</strong> состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта и полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:</p>
<p>Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (<code>delivery_address</code>, <code>milk_type</code>) — они будут сброшены в значения по умолчанию или останутся неизменными?</p>
<p>Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция <code>{ "items":[null, {…}] }</code> означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?</p>
<p><strong>Простое решение</strong> состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:</p>
<ul>
<li>повышенные размеры запросов и, как следствие, расход трафика;</li>
<li>необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;</li>
@@ -2232,58 +2311,73 @@ PATCH /v1/orders/{id}
<p>Все эти соображения, однако, на поверку оказываются мнимыми:</p>
<ul>
<li>причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);</li>
<li>концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент — что не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; более того, существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиент может ошибиться или просто полениться правильно вычислить изменившиеся поля;</li>
<li>концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
<ul>
<li>это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;</li>
<li>существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;</li>
</ul>
</li>
<li>наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
<ul>
<li>кроме того, часто в рамках той же экономии ответ на операцию частичного обновления пуст; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.</li>
<li>кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.</li>
</ul>
</li>
</ul>
<p><strong>Лучше</strong>: разделить эндпойнты. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется <a href="#chapter-10">с принципом декомпозиции</a>, который мы рассматривали в предыдущем разделе.</p>
<pre><code></code></pre>
<p>// Создаёт заказ из двух напитков
<p><strong>Лучше</strong>: разделить эндпойнт. Этот подход также хорошо согласуется <a href="#chapter-10">с принципом декомпозиции</a>, который мы рассматривали в предыдущем разделе.</p>
<pre><code>// Создаёт заказ из двух напитков
POST /v1/orders/
{
"parameters": {
"delivery_address"
}
"items": [{
"recipe": "lungo",
}, {
"recipe": "latte",
"milk_type": "oats"
}]
"parameters": {
"delivery_address"
}
"items": [{
"recipe": "lungo",
}, {
"recipe": "latte",
"milk_type": "oats"
}]
}
{
"order_id",
"created_at",
"parameters": {
"delivery_address"
"order_id",
"created_at",
"parameters": {
"delivery_address"
}
"items": [
{ "item_id", "status"},
{ "item_id", "status"}
]
}
"items": [
{"item_id", "status"},
{"item_id", "status"}
]
}</p>
<pre><code></code></pre>
<p>// Изменяет параметры заказа
</code></pre>
<pre><code>// Изменяет параметры,
// относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters
{"delivery_address"}
{ "delivery_address" }
{"delivery_address"}</p>
<pre><code></code></pre>
<p>// Частично перезаписывает заказ
// обновляет объём второго напитка
{ "delivery_address" }
</code></pre>
<pre><code>// Частично перезаписывает заказ
// обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id}
{"recipe", "volume", "milk_type"}
{
// Все поля передаются, даже если
// изменилось только какое-то одно
"recipe", "volume", "milk_type"
}
{"recipe", "volume", "milk_type"}</p>
<pre><code></code></pre>
<p>Теперь для удаления <code>volume</code> достаточно <em>не</em> передавать его в <code>PUT items/{item_id}</code>. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (<code>created_at</code> и <code>status</code>) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить <code>created_at</code>?). В этом подходе также можно в ответах операций <code>PUT</code> возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования), а не только изменённые суб-объекты.</p>
{ "recipe", "volume", "milk_type" }
</code></pre>
<pre><code>// Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id}
</code></pre>
<p>Теперь для удаления <code>volume</code> достаточно <em>не</em> передавать его в <code>PUT items/{item_id}</code>. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.</p>
<p>Этот подход также позволяет отделить неизменяемые и вычисляемые поля (<code>created_at</code> и <code>status</code>) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить <code>created_at</code>?).</p>
<p>Также в ответах операций <code>PUT</code> можно возвращать объект заказа целиком, а не перезаписываемый суб-ресурс (однако следует использовать какую-то конвенцию именования).</p>
<p><strong>NB</strong>: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).</p>
<p><strong>Ещё лучше</strong>: разработать формат описания атомарных изменений.</p>
<pre><code>POST /v1/order/changes
X-Idempotency-Token: &#x3C;см. следующий раздел>
X-Idempotency-Token: &#x3C;токен идемпотентности>
{
"changes": [{
"type": "set",
@@ -2317,7 +2411,7 @@ X-Idempotency-Token: &#x3C;см. следующий раздел>
<p>Важно понимать, что язык пользователя и юрисдикция, в которой пользователь находится — разные вещи. Цикл работы вашего API всегда должен хранить локацию пользователя. Либо она задаётся явно (в запросе указываются географические координаты), либо неявно (первый запрос с географическими координатами инициировал создание сессии, в которой сохранена локация) — но без локации корректная локализация невозможна. В большинстве случаев локацию допустимо редуцировать до кода страны.</p>
<p>Дело в том, что множество параметров, потенциально влияющих на работу API, зависят не от языка, а именно от расположения пользователя. В частности, правила форматирования чисел (разделители целой и дробной частей, разделители разрядов) и дат, первый день недели, раскладка клавиатуры, система единиц измерения (которая к тому же может оказаться не десятичной!) и так далее. В некоторых ситуациях необходимо хранить две локации: та, в которой пользователь находится, и та, которую пользователь сейчас просматривает. Например, если пользователь из США планирует туристическую поездку в Европу, то цены ему желательно показывать в местной валюте, но отформатированными согласно правилам американского письма.</p>
<p>Следует иметь в виду, что явной передачи локации может оказаться недостаточно, поскольку в мире существуют территориальные конфликты и спорные территории. Каким образом API должен себя вести при попадании координат пользователя на такие территории — вопрос, к сожалению, в первую очередь юридический. Автору этой книги приходилось как-то разрабатывать API, в котором пришлось вводить концепцию «территория государства A по мнению официальных органов государства Б».</p>
<p><strong>Важно</strong>: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 19 сообщение <code>localized_message</code> адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение <code>details.checks_failed[].message</code> написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.</p>
<p><strong>Важно</strong>: различайте локализацию для конечного пользователя и локализацию для разработчика. В примере из п. 12 сообщение <code>localized_message</code> адресовано пользователю — его должно показать приложение, если в коде обработка такой ошибки не предусмотрена. Это сообщение должно быть написано на указанном в запросе языке и отформатировано согласно правилам локации пользователя. А вот сообщение <code>details.checks_failed[].message</code> написано не для пользователя, а для разработчика, который будет разбираться с проблемой. Соответственно, написано и отформатировано оно должно быть понятным для разработчика образом — что, скорее всего, означает «на английском языке», т.к. английский де-факто является стандартом в мире разработки программного обеспечения.</p>
<p>Следует отметить, что индикация, какие сообщения следует показать пользователю, а какие написаны для разработчика, должна, разумеется, быть явной конвенцией вашего API. В примере для этого используется префикс <code>localized_</code>.</p>
<p>И ещё одна вещь: все строки должны быть в кодировке UTF-8 и никакой другой.</p><div class="page-break"></div><h3><a href="#chapter-12" class="anchor" id="chapter-12">Глава 12. Приложение к разделу I. Модельный API</a></h3>
<p>Суммируем текущее состояние нашего учебного API.</p>

View File

@@ -290,8 +290,9 @@ ul.references li p a.back-anchor {
}
pre {
margin: 0;
margin: 0.2em 0;
padding: 0.2em;
font-size: 80%;
}
ul,

View File

@@ -95,13 +95,6 @@ body {
text-align: left;
}
body,
h6 {
font-family: local-serif, serif;
font-size: 14pt;
text-align: justify;
}
.cc-by-nc-img {
display: block;
float: left;
@@ -122,7 +115,7 @@ pre {
}
code {
white-space: nowrap;
hyphens: manual;
font-size: 80%;
}
@@ -175,6 +168,14 @@ h5 {
page-break-after: avoid;
}
body,
h5,
h6 {
font-family: local-serif, serif;
font-size: 14pt;
text-align: justify;
}
h6 {
font-size: 80%;
color: darkgray;
@@ -202,11 +203,14 @@ h3 {
font-size: 140%;
}
h4,
h5 {
h4 {
font-size: 120%;
}
h5 {
font-size: 110%;
}
.annotation {
text-align: justify;
}

View File

@@ -10,7 +10,8 @@ Let's take a look at the following example:
```
// Method description
POST /v1/bucket/{id}/some-resource
POST /v1/bucket/{id}/some-resource
/{resource_id}
X-Idempotency-Token: <idempotency token>
{
@@ -23,7 +24,10 @@ Cache-Control: no-cache
{
/* And this is
a multiline comment */
"error_message"
"error_message":
"Long error message⮠
that will span several⮠
lines"
}
```
@@ -34,7 +38,8 @@ It should be read like this:
* a specific JSON, containing a `some_parameter` field and some other unspecified fields (indicated by ellipsis) is being sent as a request body payload;
* in response (marked with an arrow symbol `→`) server returns a `404 Not Founds` status code; the status might be omitted (treat it like a `200 OK` if no status is provided);
* the response could possibly contain additional notable headers;
* the response body is a JSON comprising a single `error_message` field; field value absence means that field contains exactly what you expect it should contain — some error message in this case.
* the response body is a JSON comprising a single `error_message` field; field value absence means that field contains exactly what you expect it should contain — some error message in this case;
* if some token is too long to fit a single line, we will split it into several lines adding `⮠` to indicate it continues next line.
The term ‘client’ here stands for an application being executed on a user's device, either a native or a web one. The terms ‘agent’ and ‘user agent’ are synonymous to ‘client’.

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,8 @@
Рассмотрим следующую запись:
```
// Описание метода
POST /v1/bucket/{id}/some-resource
POST /v1/bucket/{id}/some-resource
/{resource_id}
X-Idempotency-Token: <токен идемпотентности>
{
@@ -22,7 +23,10 @@ Cache-Control: no-cache
{
/* А это многострочный
комментарий */
"error_message"
"error_message":
"Длинное сообщение,⮠
которое приходится⮠
разбивать на строки"
}
```
@@ -33,7 +37,8 @@ Cache-Control: no-cache
* в качестве тела запроса передаётся JSON, содержащий поле `some_parameter` со значением `value` и ещё какие-то поля, которые для краткости опущены (что показано многоточием);
* в ответ (индицируется стрелкой `→`) сервер возвращает статус `404 Not Found`; статус может быть опущен (отсутствие статуса следует трактовать как `200 OK`);
* в ответе также могут находиться дополнительные заголовки, на которые мы обращаем внимание;
* телом ответа является JSON, состоящий из единственного поля `error_message`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке.
* телом ответа является JSON, состоящий из единственного поля `error_message`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какое-то сообщение об ошибке
* если какой-то токен оказывается слишком длинным, мы будем переносить его на следующую строку, используя символ `⮠` для индикации переноса.
Здесь термин «клиент» означает «приложение, установленное на устройстве пользователя, использующее рассматриваемый API». Приложение может быть как нативным, так и веб-приложением. Термины «агент» и «юзер-агент» являются синонимами термина «клиент».

View File

@@ -37,14 +37,16 @@ POST /orders/cancel
**Плохо**:
```
// Возвращает агрегированную статистику заказов за всё время
// Возвращает агрегированную
// статистику заказов за всё время
GET /orders/statistics
```
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
**Хорошо**:
```
// Возвращает агрегированную статистику заказов за указанный период
// Возвращает агрегированную
// статистику заказов за указанный период
POST /v1/orders/statistics/aggregate
{ "begin_date", "end_date" }
```
@@ -71,12 +73,17 @@ POST /v1/orders/statistics/aggregate
`"duration_ms": 5000`
либо
`"duration": "5000ms"`
либо
`"duration": { "unit": "ms", "value": 5000 }`.
либо
```
"duration": {
"unit": "ms",
"value": 5000
}
```
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что, как ни сделай, — кто-то останется недовольным. Классический пример такого рода — порядок географических координат («широта-долгота» против «долгота-широта»). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
##### Сущности должны именоваться конкретно
@@ -92,19 +99,32 @@ POST /v1/orders/statistics/aggregate
**Плохо**: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…
**Хорошо**: `order.get_estimated_delivery_time()`
**Хорошо**:
```
order
.get_estimated_delivery_time()
```
**Плохо**:
```
// возвращает положение первого вхождения в строку str2
// возвращает положение
// первого вхождения в строку str1
// любого символа из строки str2
strpbrk (str1, str2)
```
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
**Хорошо**: `str_search_for_characters (lookup_character_set, str)`
**Хорошо**:
```
str_search_for_characters(
lookup_character_set,
str
)
```
— однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
**NB**: иногда названия полей сокращают или вовсе опускают (например, возвращают массив разнородных объектов вместо набора именованных полей) в погоне за уменьшением количества трафика. В абсолютном большинстве случаев это бессмысленно, поскольку текстовые данные при передаче обычно дополнительно сжимают на уровне протокола.
##### Тип поля должен быть ясен из его названия
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`.
@@ -121,18 +141,23 @@ strpbrk (str1, str2)
**Хорошо**: `"task.is_finished": true`.
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа `Date` и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`.
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учётом специфики first-class citizen-типов. Например, в JSON не существует объектов типа `Date`, и даты приходится передавать в виде числа или строки; разумно такие даты индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at` и т.д.) или `_date`.
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс во избежание непонимания.
**Плохо**:
```
// Возвращает список встроенных функций кофемашины
// Возвращает список
// встроенных функций кофемашины
GET /coffee-machines/{id}/functions
```
Слово "functions" многозначное: оно может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
**Хорошо**: `GET /v1/coffee-machines/{id}/builtin-functions-list`
**Хорошо**:
```
GET /v1/coffee-machines/{id}⮠
/builtin-functions-list
```
##### Подобные сущности должны называться подобно и вести себя подобным образом
@@ -148,8 +173,10 @@ GET /coffee-machines/{id}/functions
strpos(haystack, needle)
```
```
// Находит и заменяет все вхождения строки `needle`
// внутри строки `haystack` на строку `replace`
// Находит и заменяет
// все вхождения строки `needle`
// внутри строки `haystack`
// на строку `replace`
str_replace(needle, replace, haystack)
```
Здесь нарушены сразу несколько правил:
@@ -202,13 +229,17 @@ POST /v1/orders
Новая опция `contactless_delivery` является необязательной, однако её значение по умолчанию — `true`. Возникает вопрос, каким образом разработчик должен отличить явное *нежелание* пользоваться опцией (`false`) от незнания о её существовании (поле не задано). Приходится писать что-то типа такого:
```
if (Type(order.contactless_delivery) == 'Boolean' &&
order.contactless_delivery == false) { … }
if (Type(
order.contactless_delivery
) == 'Boolean' &&
order.contactless_delivery == false) {
}
```
Эта практика ведёт к усложнению кода, который пишут разработчики, и в этом коде легко допустить ошибку, которая по сути меняет значение поля на противоположное. То же самое произойдёт, если для индикации отсутствия значения поля использовать специальное значение типа `null` или `-1`.
**NB**. Это замечание не распространяется на те случае, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
**NB**. Это замечание не распространяется на те случаи, когда платформа и протокол однозначно и без всяких дополнительных абстракций поддерживают такие специальные значения для сброса значения поля в значение по умолчанию. Однако полная и консистентная поддержка частичных операций со сбросом значений полей практически нигде не имплементирована. Пожалуй, единственный пример такого API из имеющих широкое распространение сегодня — SQL: в языке есть и концепция `NULL`, и значения полей по умолчанию, и поддержка операций вида `UPDATE … SET field = DEFAULT` (в большинстве диалектов). Хотя работа с таким протоколом всё ещё затруднена (например, во многих диалектах нет простого способа получить обратно значение по умолчанию, которое выставил `UPDATE … DEFAULT`), логика работы с умолчаниями в SQL имплементирована достаточно хорошо, чтобы использовать её как есть.
Если же протоколом явная работа со значениями по умолчанию не предусмотрена, универсальное правило — все новые необязательные булевы флаги должны иметь значение по умолчанию false.
@@ -321,14 +352,16 @@ POST /v1/coffee-machines/search
{
"reason": "wrong_parameter_value",
"localized_message":
"Что-то пошло не так. Обратитесь к разработчику приложения."
"Что-то пошло не так.
Обратитесь к разработчику приложения."
"details": {
"checks_failed": [
{
"field": "recipe",
"error_type": "wrong_value",
"message":
"Value 'lngo' unknown. Do you mean 'lungo'?"
"Value 'lngo' unknown.
Did you mean 'lungo'?"
},
{
"field": "position.latitude",
@@ -338,7 +371,9 @@ POST /v1/coffee-machines/search
"max": 90
},
"message":
"'position.latitude' value must fall in [-90, 90] interval"
"'position.latitude' value
must fall within⮠
the [-90, 90] interval"
}
]
}
@@ -379,25 +414,35 @@ POST /v1/orders
```
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.10" }]
"items": [{
"item_id": "123",
"price": "0.10"
}]
}
409 Conflict
{
"reason": "price_changed",
"details": [{ "item_id": "123", "actual_price": "0.20" }]
"details": [{
"item_id": "123",
"actual_price": "0.20"
}]
}
// Повторный запрос
// с актуальной ценой
POST /v1/orders
{
"items": [{ "item_id": "123", "price": "0.20" }]
"items": [{
"item_id": "123",
"price": "0.20"
}]
}
409 Conflict
{
"reason": "order_limit_exceeded",
"localized_message": "Лимит заказов превышен"
"localized_message":
"Лимит заказов превышен"
}
```
— какой был смысл показывать пользователю диалог об изменившейся цене, если и с правильной ценой заказ он сделать всё равно не сможет? Пока один из его предыдущих заказов завершится и можно будет сделать следующий заказ, цену, наличие и другие параметры заказа всё равно придётся корректировать ещё раз.
@@ -442,9 +487,9 @@ POST /v1/orders
Легко заметить, что в этом примере нет способа разрешить ошибку в один шаг — эту ситуацию требуется предусмотреть отдельно, и либо изменить параметры расчёта (минимальная сумма заказа не учитывает скидки), либо ввести специальную ошибку для такого кейса.
#### Правила разработки машиночитаемых API
#### Правила разработки машиночитаемых интерфейсов
В погоне за понятностью концепций API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.
В погоне за понятностью API для людей мы часто забываем, что работать с API всё-таки будут не сами разработчики, а написанный ими код. Многие концепции, которые хорошо работают для визуальных интерфейсов, плохо подходят для интерфейсов программных: в частности, разработчик не может в коде принимать решения, ориентируясь на текстовые сообщения, и не может «выйти и зайти снова» в случае нештатной ситуации.
##### Состояние системы должно быть понятно клиенту
@@ -506,16 +551,20 @@ GET /v1/users/{id}/orders
**Хорошо**:
```
{
// Machine-readable
// Машиночитаемый статус
"status": "validation_failed",
// An array; if there are several
// errors, the user might correct
// them all at once
// Массив описания проблем;
// если пользовательский ввод
// некорректен в нескольких
// аспектах, пользователь сможет
// исправить их все
"failed_checks": [
{
"field: "email",
"error_type": "malformed",
// Localized human-readable message
// Локализованное
// человекочитаемое
// сообщение
"message": "email malformed"
}
]
@@ -524,16 +573,17 @@ GET /v1/users/{id}/orders
##### Указывайте время жизни ресурсов и политики кэширования
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Желательно в такой ситуации вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
В современных системах клиент, как правило, обладает собственным состоянием и почти всегда кэширует результаты запросов — неважно, долговременно ли или в течение сессии: у каждого объекта всегда есть какое-то время автономной жизни. Поэтому желательно вносить ясность; каким образом рекомендуется кэшировать результат должно быть понятно, если не из сигнатур операций, то хотя бы из документации.
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать повтором запроса?
Следует уточнить, что кэш мы понимаем в расширенном смысле, а именно: какое варьирование параметров операции (не только времени обращения, но и прочих переменных) следует считать достаточно близким к предыдущему запросу, чтобы можно было использовать результат из кэша?
**Плохо**:
```
// Возвращает цену лунго в кафе,
// ближайшем к указанной точке
GET /v1/price?recipe=lungo
&longitude={longitude}&latitude={latitude}
GET /v1/price?recipe=lungo­⮠
&longitude={longitude}
­&latitude={latitude}
{ "currency_code", "price" }
```
@@ -547,8 +597,9 @@ GET /v1/price?recipe=lungo
```
// Возвращает предложение: за какую сумму
// наш сервис готов приготовить лунго
GET /v1/price?recipe=lungo
&longitude={longitude}&latitude={latitude}
GET /v1/price?recipe=lungo
&longitude={longitude}
&latitude={latitude}
{
"offer": {
@@ -556,7 +607,8 @@ GET /v1/price?recipe=lungo
"currency_code",
"price",
"conditions": {
// До какого времени валидно предложение
// До какого времени
// валидно предложение
"valid_until",
// Где валидно предложение:
// * город
@@ -598,23 +650,30 @@ GET /v1/records?limit=10&offset=100
```
// Возвращает указанный limit записей,
// отсортированных по дате создания,
// начиная с первой записи, созданной позднее,
// начиная с первой записи,
// созданной позднее,
// чем запись с указанным id
GET /v1/records?older_than={record_id}&limit=10
GET /v1/records
?older_than={record_id}&limit=10
// Возвращает указанный limit записей,
// отсортированных по дате создания,
// начиная с первой записи, созданной раньше,
// начиная с первой записи,
// созданной раньше,
// чем запись с указанным id
GET /v1/records?newer_than={record_id}&limit=10
GET /v1/records
?newer_than={record_id}&limit=10
```
При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.
Другой вариант организации таких списков — возврат курсора `cursor`, который используется вместо `record_id`, что делает интерфейсы более универсальными.
```
// Первый запрос данных
POST /v1/records/list
{
// Какие-то дополнительные параметры фильтрации
// Какие-то дополнительные
// параметры фильтрации
"filter": {
"category": "some_category",
"created_date": {
@@ -625,11 +684,13 @@ POST /v1/records/list
{ "cursor" }
```
```
// Последующие запросы
GET /v1/records?cursor=<значение курсора>
GET /v1/records?cursor=<курсор>
{ "records", "cursor" }
```
Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. `filter` в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее.
Вообще схему с курсором можно реализовать множеством способов (например, не разделять первый и последующие запросы данных), главное — выбрать какой-то один.
@@ -646,8 +707,10 @@ GET /v1/records?cursor=<значение курсора>
// отсортированных по полю sort_by
// в порядке sort_order,
// начиная с записи с номером offset
GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
GET /records?sort_by=date_modified
&sort_order=desc&limit=10&offset=100
```
Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям.
**Хорошо**: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.
@@ -658,16 +721,19 @@ GET /records?sort_by=date_modified&sort_order=desc&limit=10&offset=100
// Создаёт представление по указанным параметрам
POST /v1/record-views
{
sort_by: [
{ "field": "date_modified", "order": "desc" }
]
sort_by: [{
"field": "date_modified",
"order": "desc"
}]
}
{ "id", "cursor" }
```
```
// Позволяет получить часть представления
GET /v1/record-views/{id}?cursor={cursor}
GET /v1/record-views/{id}
?cursor={cursor}
```
Поскольку созданное представление уже неизменяемо, доступ к нему можно организовать как угодно: через курсор, limit/offset, заголовок Range и т.д. Однако надо иметь в виду, что при переборе таких списков порядок может быть нарушен: записи, изменённые уже после генерации представления, будут находиться не на своих местах (либо быть неактуальны, если запись копируется целиком).
@@ -701,6 +767,8 @@ POST /v1/records/modified/list
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
Если конвертация в формат с плавающей запятой заведомо приводит к потере точности (например, если мы переведём 20 минут в часы в виде десятичной дроби), то следует либо предпочесть формат без потери точности (т.е. предпочесть формат `00:20` формату `0.333333…`), либо предоставить SDK работы с такими данными, либо (в крайнем случае) описать в документации принципы округления.
##### Все операции должны быть идемпотентны
Напомним, идемпотентность — это следующее свойство: повторный вызов той же операции с теми же параметрами не изменяет результат. Поскольку мы обсуждаем в первую очередь клиент-серверное взаимодействие, узким местом в котором является ненадежность сетевой составляющей, повтор запроса при обрыве соединения — не исключительная ситуация, а норма жизни.
@@ -848,8 +916,8 @@ PATCH /v1/recipes
}]
}
// Можно воспользоваться статусом
// «частичного успеха», если он предусмотрен
// протоколом
// «частичного успеха»,
// если он предусмотрен протоколом
→ 200 OK
{
"changes": [{
@@ -899,7 +967,8 @@ PATCH /v1/recipes
"status": "fail",
"error": {
"reason": "too_many_requests"
"reason":
"too_many_requests"
}
}]
}
@@ -947,7 +1016,7 @@ PATCH /v1/recipes
##### Декларируйте технические ограничения явно
У любого поля в вашем API есть ограничения на допустимые значения: размеры текстовых полей, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
У любого поля в вашем API есть ограничения на допустимые значения: максимальная длина текста, объём прикладываемых документов в мегабайтах, разрешённые диапазоны цифровых значений. Часто разработчики API пренебрегают указанием этих лимитов — либо потому, что считают их очевидными, либо потому, что попросту не знают их сами. Это, разумеется, один большой антипаттерн: незнание пределов использования системы автоматически означает, что код партнёров может в любой момент перестать работать по не зависящим от них причинам.
Поэтому, во-первых, указывайте границы допустимых значений для всех без исключения полей в API, и, во-вторых, если эти границы нарушены, генерируйте машиночитаемую ошибку с описанием, какое ограничение на какое поле было нарушено.
@@ -963,12 +1032,19 @@ PATCH /v1/recipes
* клиент слишком часто запрашивает данные и/или слишком мало их кэширует.
Если первые две проблемы решаются чисто техническими средствами (см. соответствующие разделы), то третья проблема скорее логическая: каким образом разумно организовать канал обновления состояния клиента так, чтобы найти баланс между отзывчивостью системы и затраченными на эту отзывчивость ресурсами. Здесь мы можем дать несколько рекомендаций:
* не злоупотребляйте асинхронными интерфейсами; с одной стороны, они позволяют избежать многих технических проблем с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость (если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных); но, с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым;
* объявляйте явную политику перезапросов (например, посредством заголовка `Retry-After`); да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);
* если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы);
* если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по разумеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это как минимум позволит задавать различные политики кэширования для разных данных.
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
* не злоупотребляйте асинхронными интерфейсами;
* с одной стороны, они позволяют нивелировать многие технических проблем с производительностью API, что, в свою очередь, позволяет поддерживать обратную совместимость: если метод изначально асинхронный, то можно без проблем увеличивать время обработки и менять модель консистентности данных;
* с другой стороны, количество генерируемых клиентами запросов становится трудно предсказуемым, поскольку для получения результата клиенту необходимо сделать заранее неизвестное число обращений;
* объявляйте явную политику перезапросов (например, посредством заголовка `Retry-After`);
* да, какие-то клиенты будут её игнорировать, т.к. разработчики поленятся её имплементировать, но какие-то не будут (особенно если вы сами предоставляете SDK);
* если вы ожидаете значительного количества асинхронных операций в API, изначально дайте разработчику выбор между моделями poll (клиент самостоятельно производит новые запросы к API чтобы проверить, не изменился ли статус асинхронной операций) и push (сервер уведомляет клиентов об изменениях статусов посредством отправки специального запроса, например, через webhook-и или server push-механизмы);
* если в рамках одной сущности необходимо предоставлять как «лёгкие» (скажем, название и описание рецепта), так и «тяжёлые» данные (скажем, промо-фотография напитка, которая легко может по разумеру превышать текстовые поля в сотни раз), лучше разделить эндпойнты и отдавать только ссылку для доступа к «тяжёлым» данным (в нашем случае, ссылку на изображение) — это, как минимум, позволит задавать различные политики кэширования для разных данных.
Неплохим упражнением здесь будет промоделировать типовой жизненный цикл основной функциональности приложения партнёра (например, выполнение одного заказа) и подсчитать общее количество запросов и объём трафика на один цикл.
##### Избегайте неявных частичных обновлений
@@ -1005,22 +1081,24 @@ PATCH /v1/orders/{id}
{ /* изменения приняты */ }
```
Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными? Ну и самое неприятное — какой бы вариант вы ни выбрали, это только начало проблем.
Эта сигнатура плоха сама по себе, поскольку является нечитабельной. Что обозначает пустой первый элемент массива — это удаление элемента или указание на отсутствие изменений? Что произойдёт с полями, которые не указаны в операции обновления (`delivery_address`, `milk_type`) — они будут сброшены в значения по умолчанию или останутся неизменными?
Допустим, мы договорились, что конструкция `{ "items":[null, {…}] }` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
Самое неприятное здесь — какой бы вариант вы ни выбрали, это только начало проблем. Допустим, мы договорились, что конструкция `{ "items":[null, {…}] }` означает, что с первым элементом массива ничего не происходит, он не меняется. А как тогда всё-таки его удалить? Придумать ещё одно «зануляемое» значение специально для удаления? Аналогично, если значения неуказанных полей остаются без изменений — как сбросить их в значения по умолчанию?
**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта и полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
**Простое решение** состоит в том, чтобы всегда перезаписывать объект целиком, т.е. требовать передачи полного объекта, полностью заменять им текущее состояние и возвращать в ответ на операцию новое состояние целиком. Однако это простое решение часто не принимается по нескольким причинам:
* повышенные размеры запросов и, как следствие, расход трафика;
* необходимость вычислять, какие конкретно поля изменились — в частности для того, чтобы правильно сгенерировать сигналы (события) для подписчиков на изменения;
* невозможность совместного доступа к объекту, когда два клиента независимо редактируют его свойства.
Все эти соображения, однако, на поверку оказываются мнимыми:
* причины увеличенного расхода трафика мы разбирали выше, и передача лишних полей к ним не относится (а если и относится, то это повод декомпозировать эндпойнт);
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент — что не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций; более того, существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиент может ошибиться или просто полениться правильно вычислить изменившиеся поля;
* концепция передачи только изменившихся полей по факту перекладывает ответственность определения, какие поля изменились, на клиент;
* это не только не снижает сложность имплементации этого кода, но и чревато его фрагментацией на несколько независимых клиентских реализаций;
* существование клиентского алгоритма построения diff-ов не отменяет обязанность сервера уметь делать то же самое — поскольку клиентские разработчики могли ошибиться или просто полениться правильно вычислить изменившиеся поля;
* наконец, подобная наивная концепция организации совместного доступа работает ровно до того момента, пока изменения транзитивны, т.е. результат не зависит от порядка выполнения операций (в нашим примере это уже не так — операции удаления первого элемента и редактирования первого элемента нетранзитивны);
* кроме того, часто в рамках той же концепции экономят и на входящем трафике, возвращая пустой ответ сервера для модифицирующих операций; таким образом, два клиента, редактирующих одну и ту же сущность, не видят изменения друг друга.
**Лучше**: разделить эндпойнты. Редактируемые поля группируются и выносятся в отдельный эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
**Лучше**: разделить эндпойнт. Этот подход также хорошо согласуется [с принципом декомпозиции](#chapter-10), который мы рассматривали в предыдущем разделе.
```
// Создаёт заказ из двух напитков
@@ -1051,7 +1129,8 @@ POST /v1/orders/
```
```
// Изменяет параметры заказа
// Изменяет параметры,
// относящиеся ко всему заказу
PUT /v1/orders/{id}/parameters
{ "delivery_address" }
@@ -1060,14 +1139,27 @@ PUT /v1/orders/{id}/parameters
```
// Частично перезаписывает заказ
// обновляет объём второго напитка
// обновляет объём одного напитка
PUT /v1/orders/{id}/items/{item_id}
{ "recipe", "volume", "milk_type" }
{
// Все поля передаются, даже если
// изменилось только какое-то одно
"recipe", "volume", "milk_type"
}
{ "recipe", "volume", "milk_type" }
```
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?). В этом подходе также можно в ответах операций `PUT` возвращать объект заказа целиком (однако следует использовать какую-то конвенцию именования), а не только изменённые суб-объекты.
```
// Удаляет один из напитков в заказе
DELETE /v1/orders/{id}/items/{item_id}
```
Теперь для удаления `volume` достаточно *не* передавать его в `PUT items/{item_id}`. Кроме того, обратите внимание, что операции удаления одного напитка и модификации другого теперь стали транзитивными.
Этот подход также позволяет отделить неизменяемые и вычисляемые поля (`created_at` и `status`) от изменяемых, не создавая двусмысленных ситуаций (что произойдёт, если клиент попытается изменить `created_at`?).
Также в ответах операций `PUT` можно возвращать объект заказа целиком, а не перезаписываемый суб-ресурс (однако следует использовать какую-то конвенцию именования).
**NB**: при декомпозиции эндпойнтов велик соблазн провести границу так, чтобы разделить изменяемые и неизменяемые данные. Тогда последние можно объявить кэшируемыми условно вечно и вообще не думать над проблемами пагинации и формата обновления. На бумаге план выглядит отлично, однако с ростом API неизменяемые данные частенько перестают быть таковыми, и вся концепция не только перестаёт работать, но и выглядит как плохой дизайн. Мы скорее рекомендуем объявлять данные иммутабельными в одном из двух случаев: либо (1) они действительно не могут стать изменяемыми без слома обратной совместимости, либо (2) ссылка на ресурс (например, на изображение) поступает через API же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).