1
0
mirror of https://github.com/twirl/The-API-Book.git synced 2025-01-05 10:20:22 +02:00

Уровни абстракции, рефакторинг, продолжение

This commit is contained in:
Sergey Konstantinov 2020-11-20 17:45:31 +03:00
parent 3f78ca456d
commit b8d94da2a9
4 changed files with 188 additions and 33 deletions

View File

@ -136,7 +136,32 @@ h4, h5 {
<p>Разработка программного обеспечения характеризуется, помимо прочего, существованием множества различных парадигм разработки, адепты которых зачастую настроены весьма воинственно по отношению к адептам других парадигм. Поэтому при написании этой книги мы намеренно избегаем слов «метод», «объект», «функция» и так далее, используя нейтральный термин «сущность». Под «сущностью» понимается некоторая атомарная единица функциональности — класс, метод, объект, монада, прототип (нужное подчеркнуть).</p>
<p>Для составных частей сущности, к сожалению, достаточно нейтрального термина нам придумать не удалось, поэтому мы используем слова «поля» и «методы».</p>
<p>Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо <code>GET /v1/orders</code> вполне может быть вызов метода <code>orders.get()</code>, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.</p>
<p>Также в примерах часто применяется следующая конвенция. Запись <code>{ "begin_date" }</code> (т.е. отсутствие значения у поля в JSON-объекте) означает, что в поле находится именно то, что ожидается — т.е. в данном примере какая-то дата начала.</p><div class="page-break"></div><h2>I. Проектирование API</h2><h3 id="7api">Глава 7. Пирамида контекстов API</h3>
<p>Рассмотрим следующую запись:</p>
<pre><code>POST /v1/bucket/{id}/some-resource
{
// Это однострочный комментарий
"some_parameter": "value",
}
</code></pre>
<pre><code>{
/* А это многострочный
комментарий */
"operation_id"
}
</code></pre>
<p>Её следует читать так:</p>
<ul>
<li>выполняется POST-запрос к ресурсу <code>/v1/bucket/{id}/some-resource</code>, где <code>{id}</code> заменяется на некоторый идентификатор <code>bucket</code>-а (при отсутствии уточнений подстановки вида <code>{something}</code> следует относить к ближайшему термину слева);</li>
<li>в качестве тела запроса передаётся JSON, содержащий поле <code>some_parameter</code> со значением <code>value</code> и ещё какие-то поля, которые для краткости опущены (что показано многоточием);</li>
<li>телом ответа является JSON, состоящий из единственного поля <code>operation_id</code>; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какой-то идентификатор операции.</li>
</ul>
<p>Для упрощения неважен возможна сокращенная запись вида:</p>
<ul>
<li><code>POST /v1/bucket/{id}/some-resource</code> <code>{…,"some_parameter",…}</code> — если тела ответа нет или оно нам не понадобится в ходе рассмотрения примера.</li>
</ul>
<p>Чтобы сослаться на это описание будут использоваться выражения типа «метод <code>POST /v1/bucket/{id}/some-resource</code>» или, для простоты, «метод <code>/some-resource</code>» (если никаких других <code>some-resource</code> в контексте главы не упоминается и перепутать не с чем).</p><div class="page-break"></div><h2>I. Проектирование API</h2><h3 id="7api">Глава 7. Пирамида контекстов API</h3>
<p>Подход, который мы используем для проектирования, состоит из четырёх шагов:</p>
<ul>
<li>определение области применения;</li>
@ -286,17 +311,13 @@ h4, h5 {
// Идентификатор исполняемой программы
"program": 1,
// Запрошенный объём напитка
"volume": "200ml",
// Ожидаемое время приготовления
"preparation_time": "20s",
// Готовность
"ready": false
"volume": "200ml"
}
</code></pre>
<pre><code>POST /cancel
// Отменяет текущую программу
</code></pre>
<pre><code>GET /execution/{execution_id}/status
<pre><code>GET /execution/status
// Возвращает статус исполнения
// Формат аналогичен формату ответа `POST /execute`
</code></pre>
@ -354,18 +375,69 @@ h4, h5 {
<li>Очевидно, что разработчику хочется создавать заказ унифицированным образом — перечислить высокоуровневые параметры заказа (вид напитка, объём и специальные требования, такие как вид сиропа или молока) — и не думать о том, как на конкретной машине исполнить этот заказ.</li>
<li>Разработчику надо понимать состояние исполнения — готов ли заказ или нет; если не готов — когда ожидать готовность (и надо ли её ожидать вообще в случае ошибки исполнения).</li>
<li>Разработчику нужно уметь соотносить заказ с его положением в пространстве и времени — чтобы показать потребителю, когда и как нужно заказ забрать.</li>
<li>Наконец, разработчику нужно выполнять атомарные операции — прежде всего, отменять заказ.</li>
<li>Наконец, разработчику нужно выполнять атомарные операции — например, отменять заказ.</li>
</ol>
<p>Таким образом, наш промежуточный уровень абстракции должен:</p>
<p>Заметим, что API первого типа гораздо ближе к потребностям разработчика, нежели API второго типа. Концепция атомарной «программы» гораздо ближе к удобному для разработчика интерфейсу, нежели работа с сырыми наборами команд и данными сенсоров. В API первого типа мы видим только две проблемы:</p>
<ul>
<li>скрывать параметры создания задания на приготовление напитка — их вычисление должно производиться в нашем коде;</li>
<li>обобщать состояние исполнения в единый набор статусов, не зависящий от физического уровня приготовления напитка;</li>
<li>связывать параметры заказа (его уникальный идентификатор) с конкретным процессом приготовления напитка;</li>
<li>предоставлять атомарный интерфейс логических операций, скрывая за фасадом различия в физической имплементации этих операций.</li>
<li>отсутствие явного соответствия программ и рецептов; идентификатор программы по-хорошему вообще не нужен при работе с заказами, раз уже есть понятие рецепта;</li>
<li>отсутствие явного статуса готовности.</li>
</ul>
<p>Первая из проблем, которую мы видим на данном этапе — это отсутствие на физическом уровне машин второго типа самого понятия «заказ» или «напиток»: машина выполняет какой-то набор операций</p>
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции <code>task</code> в ответе <code>GET /orders/{id}</code> — или вовсе сказать, что <code>task</code> — это просто четверка полей (<code>ready</code>, <code>volume_requested</code>, <code>volume_prepared</code>, <code>readiness_policy</code>) и есть. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p>
<p>NB: важно заметить, что с дальнейшей проработкой уровень исполнения, скорее всего, сам должен будет разделиться на два и более уровня, т.к. «задача» по сути — просто сущность-зонтик, связывающая в рамках заказа несколько высокоуровневых сущностей. Идея определения параметров кофе-машины на этапе создания заказов не очень удобна, да и до манипуляции командами кофе-машины и состоянием сенсоров всё ещё далеко с точки зрения абстрагирования. Но мы пока оставим в таком виде, для удобства дальнейшего изложения.</p>
<p>С API второго типа всё гораздо хуже. Главная проблема, которая нас ожидает — отсутствие «памяти» исполняемых действий. API функций и сенсоров полностью stateless; это означает, что мы даже не знаем, кем, когда и в рамках какого заказа была запущена текущая функция.</p>
<p>Таким образом, нам нужно внедрить два новых уровня абстракции:</p>
<ol>
<li><p>Уровень управления исполнением, предоставляющий унифицированный интерфейс к атомарным программам. «Унифицированный интерфейс» в данном случае означает, что, независимо от того, на какого рода кофе-машине готовится заказ, разработчик может рассчитывать на:</p>
<ul>
<li>единую номенклатуру статусов и других высокоуровневых параметров исполнения (например, ожидаемого времени готовности заказа или возможных ошибок исполнения);</li>
<li>единую номенклатуру доступных методов (например, отмены заказа) и их одинаковое поведение.</li></ul></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>
<pre><code>POST /v1/programs/match
{ "recipe", "coffee-machine" }
</code></pre>
<pre><code>{ "program_id" }
</code></pre>
<p>Наконец, обладая идентификатором нужной программы, мы можем её запустить:</p>
<pre><code>POST /v1/programs/{id}/run
{
"execution_id",
"coffee_machine_id"
}
</code></pre>
<pre><code>{ "program_run_id" }
</code></pre>
<p>Обратите внимание, что во всей этой цепочке вообще никак не участвует тип API кофе-машины — собственно, ровно для этого мы и абстрагировали. Мы могли бы сделать интерфейсы более конкретными, разделив функциональность <code>run</code> и <code>match</code> для разных API, т.е. ввести раздельные endpoint-ы:</p>
<ul>
<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>
<ul>
<li>либо <code>POST /orders</code> сама обращается к доступной информации о рецепте и кофе-машине и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины в частности);</li>
<li>либо в запросе содержатся только идентификаторы, и имплементация методов сами обратятся за нужными данными через какие-то внутренние API.<br />
Оба варианта имеют право на жизнь; какой из них выбрать — зависит от деталей реализации.</li>
</ul>
<p>Любопытно, что введённая сущность <code>match</code> связывает два уровня абстракции, и тем самым не относится ни к одному из них. Такая ситуация (когда некоторые вспомогательные сущности находятся вне общей иерархии) случается довольно часто.</p>
<p>// TODO</p>
<p>Выделение уровней абстракции — прежде всего <em>логическая</em> процедура: как мы объясняем себе и разработчику, из чего состоит наш API. <strong>Абстрагируемая дистанция между сущностями существует объективно</strong>, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни <em>явно</em>. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.</p>
<h4 id="">Изоляция уровней абстракции</h4>
<p>Важное свойство правильно подобранных уровней абстракции, и отсюда требование к их проектированию — это требование изоляции: <strong>взамодействие возможно только между сущностями соседних уровней абстракции</strong>. Если при проектировании выясняется, что для выполнения того или иного действия требуется «перепрыгнуть» уровень абстракции, это явный признак того, что в проекте допущены ошибки.</p>
<p>Возвращаясь к нашему примеру с готовностью кофе: проблемы с определением готовности кофе исходя из объёма возникают именно потому, что мы не можем ожидать от пользователя, создающего заказ, знания о необходимости проверки объёма налитого реальной кофе-машиной объёма кофе. Мы вводим дополнительный уровень абстракции именно для того, чтобы на нём переформулировать, что такое «заказ готов».</p>

Binary file not shown.

View File

@ -6,4 +6,30 @@
Большинство примеров API в общих разделах будут даны в виде JSON-over-HTTP-эндпойтов. Это некоторая условность, которая помогает описать концепции, как нам кажется, максимально понятно. Вместо `GET /v1/orders` вполне может быть вызов метода `orders.get()`, локальный или удалённый; вместо JSON может быть любой другой формат данных. Смысл утверждений от этого не меняется.
Также в примерах часто применяется следующая конвенция. Запись `{ "begin_date" }` (т.е. отсутствие значения у поля в JSON-объекте) означает, что в поле находится именно то, что ожидается — т.е. в данном примере какая-то дата начала.
Рассмотрим следующую запись:
```
POST /v1/bucket/{id}/some-resource
{
// Это однострочный комментарий
"some_parameter": "value",
}
```
```
{
/* А это многострочный
комментарий */
"operation_id"
}
```
Её следует читать так:
* выполняется POST-запрос к ресурсу `/v1/bucket/{id}/some-resource`, где `{id}` заменяется на некоторый идентификатор `bucket`-а (при отсутствии уточнений подстановки вида `{something}` следует относить к ближайшему термину слева);
* в качестве тела запроса передаётся JSON, содержащий поле `some_parameter` со значением `value` и ещё какие-то поля, которые для краткости опущены (что показано многоточием);
* телом ответа является JSON, состоящий из единственного поля `operation_id`; отсутствие значения поля означает, что его значением является именно то, что в этом поле и ожидается — в данном случае какой-то идентификатор операции.
Для упрощения неважен возможна сокращенная запись вида:
* `POST /v1/bucket/{id}/some-resource` `{…,"some_parameter",…}` — если тела ответа нет или оно нам не понадобится в ходе рассмотрения примера.
Чтобы сослаться на это описание будут использоваться выражения типа «метод `POST /v1/bucket/{id}/some-resource`» или, для простоты, «метод `/some-resource`» (если никаких других `some-resource` в контексте главы не упоминается и перепутать не с чем).

View File

@ -116,11 +116,7 @@
// Идентификатор исполняемой программы
"program": 1,
// Запрошенный объём напитка
"volume": "200ml",
// Ожидаемое время приготовления
"preparation_time": "20s",
// Готовность
"ready": false
"volume": "200ml"
}
```
```
@ -128,7 +124,7 @@
// Отменяет текущую программу
```
```
GET /execution/{execution_id}/status
GET /execution/status
// Возвращает статус исполнения
// Формат аналогичен формату ответа `POST /execute`
```
@ -183,28 +179,89 @@
]
}
```
_NB_. Пример нарочно сделан умозрительным для моделирования ситуации, описанной в начале главы: для определения готовности напитка нужно сличить объём налитого с эталоном.
Теперь картина становится более явной: нам нужно абстрагировать работу с кофе-машиной так, чтобы наш «уровень исполнения» в API предоставлял общие функции (такие, как определение готовности напитка) в унифицированном виде. Важно отметить, что с точки зрения разделения абстракций два этих вида кофе-машин сами находятся на разных уровнях: первые предоставляют API более высокого уровня, нежели вторые; следовательно, и «ветка» нашего API, работающая со вторым видом машин, будет более «развесистой».
Следующий шаг, необходимый для отделения уровней абстракции — необходимо понять, какую функциональность нам, собственно, необходимо абстрагировать. Для этого нам необходимо обратиться к задачам, которые решает разработчик на уровне работы с заказами, и понять, какие проблемы у него возникнут в случае отсутствия нашего слоя абстракции.
1. Очевидно, что разработчику хочется создавать заказ унифицированным образом — перечислить высокоуровневые параметры заказа (вид напитка, объём и специальные требования, такие как вид сиропа или молока) — и не думать о том, как на конкретной машине исполнить этот заказ.
2. Разработчику надо понимать состояние исполнения — готов ли заказ или нет; если не готов — когда ожидать готовность (и надо ли её ожидать вообще в случае ошибки исполнения).
3. Разработчику нужно уметь соотносить заказ с его положением в пространстве и времени — чтобы показать потребителю, когда и как нужно заказ забрать.
4. Наконец, разработчику нужно выполнять атомарные операции — прежде всего, отменять заказ.
4. Наконец, разработчику нужно выполнять атомарные операции — например, отменять заказ.
Таким образом, наш промежуточный уровень абстракции должен:
* скрывать параметры создания задания на приготовление напитка — их вычисление должно производиться в нашем коде;
* обобщать состояние исполнения в единый набор статусов, не зависящий от физического уровня приготовления напитка;
* связывать параметры заказа (его уникальный идентификатор) с конкретным процессом приготовления напитка;
* предоставлять атомарный интерфейс логических операций, скрывая за фасадом различия в физической имплементации этих операций.
Заметим, что API первого типа гораздо ближе к потребностям разработчика, нежели API второго типа. Концепция атомарной «программы» гораздо ближе к удобному для разработчика интерфейсу, нежели работа с сырыми наборами команд и данными сенсоров. В API первого типа мы видим только две проблемы:
* отсутствие явного соответствия программ и рецептов; идентификатор программы по-хорошему вообще не нужен при работе с заказами, раз уже есть понятие рецепта;
* отсутствие явного статуса готовности.
Первая из проблем, которую мы видим на данном этапе — это отсутствие на физическом уровне машин второго типа самого понятия «заказ» или «напиток»: машина выполняет какой-то набор операций
С API второго типа всё гораздо хуже. Главная проблема, которая нас ожидает — отсутствие «памяти» исполняемых действий. API функций и сенсоров полностью stateless; это означает, что мы даже не знаем, кем, когда и в рамках какого заказа была запущена текущая функция.
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции `task` в ответе `GET /orders/{id}` — или вовсе сказать, что `task` — это просто четверка полей (`ready`, `volume_requested`, `volume_prepared`, `readiness_policy`) и есть. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
Таким образом, нам нужно внедрить два новых уровня абстракции:
NB: важно заметить, что с дальнейшей проработкой уровень исполнения, скорее всего, сам должен будет разделиться на два и более уровня, т.к. «задача» по сути — просто сущность-зонтик, связывающая в рамках заказа несколько высокоуровневых сущностей. Идея определения параметров кофе-машины на этапе создания заказов не очень удобна, да и до манипуляции командами кофе-машины и состоянием сенсоров всё ещё далеко с точки зрения абстрагирования. Но мы пока оставим в таком виде, для удобства дальнейшего изложения.
1. Уровень управления исполнением, предоставляющий унифицированный интерфейс к атомарным программам. «Унифицированный интерфейс» в данном случае означает, что, независимо от того, на какого рода кофе-машине готовится заказ, разработчик может рассчитывать на:
* единую номенклатуру статусов и других высокоуровневых параметров исполнения (например, ожидаемого времени готовности заказа или возможных ошибок исполнения);
* единую номенклатуру доступных методов (например, отмены заказа) и их одинаковое поведение.
2. Уровень программы исполнения. Для API первого типа он будет представлять собой просто обёртку над существующим API программ; для API второго типа концепцию «программ» придётся полностью имплементировать нам.
Что это будет означать практически? Разработчик по-прежнему будет создавать заказ, оперируя только высокоуровневыми терминами:
```
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 /v1/programs/match
{ "recipe", "coffee-machine" }
```
```
{ "program_id" }
```
Наконец, обладая идентификатором нужной программы, мы можем её запустить:
```
POST /v1/programs/{id}/run
{
"execution_id",
"coffee_machine_id"
}
```
```
{ "program_run_id" }
```
Обратите внимание, что во всей этой цепочке вообще никак не участвует тип API кофе-машины — собственно, ровно для этого мы и абстрагировали. Мы могли бы сделать интерфейсы более конкретными, разделив функциональность `run` и `match` для разных API, т.е. ввести раздельные endpoint-ы:
* `POST /v1/programs/{api_type}/match`
* `POST /v1/programs/{api_type}/{program_id}/run`
Достоинством такого подхода была бы возможность передавать в match и run не унифицированные наборы параметров, а только те, которые имеют значение в контексте указанного типа API. Однако в нашем дизайне API такой необходимости не прослеживается.
_NB_: в имплементации связки `execute``match``run` можно пойти одним из двух путей:
* либо `POST /orders` сама обращается к доступной информации о рецепте и кофе-машине и формирует stateless-запрос, в котором указаны все нужные данные (тип API кофе-машины в частности);
* либо в запросе содержатся только идентификаторы, и имплементация методов сами обратятся за нужными данными через какие-то внутренние API.
Оба варианта имеют право на жизнь; какой из них выбрать — зависит от деталей реализации.
Любопытно, что введённая сущность `match` связывает два уровня абстракции, и тем самым не относится ни к одному из них. Такая ситуация (когда некоторые вспомогательные сущности находятся вне общей иерархии) случается довольно часто.
// TODO
Выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
#### Изоляция уровней абстракции