From 0b53603f2c5fc784d6e723bfc8c0ae9f0d361105 Mon Sep 17 00:00:00 2001 From: Sergey Konstantinov Date: Mon, 2 Nov 2020 18:58:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D0=B4=D0=BE=D0=BB=D0=B6?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=20=D0=BF=D1=80=D0=BE=20=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=BB=D1=8F=D1=86=D0=B8=D1=8E=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B0=D0=B1=D1=81=D1=82=D1=80=D0=B0=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.ru.md | 68 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 19 deletions(-) diff --git a/API.ru.md b/API.ru.md index 18c1e5e..9a3e2c5 100644 --- a/API.ru.md +++ b/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 и сформулировать в терминах предметной области, что представляет из себя каждый объект. Напомню, что из концепции уровней абстракции следует, что каждый уровень иерархии — это некоторая собственная промежуточная предметная область, ступенька, по которой мы переходим от описания задачи в терминах одного связываемого контекста («заказанный пользователем лунго») к описанию в терминах второго («задание кофе-машине на выполнение указанной программы»).