You've already forked The-API-Book
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:
366
docs/API.ru.html
366
docs/API.ru.html
@@ -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: <токен идемпотентности>
|
||||
{
|
||||
…
|
||||
@@ -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 && !cup_absence</code> ⇔ <code>!(beans_absence || cup_absence)</code>, а вот в этом переходе ошибиться очень легко, и избегание двойных отрицаний помогает слабо. Здесь, к сожалению, есть только общий совет «избегайте ситуаций, когда разработчику нужно вычислять такие флаги».</p>
|
||||
<p>— то разработчику потребуется вычислить флаг <code>!beans_absence && !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' &&
|
||||
order.contactless_delivery == false) { … }
|
||||
<pre><code>if (Type(
|
||||
order.contactless_delivery
|
||||
) == 'Boolean' &&
|
||||
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
|
||||
&longitude={longitude}&latitude={latitude}
|
||||
GET /v1/price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
&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
|
||||
&longitude={longitude}&latitude={latitude}
|
||||
GET /v1/price?recipe=lungo⮠
|
||||
&longitude={longitude}⮠
|
||||
&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&offset=100
|
||||
<p><strong>Хорошо</strong>: в таких однонаправленных списках пагинация должна быть организована по тому ключу, порядок сортировки по которому фиксирован. Например, вот так:</p>
|
||||
<pre><code>// Возвращает указанный 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
|
||||
</code></pre>
|
||||
<p>При такой организации клиенту не надо заботиться об удалении или добавлении записей в уже перебранной части списка: он продолжает перебор по идентификатору известной записи — первой известной, если надо получить новые записи; последней известной, если надо продолжить перебор.
|
||||
Если операции удаления записей нет, то такие запросы можно свободно кэшировать — по одному и тому же URL будет всегда возвращаться один и тот же набор записей.<br>
|
||||
@@ -1897,7 +1954,8 @@ GET /v1/records?newer_than={record_id}&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=<значение курсора>
|
||||
GET /v1/records?cursor=<курсор>
|
||||
{ "records", "cursor" }
|
||||
</code></pre>
|
||||
<p>Достоинством схемы с курсором является возможность зашифровать в самом курсоре данные исходного запроса (т.е. <code>filter</code> в нашем примере), и таким образом не дублировать его в последующих запросах. Это может быть особенно актуально, если инициализирующий запрос готовит полный массив данных, например, перенося его из «холодного» хранилища в горячее.</p>
|
||||
@@ -1928,7 +1984,8 @@ 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
|
||||
</code></pre>
|
||||
<p>Сортировка по дате модификации обычно означает, что данные могут меняться. Иными словами, между запросом первой порции данных и запросом второй порции данных какая-то запись может измениться; она просто пропадёт из перечисления, т.к. автоматически попадает на первую страницу. Клиент никогда не получит те записи, которые менялись во время перебора, и у него даже нет способа узнать о самом факте такого пропуска. Помимо этого отметим, что такой API нерасширяем — невозможно добавить сортировку по двум и более полям.</p>
|
||||
<p><strong>Хорошо</strong>: в представленной постановке задача, собственно говоря, не решается. Список записей по дате изменения всегда будет непредсказуемо изменяться, поэтому необходимо изменить сам подход к формированию данных, одним из двух способов.</p>
|
||||
@@ -1936,15 +1993,17 @@ GET /records?sort_by=date_modified&sort_order=desc&limit=10&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: <см. следующий раздел>
|
||||
X-Idempotency-Token: <токен идемпотентности>
|
||||
{
|
||||
"changes": [{
|
||||
"type": "set",
|
||||
@@ -2317,7 +2411,7 @@ X-Idempotency-Token: <см. следующий раздел>
|
||||
<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>
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
}
|
||||
|
@@ -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
@@ -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». Приложение может быть как нативным, так и веб-приложением. Термины «агент» и «юзер-агент» являются синонимами термина «клиент».
|
||||
|
||||
|
@@ -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 же, и вы обладаете возможностью сделать эти ссылки персистентными (т.е. при необходимости обновить изображение будете генерировать новую ссылку, а не перезаписывать контент по старой ссылке).
|
||||
|
||||
|
Reference in New Issue
Block a user