mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-01-23 17:53:04 +02:00
Рефакторинг девятой главы, предфинальная версия
This commit is contained in:
parent
b8d94da2a9
commit
851136ccd8
298
docs/API.ru.html
298
docs/API.ru.html
@ -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": <идентификатор исполнения>
|
||||
}
|
||||
</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>
|
BIN
docs/API.ru.pdf
BIN
docs/API.ru.pdf
Binary file not shown.
@ -10,4 +10,4 @@
|
||||
|
||||
Может показаться, что наиболее полезные советы приведены в последнем разделе, однако это не так; цена ошибки, допущенной на разных уровнях весьма различна. Если исправить плохое именование довольно просто, то исправить неверное понимание того, зачем вообще нужно API, практически невозможно.
|
||||
|
||||
_NB_. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.
|
||||
**NB**. Здесь и далее мы будем рассматривать концепции разработки API на примере некоторого гипотетического API заказа кофе в городских кофейнях. На всякий случай сразу уточним, что пример является синтетическим; в реальной ситуации, если бы такое API пришлось проектировать, оно вероятно было бы совсем не похоже на наш выдуманный пример.
|
@ -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 и тем хуже будет написан использующий его код.
|
||||
|
||||
|
@ -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 нет чисел с фиксированной запятой), следует либо привести к целому, либо использовать строковый тип.
|
||||
|
@ -25,6 +25,7 @@ body {
|
||||
code, pre {
|
||||
font-family: Inconsolata, sans-serif;
|
||||
font-size: 12pt;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
pre {
|
||||
|
Loading…
x
Reference in New Issue
Block a user