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
2bcd2b7948
commit
0b53603f2c
68
API.ru.md
68
API.ru.md
@ -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 и сформулировать в терминах предметной области, что представляет из себя каждый объект. Напомню, что из концепции уровней абстракции следует, что каждый уровень иерархии — это некоторая собственная промежуточная предметная область, ступенька, по которой мы переходим от описания задачи в терминах одного связываемого контекста («заказанный пользователем лунго») к описанию в терминах второго («задание кофе-машине на выполнение указанной программы»).
|
||||
|
Loading…
x
Reference in New Issue
Block a user