1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-01-23 17:53:04 +02:00

Рефакторинг девятой главы, предфинальная версия

This commit is contained in:
Sergey Konstantinov 2020-11-23 16:33:12 +03:00
parent b8d94da2a9
commit 851136ccd8
6 changed files with 400 additions and 205 deletions

View File

@ -30,6 +30,7 @@
code, pre {
font-family: Inconsolata, sans-serif;
font-size: 12pt;
white-space: nowrap;
}
pre {
@ -171,7 +172,7 @@ h4, h5 {
</ul>
<p>Этот алгоритм строит API сверху вниз, от общих требований и сценариев использования до конкретной номенклатуры сущностей; фактически, двигаясь этим путем, вы получите на выходе готовое API — чем этот подход и ценен.</p>
<p>Может показаться, что наиболее полезные советы приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.</p>
<p><em>NB</em>. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.</p><div class="page-break"></div><h3 id="8">Глава 8. Определение области применения</h3>
<p><strong>NB</strong>. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.</p><div class="page-break"></div><h3 id="8">Глава 8. Определение области применения</h3>
<p>Ключевой вопрос, который вы должны задать себе четыре раза, выглядит так: какую проблему мы решаем? Задать его следует четыре раза с ударением на каждом из четырёх слов.</p>
<ol>
<li><p><em>Какую</em> проблему мы решаем? Можем ли мы чётко описать, в какой ситуации гипотетическим потребителям-разработчикам нужно наше API?</p></li>
@ -321,7 +322,7 @@ h4, h5 {
// Возвращает статус исполнения
// Формат аналогичен формату ответа `POST /execute`
</code></pre>
<p><em>NB</em>. На всякий случай отметим, что данное API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; оно приведено в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такое API от производителей кофе-машин, и это ещё довольно вменяемый вариант.</p></li>
<p><strong>NB</strong>. На всякий случай отметим, что данное API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; оно приведено в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такое API от производителей кофе-машин, и это ещё довольно вменяемый вариант.</p></li>
<li><p>Машины с предустановленными функциями:</p>
<pre><code>GET /functions
// Возвращает список доступных функций
@ -367,7 +368,7 @@ h4, h5 {
]
}
</code></pre>
<p><em>NB</em>. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.</p></li>
<p><strong>NB</strong>. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.</p></li>
</ul>
<p>Теперь картина становится более явной: нам нужно абстрагировать работу с кофе-машиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофе-машин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».</p>
<p>Следующий шаг, необходимый для отделения уровней абстракции — необходимо понять, какую функциональность нам, собственно, необходимо абстрагировать. Для этого нам необходимо обратиться к задачам, которые решает разработчик на уровне работы с заказами, и понять, какие проблемы у него возникнут в случае отсутствия нашего слоя абстракции.</p>
@ -389,36 +390,29 @@ h4, h5 {
<ul>
<li>единую номенклатуру статусов и других высокоуровневых параметров исполнения (например, ожидаемого времени готовности заказа или возможных ошибок исполнения);</li>
<li>единую номенклатуру доступных методов (например, отмены заказа) и их одинаковое поведение.</li></ul></li>
<li><p>Уровень программы исполнения. Для API первого типа он будет представлять собой просто обёртку над существующим API программ; для API второго типа концепцию «программ» придётся полностью имплементировать нам.</p></li>
<li><p>Уровень программы исполнения. Для API первого типа он будет представлять собой просто обёртку над существующим API программ; для API второго типа концепцию «рантайма» программ придётся полностью имплементировать нам.</p></li>
</ol>
<p>Что это будет означать практически? Разработчик по-прежнему будет создавать заказ, оперируя только высокоуровневыми терминами:</p>
<pre><code>POST /v1/coffee-machines/orders?machine_id={id}
{recipe:"lungo","volume":"800ml"}
</code></pre>
<p>Имплементация функции <code>POST /orders</code> проверит все параметры заказа, заблокирует его стоимость на карте пользователя, сформирует полный запрос на исполнение и обратится к уровню исполнения</p>
<pre><code>POST /v1/execute
{
"order_id",
"coffee_machine",
"recipe",
"volume_requested": "800ml"
}
</code></pre>
<pre><code>{
"execution_id": &lt;идентификатор исполнения&gt;
}
</code></pre>
<p>Далее нам нужно подобрать нужную программу исполнения:</p>
<p>Имплементация функции <code>POST /orders</code> проверит все параметры заказа, заблокирует его стоимость на карте пользователя, сформирует полный запрос на исполнение и обратится к уровню исполнения. Сначала необходимо подобрать правильную программу исполнения:</p>
<pre><code>POST /v1/programs/match
{ "recipe", "coffee-machine" }
</code></pre>
<pre><code>{ "program_id" }
</code></pre>
<p>Наконец, обладая идентификатором нужной программы, мы можем её запустить:</p>
<p>Получив идентификатор программы, нужно запустить её на исполнение:</p>
<pre><code>POST /v1/programs/{id}/run
{
"execution_id",
"coffee_machine_id"
"order_id",
"coffee_machine_id",
"parameters": [
{
"name": "volume",
"value": "800ml"
}
]
}
</code></pre>
<pre><code>{ "program_run_id" }
@ -428,45 +422,103 @@ h4, h5 {
<li><code>POST /v1/programs/{api_type}/match</code></li>
<li><code>POST /v1/programs/{api_type}/{program_id}/run</code></li>
</ul>
<p>Достоинством такого подхода была бы возможность передавать в match и run не унифицированные наборы параметров, а только те, которые имеют значение в контексте указанного типа API. Однако в нашем дизайне API такой необходимости не прослеживается.</p>
<p><em>NB</em>: в имплементации связки <code>execute</code><code>match</code><code>run</code> можно пойти одним из двух путей: </p>
<p>Достоинством такого подхода была бы возможность передавать в match и run не унифицированные наборы параметров, а только те, которые имеют значение в контексте указанного типа API. Однако в нашем дизайне API такой необходимости не прослеживается. Обработчик <code>run</code> сам может извлечь нужные параметры из мета-информации о программе и выполнить одно из двух действий:</p>
<ul>
<li>либо <code>POST /orders</code> сама обращается к доступной информации о рецепте и кофе-машине и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины в частности);</li>
<li>либо в запросе содержатся только идентификаторы, и имплементация методов сами обратятся за нужными данными через какие-то внутренние API.<br />
Оба варианта имеют право на жизнь; какой из них выбрать — зависит от деталей реализации.</li>
<li>вызвать <code>POST /execute</code> с передачей внутреннего идентификатор программ — для машин, поддерживающих API первого типа;</li>
<li>инициировать создание рантайма для работы с API второго типа.</li>
</ul>
<p>Любопытно, что введённая сущность <code>match</code> связывает два уровня абстракции, и тем самым не относится ни к одному из них. Такая ситуация (когда некоторые вспомогательные сущности находятся вне общей иерархии) случается довольно часто.</p>
<p>// TODO</p>
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p>
<p>Уровень рантаймов API второго типа, исходя из общих соображений, будет скорее всего непубличным, и мы плюс-минус свободны в его имплементации. Самым простым решением будет реализовать виртуальную state-машину, которая создаёт «рантайм» (т.е. stateful контекст исполнения) для выполнения программы и следит за его состоянием.</p>
<pre><code>POST /v1/runtimes
{ "coffee_machine", "program", "parameters" }
</code></pre>
<pre><code>{ "runtime_id", "state" }
</code></pre>
<p>Здесь <code>program</code> будет выглядеть примерно так:</p>
<pre><code>{
"program_id",
"api_type",
"commands": [
{
"sequence_id",
"type": "set_cup",
"parameters"
},
]
}
</code></pre>
<p>А <code>state</code> вот так:</p>
<pre><code>{
// Статус рантайма
// * pending — ожидание
// * executing — исполнение команды
// * ready_waiting — напиток готов
// * finished — все операции завершены
"status": "ready_waiting",
// Текущая исполняемая команда (необязательное)
"command_sequence_id",
// Чем закончилось исполнение программы
// (необязательное)
// * "success" — напиток приготовлен и взят
// * "terminated" — исполнение остановлено
// * "technical_error" — ошибка при приготовлении
// * "waiting_time_exceeded" — готовый заказ был
// утилизирован, т.к. его не забрали
"resolution": "success",
// Значения всех переменных,
// включая состояние сенсоров
"variables"
}
</code></pre>
<p><strong>NB</strong>: в имплементации связки <code>orders</code><code>match</code><code>run</code><code>runtimes</code> можно пойти одним из двух путей: </p>
<ul>
<li>либо обработчик <code>POST /orders</code> сам обращается к доступной информации о рецепте, кофе-машине и программе и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины и список команд в частности);</li>
<li>либо в запросе содержатся только идентификаторы, и имплементация методов сами обратятся за нужными данными через какие-то внутренние API.</li>
</ul>
<p>Оба варианта имеют право на жизнь; какой из них выбрать — зависит от деталей реализации.</p>
<h4 id="">Изоляция уровней абстракции</h4>
<p>Важное свойство правильно подобранных уровней абстракции, и отсюда требование к их проектированию — это требование изоляции: <strong>взамодействие возможно только между сущностями соседних уровней абстракции</strong>. Если при проектировании выясняется, что для выполнения того или иного действия требуется «перепрыгнуть» уровень абстракции, это явный признак того, что в проекте допущены ошибки.</p>
<p>Возвращаясь к нашему примеру с готовностью кофе: проблемы с определением готовности кофе исходя из объёма возникают именно потому, что мы не можем ожидать от пользователя, создающего заказ, знания о необходимости проверки объёма налитого реальной кофе-машиной объёма кофе. Мы вводим дополнительный уровень абстракции именно для того, чтобы на нём переформулировать, что такое «заказ готов».</p>
<p>Важным следствием этого принципа является то, что информацию о готовности заказа нам придётся «прорастить» через все уровни абстракции:</p>
<p>Вернёмся к нашему примеру. Каким образом будет работать операция получения статуса заказа? Для получения статуса будет выполнена следующая цепочка вызовов:</p>
<ul>
<li>пользователь вызовет метод <code>GET /v1/orders</code>;</li>
<li>обработчик <code>orders</code> выполнит операции своего уровня ответственности (проверку авторизации, в частности), найдёт идентификатор <code>program_run_id</code> и обратится к API программ <code>GET /v1/programs/{id}/runs/{program_run_id}</code>;</li>
<li>обработчик <code>runs</code> в свою очередь выполнит операции своего уровня (в частности, проверит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:<ul>
<li>либо вызовет <code>GET /execution/status</code> физического API кофе-машины, получит объём кофе и сличит с эталонным;</li>
<li>либо обратится к <code>GET /v1/runtimes/{runtime_id}</code>, получит <code>state.status</code> и преобразует его к статусу заказа;</li></ul></li>
<li>в случае API второго типа цепочка продолжится: обработчик <code>GET /runtimes</code> обратится к физическому API <code>GET /sensors</code> и произведёт ряд манипуляций: сличит объём стакана / молотого кофе / налитой воды с запрошенным и при необходимости изменит состояние и статус.</li>
</ul>
<p><strong>NB</strong>. Слова «цепочка вызовов» не следует воспринимать буквально. Каждый уровень может быть технически организован по-разному:</p>
<ul>
<li>можно явно проксировать все вызовы по иерархии;</li>
<li>можно кэшировать статус своего уровня и обновлять его по получению обратного вызова или события.
В частности, низкоуровневый цикл исполнения рантайма для машин второго рода очевидно должен быть независимым и обновлять свой статус в фоне, не дожидаясь явного запроса статуса.</li>
</ul>
<p>Обратите внимание, что здесь фактически происходит следующее: на каждом уровне абстракции есть какой-то свой статус (заказа, рантайма, сенсоров), который сформулирован в терминах соответствующий этому уровню абстракции предметной области. Запрет «перепрыгывания» уровней приводит к тому, что нам необходимо дублировать статус на каждом уровне независимо.</p>
<p>Рассмотрим теперь, каким образом через наши уровни абстракции «прорастёт» операция отмены заказа. В этом случае цепочка вызовов будет такой:</p>
<ul>
<li>пользователь вызовет метод <code>POST /orders/{id}/cancel</code>;</li>
<li>обработчик метода произведёт операции в своей зоне ответственности:<ul>
<li>проверит авторизацию;</li>
<li>решит денежные вопросы — нужно ли делать рефанд;</li>
<li>найдёт идентификатор <code>program_run_id</code> и обратится к <code>POST /v1/programs/{id}/runs/{program_run_id}/cancel</code>;</li></ul></li>
<li>обработчик <code>runs/cancel</code> произведёт операции своего уровня (в частности, установит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:<ul>
<li>либо вызовет <code>POST /execution/cancel</code> физического API кофе-машины;</li>
<li>либо вызовет <code>POST /v1/runtimes/{id}/terminate</code>;</li></ul></li>
<li>во втором случае цепочка продолжится: обработчик <code>terminate</code> изменит внутреннее состояние:<ul>
<li>изменит <code>resolution</code> на <code>terminated</code></li>
<li>запустит команду <code>discard_cup</code>.</li></ul></li>
</ul>
<p>Два важных момента, на которые здесь стоит обратить внимание:</p>
<ol>
<li>На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров; с точки зрения физических сенсоров нет никакой «готовности заказа», есть только состояние выполнения команд;</li>
<li>На уровне исполнения статус готовности означает, что состояние сенсоров приведено к эталонному (в случае политики "check_volume" — что налит именно тот объём кофе, который был запрошен);</li>
<li>На пользовательском уровне статус готовности заказа означает, что все ассоциированные задачи выполнены.</li>
<li><p>На каждом уровне абстракции понятие «отмена заказа» переформулируется:</p>
<ul>
<li>на уровне <code>orders</code> это действие фактически распадается на несколько «отмен» других уровней: нужно отменить блокировку денег на карте и отменить исполнение заказа;</li>
<li>при этом на физическом уровне API второго типа «отмена» как таковая не существует: «отмена» — это исполнение команды <code>discard_cup</code>, которая на этом уровне абстракции ничем не отличается от любых других команд.<br />
Промежуточный уровень абстракции как раз необходим для того, чтобы переход между «отменами» разных уровней произошёл гладко, без необходимости перепрыгивания через уровни абстракции.</li></ul></li>
<li><p>С точки зрения верхнеуровневого API отмена заказа является терминальным действием, т.е. никаких последующих операций уже быть не может; а с точки зрения низкоуровневого API обработка заказа продолжается, т.к. нужно дождаться, когда стакан будет утилизирован, и после этого освободить кофе-машину (т.е. разрешить создание новых рантаймов на ней). Это вторая задача для уровня исполнения: связывать оба статуса, внешний (заказ отменён) и внутренний (исполнение продолжается).</p></li>
</ol>
<p>На каждом уровне абстракции понятие «готовность» переформулируется в терминах нижележащей предметной области, и так вплоть до физического уровня.</p>
<p>Аналогично нам придётся поступить и с действиями, доступными на том или ином уровне. Если, допустим, в нашем API появится метод отмены заказа <code>cancel</code>, то его придётся точно так же «спустить» по всем уровням абстракции.</p>
<ul>
<li><code>POST /orders/{id}/cancel</code> работает с высокоуровневыми данными о заказе:<ul>
<li>проверяет авторизацию, т.е. имеет ли право этот пользователь отменять этот заказ;</li>
<li>решает денежные вопросы — нужно ли делать рефанд;</li>
<li>находит все незавершённые задачи и отменяет их;</li></ul></li>
<li><code>POST /tasks/{id}/cancel</code> работает с исполнением заказа:<ul>
<li>определяет, возможно ли физически отменить исполнение, есть ли такая функция у кофе-машины;</li>
<li>генерирует последовательность действий отмены (возможно, не только непосредственно для самой машины — вполне вероятно, необходимо будет поставить задание сотруднику кофейни утилизировать невостребованный напиток);</li></ul></li>
<li><code>POST /coffee-machines/{id}/operations</code> выполняет операции на кофе-машине, сгенерированные на предыдущем шаге.</li>
</ul>
<p>Обратите также внимание, что содержание операции «отменить заказ» изменяется на каждом из уровней. На пользовательском уровне заказ отменён, когда решены все важные для пользователя вопросы. То, что отменённый заказ какое-то время продолжает исполняться (например, ждёт утилизации) — пользователю неважно. На уровне исполнения же нужно связать оба контекста:</p>
<ul>
<li><code>GET /tasks/{id}/status</code><br />
<code>{"status":"canceled","operation_state":{"status":"canceling","operations":[…]}</code><br />
— с т.з. высокоуровневого кода задача завершена (<code>canceled</code>), но с точки зрения низкоуровневого кода список исполняемых операций непуст, т.е. задача продолжает работать.</li>
</ul>
<p><strong>NB</strong>: так как <code>task</code> связывает два разных уровня абстракции, то и статусов у неё два: внешний <code>canceled</code> и внутренний <code>canceling</code>. Мы могли бы опустить второй статус и предложить ориентироваться на содержание <code>operations</code>, но это (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе <code>operation_state</code>, что, быть может, разработчику вовсе и не нужно.</p>
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p><div class="page-break"></div><h3 id="10">Глава 10. Разграничение областей ответственности</h3>
<p>Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.</p>
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p><div class="page-break"></div><h3 id="10">Глава 10. Разграничение областей ответственности</h3>
<p>Исходя из описанного в предыдущей главе, мы понимаем, что иерархия абстракций в нашем гипотетическом проекте должна выглядеть примерно так:</p>
<ul>
<li>пользовательский уровень (те сущности, с которыми непосредственно взаимодействует пользователь и сформулированы в понятных для него терминах; например, заказы и виды кофе);</li>
@ -522,22 +574,21 @@ h4, h5 {
<h4 id="1">1. Явное лучше неявного</h4>
<p>Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование.</p>
<ul>
<li><p>плохо: </p>
<li><p><strong>Плохо</strong>: </p>
<pre><code>GET /orders/cancellation
// отменяет заказ
</code></pre>
<p>Неочевидно, что достаточно просто обращения к сущности <code>cancellation</code> (что это?), тем более немодифицирующим методом <code>GET</code>, чтобы отменить заказ; </p>
<p>Хорошо: </p>
<p><strong>Хорошо</strong>: </p>
<pre><code>POST /orders/cancel
отменяет заказ
</code></pre>
<ul>
<li>плохо:</li></ul>
</code></pre></li>
<li><p><strong>Плохо</strong>:</p>
<pre><code>GET /orders/statistics
// Возвращает агрегированную статистику заказов за всё время
</code></pre>
<p>Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.</p>
<p>Хорошо:</p>
<p><strong>Хорошо</strong>:</p>
<pre><code>POST /orders/statistics/aggregate
{ "start_date", "end_date" }
// Возвращает агрегированную статистику заказов за указанный период
@ -547,56 +598,75 @@ h4, h5 {
<p>Два важных следствия:</p>
<p><strong>1.1.</strong> Если операция модифицирующая, это должно быть очевидно из сигнатуры. В частности, не может быть модифицирующих операций за <code>GET</code>.</p>
<p><strong>1.2.</strong> Если операция асинхронная, это должно быть очевидно из сигнатуры, <strong>либо</strong> должна существовать конвенция именования, позволяющая отличаться синхронные операции от асинхронных.</p>
<h4 id="2">2. Сущности должны именоваться конкретно</h4>
<h4 id="2">2. Использованные стандарты указывайте явно</h4>
<p>К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «начинать ли неделю с понедельника или с воскресенья», что уж говорить о каких-то более сложных стандартах.</p>
<p>Поэтому <em>всегда</em> указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе:</p>
<ul>
<li><strong>плохо</strong>: <code>"date":"11/12/2020"</code> — стандартов записи дат существует огромное количество, плюс из этой записи невозможно даже понять, что здесь число, а что месяц;<br />
<strong>хорошо</strong>: <code>"iso_date":"2020-11-12"</code>.</li>
<li><strong>плохо</strong>: <code>"duration":5000</code> — пять тысяч чего?<br />
<strong>хорошо</strong>:<br />
<code>"duration_ms":5000</code><br />
либо<br />
<code>"duration":"5000ms"</code><br />
либо<br />
<code>"duration":{"unit":"ms","value":5000}</code>.</li>
</ul>
<p>Отдельное следствие из этого правила — денежные величины <em>всегда</em> должны сопровождаться указанием кода валюты.</p>
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.</p>
<h4 id="3">3. Сохраняйте точность дробных чисел</h4>
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.</p>
<p>Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.</p>
<h4 id="4">4. Сущности должны именоваться конкретно</h4>
<p>Избегайте слов-«амёб» без определённой семантики, таких как get, apply, make. Сущности должны именоваться конкретно:</p>
<ul>
<li>плохо: <code>user.get()</code> — неочевидно, что конкретно будет возвращено;<br />
хорошо: <code>user.get_id()</code>;</li>
<li><strong>плохо</strong>: <code>user.get()</code> — неочевидно, что конкретно будет возвращено;<br />
<strong>хорошо</strong>: <code>user.get_id()</code>.</li>
</ul>
<h4 id="3">3. Не экономьте буквы</h4>
<h4 id="5">5. Не экономьте буквы</h4>
<p>В XXI веке давно уже нет нужды называть переменные покороче.</p>
<ul>
<li>плохо: <code>order.time()</code> — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…<br />
хорошо: <code>order.get_estimated_delivery_time()</code></li>
<li>плохо:
<li><strong>Плохо</strong>: <code>order.time()</code> — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…<br />
<strong>Хорошо</strong>: <code>order.get_estimated_delivery_time()</code></li>
<li><strong>Плохо</strong>:
<code>
strpbrk (str1, str2)
// возвращает положение первого вхождения в строку str2
// любого символа из строки str2
</code>
Возможно, автору этого API казалось, что аббревиатура <code>pbrk</code> что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк <code>str1</code>, <code>str2</code> является набором символов для поиска.
Хорошо: <code>str_search_for_characters(lookup_character_set, str)</code><br />
Возможно, автору этого API казалось, что аббревиатура <code>pbrk</code> что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк <code>str1</code>, <code>str2</code> является набором символов для поиска.<br />
<strong>Хорошо</strong>: <code>str_search_for_characters(lookup_character_set, str)</code><br />
Однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение <code>string</code> до <code>str</code> выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.</li>
</ul>
<h4 id="4">4. Тип поля должен быть ясен из его названия</h4>
<p>Если поле называется <code>recipe</code> — мы ожидаем, что его значением является сущность типа <code>Recipe</code>. Если поле называется <code>recipe_id</code> — мы ожидаем, что его значением является идентификатор, который я могу найти в составе сущности <code>Recipe</code>.</p>
<h4 id="6">6. Тип поля должен быть ясен из его названия</h4>
<p>Если поле называется <code>recipe</code> — мы ожидаем, что его значением является сущность типа <code>Recipe</code>. Если поле называется <code>recipe_id</code> — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности <code>Recipe</code>.</p>
<p>Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — <code>objects</code>, <code>children</code>; если это невозможно (термин неисчисляемый), следует добавить префикс или постфикс, не оставляющий сомнений:</p>
<ul>
<li>плохо: <code>GET /news</code> — неясно, будет ли получена какая-то конкретная новость или массив новостей;
хорошо: <code>GET /news-list</code>.</li>
<li><strong>плохо</strong>: <code>GET /news</code> — неясно, будет ли получена какая-то конкретная новость или массив новостей;
<strong>хорошо</strong>: <code>GET /news-list</code>.</li>
</ul>
<p>Аналогично, если ожидается булево значение, то из названия это должно быть очевидно, т.е. именование должно описывать некоторое качественное состояние, например, <code>is_ready</code>, <code>open_now</code>:</p>
<ul>
<li>плохо: <code>"task.status": true</code> — неочевидно, что статус бинарен, плюс такое API будет нерасширяемым;<br />
хорошо: <code>"task.is_finished": true</code></li>
<li><strong>плохо</strong>: <code>"task.status": true</code> — неочевидно, что статус бинарен, плюс такое API будет нерасширяемым;<br />
<strong>хорошо</strong>: <code>"task.is_finished": true</code>.</li>
</ul>
<p>Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа <code>Date</code>, если таковые имеются, разумно индицировать с помощью, например, постфикса <code>_at</code> (<code>created_at</code>, <code>occurred_at</code>, etc) или <code>_date</code>.</p>
<p>Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс, чтобы избежать непонимания.</p>
<ul>
<li>Плохо:
<li><strong>Плохо</strong>:
<code>
GET /coffee-machines/functions
// Возвращает список встроенных функций кофе-машины
</code>
Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).<br />
Хорошо: <code>GET /coffee-machines/builtin-functions-list</code></li>
<strong>Хорошо</strong>: <code>GET /coffee-machines/builtin-functions-list</code></li>
</ul>
<h4 id="5">5. Подобные сущности должны называться подобно и вести себя подобным образом</h4>
<h4 id="7">7. Подобные сущности должны называться подобно и вести себя подобным образом</h4>
<ul>
<li><p>плохо: <code>begin_transition</code> / <code>stop_transition</code><br />
<code>begin</code> и <code>stop</code> — непарные термины; разработчик будет вынужден рыться в документации;<br />
хорошо: <code>begin_transition</code> / <code>end_transition</code> либо <code>start_transition</code> / <code>stop_transition</code>;</p></li>
<li><p>плохо: </p>
<li><p><strong>Плохо</strong>: <code>begin_transition</code> / <code>stop_transition</code><br />
<code>begin</code> и <code>stop</code> — непарные термины; разработчик будет вынужден рыться в документации.<br />
<strong>Хорошо</strong>: <code>begin_transition</code> / <code>end_transition</code> либо <code>start_transition</code> / <code>stop_transition</code>.</p></li>
<li><p><strong>Плохо</strong>: </p>
<pre><code>strpos(haystack, needle)
// Находит первую позицию позицию строки `needle`
// внутри строки `haystack`
@ -612,24 +682,50 @@ GET /coffee-machines/functions
<li>первый из методов находит только первое вхождение строки <code>needle</code>, а другой — все вхождения, и об этом поведении никак нельзя узнать из сигнатуры функций.</li></ul>
<p>Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.</p></li>
</ul>
<h4 id="6">6. Использованные стандарты указывайте явно</h4>
<p>К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «начинать ли неделю с понедельника или с воскресенья», что уж говорить о каких-то более сложных стандартах.</p>
<p>Поэтому <em>всегда</em> указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.</p>
<h4 id="8">8. Клиент всегда должен знать полное состояние системы</h4>
<p>Правило можно ещё сформулировать так: не заставляйте клиент гадать.</p>
<ul>
<li>Плохо: <code>"date":"11/12/2020"</code> — стандартов записи дат существует огромное количество, плюс из этой записи невозможно даже понять, что здесь число, а что месяц;<br />
хорошо: <code>"iso_date":"2020-11-12"</code>.</li>
<li>Плохо: <code>"duration":5000</code> — пять тысяч чего?<br />
Хорошо:<br />
<code>"duration_ms":5000</code><br />
либо<br />
<code>"duration":"5000ms"</code><br />
либо<br />
<code>"duration":{"unit":"ms","value":5000}</code></li>
</ul>
<p>Отдельное следствие из этого правила — денежные величины <em>всегда</em> должны сопровождаться указанием кода валюты.</p>
<p>Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, вам остаётся только смириться и проявлять выдержку при нападках на ваше API.</p>
<p>// TODO: блокнот душевного спокойствия</p>
<h4 id="7">7. Сохраняйте точность дробных чисел</h4>
<p>Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.</p>
<p>Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому, либо использовать строковый тип.</p><div class="page-break"></div></article>
<li><strong>Плохо</strong>:
<code>
POST /comments
{ "content" }
// Создаёт комментарий и возвращает его id
</code>
<code>
{ "comment_id" }
</code>
<code>
GET /comments/{id}
// Возвращает комментарий по его id
</code>
<code>
{
// Комментарий не опубликован
// и ждёт прохождения капчи
"published": false,
"action_required": "solve_captcha",
"content"
}
</code>
— хотя операция будто бы выполнена успешна, клиенту необходимо сделать дополнительный запрос, чтобы понять необходимость решения капчи. Между вызовами <code>POST /comments</code> и <code>GET /comments/{id}</code> клиент находится в состоянии кота Шрёдингера: непонятно, опубликован комментарий или нет, и как отразить это пользователю.<br />
<strong>Хорошо</strong>:
<code>
POST /comments
{ "content" }
// Создаёт комментарий и возвращает его
</code>
<code>
{ "comment_id", "published", "action_required", "content" }
</code>
<code>
GET /comments/{id}
// Возвращает комментарий по его id
</code>
<code>
{ /* в точности тот же формат,
что и в ответе POST /comments */
}
</code>
Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа невелик) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.</li>
</ul><div class="page-break"></div></article>
</body></html>

Binary file not shown.

View File

@ -10,4 +10,4 @@
Может показаться, что наиболее полезные советы приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.
_NB_. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.
**NB**. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.

View File

@ -129,7 +129,7 @@
// Формат аналогичен формату ответа `POST /execute`
```
_NB_. На всякий случай отметим, что данное API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; оно приведено в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такое API от производителей кофе-машин, и это ещё довольно вменяемый вариант.
**NB**. На всякий случай отметим, что данное API нарушает множество описанных нами принципов проектирования, начиная с отсутствия версионирования; оно приведено в таком виде по двум причинам: (1) чтобы мы могли показать, как спроектировать API более удачно; (2) скорее всего, в реальной жизни вы получите именно такое API от производителей кофе-машин, и это ещё довольно вменяемый вариант.
* Машины с предустановленными функциями:
```
@ -180,7 +180,7 @@
}
```
_NB_. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.
**NB**. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.
Теперь картина становится более явной: нам нужно абстрагировать работу с кофе-машиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофе-машин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».
@ -203,7 +203,7 @@
* единую номенклатуру статусов и других высокоуровневых параметров исполнения (например, ожидаемого времени готовности заказа или возможных ошибок исполнения);
* единую номенклатуру доступных методов (например, отмены заказа) и их одинаковое поведение.
2. Уровень программы исполнения. Для API первого типа он будет представлять собой просто обёртку над существующим API программ; для API второго типа концепцию «программ» придётся полностью имплементировать нам.
2. Уровень программы исполнения. Для API первого типа он будет представлять собой просто обёртку над существующим API программ; для API второго типа концепцию «рантайма» программ придётся полностью имплементировать нам.
Что это будет означать практически? Разработчик по-прежнему будет создавать заказ, оперируя только высокоуровневыми терминами:
```
@ -211,22 +211,7 @@ POST /v1/coffee-machines/orders?machine_id={id}
{recipe:"lungo","volume":"800ml"}
```
Имплементация функции `POST /orders` проверит все параметры заказа, заблокирует его стоимость на карте пользователя, сформирует полный запрос на исполнение и обратится к уровню исполнения
```
POST /v1/execute
{
"order_id",
"coffee_machine",
"recipe",
"volume_requested": "800ml"
}
```
```
{
"execution_id": <идентификатор исполнения>
}
```
Далее нам нужно подобрать нужную программу исполнения:
Имплементация функции `POST /orders` проверит все параметры заказа, заблокирует его стоимость на карте пользователя, сформирует полный запрос на исполнение и обратится к уровню исполнения. Сначала необходимо подобрать правильную программу исполнения:
```
POST /v1/programs/match
{ "recipe", "coffee-machine" }
@ -234,12 +219,18 @@ POST /v1/programs/match
```
{ "program_id" }
```
Наконец, обладая идентификатором нужной программы, мы можем её запустить:
Получив идентификатор программы, нужно запустить её на исполнение:
```
POST /v1/programs/{id}/run
{
"execution_id",
"coffee_machine_id"
"order_id",
"coffee_machine_id",
"parameters": [
{
"name": "volume",
"value": "800ml"
}
]
}
```
```
@ -250,50 +241,108 @@ POST /v1/programs/{id}/run
* `POST /v1/programs/{api_type}/match`
* `POST /v1/programs/{api_type}/{program_id}/run`
Достоинством такого подхода была бы возможность передавать в match и run не унифицированные наборы параметров, а только те, которые имеют значение в контексте указанного типа API. Однако в нашем дизайне API такой необходимости не прослеживается.
Достоинством такого подхода была бы возможность передавать в match и run не унифицированные наборы параметров, а только те, которые имеют значение в контексте указанного типа API. Однако в нашем дизайне API такой необходимости не прослеживается. Обработчик `run` сам может извлечь нужные параметры из мета-информации о программе и выполнить одно из двух действий:
* вызвать `POST /execute` с передачей внутреннего идентификатор программ — для машин, поддерживающих API первого типа;
* инициировать создание рантайма для работы с API второго типа.
Уровень рантаймов API второго типа, исходя из общих соображений, будет скорее всего непубличным, и мы плюс-минус свободны в его имплементации. Самым простым решением будет реализовать виртуальную state-машину, которая создаёт «рантайм» (т.е. stateful контекст исполнения) для выполнения программы и следит за его состоянием.
```
POST /v1/runtimes
{ "coffee_machine", "program", "parameters" }
```
```
{ "runtime_id", "state" }
```
Здесь `program` будет выглядеть примерно так:
```
{
"program_id",
"api_type",
"commands": [
{
"sequence_id",
"type": "set_cup",
"parameters"
},
]
}
```
А `state` вот так:
```
{
// Статус рантайма
// * pending — ожидание
// * executing — исполнение команды
// * ready_waiting — напиток готов
// * finished — все операции завершены
"status": "ready_waiting",
// Текущая исполняемая команда (необязательное)
"command_sequence_id",
// Чем закончилось исполнение программы
// (необязательное)
// * "success" — напиток приготовлен и взят
// * "terminated" — исполнение остановлено
// * "technical_error" — ошибка при приготовлении
// * "waiting_time_exceeded" — готовый заказ был
// утилизирован, т.к. его не забрали
"resolution": "success",
// Значения всех переменных,
// включая состояние сенсоров
"variables"
}
```
**NB**: в имплементации связки `orders``match``run``runtimes` можно пойти одним из двух путей:
* либо обработчик `POST /orders` сам обращается к доступной информации о рецепте, кофе-машине и программе и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины и список команд в частности);
* либо в запросе содержатся только идентификаторы, и имплементация методов сами обратятся за нужными данными через какие-то внутренние API.
_NB_: в имплементации связки `execute``match``run` можно пойти одним из двух путей:
* либо `POST /orders` сама обращается к доступной информации о рецепте и кофе-машине и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины в частности);
* либо в запросе содержатся только идентификаторы, и имплементация методов сами обратятся за нужными данными через какие-то внутренние API.
Оба варианта имеют право на жизнь; какой из них выбрать — зависит от деталей реализации.
Любопытно, что введённая сущность `match` связывает два уровня абстракции, и тем самым не относится ни к одному из них. Такая ситуация (когда некоторые вспомогательные сущности находятся вне общей иерархии) случается довольно часто.
// TODO
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
#### Изоляция уровней абстракции
Важное свойство правильно подобранных уровней абстракции, и отсюда требование к их проектированию — это требование изоляции: **взамодействие возможно только между сущностями соседних уровней абстракции**. Если при проектировании выясняется, что для выполнения того или иного действия требуется «перепрыгнуть» уровень абстракции, это явный признак того, что в проекте допущены ошибки.
Возвращаясь к нашему примеру с готовностью кофе: проблемы с определением готовности кофе исходя из объёма возникают именно потому, что мы не можем ожидать от пользователя, создающего заказ, знания о необходимости проверки объёма налитого реальной кофе-машиной объёма кофе. Мы вводим дополнительный уровень абстракции именно для того, чтобы на нём переформулировать, что такое «заказ готов».
Вернёмся к нашему примеру. Каким образом будет работать операция получения статуса заказа? Для получения статуса будет выполнена следующая цепочка вызовов:
* пользователь вызовет метод `GET /v1/orders`;
* обработчик `orders` выполнит операции своего уровня ответственности (проверку авторизации, в частности), найдёт идентификатор `program_run_id` и обратится к API программ `GET /v1/programs/{id}/runs/{program_run_id}`;
* обработчик `runs` в свою очередь выполнит операции своего уровня (в частности, проверит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:
* либо вызовет `GET /execution/status` физического API кофе-машины, получит объём кофе и сличит с эталонным;
* либо обратится к `GET /v1/runtimes/{runtime_id}`, получит `state.status` и преобразует его к статусу заказа;
* в случае API второго типа цепочка продолжится: обработчик `GET /runtimes` обратится к физическому API `GET /sensors` и произведёт ряд манипуляций: сличит объём стакана / молотого кофе / налитой воды с запрошенным и при необходимости изменит состояние и статус.
Важным следствием этого принципа является то, что информацию о готовности заказа нам придётся «прорастить» через все уровни абстракции:
**NB**. Слова «цепочка вызовов» не следует воспринимать буквально. Каждый уровень может быть технически организован по-разному:
* можно явно проксировать все вызовы по иерархии;
* можно кэшировать статус своего уровня и обновлять его по получению обратного вызова или события.
В частности, низкоуровневый цикл исполнения рантайма для машин второго рода очевидно должен быть независимым и обновлять свой статус в фоне, не дожидаясь явного запроса статуса.
1. На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров; с точки зрения физических сенсоров нет никакой «готовности заказа», есть только состояние выполнения команд;
2. На уровне исполнения статус готовности означает, что состояние сенсоров приведено к эталонному (в случае политики "check_volume" — что налит именно тот объём кофе, который был запрошен);
3. На пользовательском уровне статус готовности заказа означает, что все ассоциированные задачи выполнены.
Обратите внимание, что здесь фактически происходит следующее: на каждом уровне абстракции есть какой-то свой статус (заказа, рантайма, сенсоров), который сформулирован в терминах соответствующий этому уровню абстракции предметной области. Запрет «перепрыгывания» уровней приводит к тому, что нам необходимо дублировать статус на каждом уровне независимо.
На каждом уровне абстракции понятие «готовность» переформулируется в терминах нижележащей предметной области, и так вплоть до физического уровня.
Рассмотрим теперь, каким образом через наши уровни абстракции «прорастёт» операция отмены заказа. В этом случае цепочка вызовов будет такой:
Аналогично нам придётся поступить и с действиями, доступными на том или ином уровне. Если, допустим, в нашем API появится метод отмены заказа `cancel`, то его придётся точно так же «спустить» по всем уровням абстракции.
* пользователь вызовет метод `POST /orders/{id}/cancel`;
* обработчик метода произведёт операции в своей зоне ответственности:
* проверит авторизацию;
* решит денежные вопросы — нужно ли делать рефанд;
* найдёт идентификатор `program_run_id` и обратится к `POST /v1/programs/{id}/runs/{program_run_id}/cancel`;
* обработчик `runs/cancel` произведёт операции своего уровня (в частности, установит тип API кофе-машины) и в зависимости от типа API пойдёт по одной из двух веток исполнения:
* либо вызовет `POST /execution/cancel` физического API кофе-машины;
* либо вызовет `POST /v1/runtimes/{id}/terminate`;
* во втором случае цепочка продолжится: обработчик `terminate` изменит внутреннее состояние:
* изменит `resolution` на `terminated`
* запустит команду `discard_cup`.
* `POST /orders/{id}/cancel` работает с высокоуровневыми данными о заказе:
* проверяет авторизацию, т.е. имеет ли право этот пользователь отменять этот заказ;
* решает денежные вопросы — нужно ли делать рефанд;
* находит все незавершённые задачи и отменяет их;
* `POST /tasks/{id}/cancel` работает с исполнением заказа:
* определяет, возможно ли физически отменить исполнение, есть ли такая функция у кофе-машины;
* генерирует последовательность действий отмены (возможно, не только непосредственно для самой машины — вполне вероятно, необходимо будет поставить задание сотруднику кофейни утилизировать невостребованный напиток);
* `POST /coffee-machines/{id}/operations` выполняет операции на кофе-машине, сгенерированные на предыдущем шаге.
Два важных момента, на которые здесь стоит обратить внимание:
Обратите также внимание, что содержание операции «отменить заказ» изменяется на каждом из уровней. На пользовательском уровне заказ отменён, когда решены все важные для пользователя вопросы. То, что отменённый заказ какое-то время продолжает исполняться (например, ждёт утилизации) — пользователю неважно. На уровне исполнения же нужно связать оба контекста:
* `GET /tasks/{id}/status`
`{"status":"canceled","operation_state":{"status":"canceling","operations":[…]}`
— с т.з. высокоуровневого кода задача завершена (`canceled`), но с точки зрения низкоуровневого кода список исполняемых операций непуст, т.е. задача продолжает работать.
1. На каждом уровне абстракции понятие «отмена заказа» переформулируется:
* на уровне `orders` это действие фактически распадается на несколько «отмен» других уровней: нужно отменить блокировку денег на карте и отменить исполнение заказа;
* при этом на физическом уровне API второго типа «отмена» как таковая не существует: «отмена» — это исполнение команды `discard_cup`, которая на этом уровне абстракции ничем не отличается от любых других команд.
Промежуточный уровень абстракции как раз необходим для того, чтобы переход между «отменами» разных уровней произошёл гладко, без необходимости перепрыгивания через уровни абстракции.
**NB**: так как `task` связывает два разных уровня абстракции, то и статусов у неё два: внешний `canceled` и внутренний `canceling`. Мы могли бы опустить второй статус и предложить ориентироваться на содержание `operations`, но это (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе `operation_state`, что, быть может, разработчику вовсе и не нужно.
2. С точки зрения верхнеуровневого API отмена заказа является терминальным действием, т.е. никаких последующих операций уже быть не может; а с точки зрения низкоуровневого API обработка заказа продолжается, т.к. нужно дождаться, когда стакан будет утилизирован, и после этого освободить кофе-машину (т.е. разрешить создание новых рантаймов на ней). Это вторая задача для уровня исполнения: связывать оба статуса, внешний (заказ отменён) и внутренний (исполнение продолжается).
Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.

View File

@ -17,26 +17,27 @@
#### 1. Явное лучше неявного
Из названия любой сущности должно быть очевидно, что она делает и к каким сайд-эффектам может привести её использование.
* плохо:
* **Плохо**:
```
GET /orders/cancellation
// отменяет заказ
```
Неочевидно, что достаточно просто обращения к сущности `cancellation` (что это?), тем более немодифицирующим методом `GET`, чтобы отменить заказ;
Хорошо:
**Хорошо**:
```
POST /orders/cancel
отменяет заказ
```
* плохо:
* **Плохо**:
```
GET /orders/statistics
// Возвращает агрегированную статистику заказов за всё время
```
Даже если операция немодифицирующая, но вычислительно дорогая — следует об этом явно индицировать, особенно если вычислительные ресурсы тарифицируются для пользователя; тем более не стоит подбирать значения по умолчанию так, чтобы вызов операции без параметров максимально расходовал ресурсы.
Хорошо:
**Хорошо**:
```
POST /orders/statistics/aggregate
{ "start_date", "end_date" }
@ -51,56 +52,84 @@
**1.2.** Если операция асинхронная, это должно быть очевидно из сигнатуры, **либо** должна существовать конвенция именования, позволяющая отличаться синхронные операции от асинхронных.
#### 2. Сущности должны именоваться конкретно
#### 2. Использованные стандарты указывайте явно
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «начинать ли неделю с понедельника или с воскресенья», что уж говорить о каких-то более сложных стандартах.
Поэтому _всегда_ указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе:
* **плохо**: `"date":"11/12/2020"` — стандартов записи дат существует огромное количество, плюс из этой записи невозможно даже понять, что здесь число, а что месяц;
**хорошо**: `"iso_date":"2020-11-12"`.
* **плохо**: `"duration":5000` — пять тысяч чего?
**хорошо**:
`"duration_ms":5000`
либо
`"duration":"5000ms"`
либо
`"duration":{"unit":"ms","value":5000}`.
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, есть только один работающий метод борьбы с фрустрацией — «блокнот душевного спокойствия», который будет описан в разделе II.
#### 3. Сохраняйте точность дробных чисел
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому (путём домножения на указанный множитель), либо использовать строковый тип.
#### 4. Сущности должны именоваться конкретно
Избегайте слов-«амёб» без определённой семантики, таких как get, apply, make. Сущности должны именоваться конкретно:
* плохо: `user.get()` — неочевидно, что конкретно будет возвращено;
хорошо: `user.get_id()`;
* **плохо**: `user.get()` — неочевидно, что конкретно будет возвращено;
**хорошо**: `user.get_id()`.
#### 3. Не экономьте буквы
#### 5. Не экономьте буквы
В XXI веке давно уже нет нужды называть переменные покороче.
* плохо: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…
хорошо: `order.get_estimated_delivery_time()`
* плохо:
* **Плохо**: `order.time()` — неясно, о каком времени идёт речь: время создания заказа, время готовности заказа, время ожидания заказа?…
**Хорошо**: `order.get_estimated_delivery_time()`
* **Плохо**:
```
strpbrk (str1, str2)
// возвращает положение первого вхождения в строку str2
// любого символа из строки str2
```
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
Хорошо: `str_search_for_characters(lookup_character_set, str)`
Возможно, автору этого API казалось, что аббревиатура `pbrk` что-то значит для читателя, но он явно ошибся. К тому же, невозможно сходу понять, какая из строк `str1`, `str2` является набором символов для поиска.
**Хорошо**: `str_search_for_characters(lookup_character_set, str)`
Однако необходимость существования такого метода вообще вызывает сомнения, достаточно было бы иметь удобную функцию поиска подстроки с нужными параметрами. Аналогично сокращение `string` до `str` выглядит совершенно бессмысленным, но, увы, является устоявшимся для большого количества предметных областей.
#### 4. Тип поля должен быть ясен из его названия
#### 6. Тип поля должен быть ясен из его названия
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который я могу найти в составе сущности `Recipe`.
Если поле называется `recipe` — мы ожидаем, что его значением является сущность типа `Recipe`. Если поле называется `recipe_id` — мы ожидаем, что его значением является идентификатор, который мы сможем найти в составе сущности `Recipe`.
Сущности-массивы должны именоваться во множественном числе или собирательными выражениями — `objects`, `children`; если это невозможно (термин неисчисляемый), следует добавить префикс или постфикс, не оставляющий сомнений:
* плохо: `GET /news` — неясно, будет ли получена какая-то конкретная новость или массив новостей;
хорошо: `GET /news-list`.
* **плохо**: `GET /news` — неясно, будет ли получена какая-то конкретная новость или массив новостей;
**хорошо**: `GET /news-list`.
Аналогично, если ожидается булево значение, то из названия это должно быть очевидно, т.е. именование должно описывать некоторое качественное состояние, например, `is_ready`, `open_now`:
* плохо: `"task.status": true` — неочевидно, что статус бинарен, плюс такое API будет нерасширяемым;
хорошо: `"task.is_finished": true`
* **плохо**: `"task.status": true` — неочевидно, что статус бинарен, плюс такое API будет нерасширяемым;
**хорошо**: `"task.is_finished": true`.
Отдельно следует оговорить, что на разных платформах эти правила следует дополнить по-своему с учетом специфики first-class citizen-типов. Например, объекты типа `Date`, если таковые имеются, разумно индицировать с помощью, например, постфикса `_at` (`created_at`, `occurred_at`, etc) или `_date`.
Если наименование сущности само по себе является каким-либо термином, способным смутить разработчика, лучше добавить лишний префикс или постфикс, чтобы избежать непонимания.
* Плохо:
* **Плохо**:
```
GET /coffee-machines/functions
// Возвращает список встроенных функций кофе-машины
```
Слово "functions" многозначное; может означать и встроенные функции, и написанный код, и состояние (функционирует-не функционирует).
Хорошо: `GET /coffee-machines/builtin-functions-list`
**Хорошо**: `GET /coffee-machines/builtin-functions-list`
#### 5. Подобные сущности должны называться подобно и вести себя подобным образом
#### 7. Подобные сущности должны называться подобно и вести себя подобным образом
* плохо: `begin_transition` / `stop_transition`
`begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации;
хорошо: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`;
* плохо:
* **Плохо**: `begin_transition` / `stop_transition`
`begin` и `stop` — непарные термины; разработчик будет вынужден рыться в документации.
**Хорошо**: `begin_transition` / `end_transition` либо `start_transition` / `stop_transition`.
* **Плохо**:
```
strpos(haystack, needle)
// Находит первую позицию позицию строки `needle`
@ -118,30 +147,50 @@
Упражнение «как сделать эти интерфейсы хорошо» предоставим читателю.
#### 6. Использованные стандарты указывайте явно
#### 8. Клиент всегда должен знать полное состояние системы
К сожалению, человечество не в состоянии договориться о таких простейших вещах, как «начинать ли неделю с понедельника или с воскресенья», что уж говорить о каких-то более сложных стандартах.
Правило можно ещё сформулировать так: не заставляйте клиент гадать.
Поэтому _всегда_ указывайте, по какому конкретно стандарту вы отдаёте те или иные величины. Исключения возможны только там, где вы на 100% уверены, что в мире существует только один стандарт для этой сущности, и всё население земного шара о нём в курсе.
* **Плохо**:
```
POST /comments
{ "content" }
// Создаёт комментарий и возвращает его id
```
```
{ "comment_id" }
```
```
GET /comments/{id}
// Возвращает комментарий по его id
```
```
{
// Комментарий не опубликован
// и ждёт прохождения капчи
"published": false,
"action_required": "solve_captcha",
"content"
}
```
— хотя операция будто бы выполнена успешна, клиенту необходимо сделать дополнительный запрос, чтобы понять необходимость решения капчи. Между вызовами `POST /comments` и `GET /comments/{id}` клиент находится в состоянии кота Шрёдингера: непонятно, опубликован комментарий или нет, и как отразить это пользователю.
**Хорошо**:
```
POST /comments
{ "content" }
// Создаёт комментарий и возвращает его
```
```
{ "comment_id", "published", "action_required", "content" }
```
```
GET /comments/{id}
// Возвращает комментарий по его id
```
```
{ /* в точности тот же формат,
что и в ответе POST /comments */
}
```
Вообще в 9 случаях из 10 (а фактически — всегда, когда размер ответа невелик) во всех отношениях лучше из любой модифицирующей операции возвращать полное состояние сущности в том же формате, что и из операции доступа на чтение.
* Плохо: `"date":"11/12/2020"` — стандартов записи дат существует огромное количество, плюс из этой записи невозможно даже понять, что здесь число, а что месяц;
хорошо: `"iso_date":"2020-11-12"`.
* Плохо: `"duration":5000` — пять тысяч чего?
Хорошо:
`"duration_ms":5000`
либо
`"duration":"5000ms"`
либо
`"duration":{"unit":"ms","value":5000}`
Отдельное следствие из этого правила — денежные величины *всегда* должны сопровождаться указанием кода валюты.
Также следует отметить, что в некоторых областях ситуация со стандартами настолько плоха, что как ни сделай — кто-то останется недовольным. Классический пример такого рода — порядок геокоординат ("широта-долгота" против "долгота-широта"). Здесь, увы, вам остаётся только смириться и проявлять выдержку при нападках на ваше API.
// TODO: блокнот душевного спокойствия
#### 7. Сохраняйте точность дробных чисел
Там, где это позволено протоколом, дробные числа с фиксированной запятой — такие, как денежные суммы, например — должны передаваться в виде специально предназначенных для этого объектов, например, Decimal или аналогичных.
Если в протоколе нет Decimal-типов (в частности, в JSON нет чисел с фиксированной запятой), следует либо привести к целому, либо использовать строковый тип.

View File

@ -25,6 +25,7 @@ body {
code, pre {
font-family: Inconsolata, sans-serif;
font-size: 12pt;
white-space: nowrap;
}
pre {