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

Продолжаем про изоляцию уровней абстракции

This commit is contained in:
Sergey Konstantinov 2020-11-02 18:58:29 +03:00
parent 2bcd2b7948
commit 0b53603f2c

View File

@ -184,7 +184,7 @@ _NB_. Здесь и далее мы будем рассматривать кон
Допустим, мы имеем следующий интерфейс:
* `GET /recipes/lungo` возвращает рецепт лунго;
* `POST /coffee-machines/order?machine_id={id}` `{recipe:"lungo"}` размещает на указанной кофе-машине заказ на приготовление лунго и возвращает идентификатор заказа;
* `POST /coffee-machines/orders?machine_id={id}` `{recipe:"lungo"}` размещает на указанной кофе-машине заказ на приготовление лунго и возвращает идентификатор заказа;
* `GET /orders?order_id={id}` возвращает состояние заказа;
И зададимся вопросом, каким образом разработчик определит, что заказ клиента готов. Допустим, мы сделаем так: добавим в рецепт лунго эталонный объём, а в состояние заказа — количество уже налитого кофе. Тогда разработчику нужно будет проверить совпадение этих двух цифр, чтобы убедиться, что кофе готов.
@ -196,9 +196,9 @@ _NB_. Здесь и далее мы будем рассматривать кон
2. Мы автоматически получаем проблемы, если захотим варьировать размер кофе. Допустим, в какой-то момент мы захотим представить пользователю выбор, сколько конкретно миллилитров лунго он желает. Тогда нам придётся проделать один из следующих трюков:
* или мы фиксируем список допустимых объёмов и заводим фиктивные рецепты типа `/recipes/small-lungo`, `recipes/large-lungo`. Почему фиктивные? Потому что рецепт один и тот же, меняется только объём. Нам придётся либо тиражировать одинаковые рецепты, отличающиеся только объёмом, либо вводить какое-то «наследование» рецептов, чтобы можно было указать базовый рецепт и только переопределить объём;
* или мы модифицируем интерфейс, объявляя объём кофе, указанный в рецепте, значением по умолчанию; при размещении заказа мы разрешаем указать объём, отличный от эталонного:
`POST /coffee-machines/order?machine_id={id}` `{recipe:"lungo","volume":"800ml"}`
`POST /coffee-machines/orders?machine_id={id}` `{recipe:"lungo","volume":"800ml"}`
Для таких кофе произвольного объёма нужно будет получать требуемый объём не из `GET /recipes`, а из `GET /orders`. Сделав так, мы сразу получаем клубок из связанных проблем:
* разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с `POST /coffee-machines/order` нужно не забыть переписать код проверки готовности заказа;
* разработчик, которому придётся поддержать эту функциональность, имеет высокие шансы сделать ошибку: добавив поддержку произвольного объёма кофе в код, работающий с `POST /coffee-machines/orders` нужно не забыть переписать код проверки готовности заказа;
* мы получим классическую ситуацию, когда одно и то же поле (объём кофе) значит разные вещи в разных интерфейсах. В `GET /recipes` поле «объём» теперь значит «объём, который будет запрошен, если не передать его явно в `POST /coffee-machines/orders`; переименовать его в «объём по умолчанию» уже не получиться, с этой проблемой теперь придётся жить.
3. Вся эта схема полностью неработоспособна, если разные модели кофе-машин производят лунго разного объёма. Для решения задачи «объём лунго зависит от вида машины» нам придётся сделать совсем неприятную вещь: сделать рецепт зависимым от id машины. Тем самым мы начнём активно смешивать уровни абстракции: одной частью нашего API (рецептов) станет невозможно пользоваться без другой части (информации о кофе-машинах). Что немаловажно, от разработчиков потребуется изменить логику своего приложения: если раньше они могли предлагать сначала выбрать объём, а потом кофе-машину, то теперь им придётся полностью изменить этот шаг.
@ -225,35 +225,66 @@ _NB_. Здесь и далее мы будем рассматривать кон
* заказ порождает одно или несколько заданий, указывая для задания конкретный рецепт и кофе-машину;
* задание в свою очередь оперирует командами кофе-машины и отвечает за интерпретацию состояния датчиков.
Таким образом, нам необходим по крайней мере один новый метод API:
Таким образом, наше API будет выглядеть примерно так:
* `POST /orders` — создаёт заказ;
* `GET /tasks?order_id={order_id}` — позволяет получить список заданий по заказу.
NB: важно заметить, что с дальнейшей проработкой уровень исполнения, скорее всего, сам должен будет разделиться на два и более уровня, т.к. от «задача» по сути — просто сущность-зонтик, связывающая в рамках заказа несколько высокоуровневых сущностей, и до манипуляции командами кофе-машины и состоянием сенсоров всё ещё далеко с точки зрения абстрагирования. Но мы пока оставим в таком виде, для удобства дальнейшего изложения.
Внимательный читатель может здесь поинтересоваться, а в чём, собственно разница по сравнению с наивным подходом? Напомню, мы рассмотрели выше примерно такой вариант:
* `POST /coffee-machines/orders?machine_id={id}`\
`{recipe:"lungo","volume":"800ml"}`\
— создаёт заказ указанного объёма
* `GET /orders/{id}`\
`{…"volume_requested":"800ml","volume_prepared":"120ml"…}`\
— состояние исполнения заказа (налито 120 мл из запрошенных 800).
По сути пара `volume_requested`/`volume_prepared` и является аналогом дополнительной сущности `task`, зачем мы тогда усложняли?
Во-первых, в схеме с дополнительным уровнем абстракции мы скрываем конструирование самого объекта `task`. Если от `GET /orders/{id}` ожидается, что он вернёт хотя бы логически те же параметры заказа, что были переданы в `POST /coffee-machines/orders`, то при конструировании `task` сформировать нужный набор параметров — уже наша ответственность, спрятанная внутри обработчика создания заказа. Мы можем переформулировать параметры заказа в более удобные для исполнения на кофе-машине термины — например, возвращаясь к вопросу проверки готовности, явно сформулировать политику определения готовности кофе:
* `POST /tasks/?order_id={order_id}`\
`{…"volume_requested":"800ml","readiness_policy":"check_volume"…}`\
— внутри обработчика создания заказа мы обратились к спецификации кофе-машины и поставили задачу в соответствии с ней. (Здесь мы предполагаем, что `POST /tasks` — внутренний метод создания задач; он может и не существовать в виде API.)
* `GET /tasks/{id}/status`\
`{…"volume_prepared":"200ml","ready":false}`
— в публичном интерфейсе
На это (совершенно верное!) замечаниемы ответим, что выделение уровней абстракции — прежде всего _логическая_ процедура: как мы объясняем себе и разработчику, из чего состоит наш API. Мы могли бы просто ограничиться выделением секции `task` в ответе `GET /orders/{id}` — или вовсе сказать, что `task` — это просто четверка полей (`ready`, `volume_requested`, `volume_prepared`, `readiness_policy`) и есть. **Абстрагируемая дистанция между сущностями существует объективно**, каким бы образом мы ни написали конкретные интерфейсы. Наша задача состоит только лишь в том, чтобы эта дистанция была разделена на уровни _явно_. Чем неявнее разведены уровни абстракции (или хуже того, перемешаны) уровни абстракции, тем сложнее будет разобраться в вашем API и тем хуже будет написан использующий его код.
NB: важно заметить, что с дальнейшей проработкой уровень исполнения, скорее всего, сам должен будет разделиться на два и более уровня, т.к. «задача» по сути — просто сущность-зонтик, связывающая в рамках заказа несколько высокоуровневых сущностей. Идея определения параметров кофе-машины на этапе создания заказов не очень удобна, да и до манипуляции командами кофе-машины и состоянием сенсоров всё ещё далеко с точки зрения абстрагирования. Но мы пока оставим в таком виде, для удобства дальнейшего изложения.
#### Изоляция уровней абстракции
Важное свойство правильно подобранных уровней абстракции, и отсюда требование к их проектированию — это требование изоляции: **взамодействие возможно только между сущностями соседних уровней абстракции**. Если при проектировании выясняется, что для выполнения того или иного действия требуется «перепрыгнуть» уровень абстракции, это явный признак того, что в проекте допущены ошибки.
Возвращаясь к нашему примеру с готовностью кофе: как быть, если для определения готовности кофе *действительно* нужно сравнить объём приготовленного напитка? Предположим, что кофемашина работает именно так — определяет готовность по объёму?
Возвращаясь к нашему примеру с готовностью кофе: проблемы с определением готовности кофе исходя из объёма возникают именно потому, что мы не можем ожидать от пользователя, создающего заказ, знания о необходимости проверки объёма налитого реальной кофе-машиной объёма кофе. Мы вводим дополнительный уровень абстракции именно для того, чтобы на нём переформулировать, что такое «заказ готов».
Для этого нам как раз пригодится наша промежуточная сущность «задание».
Важным следствием этого принципа является то, что информацию о готовности заказа нам придётся «прорастить» через все уровни абстракции:
В этой ситуации необходимо «прорастить» статус готовности через все уровни абстракции, например так:
1. На физическом уровне мы будем оперировать состоянием кофе-машины, её сенсоров;
2. На уровне исполнения статус готовности означает, что состояние сенсоров приведено к эталонному (в случае политики "check_volume" — что налит именно тот объём кофе, который был запрошен);
3. На пользовательском уровне статус готовности заказа означает, что все ассоциированные задачи выполнены.
1. При проверке готовности заказа — обратиться к заданию на приготовление конкретного рецепта на конкретной кофе-машине и опросить его статус.
На каждом уровне абстракции понятие «готовность» переформулируется в терминах нижележащей предметной области, и так вплоть до физического уровня.
2. При проверке статуса задания — обратиться к спецификациям конкретной кофе-машины и выполнить команду сверки приготовленного объёма с эталонным.
Аналогично нам придётся поступить и с действиями, доступными на том или ином уровне. Если, допустим, в нашем API появится метод отмены заказа `cancel`, то его придётся точно так же «спустить» по всем уровням абстракции.
3. При выполнении команды сверки — обратиться к физическим датчикам и считать конкретные физические значения.
* `POST /orders/{id}/cancel` работает с высокоуровневыми данными о заказе:
* проверяет авторизацию, т.е. имеет ли право этот пользователь отменять этот заказ;
* решает денежные вопросы — нужно ли делать рефанд
* находит все незавершённые задачи и отменяет их
* `POST /tasks/{id}/cancel` работает с исполнением заказа:
* определяет, возможно ли физически отменить исполнение, есть ли такая функция у кофе-машины;
* генерирует последовательность действий отмены (возможно, не только непосредственно для самой машины — вполне вероятно, необходимо будет поставить задание сотруднику кофейни утилизировать невостребованный напиток);
* `POST /coffee-machines/{id}/operations` выполняет операции на кофе-машине, сгенерированные на предыдущем шаге.
На каждом уровне абстракции понятие «заказ готов» переформулируется в терминах нижележащей предметной области, и так вплоть до физического уровня.
Обратите также внимание, что содержание операции «отменить заказ» изменяется на каждом из уровней. На пользовательском уровне заказ отменён, когда решены все важные для пользователя вопросы. То, что отменённый заказ какое-то время продолжает исполняться (например, ждёт утилизации) — пользователю неважно. На уровне исполнения же нужно связать оба контекста:
* `GET /tasks/{id}/status`\
`{"status":"canceled","finished":false,"operations":[…]}`\
С т.з. высокоуровневого кода задача завершена (`canceled`), но с точки зрения низкоуровневого кода список исполняемых операций непуст, т.е. задача продолжает работать.
Если в нашем API мы предоставляем доступ к более низким уровням абстракции, то это будет означать необходимость иметь на каждом уровне свою версию одного и того же метода. Условно:
* `GET /orders?order_id={id}` — возвращает статус заказа *и* идентификаторы заданий, созданных в его рамках
* `GET /tasks?task_id={id}` — возращает статус конкретного задания, в том числе идентификатор кофе-машины и идентификаторы рецепта (или иного способа получить эталонные значения объема)
* `GET /machine?machine_id={id}&sensor=volume` — возращает состояние конкретного сенсора конкретной кофе-машины.
NB: так как `task` связывает два разных уровня абстракции, то и статусов у неё два: внешний `canceled` и внутренний `finished`. Мы могли бы опустить второй статус и предложить ориентироваться на содержание `operations`, но это вновь (а) неявно, (б) предполагает необходимость разбираться в более низкоуровневом интерфейсе `operation`, что, быть может, разработчику вовсе и не нужно.
Может показаться, что соблюдение правила изоляции уровней абстракции является избыточным и заставляет усложнять интерфейс. И это в действительности так: важно понимать, что никакая гибкость, логичность, читабельность и расширяемость не бывает бесплатной. Можно построить API так, чтобы оно выполняло свою функцию с минимальными накладными расходами, по сути — дать интерфейс к микроконтроллерам кофе-машины. Однако пользоваться им будет крайне неудобно, и расширяемость такого API будет нулевой.
@ -268,7 +299,6 @@ NB: важно заметить, что с дальнейшей проработ
* Пользовательский уровень (те сущности, с которыми непосредственно взаимодействует пользователь и сформулированы в понятных для него терминах; например, заказы и виды кофе).
* Физический уровень (непосредственно сами датчики машины).
Теперь нам необходимо определить ответственность каждой сущности: в чём смысл её существования в рамках нашего API, какие действия можно выполнять с самой сущностью, а какие — делегировать другим объектам. Фактически, нам нужно применить «зачем-принцип» к каждой отдельной сущности нашего API.
Для этого нам нужно пройти по нашему API и сформулировать в терминах предметной области, что представляет из себя каждый объект. Напомню, что из концепции уровней абстракции следует, что каждый уровень иерархии — это некоторая собственная промежуточная предметная область, ступенька, по которой мы переходим от описания задачи в терминах одного связываемого контекста («заказанный пользователем лунго») к описанию в терминах второго («задание кофе-машине на выполнение указанной программы»).