mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-03-17 20:42:26 +02:00
Раздел II вчерне закончен
This commit is contained in:
parent
e7080d021d
commit
c20048cbc5
@ -1,4 +1,4 @@
|
||||
### Сильная связность и сопутствующие проблемы
|
||||
### Расширение через абстрагирование
|
||||
|
||||
В предыдущих разделах мы старались приводить теоретические правила и принципы, и иллюстрировать их на практических примерах. Однако понимание принципов проектирования API, устойчивого к изменениям, как ничто другое требует прежде всего практики. Знание о том, куда стоит «постелить соломку» — оно во многом «сын ошибок трудных». Нельзя предусмотреть всего — но можно выработать необходимый уровень технической интуиции.
|
||||
|
||||
@ -12,212 +12,88 @@
|
||||
|
||||
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; на практике такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реальном времени применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем подобной сложности было бы, однако, чересчур затруднительно.
|
||||
|
||||
Предположим, что мы решили предоставить партнёрам возможность готовить кофе по их собственным рецептам. Какова мотивация предоставления такой функциональности?
|
||||
Начнём с базового интерфейса. Предположим, что мы пока что вообще не раскрывали никакой функциональности помимо поиска предложений и заказа, т.е. мы предоставляем API из двух методов — `POST /offers/search` и `POST /orders`.
|
||||
|
||||
* возможно, партнерская сеть кофеен хочет предложить клиентам особенные «брендовые» напитки;
|
||||
* возможно, партнер хочет построить полностью своё приложение со своим ассортиментом на нашей платформе.
|
||||
Сделаем следующий логический шаг и предположим, что партнёры захотят динамически подключать к нашей платформе свои собственные кофе машины с каким-то новым API. Для этого нам будет необходимо предоставить им три новых эндпойта, посредством которых партнер сможет:
|
||||
* завести в системе словарь новых типов API;
|
||||
* завести в системе список своих кофе-машин с указанием типа;
|
||||
* договориться о формате обратного вызова, каким образом мы будем вызывать API партнёра.
|
||||
|
||||
В обоих вариантах нам необходимо начать с рецепта. Какие данные нам необходимы для того, чтобы партнёр мог добавить в систему новый рецепт? Вспомним, какие контексты связывает сущность «рецепт»: эта сущность нам нужна, чтобы связать выбор пользователя с правилами приготовления напитка. На первый взгляд может показаться, что именно так нам и следует описать сущность рецепта:
|
||||
Например, можно предоставить вот такие методы:
|
||||
|
||||
```
|
||||
// Добавляет новый рецепт
|
||||
POST /v1/recipes
|
||||
// 1. Зарегистрировать новый тип API
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Прочие параметры, описывающие
|
||||
// напиток для пользователя
|
||||
…
|
||||
},
|
||||
"execution_properties": {
|
||||
// Идентификатор программы
|
||||
"program_id",
|
||||
// Параметры исполнения программы
|
||||
"parameters"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
На первый взгляд, вполне разумный и простой интерфейс, который явно декомпозируется согласно уровням абстракции. Попробуем теперь представить, что произойдёт в будущем — как дальнейшее развитие функциональности повлияет на этот интерфейс.
|
||||
|
||||
Первая проблема очевидна тем, кто внимательно читал [главу 11](#chapter-11-paragraph-20): продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:
|
||||
|
||||
```
|
||||
"product_properties": {
|
||||
// "l10n" — стандартное сокращение
|
||||
// для "localization"
|
||||
"l10n" : [{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
}, /* другие языки и страны */ … ]
|
||||
]
|
||||
```
|
||||
|
||||
И здесь возникает первый большой вопрос — а что делать с `default_volume`? С одной стороны, это объективная величина, выраженная в стандартизированных единицах измерения, и она используется для запуска программы на исполнение. С другой стороны, для таких стран, как США, мы будем обязаны указать объём не в виде «300 мл», а в виде «10 унций». Мы можем предложить одно из двух решений:
|
||||
|
||||
* либо партнёр указывает только числовой объём, а числовые представления мы сделаем сами;
|
||||
* либо партнёр указывает и объём, и все его локализованные представления.
|
||||
|
||||
Первый вариант плох тем, что партнёр с помощью нашего API может как раз захотеть разработать сервис для какой-то новой страны или языка — и не сможет, пока локализация для этого региона не будет поддержана в самом API. Второй вариант плох тем, что сработает только для заранее заданных объёмов — заказать кофе произвольного объёма нельзя. И вот практически первым же действием мы сами загоняем себя в тупик.
|
||||
|
||||
Проблемами с локализацией, однако, недостатки дизайна этого API не заканчиваются. Следует задать себе вопрос — а *зачем* вообще здесь нужны `name` и `description`? Ведь это по сути просто строки, не имеющие никакой определённой семантики. На первый взгляд — чтобы возвращать их обратно из метода `/v1/search`, но ведь это тоже не ответ: а зачем эти строки возвращаются из `search`?
|
||||
|
||||
Корректный ответ — потому что существует некоторое представление, UI для выбора типа напитка. По-видимому, `name` и `description` — это просто два описания напитка, короткое (для показа в общем прейскуранте) и длинное (для показа расширенной информации о продукте). Получается, что мы устанавливаем требования на API исходя из вполне конкретного дизайна. Но что, если партнёр сам делает UI для своего приложения? Мало того, что ему могут быть не нужны два описания, так мы по сути ещё и вводим его в заблуждение: `name` — это не «какое-то» название, оно предполагает некоторые ограничения. Во-первых, у него есть некоторая рекомендованная длина, оптимальная для конкретного UI; во-вторых, оно должно консистентно выглядеть в одном списке с другими напитками. В самом деле, будет очень странно смотреться, если среди «Капучино», «Лунго» и «Латте» вдруг появится «Бодрящая свежесть» или «Наш самый качественный кофе».
|
||||
|
||||
Эта проблема разворачивается и в другую сторону — UI (наш или партнера) обязательно будет развиваться, в нём будут появляться новые элементы (картинка для кофе, его пищевая ценность, информация об аллергенах и так далее). `product_properties` со временем превратится в свалку из большого количества необязательных полей, и выяснить, задание каких из них приведёт к каким эффектам в каком приложении можно будет только методом проб и ошибок.
|
||||
|
||||
Проблемы, с которыми мы столкнулись — это проблемы *сильной связности*. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самому принципу проектирования API «сверху вниз», поскольку **низкоуровневые сущности не должны определять высокоуровневые**. Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность.
|
||||
|
||||
#### Правило контекстов
|
||||
|
||||
Выход из этого логического лабиринта таков: высокоуровневые сущности должны *определять контекст*, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.
|
||||
|
||||
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработало на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API есть функция форматирования строк для отображения объёма напитка:
|
||||
|
||||
```
|
||||
l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
```
|
||||
|
||||
Чтобы наше API корректно заработало с новым языком или регионом, партнер должен или задать эту функцию, или указать, какую из существующих локализаций необходимо использовать. Это можно сделать, например, так:
|
||||
|
||||
```
|
||||
// Добавляем общее правило форматирования
|
||||
// для русского языка
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Добавляем частное правило форматирования
|
||||
// для русского языка в регионе «США»
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// В США требуется сначала пересчитать
|
||||
// объём, потом добавить постфикс
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: мы, разумеется, в курсе, что таким простым форматом локализации единиц измерения в реальной жизни обойтись невозможно, и необходимо либо положиться на существующие библиотеки, либо разработать сложный формат описания (учитывающий, например, падежи слов и необходимую точность округления), либо принимать правила форматирования в императивном виде (т.е. в виде кода функции). Пример выше приведён исключительно в учебных целях.
|
||||
|
||||
Вернёмся теперь к проблеме `name` и `description`. Для того, чтобы снизить связность в этом аспекте, нужно прежде всего формализовать (возможно, для нас самих, необязательно во внешнем API) понятие «макета». Мы требуем `name` и `description` не просто так в вакууме, а чтобы представить их во вполне конкретном UI. Этому конкретному UI можно дать идентификатор или значимое имя.
|
||||
|
||||
```
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// Макетов вполне возможно будет много разных,
|
||||
// поэтому имеет смысл сразу заложить
|
||||
// расширяемоесть
|
||||
"kind": "recipe_search",
|
||||
// Описываем каждое свойство рецепта,
|
||||
// которое должно быть задано для
|
||||
// корректной работы макета
|
||||
"properties": [{
|
||||
// Раз уж мы договорились, что `name`
|
||||
// на самом деле нужен как заголовок
|
||||
// в списке результатов поиска —
|
||||
// разумнее его так и назвать `seach_title`
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Машиночитаемое описание того,
|
||||
// как будет показано поле
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
"order_execution_endpoint": {
|
||||
// Описание функции обратного вызова
|
||||
}
|
||||
}, …],
|
||||
// Какие поля обязательны
|
||||
"required": ["search_title", "search_description"]
|
||||
}
|
||||
```
|
||||
|
||||
Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:
|
||||
```
|
||||
PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
// 2. Предоставить список кофе-машин с разбивкой
|
||||
// по типу API
|
||||
PUT /v1/partners/{partnerId}/coffee-machines
|
||||
{
|
||||
"search_title", "search_description"
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes"
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
Либо создать свой макет и задавать нужные для него поля:
|
||||
Таким образом механика следующая:
|
||||
* партнер описывает свои виды API, кофе-машины и поддерживаемые рецепты;
|
||||
* при получении заказа, который необходимо выполнить на конкретной кофе машине, наш сервер вызывает указанную функцию обратного запуска и передаёт ей параметры в заранее согласованном виде.
|
||||
|
||||
```
|
||||
POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
```
|
||||
Теперь партнёры могут динамически подключать свои кофе-машины и обрабатывать заказы. Займёмся теперь, однако, вот каким упражнением:
|
||||
1. Перечислим все неявные предположения, которые мы допустили.
|
||||
2. Перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.
|
||||
|
||||
В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.
|
||||
Может показаться, что в нашем API нет ни того, ни другого, ведь оно очень просто и по сути просто сводится к вызову какого-то HTTP-метода — но это неправда. Список таких неявностей довольно велик.
|
||||
1. Информации, которую мы передаём на указанный партнёром эндпойнт, достаточно для запуска программы на исполнение.
|
||||
2. Предполагается, что каждая кофе-машина поддерживает все возможные опции заказа (например, допустимый объём напитка).
|
||||
3. Кофе-машины партнёра не требуется как-то специально выделять в списке результатов поиска, они имеют точно такую же карточку результата, как и предложения других партнеров, и ранжируются на общих основаниях.
|
||||
4. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.
|
||||
|
||||
Ту же самую технику — выделение отдельной сущности, которая занимается сопоставлением рецепта с его свойствами для нижележащих систем — мы можем использовать и для `execution_properties`, тем самым позволив партнеру контролировать ещё и то, каким образом рецепт связывается с программами исполнения. Тогда наш интерфейс получит вот такой вид:
|
||||
Все эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях объём кофе фиксирован — что должно измениться в нашем API?
|
||||
|
||||
```
|
||||
POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
{ "id" }
|
||||
```
|
||||
Универсальный паттерн внесения подобных изменений таков: мы должны рассмотреть существующий интерфейс как частный случай некоторого более общего, в котором значения некоторых параметров приняты известными по умолчанию, а потому опущены. Таким образом, внесение изменений всегда происходит в три шага:
|
||||
1. Явная фиксация программного контракта *в том объёме, в котором она действует на текущий момент*.
|
||||
2. Расширение функциональности: добавление нового метода, которые позволяют обойти ограничение, зафиксированное в п. 1.
|
||||
3. Объявление существующих вызовов (из п. 1) "хелперами" к новому формату (из п. 2), в которых значение новых опций считается равным значению по умолчанию.
|
||||
|
||||
Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом:
|
||||
На нашем примере с изменением списка доступных опций заказа:
|
||||
|
||||
```
|
||||
POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
// Задаём свойства рецепта
|
||||
"product_properties": {
|
||||
"default_volume",
|
||||
"l10n"
|
||||
},
|
||||
// Правила исполнения
|
||||
"execution_properties"
|
||||
// Создаём необходимые макеты
|
||||
"layouts": [{
|
||||
"id", "kind", "properties"
|
||||
}],
|
||||
// Добавляем нужные форматтеры
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
]
|
||||
},
|
||||
// Прочие действия, которые необходимо
|
||||
// выполнить для корректного заведения
|
||||
// нового рецепта в системе
|
||||
…
|
||||
}
|
||||
```
|
||||
1. Документируется текущее состояние. Все кофе-машины, подключаемые по API, обязаны поддерживать три опции: посыпку корицей, изменение объёма и бесконтактную выдачу.
|
||||
|
||||
Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнерских рецептов всегда пользоваться парой идентификаторов (партнера и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в [главе 11](#chapter-11-paragraph-8).
|
||||
2. Добавляем новый метод `with-options`:
|
||||
```
|
||||
PUT /v1/partners/{partnerId}/coffee-machines-with-options
|
||||
{
|
||||
"coffee_machines": [{
|
||||
"api_type",
|
||||
"location",
|
||||
"supported_recipes",
|
||||
"supported_options": [
|
||||
{"type": "volume_change"}
|
||||
]
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
```
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// Первая часть идентификатора:
|
||||
// например, в виде идентификатора клиента
|
||||
"namespace": "my-coffee-company",
|
||||
// Вторая часть идентификатора
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
```
|
||||
3. Объявляем, что вызов `PUT /coffee-machines`, как он представлен сейчас в протоколе, эквивалентен вызову `PUT /coffee-machines-with-options`, если в последний передать три опции — посыпку корицей, изменение объёма и бесконтактную выдачу, и, таким образом, является частным случаем — хелпером к более общему вызову.
|
||||
|
||||
Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такое API мы сможем использовать для организации нашей собственной панели управления контентом.)
|
||||
Часто вместо добавления нового метода можно добавить просто необязательный параметр к существующему — в нашем случае, можно добавить необязательный параметр `options` к вызову `PUT /cofee-machines`.
|
||||
|
||||
#### Границы применимости
|
||||
|
||||
Хотя это упражнение выглядит весьма простым и универсальным, его использование возможно только при наличии хорошо продуманной архитектуры сущностей и, что ещё более важного, понятного вектора дальнейшего развития API. Представим, что через какое-то время к поддерживаемым опциям добавились новые — ну, скажем, добавление сиропа и второго шота эспрессо. Список опций расширить мы можем — а вот изменить соглашение по умолчанию уже нет. Через некоторое время это приведёт к тому, что «дефолтный» интерфейс `PUT /coffee-machines` окажется никому не нужен, поскольку «дефолтный» список из трёх опций окажется не только редко востребованным, но и просто абсурдным — почему эти три, чем они лучше всех остальных? По сути значения по умолчанию и номенклатура старых методов начнут отражать исторические этапы развития нашего API, а это совершенно не то, чего мы хотели бы от номенклатуры хелперов и значений по умолчанию.
|
||||
|
||||
Увы, здесь мы сталкиваемся с плохо разрешимым противоречием: мы хотим, с одной стороны, чтобы разработчик писал лаконичный код, следовательно, должны предоставлять хорошие хелперные методы и значения по умолчанию. С другой, знать наперёд какими будут самые частотные наборы опций через несколько лет развития API — очень сложно.
|
||||
|
||||
**NB**. Замаскировать эту проблему можно так: в какой-то момент собрать все эти «странности» в одном месте и переопределить все значения по умолчанию скопом под одним параметром. Условно говоря, вызов одного метода, например, `PUT /defaults {"version": "v2"}` переопределяет все значения по умолчанию на более разумные. Это упростит порог входа и уменьшит количество вопросов, но документация от этого станет выглядеть только хуже.
|
||||
|
||||
В реальной жизни как-то нивелировать проблему помогает лишь слабая связность объектов, речь о которой пойдёт в следующей главе.
|
@ -1,201 +1,201 @@
|
||||
### Слабая связность
|
||||
|
||||
В предыдущей главе мы продемонстрировали, как разрыв сильной связанности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше [в главе 9](#chapter-9) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
|
||||
|
||||
```
|
||||
GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* описание способа запуска
|
||||
указанного рецепта на
|
||||
машинах с поддержкой
|
||||
указанного типа API */ }
|
||||
```
|
||||
|
||||
Тогда разработчикам пришлось бы сделать примерно следующее для запуска приготовления кофе:
|
||||
* выяснить тип API конкретной кофе-машины;
|
||||
* получить описание способа запуска программы выполнения рецепта на машине с API такого типа;
|
||||
* в зависимости от типа API выполнить специфические команды запуска.
|
||||
|
||||
Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофе-машина. Для того, чтобы не допустить такого плохого интерфейса мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».
|
||||
|
||||
Аналогичным образом устроена и сущность `program_run_id`, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.
|
||||
|
||||
Зададимся теперь, однако, более интересным вопросом. Наше учебное API позволяет создавать и запускать программы для вполне конкретных кофе-машин со вполне конкретным API. Предположим теперь, что к нам пришёл партнёр со своей сетью, которая представлена множеством различных кофе-машин, которые работают посредством множества различных API. Каким образом мы можем предоставить партнёру доступ к API программ, чтобы он мог сам подключать свои кофе-машины к системе?
|
||||
|
||||
Исходя из общей логики мы можем предположить, что любое API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:
|
||||
|
||||
```
|
||||
// Эндпойнт добавления списка
|
||||
// кофе-машин партнёра
|
||||
PUT /partners/{id}/coffee-machines
|
||||
{
|
||||
"coffee-machines": [{
|
||||
"id",
|
||||
…
|
||||
"program_api": {
|
||||
"program_run_endpoint": {
|
||||
/* Какое-то описание
|
||||
удалённого вызова эндпойнта */
|
||||
"type": "rpc",
|
||||
"endpoint": <URL>,
|
||||
"format"
|
||||
},
|
||||
"program_state_endpoint",
|
||||
"program_stop_endpoint"
|
||||
}
|
||||
}, …]
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в `program_run_endpoint`, и в каком формате должен отвечать `program_state_endpoint`, но в рамках этой главы мы сфокусируемся на других вопросах.)
|
||||
|
||||
Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы.
|
||||
1. Он хорошо описывает уже реализованные нами интеграции (т.е. в эту схему легко добавить поддержку известных нам типов API), но не привносит никакой гибкости в подход: по сути мы описали только известные нам способы интеграции, не попытавшись взглянуть на более общую картину.
|
||||
2. Этот дизайн изначально основан на следующем принципе: любое приготовление заказа можно описать этими тремя императивными командами.
|
||||
|
||||
Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — ну, например, попросить посыпать кофе корицей или выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, `program_modify_endpoint`, и новых сложностей в формате обмена данными (нам нужно уметь понимать в реальном времени, можно ли этот конкретный кофе посыпать корицей). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.
|
||||
|
||||
Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наше API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт `modify` и вся его обвязка для этого типа API бесполезны — автомат не умеет посыпать кофе корицей, а требование бесконтактной выдачи попросту ничего не значит. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа *подтверждения выдачи* напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.
|
||||
|
||||
Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, `program_takeout_endpoint`. И вот мы уже запутались в лесу из трёх эндпойнтов:
|
||||
* для работы вендинговых автоматов нужно реализовать эндпойнт `program_takeout_endpoint`, но не нужно реализовывать `program_modify_endpoint`;
|
||||
* для работы обычных кофеен нужно реализовать эндпойнт `program_modify_endpoint`, но не нужно реализовывать `program_takeout_endpoint`.
|
||||
|
||||
При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс `takeout` весьма специфичен. Если посыпку корицей мы как-то скрыли за общим `modify`, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.
|
||||
|
||||
Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что *всегда*, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятное и ясное API превращается в свалку из легаси-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни станут автоматическими — то мы быстро придём к ситуации, когда половина методов *вообще не нужна*, как метод запроса бесконтактной выдачи напитка.
|
||||
|
||||
Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятия «бесконтактная выдача», это по сути продуктовый термин.
|
||||
|
||||
Каким же образом мы можем решить эту проблему? Одним из двух способов: или досконально изучить предметную область и тренды её развития на несколько лет вперёд, или перейти от сильной связанности к слабой. Как выглядит идеальное решение с точки зрения обеих взаимодействующих сторон? Как-то так:
|
||||
* вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, с корицей, выдать такому-то пользователю;
|
||||
* нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.
|
||||
|
||||
Если мы посмотрим на принципы, описанные в предыдущей главе, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать *информационный контекст* на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в [разделе «Потоки данных»](#chapter-9).
|
||||
|
||||
В нашем конкретном примере нам нужно имплементировать следующие механизмы:
|
||||
* запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;
|
||||
* существует способ обмена информацией об изменении данных: исполнитель может читать контекст, узнавать о всех его изменениях и сообщать обратно о изменениях своего состояния.
|
||||
|
||||
Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае SDK эту идею можно было бы выразить так:
|
||||
|
||||
```
|
||||
/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофе-машинах */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Инициализируем запуск исполнения
|
||||
// программы на стороне партнера
|
||||
let execution = initExecution(…);
|
||||
// Подписываемся на события
|
||||
// изменения контекста
|
||||
program.context.on('takeout_requested', () => {
|
||||
// Если запрошена выдача напитка,
|
||||
// инициализируем выдачу
|
||||
execution.prepareTakeout(() => {
|
||||
// как только напиток готов к выдаче,
|
||||
// сигнализируем об этом
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
```
|
||||
|
||||
**NB**: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов чтения очередей событий типа `GET /program-run/events` и `GET /partner/{id}/execution/events`, это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SQS.
|
||||
|
||||
Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:
|
||||
* вместо вызова метода `takeout` мы теперь генерируем пару событий `takeout_requested`/`takeout_ready`;
|
||||
* вместо длинного списка методов, которые необходимо реализовать для интеграции API партнера, появляются длинные списки полей сущности `context` и событий, которые она генерирует;
|
||||
* проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.
|
||||
|
||||
Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны *вынуждены* имплементировать функциональность, которая в их контексте бессмысленна; и эта имплементация будет состоять из какого-то (хорошо если явного!) способа ответить, что данная функциональность не поддерживается (или поддерживается всегда и безусловно).
|
||||
|
||||
Разница между жёстким связыванием и слабым в данном случае состоит в том, что механизм полей и событий *не является обязывающим*. Вспомним, чего мы добивались:
|
||||
* верхнеуровневый контекст не знает, как устроено низкоуровневое API — и он действительно не знает; он описывает те изменения, которые происходят *в нём самом* и реагирует только на те события, которые имеют смысл *для него самого*;
|
||||
* низкоуровневый контекст не знает ничего об альтернативных реализациях — он обрабатывает только те события, которые имеют смысл на его уровне, и оповещает только о тех событиях, которые могут происходить в его конкретной реализации.
|
||||
|
||||
В пределе может вообще оказаться так, что обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.
|
||||
|
||||
Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст `program` содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст `execution` должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, `execution`-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как `program`-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.
|
||||
|
||||
Ещё одним важным свойством такой событийной связности является то, что она позволяет сущности иметь несколько вышестоящих контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвященном разработке SDK.
|
||||
|
||||
#### Инверсия ответственности
|
||||
|
||||
Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:
|
||||
|
||||
```
|
||||
/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофе-машинах */
|
||||
registerProgramRunHandler(apiType, (program) => {
|
||||
// Инициализируем запуск исполнения
|
||||
// программы на стороне партнера
|
||||
let execution = initExection(…);
|
||||
// Подписываемся на события
|
||||
// изменения контекста
|
||||
program.context.on('takeout_requested', () => {
|
||||
// Если запрошена выдача напитка,
|
||||
// инициализируем выдачу
|
||||
execution.prepareTakeout(() => {
|
||||
/* как только напиток готов к выдаче,
|
||||
сигнализируем об этом, но не
|
||||
посредством генерации события */
|
||||
// execution.context.emit('takeout_ready')
|
||||
program.context.set('takeout_ready');
|
||||
// Или ещё более жёстко:
|
||||
// program.setTakeoutReady();
|
||||
});
|
||||
});
|
||||
// Так как мы сами изменяем родительский контекст
|
||||
// нет нужды что-либо возвращать
|
||||
// return execution.context;
|
||||
}
|
||||
```
|
||||
|
||||
Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жестко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций *нижележащего* уровня абстракции. Ситуации, когда появляются альтернативные реализации *вышележащего* уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт сверху вниз.
|
||||
|
||||
Другой аспект заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:
|
||||
* если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнерами код;
|
||||
* если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API *бесполезно*. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.
|
||||
|
||||
В конечном итоге это приводит к тому, что API вышележащих сущностей меняется медленнее и более последовательно по сравнению с API нижележащих уровней, а значит подобного рода «обратная» жёсткая связь зачастую вполне допустима и даже желательна исходя из соотношения «цена-качество».
|
||||
|
||||
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Вместо обращения к вышестоящей сущности
|
||||
// или генерации события на себе,
|
||||
// компонент обращается к глобальному
|
||||
// состоянию и вызывает действия над ним
|
||||
dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
|
||||
Надо отметить, что такой подход *в принципе* не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жесткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Вместо обращения к вышестоящей сущности
|
||||
// или генерации события на себе,
|
||||
// компонент обращается к вышестоящему
|
||||
// объекту
|
||||
program.context.dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
```
|
||||
// Имплементация program.context.dispatch
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context обращается к своему
|
||||
// вышестоящему объекту, или к глобальному
|
||||
// состоянию, если такого объекта нет
|
||||
globalContext.dispatch(
|
||||
// При этом сама суть действия
|
||||
// может и должна быть переформулирована
|
||||
// в терминах соответствующего уровня
|
||||
// абстракции
|
||||
this.generateAction(action);
|
||||
)
|
||||
}
|
||||
```
|
||||
### Сильная связность и сопутствующие проблемы
|
||||
|
||||
Для демонстрации проблем сильной связности перейдём теперь к *действительно интересным* вещам. Продолжим наш «вариационный анализ»: что, если партнёры хотят не просто готовить кофе по стандартным рецептам, но и предлагать свои авторские напитки? Вопрос этот с подвохом: в том виде, как мы описали партнёрское API в предыдущей главе, факт существования партнерской сети никак не отражен в нашем API с точки зрения продукта, предлагаемого пользователю, а потому представляет собой довольно простой кейс. Если же мы пытаемся предоставить не какую-то дополнительную возможность, а модифицировать саму базовую функциональность API, то мы быстро столкнёмся с проблемами совсем другого порядка.
|
||||
|
||||
Итак, добавим ещё один эндпойнт — для регистрации собственного рецепта партнёра.
|
||||
|
||||
```
|
||||
// Добавляет новый рецепт
|
||||
POST /v1/recipes
|
||||
{
|
||||
"id",
|
||||
"product_properties": {
|
||||
"name",
|
||||
"description",
|
||||
"default_value"
|
||||
// Прочие параметры, описывающие
|
||||
// напиток для пользователя
|
||||
…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
На первый взгляд, вполне разумный и простой интерфейс, который явно декомпозируется согласно уровням абстракции. Попробуем теперь представить, что произойдёт в будущем — как дальнейшее развитие функциональности повлияет на этот интерфейс.
|
||||
|
||||
Первая проблема очевидна тем, кто внимательно читал [главу 11](#chapter-11-paragraph-20): продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:
|
||||
|
||||
```
|
||||
"product_properties": {
|
||||
// "l10n" — стандартное сокращение
|
||||
// для "localization"
|
||||
"l10n" : [{
|
||||
"language_code": "en",
|
||||
"country_code": "US",
|
||||
"name",
|
||||
"description"
|
||||
}, /* другие языки и страны */ … ]
|
||||
]
|
||||
```
|
||||
|
||||
И здесь возникает первый большой вопрос — а что делать с `default_volume`? С одной стороны, это объективная величина, выраженная в стандартизированных единицах измерения, и она используется для запуска программы на исполнение. С другой стороны, для таких стран, как США, мы будем обязаны указать объём не в виде «300 мл», а в виде «10 унций». Мы можем предложить одно из двух решений:
|
||||
|
||||
* либо партнёр указывает только числовой объём, а числовые представления мы сделаем сами;
|
||||
* либо партнёр указывает и объём, и все его локализованные представления.
|
||||
|
||||
Первый вариант плох тем, что партнёр с помощью нашего API может как раз захотеть разработать сервис для какой-то новой страны или языка — и не сможет, пока локализация для этого региона не будет поддержана в самом API. Второй вариант плох тем, что сработает только для заранее заданных объёмов — заказать кофе произвольного объёма нельзя. И вот практически первым же действием мы сами загоняем себя в тупик.
|
||||
|
||||
Проблемами с локализацией, однако, недостатки дизайна этого API не заканчиваются. Следует задать себе вопрос — а *зачем* вообще здесь нужны `name` и `description`? Ведь это по сути просто строки, не имеющие никакой определённой семантики. На первый взгляд — чтобы возвращать их обратно из метода `/v1/search`, но ведь это тоже не ответ: а зачем эти строки возвращаются из `search`?
|
||||
|
||||
Корректный ответ — потому что существует некоторое представление, UI для выбора типа напитка. По-видимому, `name` и `description` — это просто два описания напитка, короткое (для показа в общем прейскуранте) и длинное (для показа расширенной информации о продукте). Получается, что мы устанавливаем требования на API исходя из вполне конкретного дизайна. Но что, если партнёр сам делает UI для своего приложения? Мало того, что ему могут быть не нужны два описания, так мы по сути ещё и вводим его в заблуждение: `name` — это не «какое-то» название, оно предполагает некоторые ограничения. Во-первых, у него есть некоторая рекомендованная длина, оптимальная для конкретного UI; во-вторых, оно должно консистентно выглядеть в одном списке с другими напитками. В самом деле, будет очень странно смотреться, если среди «Капучино», «Лунго» и «Латте» вдруг появится «Бодрящая свежесть» или «Наш самый качественный кофе».
|
||||
|
||||
Эта проблема разворачивается и в другую сторону — UI (наш или партнера) обязательно будет развиваться, в нём будут появляться новые элементы (картинка для кофе, его пищевая ценность, информация об аллергенах и так далее). `product_properties` со временем превратится в свалку из большого количества необязательных полей, и выяснить, задание каких из них приведёт к каким эффектам в каком приложении можно будет только методом проб и ошибок.
|
||||
|
||||
Проблемы, с которыми мы столкнулись — это проблемы *сильной связности*. Каждый раз, предлагая интерфейс, подобный вышеприведённому, мы фактически описываем имплементацию одной сущности (рецепта) через имплементации других (визуального макета, правил локализации). Этот подход противоречит самому принципу проектирования API «сверху вниз», поскольку **низкоуровневые сущности не должны определять высокоуровневые**. Как бы парадоксально это ни звучало, обратное утверждение тоже верно: высокоуровневые сущности тоже не должны определять низкоуровневые. Это попросту не их ответственность.
|
||||
|
||||
#### Правило контекстов
|
||||
|
||||
Выход из этого логического лабиринта таков: высокоуровневые сущности должны *определять контекст*, который другие объекты будут интерпретировать. Чтобы спроектировать добавление нового рецепта нам нужно не формат данных подобрать — нам нужно понять, какие (возможно, неявные, т.е. не представленные в виде API) контексты существуют в нашей предметной области.
|
||||
|
||||
Как уже понятно, существует контекст локализации. Есть какой-то набор языков и регионов, которые мы поддерживаем в нашем API, и есть требования — что конкретно необходимо предоставить партнёру, чтобы API заработало на новом языке в новом регионе. Конкретно в случае объёма кофе где-то в недрах нашего API есть функция форматирования строк для отображения объёма напитка:
|
||||
|
||||
```
|
||||
l10n.volume.format(value, language_code, country_code)
|
||||
// l10n.formatVolume('300ml', 'en', 'UK') → '300 ml'
|
||||
// l10n.formatVolume('300ml', 'en', 'US') → '10 fl oz'
|
||||
```
|
||||
|
||||
Чтобы наше API корректно заработал с новым языком или регионом, партнер должен или задать эту функцию, или указать, какую из существующих локализаций необходимо использовать. Для этого мы абстрагируем-и-расширяем API, в соответствии с описанной в предыдущей главе процедурой, и добавляем новый эндпойнт — настройки форматирования:
|
||||
|
||||
```
|
||||
// Добавляем общее правило форматирования
|
||||
// для русского языка
|
||||
PUT /formatters/volume/ru
|
||||
{
|
||||
"template": "{volume} мл"
|
||||
}
|
||||
// Добавляем частное правило форматирования
|
||||
// для русского языка в регионе «США»
|
||||
PUT /formatters/volume/ru/US
|
||||
{
|
||||
// В США требуется сначала пересчитать
|
||||
// объём, потом добавить постфикс
|
||||
"value_preparation": {
|
||||
"action": "divide",
|
||||
"divisor": 30
|
||||
},
|
||||
"template": "{volume} ун."
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: мы, разумеется, в курсе, что таким простым форматом локализации единиц измерения в реальной жизни обойтись невозможно, и необходимо либо положиться на существующие библиотеки, либо разработать сложный формат описания (учитывающий, например, падежи слов и необходимую точность округления), либо принимать правила форматирования в императивном виде (т.е. в виде кода функции). Пример выше приведён исключительно в учебных целях.
|
||||
|
||||
Вернёмся теперь к проблеме `name` и `description`. Для того, чтобы снизить связность в этом аспекте, нужно прежде всего формализовать (возможно, для нас самих, необязательно во внешнем API) понятие «макета». Мы требуем `name` и `description` не просто так в вакууме, а чтобы представить их во вполне конкретном UI. Этому конкретному UI можно дать идентификатор или значимое имя.
|
||||
|
||||
```
|
||||
GET /v1/layouts/{layout_id}
|
||||
{
|
||||
"id",
|
||||
// Макетов вполне возможно будет много разных,
|
||||
// поэтому имеет смысл сразу заложить
|
||||
// расширяемоесть
|
||||
"kind": "recipe_search",
|
||||
// Описываем каждое свойство рецепта,
|
||||
// которое должно быть задано для
|
||||
// корректной работы макета
|
||||
"properties": [{
|
||||
// Раз уж мы договорились, что `name`
|
||||
// на самом деле нужен как заголовок
|
||||
// в списке результатов поиска —
|
||||
// разумнее его так и назвать `seach_title`
|
||||
"field": "search_title",
|
||||
"view": {
|
||||
// Машиночитаемое описание того,
|
||||
// как будет показано поле
|
||||
"min_length": "5em",
|
||||
"max_length": "20em",
|
||||
"overflow": "ellipsis"
|
||||
}
|
||||
}, …],
|
||||
// Какие поля обязательны
|
||||
"required": ["search_title", "search_description"]
|
||||
}
|
||||
```
|
||||
|
||||
Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:
|
||||
|
||||
```
|
||||
PUT /v1/recipes/{id}/properties/l10n/{lang}
|
||||
{
|
||||
"search_title", "search_description"
|
||||
}
|
||||
```
|
||||
|
||||
Либо создать свой макет и задавать нужные для него поля:
|
||||
|
||||
```
|
||||
POST /v1/layouts
|
||||
{
|
||||
"properties"
|
||||
}
|
||||
→
|
||||
{ "id", "properties" }
|
||||
```
|
||||
|
||||
В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.
|
||||
|
||||
Наш интерфейс добавления рецепта получит в итоге вот такой вид:
|
||||
|
||||
```
|
||||
POST /v1/recipes
|
||||
{ "id" }
|
||||
→
|
||||
{ "id" }
|
||||
```
|
||||
|
||||
Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом:
|
||||
|
||||
```
|
||||
POST /v1/recipe-builder
|
||||
{
|
||||
"id",
|
||||
// Задаём свойства рецепта
|
||||
"product_properties": {
|
||||
"default_volume",
|
||||
"l10n"
|
||||
},
|
||||
// Создаём необходимые макеты
|
||||
"layouts": [{
|
||||
"id", "kind", "properties"
|
||||
}],
|
||||
// Добавляем нужные форматтеры
|
||||
"formatters": {
|
||||
"volume": [
|
||||
{ "language_code", "template" },
|
||||
{ "language_code", "country_code", "template" }
|
||||
]
|
||||
},
|
||||
// Прочие действия, которые необходимо
|
||||
// выполнить для корректного заведения
|
||||
// нового рецепта в системе
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнерских рецептов всегда пользоваться парой идентификаторов (партнера и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в [главе 11](#chapter-11-paragraph-8).
|
||||
|
||||
```
|
||||
POST /v1/recipes/custom
|
||||
{
|
||||
// Первая часть идентификатора:
|
||||
// например, в виде идентификатора клиента
|
||||
"namespace": "my-coffee-company",
|
||||
// Вторая часть идентификатора
|
||||
"id_component": "lungo-customato"
|
||||
}
|
||||
→
|
||||
{
|
||||
"id": "my-coffee-company:lungo-customato"
|
||||
}
|
||||
```
|
||||
|
||||
Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такое API мы сможем использовать для организации нашей собственной панели управления контентом.)
|
||||
|
@ -1,136 +1,206 @@
|
||||
### Расширение через абстрагирование
|
||||
|
||||
Попробуем теперь по шагам воспроизвести последовательность шагов, которые мы сделали бы в реальном мире для предоставления функциональности подключения новых видов кофе-машин к нашей платформе. Предположим, что изначально наше API вообще не предоставляло никакого доступа к управлению запуском программ — единственными эндпойнтами, доступным клиенту, были бы функции поиска предложений и создания заказа.
|
||||
|
||||
```
|
||||
let searchResults = api.search({
|
||||
/* выбранные пользователем параметры */
|
||||
});
|
||||
|
||||
/* пользователь как-то взаимодействует
|
||||
с результатами поиска и выбирает
|
||||
конкретное предложение */
|
||||
|
||||
let selectedResult = userPick(searchResults);
|
||||
|
||||
api.postOrder({
|
||||
offer: selectedResult.offer
|
||||
});
|
||||
```
|
||||
|
||||
На данном этапе вообще нет таких понятий, как «идентификатор кофе-машины» или «идентификатор рецепта» — достаточно передать только лишь оффер. Нужные идентификаторы могут храниться в базе данных по идентификатору оффера или быть закодированными в самом идентификаторе.
|
||||
|
||||
**NB**: тем не менее, обратите внимание не то, что, хотя никакие данные помимо оффера для заказа не нужны, мы, тем не менее, оставили возможность эти данные передавать: функция placeOrder принимает не offerId, а потенциально расширяемую структуру данных.
|
||||
|
||||
API для регистрации своего обработчика запуска программ мы предложили в предыдущей главе:
|
||||
|
||||
```
|
||||
api.registerProgramRunHandler(
|
||||
apiType,
|
||||
function (program) {
|
||||
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
Заметим, что партнёр мог бы не пользоваться ни одной из вышеперечисленных функций (`search`, `postOrder`, `registerProgramHandler`) и попросту написать реализацию выбора и заказа напитка сам. Тогда, правда, возникает вопрос, зачем ему вообще тогда нужно наше API (см. «какую проблему мы решаем», [Глава 8](#chapter-8)). Предположим всё-таки, что партнёр не строит полностью свой сервис, а только встраивает свои собственные кофейни в нашу платформу. Как и раньше, мы рассматриваем динамическое связывание на клиенте в реальном времени в учебных целях, в реальной жизни такая функциональность предоставлялась бы как серверное партнёрское API.
|
||||
|
||||
Для полностью рабочей интеграции на данном этапе осталось два шага:
|
||||
* указать соответствие рецептов и программ для нового типа API;
|
||||
* предоставить список партнерских кофе-машин.
|
||||
|
||||
```
|
||||
api.setRecipePrograms(apiType, [{
|
||||
"recipe_id",
|
||||
"programRunParameters": {
|
||||
/* какое-то описание
|
||||
параметров запуска
|
||||
программы */
|
||||
}
|
||||
}]);
|
||||
|
||||
/* Во избежание коллизий сразу
|
||||
заводим составные идентификаторы,
|
||||
поэтому функция должна
|
||||
их в каком-то виде возвращать */
|
||||
let coffeeMachinesList =
|
||||
api.coffeeMachines.putList({
|
||||
namespace: partnerId,
|
||||
coffeeMachines: [{
|
||||
internalId,
|
||||
apiType
|
||||
}, …]
|
||||
});
|
||||
```
|
||||
|
||||
Теперь вся конструкция должна работать. Займёмся теперь, однако, вот каким упражнением:
|
||||
1. Перечислим все неявные предположения, которые мы допустили.
|
||||
2. Перечислим все неявные механизмы связывания, которые необходимы для функционирования платформы.
|
||||
|
||||
Может показаться, что в нашем API нет ни того, ни другого — но это неправда. Список таких неявностей довольно велик.
|
||||
1. Cтатической информации, которую мы передаём внутри `supportedRecipes.program`, достаточно для запуска программы на исполнение, и она не зависит от конкретной кофе-машины.
|
||||
2. Каждая кофе-машина поддерживает все возможные параметры рецепта (например, допустимый объём напитка).
|
||||
3. Кофе-машины партнёра не требуется как-то специально выделять в списке результатов поиска, они имеют точно такую же карточку результата, как и предложения других партнеров, и ранжируются на общих основаниях.
|
||||
4. Обработчик `postOrder` производит все необходимые проверки (например, действительно ли на данной кофе-машине можно сварить кофе по указанному рецепту), в том числе определяет тип API по офферу, и инициирует запуск нужной программы.
|
||||
5. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.
|
||||
|
||||
Все эти пункты мы выписали с одной целью: нам нужно понять, каким конкретно образом мы будем переводить неявные договорённости в явные, если нам это потребуется. Например, если разные кофе-машины предоставляют разный объём функциональности — допустим, в каких-то кофейнях не посыпают корицей — что должно измениться в нашем API?
|
||||
|
||||
Самое простое решение, шаг 1: при регистрации кофе машины указать, какие опции она поддерживает.
|
||||
|
||||
Следующий вопрос: а *что, если* корицей вообще-то посыпают, но прямо сейчас она закончилась? Исходя из изложенного в предыдущей главе, нам тогда каким-то образом необходимо организовать поток обновлений состояния кофе-машин — это шаг 2.
|
||||
|
||||
А *что, если* корица закончилась в промежутке времени между получением заказа и его исполнением? Нужна тогда обратная связь, сообщение от уровня исполнения о невозможности исполнить заказ — это шаг 3.
|
||||
|
||||
На первый взгляд, на каждом шаге мы гадаем на кофейной гуще. Если нужно будет реализовать вот это, то мы поступим вот так. Но, если присмотреться внимательно, то закономерность можно найти: мы каждый раз берём какой-то частный случай и заменяем его более общим.
|
||||
|
||||
На шаге 2 нам нужно имплементировать две операции: (а) получение текущего списка поддерживаемых опций, (б) его динамическое обновление по наступлению какого-то события. Но ведь операции (а) нам как раз было бы достаточно, чтобы реализовать функциональность шага 1, т.е. предоставить информацию о поддерживаемых опциях статически.
|
||||
|
||||
На шаге 3 нам нужно на уровне запуска программ имплементировать поддержку определения количества доступной корицы и ввести какой-то сигнал о его изменении. Как только мы это сделаем — мы немедленно сможем реализовать операции (а) и (б), необходимые нам на шаге 2.
|
||||
|
||||
Если бы мы начинали сразу с шага 3, то никакой проблемы предоставить функциональность в объёме шагов 1 и 2 у нас бы не возникло. Но вот обратное неверно: мы можем так реализовать предыдущий шаг, что последующий шаг потребует введения излишних концепций или просто окажется невозможен без слома обратной совместимости. Например, если на шаге 1 мы привяжем определение доступности опции к офферу:
|
||||
|
||||
```
|
||||
// возвращает список доступных опций
|
||||
selectedResult.offer.getAvailableOptions()
|
||||
```
|
||||
|
||||
Такое связывание прекрасно работает, пока мы оперируем неизменяемым фактом наличия или отсутствия поддержки опции. Но на шаге 3 такой интерфейс начинает вызывать большие вопросы: должен ли этот метод продолжать работать, если по офферу был сделан заказ? Если да, то, выходит, оффер должен уметь обратиться к рантайму кофе-машины, для которой был создан — что абсолютно точно вне его области ответственности. Если нет — тогда у нас получится два разных метода `getAvailableOptions` у двух разных объектов (один из них оффер, а второй, по-видимому, придётся привязать к сущности «заказ»), один имеет смысл до заказа, а другой — после (и тут ещё надо каким-то образом определить, что считается моментом заказа); и нам придётся ещё как-то объяснять, когда пользоваться первым, а когда вторым, и почему они могут вернуть разный результат.
|
||||
|
||||
Для того, чтобы избежать подобного рода проблем, нужно научиться «думать с конца»: предоставляя любой интерфейс нужно думать о нём как о частной реализации какой-то более общей логики, как о некотором шорткате или хэлпере для облегчения жизни разработчика, чтобы ему не приходилось зарываться в более слои документации. В частности, нужно чётко понимать, откуда финально берётся та информация, которую мы предоставляем через «шорткат»: указанной ошибки в связывании опций с оффером можно легко избежать, если вспомнить о том, что информация о наличии корицы приходит откуда-то из реального мира, или от датчика, или от баристы, никак не из нашей виртуальной системы офферов.
|
||||
|
||||
Вернёмся теперь к списку неявностей, который мы сформулировали в начале главы, и попробуем применить сформулированный выше принцип к каждому из пунктов.
|
||||
|
||||
1. Cтатической информации, которую мы передаём внутри `supportedRecipes.program`, достаточно для запуска программы на исполнение, и она не зависит от конкретной кофе-машины.
|
||||
|
||||
Статическая передача параметров в program — это частный случай динамической. Мы можем дать возможность сформировать параметры запуска программы динамически, а передачу `program` в `setRecipePrograms` объявить просто шорткатом для тех ситуаций, когда дополнительно формировать параметры не требуется. Например, так:
|
||||
|
||||
```
|
||||
api.setRecipePrograms(apiType, [{
|
||||
"recipe_id",
|
||||
/* Можно задать одно из двух,
|
||||
либо поле `program`
|
||||
"programRunParameters": { … }
|
||||
либо функцию формирования
|
||||
параметров запуска */
|
||||
"programRunParametersGenerator":
|
||||
function (order) {
|
||||
return {
|
||||
// Это статический параметр
|
||||
"program_id": 123,
|
||||
// А это динамический
|
||||
"volume": order.volume
|
||||
};
|
||||
}
|
||||
}]);
|
||||
```
|
||||
|
||||
**NB**: альтернативно можно развивать декларативный формат описания параметров, разрешив в нём подстановки и вычисления. Это упражнение мы оставим читателю.
|
||||
|
||||
2. Каждая кофе-машина поддерживает все возможные параметры рецепта (например, допустимый объём напитка).
|
||||
|
||||
Поддержка всех возможных параметров - это частный случай. Мы предоставили шорткат для того, чтобы
|
||||
|
||||
3. Кофе-машины партнёра не требуется как-то специально выделять в списке результатов поиска, они имеют точно такую же карточку результата, как и предложения других партнеров, и ранжируются на общих основаниях;
|
||||
4. Обработчик `postOrder` производит все необходимые проверки (например, действительно ли на данной кофе-машине можно сварить кофе по указанному рецепту), в том числе определяет тип API по офферу, и инициирует запуск нужной программы;
|
||||
5. Цена напитка не зависит ни от партнёра, ни от типа кофе-машины.
|
||||
### Слабая связность
|
||||
|
||||
В предыдущей главе мы продемонстрировали, как разрыв сильной связанности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше [в главе 9](#chapter-9) на примере сущностей «программа» и «запуск программы». В самом деле, мы могли бы обойтись без программ и без эндпойнта `program-matcher` и пойти вот таким путём:
|
||||
|
||||
```
|
||||
GET /v1/recipes/{id}/run-data/{api_type}
|
||||
→
|
||||
{ /* описание способа запуска
|
||||
указанного рецепта на
|
||||
машинах с поддержкой
|
||||
указанного типа API */ }
|
||||
```
|
||||
|
||||
Тогда разработчикам пришлось бы сделать примерно следующее для запуска приготовления кофе:
|
||||
* выяснить тип API конкретной кофе-машины;
|
||||
* получить описание способа запуска программы выполнения рецепта на машине с API такого типа;
|
||||
* в зависимости от типа API выполнить специфические команды запуска.
|
||||
|
||||
Очевидно, что такой интерфейс совершенно недопустим — просто потому, что в подавляющем большинстве случаев разработчикам совершенно неинтересно, какого рода API поддерживает та или иная кофе-машина. Для того, чтобы не допустить такого плохого интерфейса мы ввели новую сущность «программа», которая по факту представляет собой не более чем просто идентификатор контекста, как и сущность «рецепт».
|
||||
|
||||
Аналогичным образом устроена и сущность `program_run_id`, идентификатор запуска программы. Он также по сути не имеет почти никакого интерфейса и состоит только из идентификатора запуска.
|
||||
|
||||
Вернёмся теперь к вопросу, который мы вскользь затронули две главы назад — каким образом нам параметризовать приготовление заказа, если оно исполняется через сторонний API. Иными словами, что такое этот самый `program_execution_endpoint`, передавать который мы потребовали при регистрации нового типа API?
|
||||
|
||||
```
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint": {
|
||||
// ???
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Исходя из общей логики мы можем предположить, что любое API так или иначе будет выполнять три функции: запускать программы с указанными параметрами, возвращать текущий статус запуска и завершать (отменять) заказ. Самый очевидный подход к реализации такого API — просто потребовать от партнёра имплементировать вызов этих трёх функций удалённо, например следующим образом:
|
||||
|
||||
```
|
||||
// Эндпойнт добавления списка
|
||||
// кофе-машин партнёра
|
||||
PUT /v1/api-types/{api_type}
|
||||
{
|
||||
"order_execution_endpoint":
|
||||
"program_run_endpoint": {
|
||||
/* Какое-то описание
|
||||
удалённого вызова эндпойнта */
|
||||
"type": "rpc",
|
||||
"endpoint": <URL>,
|
||||
"format"
|
||||
},
|
||||
"program_state_endpoint",
|
||||
"program_stop_endpoint"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: во многом таким образом мы переносим сложность разработки API в плоскость разработки форматов данных (каким образом мы будем передавать параметры запуска в `program_run_endpoint`, и в каком формате должен отвечать `program_state_endpoint`, но в рамках этой главы мы сфокусируемся на других вопросах.)
|
||||
|
||||
Хотя это API и кажется абсолютно универсальным, на его примере можно легко показать, каким образом изначально простые и понятные API превращаются в сложные и запутанные. У этого дизайна есть две основные проблемы.
|
||||
1. Он хорошо описывает уже реализованные нами интеграции (т.е. в эту схему легко добавить поддержку известных нам типов API), но не привносит никакой гибкости в подход: по сути мы описали только известные нам способы интеграции, не попытавшись взглянуть на более общую картину.
|
||||
2. Этот дизайн изначально основан на следующем принципе: любое приготовление заказа можно описать этими тремя императивными командами.
|
||||
|
||||
Пункт 2 очень легко опровергнуть, что автоматически вскроет проблемы пункта 1. Предположим для начала, что в ходе развития функциональности мы решили дать пользователю возможность изменять свой заказ уже после того, как он создан — ну, например, попросить посыпать кофе корицей или выдать заказ бесконтактно. Это автоматически влечёт за собой добавление нового эндпойнта, ну скажем, `program_modify_endpoint`, и новых сложностей в формате обмена данными (нам нужно уметь понимать в реальном времени, можно ли этот конкретный кофе посыпать корицей). Что важно, и то, и другое (и эндпойнт, и новые поля данных) из соображений обратной совместимости будут необязательными.
|
||||
|
||||
Теперь попытаемся придумать какой-нибудь пример реального мира, который не описывается нашими тремя императивами. Это довольно легко: допустим, мы подключим через наше API не кофейню, а вендинговый автомат. Это, с одной стороны, означает, что эндпойнт `modify` и вся его обвязка для этого типа API бесполезны — автомат не умеет посыпать кофе корицей, а требование бесконтактной выдачи попросту ничего не значит. С другой, автомат, в отличие от оперируемой людьми кофейни, требует программного способа *подтверждения выдачи* напитка: пользователь делает заказ, находясь где-то в другом месте, потом доходит до автомата и нажимает в приложении кнопку «выдать заказ». Мы могли бы, конечно, потребовать, чтобы пользователь создавал заказ автомату, стоя прямо перед ним, но это, в свою очередь, противоречит нашей изначальной концепции, в которой пользователь выбирает и заказывает напиток, исходя из доступных опций, а потом идёт в указанную точку, чтобы его забрать.
|
||||
|
||||
Программная выдача напитка потребует добавления ещё одного эндпойнта, ну скажем, `program_takeout_endpoint`. И вот мы уже запутались в лесу из трёх эндпойнтов:
|
||||
* для работы вендинговых автоматов нужно реализовать эндпойнт `program_takeout_endpoint`, но не нужно реализовывать `program_modify_endpoint`;
|
||||
* для работы обычных кофеен нужно реализовать эндпойнт `program_modify_endpoint`, но не нужно реализовывать `program_takeout_endpoint`.
|
||||
|
||||
При этом в документации интерфейса мы опишем и тот, и другой эндпойнт. Как несложно заметить, интерфейс `takeout` весьма специфичен. Если посыпку корицей мы как-то скрыли за общим `modify`, то на вот такие операции типа подтверждения выдачи нам каждый раз придётся заводить новый метод с уникальным названием. Несложно представить себе, как через несколько итераций интерфейс превратится в свалку из визуально похожих методов, притом формально необязательных — но для подключения своего API нужно будет прочитать документацию каждого и разобраться в том, нужен ли он в конкретной ситуации или нет.
|
||||
|
||||
Мы не знаем, правда ли в реальном мире API кофемашин возникнет проблема, подобная описанной. Но мы можем сказать со всей уверенностью, что *всегда*, когда речь идёт об интеграции «железного» уровня, происходят именно те процессы, которые мы описали: меняется нижележащая технология, и вроде бы понятное и ясное API превращается в свалку из легаси-методов, половина из которых не несёт в себе никакого практического смысла в рамках конкретной интеграции. Если мы добавим к проблеме ещё и технический прогресс — представим, например, что со временем все кофейни станут автоматическими — то мы быстро придём к ситуации, когда половина методов *вообще не нужна*, как метод запроса бесконтактной выдачи напитка.
|
||||
|
||||
Заметим также, что мы невольно начали нарушать принцип изоляции уровней абстракции. На уровне API вендингового автомата вообще не существует понятия «бесконтактная выдача», это по сути продуктовый термин.
|
||||
|
||||
Каким же образом мы можем решить эту проблему? Одним из двух способов: или досконально изучить предметную область и тренды её развития на несколько лет вперёд, или перейти от сильной связанности к слабой. Как выглядит идеальное решение с точки зрения обеих взаимодействующих сторон? Как-то так:
|
||||
* вышестоящий API программ не знает, как устроен уровень исполнения его команд; он формулирует задание так, как понимает на своём уровне: сварить такой-то кофе такого-то объёма, с корицей, выдать такому-то пользователю;
|
||||
* нижележащий API исполнения программ не заботится о том, какие ещё вокруг бывают API того же уровня; он трактует только ту часть задания, которая имеет для него смысл.
|
||||
|
||||
Если мы посмотрим на принципы, описанные в предыдущей главе, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать *информационный контекст* на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в [разделе «Потоки данных»](#chapter-9).
|
||||
|
||||
В нашем конкретном примере нам нужно имплементировать следующие механизмы:
|
||||
* запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;
|
||||
* существует способ обмена информацией об изменении данных: исполнитель может читать контекст, узнавать о всех его изменениях и сообщать обратно о изменениях своего состояния.
|
||||
|
||||
Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае SDK эту идею можно было бы выразить так:
|
||||
|
||||
```
|
||||
/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофе-машинах */
|
||||
registerProgramRunHandler(apiType, (context) => {
|
||||
// Инициализируем запуск исполнения
|
||||
// программы на стороне партнера
|
||||
let execution = initExecution(context, …);
|
||||
// Подписываемся на события
|
||||
// изменения контекста
|
||||
context.on('takeout_requested', () => {
|
||||
// Если запрошена выдача напитка,
|
||||
// инициализируем выдачу
|
||||
execution.prepareTakeout(() => {
|
||||
// как только напиток готов к выдаче,
|
||||
// сигнализируем об этом
|
||||
execution.context.emit('takeout_ready');
|
||||
});
|
||||
});
|
||||
|
||||
return execution.context;
|
||||
});
|
||||
```
|
||||
|
||||
**NB**: в случае HTTP API соответствующий пример будет выглядеть более громоздко, поскольку потребует создания отдельных эндпойнтов чтения очередей событий типа `GET /program-run/events` и `GET /partner/{id}/execution/events`, это упражнение мы оставляем читателю. Следует также отметить, что в реальных системах потоки событий часто направляют через внешнюю шину типа Apache Kafka или Amazon SNS/SQS.
|
||||
|
||||
Внимательный читатель может возразить нам, что фактически, если мы посмотрим на номенклатуру возникающих сущностей, мы ничего не изменили в постановке задачи, и даже усложнили её:
|
||||
* вместо вызова метода `takeout` мы теперь генерируем пару событий `takeout_requested`/`takeout_ready`;
|
||||
* вместо длинного списка методов, которые необходимо реализовать для интеграции API партнера, появляются длинные списки полей сущности `context` и событий, которые она генерирует;
|
||||
* проблема устаревания технологии не меняется, вместо устаревших методов мы теперь имеем устаревшие поля и события.
|
||||
|
||||
Это замечание совершенно верно. Изменение формата API само по себе не решает проблем, связанных с эволюцией функциональности и нижележащей технологии. Формат API решает другую проблему: как оставить при этом код читаемым и поддерживаемым. Почему в примере с интеграцией через методы код становится нечитаемым? Потому что обе стороны *вынуждены* имплементировать функциональность, которая в их контексте бессмысленна; и эта имплементация будет состоять из какого-то (хорошо если явного!) способа ответить, что данная функциональность не поддерживается (или, наоборот, поддерживается всегда и безусловно).
|
||||
|
||||
Разница между жёстким связыванием и слабым в данном случае состоит в том, что механизм полей и событий *не является обязывающим*. Вспомним, чего мы добивались:
|
||||
* верхнеуровневый контекст не знает, как устроено низкоуровневое API — и он действительно не знает; он описывает те изменения, которые происходят *в нём самом* и реагирует только на те события, которые имеют смысл *для него самого*;
|
||||
* низкоуровневый контекст не знает ничего об альтернативных реализациях — он обрабатывает только те события, которые имеют смысл на его уровне, и оповещает только о тех событиях, которые могут происходить в его конкретной реализации.
|
||||
|
||||
В пределе может вообще оказаться так, что обе стороны вообще ничего не знают друг о друге и никак не взаимодействуют — не исключаем, что на каком-то этапе развития технологии именно так и произойдёт.
|
||||
|
||||
Важно также отметить, что, хотя количество сущностей (полей, событий) эффективно удваивается по сравнению с сильно связанным API, это удвоение является качественным, а не количественным. Контекст `program` содержит описание задания в своих терминах (вид напитка, объём, посыпка корицей); контекст `execution` должен эти термины переформулировать для своей предметной области (чтобы быть, в свою очередь, таким же информационным контекстом для ещё более низкоуровневого API). Что важно, `execution`-контекст имеет право эти термины конкретизировать, поскольку его нижележащие объекты будут уже работать в рамках какого-то конкретного API, в то время как `program`-контекст обязан выражаться в общих терминах, применимых к любой возможной нижележащей технологии.
|
||||
|
||||
Ещё одним важным свойством такой событийной связности является то, что она позволяет сущности иметь несколько родительских контекстов. В обычных предметных областях такая ситуация выглядела бы ошибкой дизайна API, но в сложных системах, где присутствуют одновременно несколько агентов, влияющих на состояние системы, такая ситуация не является редкостью. В частности, вы почти наверняка столкнётесь с такого рода проблемами при разработке пользовательского UI. Более подробно о подобных двойных иерархиях мы расскажем в разделе, посвященном разработке SDK.
|
||||
|
||||
#### Инверсия ответственности
|
||||
|
||||
Как несложно понять из вышесказанного, двусторонняя слабая связь означает существенное усложнение имплементации обоих уровней, что во многих ситуациях может оказаться излишним. Часто двустороннюю слабую связь можно без потери качества заменить на одностороннюю, а именно — разрешить нижележащей сущности вместо генерации событий напрямую вызывать методы из интерфейса более высокого уровня. Наш пример изменится примерно вот так:
|
||||
|
||||
```
|
||||
/* Имплементация партнёром интерфейса
|
||||
запуска программы на его кофе-машинах */
|
||||
registerProgramRunHandler(apiType, (context) => {
|
||||
// Инициализируем запуск исполнения
|
||||
// программы на стороне партнера
|
||||
let execution = initExecution(context, …);
|
||||
// Подписываемся на события
|
||||
// изменения контекста
|
||||
context.on('takeout_requested', () => {
|
||||
// Если запрошена выдача напитка,
|
||||
// инициализируем выдачу
|
||||
execution.prepareTakeout(() => {
|
||||
/* как только напиток готов к выдаче,
|
||||
сигнализируем об этом, но не
|
||||
посредством генерации события */
|
||||
// execution.context.emit('takeout_ready')
|
||||
context.set('takeout_ready');
|
||||
// Или ещё более жёстко:
|
||||
// context.setTakeoutReady();
|
||||
});
|
||||
});
|
||||
// Так как мы сами изменяем родительский контекст
|
||||
// нет нужды что-либо возвращать
|
||||
// return execution.context;
|
||||
}
|
||||
```
|
||||
|
||||
Вновь такое решение выглядит контринтуитивным, ведь мы снова вернулись к сильной связи двух уровней через жестко определённые методы. Однако здесь есть важный момент: мы городим весь этот огород потому, что ожидаем появления альтернативных реализаций *нижележащего* уровня абстракции. Ситуации, когда появляются альтернативные реализации *вышележащего* уровня абстракции, конечно, возможны, но крайне редки. Обычно дерево альтернативных реализаций растёт сверху вниз.
|
||||
|
||||
Другой аспект заключается в том, что, хотя серьёзные изменения концепции возможны на любом из уровней абстракции, их вес принципиально разный:
|
||||
* если меняется технический уровень, это не должно существенно влиять на продукт, а значит — на написанный партнерами код;
|
||||
* если меняется сам продукт, ну например мы начинаем продавать билеты на самолёт вместо приготовления кофе на заказ, сохранять обратную совместимость на промежуточных уровнях API *бесполезно*. Мы вполне можем продавать билеты на самолёт тем же самым API программ и контекстов, да только написанный партнёрами код всё равно надо будет полностью переписывать с нуля.
|
||||
|
||||
В конечном итоге это приводит к тому, что API вышележащих сущностей меняется медленнее и более последовательно по сравнению с API нижележащих уровней, а значит подобного рода «обратная» жёсткая связь зачастую вполне допустима и даже желательна исходя из соотношения «цена-качество».
|
||||
|
||||
**NB**: во многих современных системах используется подход с общим разделяемым состоянием приложения. Пожалуй, самый популярный пример такой системы — Redux. В парадигме Redux вышеприведённый код выглядел бы так:
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Вместо обращения к вышестоящей сущности
|
||||
// или генерации события на себе,
|
||||
// компонент обращается к глобальному
|
||||
// состоянию и вызывает действия над ним
|
||||
dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
|
||||
Надо отметить, что такой подход *в принципе* не противоречит описанному принципу, но нарушает другой — изоляцию уровней абстракции, а поэтому плохо подходит для написания сложных API, в которых не гарантирована жесткая иерархия компонентов. При этом использовать глобальный (или квази-глобальный) менеджер состояния в таких системах вполне возможно, но требуется имплементировать более сложную пропагацию сообщений по иерархии, а именно: подчинённый объект всегда вызывает методы только ближайшего вышестоящего объекта, а уже тот решает, как и каким образом этот вызов передать выше по иерархии.
|
||||
|
||||
```
|
||||
execution.prepareTakeout(() => {
|
||||
// Вместо обращения к вышестоящей сущности
|
||||
// или генерации события на себе,
|
||||
// компонент обращается к вышестоящему
|
||||
// объекту
|
||||
context.dispatch(takeoutReady());
|
||||
});
|
||||
```
|
||||
```
|
||||
// Имплементация program.context.dispatch
|
||||
ProgramContext.dispatch = (action) => {
|
||||
// program.context обращается к своему
|
||||
// вышестоящему объекту, или к глобальному
|
||||
// состоянию, если такого объекта нет
|
||||
globalContext.dispatch(
|
||||
// При этом сама суть действия
|
||||
// может и должна быть переформулирована
|
||||
// в терминах соответствующего уровня
|
||||
// абстракции
|
||||
this.generateAction(action)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
@ -1,15 +1,25 @@
|
||||
### Интерфейсы как универсальный паттерн
|
||||
|
||||
Как мы указали в предыдущей главе, основные причины внесения изменений в API — развитие самого API (добавление новой функциональности) и эволюция платформ (клиентской, серверной и предметной) — следует предусматривать ещё на этапе проектирования. Может показаться, что совет этот полезен примерно так же, как и сократовское определение человека — очень конкретен, и столь же бесполезен — но это не так. Методология, позволяющая получить устойчивое к изменениям API, существует и вполне конкретна: это применение концепции «интерфейса» ко всем уровням абстракции.
|
||||
Попробуем кратко суммировать написанное в трёх предыдущих главах.
|
||||
|
||||
На практике это означает следующая: нам необходимо рассмотреть каждую сущность нашего API и выделить её абстрактную модель, т.е. разделить все поля и методы сущности на две группы: те, которые абсолютно необходимы для корректного цикла работы API, и те, которые мы можем назвать «деталями имплементации». Первые образуют *интерфейс* сущности: если заменить одну конкретную реализацию этого интерфейса на другую, API будет продолжать работать.
|
||||
1. Расширение функциональности API производится через абстрагирование: необходимо так переосмыслить номенклатуру сущностей, чтобы существующие методы стали частным (желательно — самым частотным) упрощённым случаем реализации.
|
||||
2. Вышестоящие сущности должны при этом оставаться информационными контекстами для нижестоящих, т.е. не предписывать конкретное поведение, а только сообщать о своём состоянии и предоставлять функциональность для его изменения (прямую через соответствующие методы либо косвенную через получение определённых событий).
|
||||
3. Конкретная функциональность, т.е. работа непосредственно с «железом», нижележащим API платформы, должна быть делегирована сущностям самого низкого уровня.
|
||||
|
||||
**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных» и «контрактное программирование», но это методологически неверно: разработка API в принципе представляет собой контрактное программирование, при этом большинство клиент-серверных архитектур подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL[[ref B. Stroustrup, A. Sutton. A Concept Design for the STL, p. 38]](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3351.pdf), но термин «интерфейс» нам кажется более понятным.
|
||||
**NB**. В этих правилах нет ничего особенно нового: в них легко опознаются принципы архитектуры [SOLID](https://en.wikipedia.org/wiki/SOLID) — что неудивительно, поскольку SOLID концентрируется на контрактно-ориентированном подходе к разработке, а API по определению и есть контракт. Мы лишь добавляем в эти принципы понятие уровней абстракции и информационных контекстов.
|
||||
|
||||
Мы будем использовать термин «интерфейс» как обобщение понятия «абстрактный тип данных» и «контракт». «Интерфейс» — это некоторое абстрактное подмножество абстрактного типа данных, «метаконтракт». Интерфейсы мы будем обозначать с помощью префикса `I`, например: `Recipe` — это модель данных «рецепт», а `IRecipe` — это интерфейс рецепта: «минимальная» модель данных и операций над ними, которая достаточна для корректной работы API. Объект `Recipe` таким образом имплементирует интерфейс `IRecipe`.
|
||||
Остаётся, однако, неотвеченным вопрос о том, как изначально выстроить номенклатуру сущностей таким образом, чтобы расширение API не превращало её в мешанину из различных неконсистентных методов разных эпох. Впрочем, ответ на него довольно очевиден: чтобы при абстрагировании не возникало неловких ситуаций, подобно рассмотренному нами примеру с поддерживаемыми кофе-машиной опциями, все сущности необходимо *изначально* рассматривать как частную реализацию некоторого более общего интерфейса, даже если никаких альтернативных реализаций в настоящий момент не предвидится.
|
||||
|
||||
Попробуем применить этот (дважды) абстрактный концепт к нашему кофейному API. Представьте, что на этапе разработки архитектуры бизнес выдвинул следующее требование: мы не только предоставляем доступ к оборудованию партнеров, но и предлагаем партнерам наше ПО (т.е. в данном случае API), чтобы они могли строить поверх него свои собственные приложения. Иными словами, подойдём к каждой концепции нашего API с вопросом «что, если?…»
|
||||
**NB**: мы понимаем, что вносим некоторую путаницу, поскольку термин «интерфейс» также используется для обозначения совокупности свойств и методов сущности, да и вообще отвечает за букву «I» в самой аббревиатуре «API»; однако использование других терминов внесёт ещё больше путаницы. Мы могли бы оперировать выражениями «абстрактные типы данных», но это методологически неверно: разработка API, как правило, подразумевают независимость имплементации клиента и сервера, так что никаких «неабстрактных» типов данных в них не существует. Термины типа «виртуальный класс» и «виртуальное наследование» неприменимы по той же причине. Мы могли бы использовать «фасад», но под «фасадом» обычно понимают всё-таки конкретную имплементацию, а не абстракцию. Ближе всего по смыслу подходят «концепты» в том смысле, который вкладывается в них в STL[[ref B. Stroustrup, A. Sutton. A Concept Design for the STL, p. 38]](http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3351.pdf), но термин «интерфейс» нам кажется более понятным.
|
||||
|
||||
**NB**: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; в реальном мире такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реалтайме применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем такой сложности было бы затруднительно.
|
||||
Например, разрабатывая API эндпойнта `POST /search` мы должны были задать себе вопрос: а «результат поиска» — это абстракция над каким интерфейсом? Для этого нам нужно аккуратно декомпозировать эту сущность, чтобы понять, каким своим срезом она выступает во взаимодействии с каким объектами.
|
||||
|
||||
**Что произойдёт, если…** потребуется предоставить партнёру возможность готовить напитки по своему собственному рецепту?
|
||||
Тогда мы придём к пониманию, что результат поиска — это, на самом деле, композиция двух интерфейсов:
|
||||
* при создании заказа из всего результата поиска необходимы поля, описывающие собственно заказ; это может быть структура `{coffee_machine_id, recipe_id, volume, currency_code, price}`, либо мы можем закодировать все эти данные в одном `offer_id`;
|
||||
* при отображении результата поиска в приложении нам важны другие поля — `name`, `description`, а также отформатированная и локализованная цена.
|
||||
|
||||
Таким образом, наш интерфейс (назовём его `ISearchResult`) — это композиция двух других интерфейсов: `IOrderParameters` (сущности, позволяющей сделать заказ) и `ISearchItemViewParameters` (некоторого абстрактного представления результатов поиска в UI). Подобное разделение должно автоматически подводить нас к ряду вопросов.
|
||||
1. Каким образом мы будем связывать одно с другим? Очевидно, что эти два суб-интерфейса зависимы: например, отформатированная человекочитаемая цена должна совпадать с машиночитаемой. Это естественным образом подводит нас к концепции абстрагирования форматирования, описанной в главе «Сильная связность и сопутствующие проблемы»
|
||||
2. А что такое, в свою очередь, «абстрактное представление результатов поиска в UI»? Есть ли у нас какие-то другие виды поисков, не является ли `ISearchItemViewParameters` сам наследником какого-либо другого интерфейса или композицией других интерфейсов?
|
||||
|
||||
Замена конкретных имплементаций интерфейсами позволяет не только точнее ответить на многие вопросы, которые должны были у вас возникнуть в ходе проектирования API, но и наметить множество возможных векторов развития API, что поможет избежать проблем с неконсистентностью API в ходе дальнейшей эволюции программного продукта.
|
35
src/ru/drafts/03-Раздел II. Обратная совместимость/07.md
Normal file
35
src/ru/drafts/03-Раздел II. Обратная совместимость/07.md
Normal file
@ -0,0 +1,35 @@
|
||||
### Блокнот душевного покоя
|
||||
|
||||
Помимо вышеперечисленных абстрактных принципов хотелось бы также привести набор вполне конкретных рекомендаций по внесению изменений в существующее API с поддержанием обратной совместимости.
|
||||
|
||||
##### Помните о подводной части айсберга
|
||||
|
||||
То, что вы не давали конкретных гарантий и обязательств, совершенно не означает, что эти неформальные гарантии и обязательства можно нарушать. Зачастую даже исправление багов может нарушить работу чьего-то кода. Можно привести следующий пример из реальной жизни, с которым столкнулся автор этой книги:
|
||||
* существовало некоторое API размещения кнопок в визуальном контейнере; по контракту оно принимало позицию размещаемой кнопки (отступы от углов контейнера) в качестве обязательного параметра;
|
||||
* в реализации была допущена ошибка: если позицию не передать, то исключения не происходило — добавленные таким образом кнопки размещались в левом верхнем углу контейнера одна за другой;
|
||||
* в день, когда ошибка была исправлена, в техническую поддержку пришло множество обращений от разработчиков, чей код перестал работать; как оказалось, клиенты использовали эту ошибку для того, чтобы последовательно размещать кнопки в левом верхнем углу контейнера.
|
||||
|
||||
Если исправления ошибок затрагивают реальных потребителей — вам ничего не остаётся кроме как продолжать эмулировать ошибочное поведение но следующего мажорного релиза. При разработке больших API с широким кругом потребителей такие ситуации встречаются сплошь и рядом — например, разработчики API операционных систем буквально вынуждены портировать старые баги в новые версии ОС.
|
||||
|
||||
##### Тестируйте формальные интерфейсы
|
||||
|
||||
Любое программное обеспечение должно тестироваться, и API не исключение. Однако здесь есть свои тонкости: поскольку API предоставляет формальные интерфейсы, тестироваться должны именно они. Это приводит к ошибкам нескольких видов.
|
||||
|
||||
1. Часто требования вида «функция `setEntity` возвращает значение, установленное вызовом функции `getEntity`» кажутся и разработчикам, и QA-инженерам самоочевидными и не проверяются. Между тем допустить ошибку в их реализации очень даже возможно, мы встречались с такими случаями на практике.
|
||||
2. Принцип абстрагирования интерфейсов тоже необходимо проверять. В теории вы может быть и рассматриваете каждую сущность как конкретную имплементацию абстрактного интерфейса — но на практике может оказаться, что вы чего-то не учли и ваш абстрактный интерфейс на деле невозможен. Для целей тестирования очень желательно иметь пусть виртуальную, но отличную от базовой реализацию каждого интерфейса.
|
||||
|
||||
##### Реализуйте функциональность своего API поверх публичных интерфейсов
|
||||
|
||||
Часто можно увидеть антипаттерн: разработчики API используют внутренние непубличные реализации тех или иных методов взамен существующих публичных. Это происходит по двум причинам:
|
||||
* часто публичное API является лишь дополнением к более специализированному внутреннему ПО компании, и наработки, представленные в публичном API, не портируются обратно в непубличную часть проекта, или же разработчики публичного API попросту не знают о существовании аналогичных непубличных функций;
|
||||
* в ходе развития API некоторые интерфейсы абстрагируются, но имплементация уже существующих интерфейсов при этом по разным причинам не затрагивается; например, можно представить себе, что при реализации интерфейса `PUT /formatters`, описанного в главе «Сильная связность и сопутствующие проблемы», разработчики сделали отдельную, более общую, версию функции форматирования объёма для пользовательских языков в API и не переписали функцию форматирования для известных языков поверх неё.
|
||||
|
||||
Помимо очевидных частных проблем, вытекающих из такого подхода (неконсистентность поведения разных функций в API, не найденные при тестировании ошибки), здесь есть и одна глобальная: легко может оказаться, что вашим API попросту невозможно будет пользоваться, если сделать хоть один «шаг в сторону» — попытка воспользоваться любой нестандартной функциональностью может привести к проблемам производительности, многочисленным ошибкам, нестабильной работе и так далее.
|
||||
|
||||
**NB**. Идеальным примером строгого избегания данного антипаттерна следует признать разработку компиляторов — в этой сфере принято компилировать новую версию компилятора при помощи его же предыдущей версии.
|
||||
|
||||
##### Заведите блокнот
|
||||
|
||||
Несмотря на всё рассказанное выше в настоящей главе, с большой вероятностью вы *ничего* не сможете сделать с накапливающейся неконсистентностью вашего API. Да, можно замедлить скорость накопления, предусмотреть какие-то проблемы заранее, заложить запасы устойчивости — но предугадать *всё* решительно невозможно. На этом этапе многие разработчики склонные принимать скоропалительные решение — т.е. выпустить новую минорную версию с явным или неявным нарушением обратной совместимости в целях исправления ошибок дизайна.
|
||||
|
||||
Так делать мы крайне не рекомендуем — поскольку, напомним, API является помимо прочего и мультипликатором ваших ошибок. Что мы рекомендуем — так это завести блокнот, где вы будете записывать выученные уроки, которые нужно не забыть применить на практике при выпуске новой мажорной версии API.
|
Loading…
x
Reference in New Issue
Block a user