Pull to refresh

Слабая связность

Reading time19 min
Views11K

NB: это черновик двух новых глав моей книги, посвященной дизайну API. В тексте встречаются отсылки к предыдущим главам.


Сильная связность и сопутствующие проблемы


В предыдущих разделах мы старались приводить теоретические правила и принципы, и иллюстрировать их на практических примерах. Однако понимание принципов проектирования API, устойчивого к изменениям, как ничто другое требует прежде всего практики. Знание о том, куда стоит «постелить соломку» — оно во многом «сын ошибок трудных». Нельзя предусмотреть всего — но можно выработать необходимый уровень технической интуиции.


Поэтому в этом разделе мы поступим следующим образом: возьмём наше модельное API из предыдущего раздела, и проверим его на устойчивость в каждой возможной точке — проведём некоторый «вариационный анализ» наших интерфейсов. Ещё более конкретно — к каждой сущности мы подойдём с вопросом «что, если?» — что, если нам потребуется предоставить партнерам возможность написать свою независимую реализацию этого фрагмента логики.


Первый важный момент, на который стоит обратить внимание: мы говорим именно о вариантах реализации продуктовой логики. Не о вариантах реализации сущности: изменения в API вносятся прежде всего для того, чтобы можно было сделать что-то, не предусмотренное изначальным дизайном — что-то полезное. Заниматься реимплементацией интерфейсов просто так ваши потребители не будут.


Это соображение вносит определённые ограничения, которые позволяют нам не заниматься варьированием интерфейсов вслепую (в конце концов, таких вариантов бесконечное количество, и предусматривать их все — сизифов труд): нам нужно понять в первую очередь зачем нужны те или иные изменения, и отсюда мы уже поймём как их следует внести.


Второй важный момент состоит в том, что многие решения, допускающие эту вариативность, уже заложены в дизайне API. Какие-то из них (например, вопрос определения готовности) мы осветили в предыдущих главах подробнее, а какие-то дали без комментариев — настало время объяснить, почему эти решения были приняты.


NB: в рассматриваемых нами примерах мы будем выстраивать интерфейсы так, чтобы связывание разных сущностей происходило динамически в реальном времени; на практике такие интеграции будут делаться на стороне сервера путём написания ad hoc кода и формирования конкретных договорённостей с конкретным клиентом, однако мы для целей обучения специально будем идти более сложным и абстрактным путём. Динамическое связывание в реальном времени применимо скорее к сложным программным конструктам типа API операционных систем или встраиваемых библиотек; приводить обучающие примеры на основе систем подобной сложности было бы, однако, чересчур затруднительно.


Предположим, что мы решили предоставить партнёрам возможность готовить кофе по их собственным рецептам. Какова мотивация предоставления такой функциональности?


  • возможно, партнерская сеть кофеен хочет предложить клиентам особенные «брендовые» напитки;
  • возможно, партнер хочет построить полностью своё приложение со своим ассортиментом на нашей платформе.

В обоих вариантах нам необходимо начать с рецепта. Какие данные нам необходимы для того, чтобы партнёр мог добавить в систему новый рецепт? Вспомним, какие контексты связывает сущность «рецепт»: эта сущность нам нужна, чтобы связать выбор пользователя с правилами приготовления напитка. На первый взгляд может показаться, что именно так нам и следует описать сущность рецепта:


// Добавляет новый рецепт
POST /v1/recipes
{
  "id",
  "product_properties": {
    "name",
    "description",
    "default_value"
    // Прочие параметры, описывающие
    // напиток для пользователя
    …
  },
  "execution_properties": {
    // Идентификатор программы
    "program_id",
    // Параметры исполнения программы
    "parameters"
  }
}

На первый взгляд, вполне разумный и простой интерфейс, который явно декомпозируется согласно уровням абстракции. Попробуем теперь представить, что произойдёт в будущем — как дальнейшее развитие функциональности повлияет на этот интерфейс.


Первая проблема очевидна тем, кто внимательно читал главу 11: продуктовые данные должны быть локализованы. Это приведёт нас к первому изменению:


"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"
    }
  }, …],
  // Какие поля обязательны
  "required": ["search_title", "search_description"]
}

Таким образом, партнёр сможет сам решить, какой вариант ему предпочтителен. Можно задать необходимые поля для стандартного макета:


PUT /v1/recipes/{id}/properties/l10n/{lang}
{
  "search_title", "search_description"
}

Либо создать свой макет и задавать нужные для него поля:


POST /v1/layouts
{
  "properties"
}
→
{ "id", "properties" }

В конце концов, партнёр может отрисовывать UI самостоятельно и вообще не пользоваться этой техникой, не задавая ни макеты, ни поля.


Ту же самую технику — выделение отдельной сущности, которая занимается сопоставлением рецепта с его свойствами для нижележащих систем — мы можем использовать и для execution_properties, тем самым позволив партнеру контролировать ещё и то, каким образом рецепт связывается с программами исполнения. Тогда наш интерфейс получит вот такой вид:


POST /v1/recipes
{ "id" }
→
{ "id" }

Этот вывод может показаться совершенно контринтуитивным, однако отсутствие полей у сущности «рецепт» говорит нам только о том, что сама по себе она не несёт никакой семантики и служит просто способом указания контекста привязки других сущностей. В реальном мире следовало бы, пожалуй, собрать эндпойнт-строитель, который может создавать сразу все нужные контексты одним запросом:


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" }
    ]
  },
  // Прочие действия, которые необходимо
  // выполнить для корректного заведения
  // нового рецепта в системе
  …
}

Заметим, что передача идентификатора вновь создаваемой сущности клиентом — не лучший паттерн. Но раз уж мы с самого начала решили, что идентификаторы рецептов — не просто случайные наборы символов, а значимые строки, то нам теперь придётся с этим как-то жить. Очевидно, в такой ситуации мы рискуем многочисленными коллизиями между названиями рецептов разных партнёров, поэтому операцию, на самом деле, следует модифицировать: либо для партнерских рецептов всегда пользоваться парой идентификаторов (партнера и рецепта), либо ввести составные идентификаторы, как мы ранее рекомендовали в главе 11.


POST /v1/recipes/custom
{
  // Первая часть идентификатора:
  // например, в виде идентификатора клиента
  "namespace": "my-coffee-company",
  // Вторая часть идентификатора
  "id_component": "lungo-customato"
}
→
{
  "id": "my-coffee-company:lungo-customato"
}

Заметим, что в таком формате мы сразу закладываем важное допущение: различные партнёры могут иметь как полностью изолированные неймспейсы, так и разделять их. Более того, мы можем ввести специальные неймспейсы типа "common", которые позволят публиковать новые рецепты для всех. (Это, кстати говоря, хорошо ещё и тем, что такое API мы сможем использовать для организации нашей собственной панели управления контентом.)


Слабая связность


В предыдущей главе мы продемонстрировали, как разрыв сильной связанности приводит к декомпозиции сущностей и схлопыванию публичных интерфейсов до минимума. Внимательный читатель может подметить, что этот приём уже был продемонстрирован в нашем учебном API гораздо раньше в главе 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 того же уровня; он трактует только ту часть задания, которая имеет для него смысл.

Если мы посмотрим на принципы, описанные в предыдущей глава, то обнаружим, что этот принцип мы уже формулировали: нам необходимо задать информационный контекст на каждом из уровней абстракции, и разработать механизм его трансляции. Более того, в общем виде он был сформулирован ещё в разделе «Потоки данных».


В нашем конкретном примере нам нужно имплементировать следующие механизмы:


  • запуск программы создаёт контекст её исполнения, содержащий все существенные параметры;
  • существует способ обмена информацией об изменении данных: исполнитель может читать контекст, узнавать о всех его изменениях и сообщать обратно о изменениях своего состояния.

Организовать и то, и другое можно разными способами, однако по сути мы имеем два описания состояния (верхне- и низкоуровневое) и поток событий между ними. В случае 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. Работа ведётся на Github. Англоязычный вариант этой же главы опубликован на medium. Я буду признателен, если вы пошарите его на реддит — я сам не могу согласно политике платформы.

Tags:
Hubs:
Total votes 7: ↑6 and ↓1+6
Comments3

Articles