diff --git a/docs/API.ru.html b/docs/API.ru.html index e095c73..567bb87 100644 --- a/docs/API.ru.html +++ b/docs/API.ru.html @@ -202,7 +202,7 @@ h6 { color: darkgray; text-align: center; padding: 0; - margin: 0 1em 0 2em; + margin: 0 1em 1.2em 1em; font-weight: normal; } @@ -5314,6 +5314,12 @@ If-Match: <ревизия>
  • 429 Too Many Requests при превышении лимитов.
  • Разработчики стандарта HTTP об этой проблеме вполне осведомлены, и отдельно отмечают, что для решения бизнес-сценариев необходимо передавать в метаданных либо теле ответа дополнительные данные для описания возникшей ситуации («the server SHOULD send a representation containing an explanation of the error situation, and whether it is a temporary or permanent condition»), что (как и введение новых специальных кодов ошибок) противоречит самой идее унифицированного машиночитаемого формата ошибок. (Отметим, что отсутствие стандартов описания ошибок в бизнес-логике — одна из основных причин, по которым мы считаем разработку REST API как его описал Филдинг в манифесте 2008 года невозможной; клиент должен обладать априорным знанием о том, как работать с метаинформацией об ошибке, иначе он сможет восстанавливать своё состояние после ошибки только перезагрузкой.)

    +

    NB: не так давно разработчики стандарта предложили собственную версию спецификации JSON-описания HTTP-ошибок — RFC 9457. Вы можете воспользоваться ей, но имейте в виду, что она покрывает только самый базовый сценарий:

    +

    Дополнительно, у проблемы есть и третье измерение в виде серверного ПО мониторинга состояния системы, которое часто полагается на статус-коды ответов при построении графиков и уведомлений. Между тем, ошибки, скрывающиеся под одним статус кодом — например ввод неправильного пароля и истёкший срок жизни токена — могут быть очень разными по смыслу; повышенный фон первой ошибки может говорить о потенциальной попытке взлома путём перебора паролей, а второй — о потенциальных ошибках в новой версии приложения, которая может неверно кэшировать токены авторизации.

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

    POST /v1/coffee-machines/search HTTP/1.1
    @@ -5753,249 +5759,471 @@ do {
     

    Если бы возможность кастомизации вообще не предоставлялась, эту функциональность было бы гораздо проще поддерживать. Да, разработчики были бы не рады необходимости разработать с нуля собственную панель поиска просто для замены иконки. Но в их коде замена иконки хотя бы будет находиться в ожидаемом месте — где-то в функции рендеринга панели.

    NB: существует много других возможностей позволить разработчику кастомизировать кнопку, запрятанную где-то глубоко в дебрях компонента: разрешить dependency injection или переопределение фабрик суб-компонентов, предоставить прямой доступ к отрендеренному представлению компонента, настроить пользовательские макеты кнопок и так далее. Все они страдают от той же проблемы: крайне сложно консистентно описать порядок и приоритет применения инъекций / обработчиков событий рендеринга / пользовательских шаблонов.

    С решением вышеуказанных проблем, увы, всё обстоит очень сложно. В следующих главах мы рассмотрим паттерны проектирования, позволяющие в том числе разделить области ответственности составляющих компонента; но очень важно уяснить одну важную мысль: полное разделение, то есть разработка функционального SDK+UI, дающего разработчику свободу в переопределении и внешнего вида, и бизнес-логики, и UX компонентов — невероятно дорогая в разработке задача, которая в лучшем случае утроит вашу иерархию абстракций. Универсальный совет здесь ровно один: три раза подумайте прежде чем предоставлять возможность программной настройки UI-компонентов. Хотя цена ошибки дизайна программных интерфейсов для UI-библиотек, как правило, не очень высока (вряд ли клиент потребует рефанд из-за неработающей анимации нажатия кнопки), плохо структурированный, нечитабельный и глючный SDK вряд ли может рассматриваться как сильное клиентское преимущество вашего API.

    Глава 44. Декомпозиция UI-компонентов 

    -

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

    +

    Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента приводит к кратному усложнению интерфейсов. Продолжим рассматривать пример компонента SearchBox из предыдущей главы. Напомним, что мы выделили следующие факторы, осложняющие проектирование API визуальных компонентов:

    Сделаем задачу более конкретной, и попробуем разработать наш SearchBox так, чтобы он допускал следующие модификации:

    1. -

      Брендирование предложения и кнопки заказа иконкой сети кофеен:

      +

      Замена списочного представления предложений, например, на представление в виде карты с подсвечиваемыми метками:

        -
      • иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
      • +
      • иллюстрирует проблему полной замены одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы, а также сложности имплементации разделяемого состояния;
      -
    2. -
    3. -

      Замена списочного представления предложений, например, на представление в виде карты с метками:

      -
        -
      • иллюстрирует проблему изменения дизайна одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы;
      • -
      -
      Результаты поиска на карте.
      Результаты поиска на карте. Нажмите для увеличения +
      Результаты поиска на карте.
      Результаты поиска на карте. Нажмите для увеличения
    4. -

      Добавление кнопки быстрого заказа в каждое предложение в списке:

      +

      Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)

        -
      • иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
      • +
      • иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
      -
      Список результатов поиска с кнопками быстрых действий.
      Список результатов поиска с кнопками быстрых действий. Нажмите для увеличения +
      Список результатов поиска с короткими описаниями предложений.
      Список результатов поиска с короткими описаниями предложений. Нажмите для увеличения +
      +
      Список результатов поиска, в котором некоторые предложения развёрнуты.
      Список результатов поиска, в котором некоторые предложения развёрнуты. Нажмите для увеличения
    5. -

      Динамическое добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):

      +

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

        -
      • иллюстрирует проблему динамического изменения бизнес-логики компонента при сохранении внешнего вида и UX компонента, а также неоднозначности иерархий наследования (поскольку новая кнопка должна располагать своими отдельными настройками).
      • +
      • кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
      • +
      • кнопка «позвонить» показывается динамически, если номер кофейни известен, и, таким образом, требует возможности определения списка показываемых кнопок динамически, отдельно для каждого конкретного предложения.
      -
      Дополнительная кнопка «Позвонить».
      Дополнительная кнопка «Позвонить». Нажмите для увеличения +

      Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов.

      +
      Панель предложения с дополнительными кнопками и иконками.
      Панель предложения с дополнительными кнопками и иконками. Нажмите для увеличения
    -

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

    -
    class SearchBox {
    -  // Компонент списка предложений
    -  public offerList: OfferList;
    -  // Панель отображения
    -  // выбранного предложения
    -  public offerPanel: OfferPanel;
    +

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

    +
    class SearchBox implements ISearchBox {
    +  // Ответственность `SearchBox`:
    +  // 1. Создать контейнер для визуального
    +  // отображения списка предложений,
    +  // сгенерировать опции и создать
    +  // сам компонент `OfferList`
    +  constructor(container, options) {
    +    …
    +    this.offerList = new OfferList(
    +      this,
    +      offerListContainer,
    +      offerListOptions
    +    );
    +  }
    +  // 2. Выполнять поиск предложений
    +  // при нажатии пользователем на кнопку поиска
    +  // и предоставлять аналогичный программный
    +  // интерфейс для разработчика
    +  onSearchButtonClick() {
    +    this.search(this.searchInput.value);
    +  }
    +  search(query) {
    +    …
    +  }
    +  // 3. При получении новых результатов поиска
    +  // оповестить об этом
    +  onSearchResultsReceived(searchResults) {
    +    …
    +    this.offerList.setOfferList(searchResults)
    +  }
    +  // 4. Создавать заказы (и выполнять нужные
    +  // операции над компонентами)
    +  createOrder(offer) {
    +    this.offerListDestroy();
    +    ourCoffeeSdk.createOrder(offer);
    +    …
    +  }
    +  // 5. Самоуничтожаться
    +  destroy() {
    +    this.offerList.destroy();
    +    …
    +  }
    +}
    +
    +
    class OfferList implements IOfferList {
    +  // Ответственность OfferList:
    +  // 1. Создать контейнер для визуального
    +  // отображения панели предложений,
    +  // сгенерировать опции и создать
    +  // сам компонент `OfferPanel`
    +  constructor(searchBox, container, options) {
    +    …
    +    this.offerPanel = new OfferPanel(
    +      searchBox,
    +      offerPanelContainer,
    +      offerPanelOptions
    +    );
    +    …
    +  }
    +  // 2. Предоставлять метод для изменения
    +  // списка предложений
    +  setOfferList(offerList) { … }
    +  // 3. При выборе предложения, вызывать метод
    +  // его показа в панели предложений
    +  onOfferClick(offer) {
    +    this.offerPanel.show(offer)
    +  }
    +  // 4. Самоуничтожаться
    +  destroy() {
    +    this.offerPanel.destroy();
    +    …
    +  }
    +}
    +
    +
    class OfferPanel implements IOfferPanel {
    +  constructor(searchBox, container, options) { … }
    +  // Ответственность панели показа предложения:
    +  // 1. Собственно, показывать предложение
    +  show(offer) { 
    +    this.offer = offer;
    +    …
    +  }
    +  // 2. Создавать заказ по нажатию на кнопку
    +  // создания заказа
    +  onCreateOrderButtonClick() {
    +    this.searchBox.createOrder(this.offer);
    +  }
    +  // 3. Закрываться по нажатию на кнопку
    +  // отмены
    +  onCancelButtonClick() {
    +    // …
    +  }
    +  // 4. Самоуничтожаться
    +  destroy() { … }
    +}
    +
    +

    Интерфейсы ISearchBox / IOfferPanel / IOfferView также очень просты (конструкторы и деструкторы опущены):

    +
    interface ISearchBox {
    +  search(query);
    +  createOrder(offer);
    +}
    +interface IOfferList {
    +  setOfferList(offerList);
    +}
    +interface IOfferPanel {
    +  show(offer);
    +}
    +
    +

    Если бы мы не разрабатывали SDK и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:

    +
      +
    1. +

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

      +
      class CustomOfferPanel extends OfferPanel {
      +  constructor(
      +    searchBox, offerMap, container, options
      +  ) {
      +    this.offerMap = offerMap;
      +    super(searchBox, container, options);
      +  }
      +  onCancelButtonClick() {
      +    offerMap.resetCurrentOffer();
      +    super.onCancelButtonClick();
      +  }
      +}
      +class OfferMap implements IOfferList {
      +  constructor(searchBox, container, options) {
      +    …
      +    this.offerPanel = new CustomOfferPanel(
      +      this,
      +      searchBox,
      +      offerPanelContainer,
      +      offerPanelOptions
      +    )
      +  }
      +  resetCurrentOffer() { … }
      +  …
      +}
      +
      +

      Нам пришлось создать новый класс CustomOfferPanel, который, в отличие от своего родителя, теперь работает только со специфической имплементацией интерфейса IOfferList.

      +
    2. +
    3. +

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

      +
    4. +
    5. +

      Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы задаимся какой нибудь простой кастомизацией, например, текста кнопки «Сделать заказ», то в данном коде она фактически является ответственностью класса OfferList:

      +
      const searchBox = new SearchBox(…, {
      +  offerPanelCreateOrderButtonText:
      +    'Drink overpriced coffee!'
      +});
       
      -  // Инициализация
      -  setupComponents() {
      -    // Подписываемся на клик по
      -    // предложению
      -    this.offerList.events.on(
      -      'click', 
      -      (event) => {
      -        this.selectedOffer =
      -          event.target.offer;
      -        this.offerPanel.show(
      -          this.selectedOffer
      -        );
      -      });
      -    this.offerPanel.on(
      -      'click', () => {
      -        this.createOrder();
      +class OfferList {
      +  constructor(…, options) {
      +    …
      +    // Вычленять из опций `SearchBox`
      +    // настройки панели предложений
      +    // вынужен конструктор класса 
      +    // `OfferList`
      +    this.offerPanel = new OfferPanel(…, {
      +      createOrderButtonText: options
      +        .offerPanelCreateOrderButtonText
      +      …
      +    })
      +  }
      +}
      +
      +
    6. +
    +

    Самая же неприятная особенность кода из п. 1 — его очень плохая расширяемость. Допустим, мы решили сделать функциональность реакции OfferList на закрытие панели предложений частью интерфейса, чтобы программист мог ей воспользоваться. Для этого нам придётся объявить новый необязательный метод:

    +
    interface IOfferList {
    +  …
    +  onOfferPanelClose?();
    +}
    +
    +

    и писать в коде OfferPanel что-то типа:

    +
    if (Type(this.offerList.onOfferPanelClose)
    +  == 'function') {
    +    this.offerList.onOfferPanelClose();
    +  }
    +
    +

    Что, во-первых, совершенно не красит наш код и, во-вторых, делает связность OfferPanel и OfferList ещё более сильной.

    +

    Как мы описывали ранее в главе «Слабая связность», избавиться от такого рода проблем мы можем, если перейдём от сильной связности к слабой, например, через генерацию событий вместо вызова методов:

    +
    class OfferList {
    +  setup() {
    +    this.offerPanel.events.on(
    +      'close',
    +      function () {
    +        this.resetCurrentOffer();
           }
         )
       }
    -
    -  // Ссылка на текущее выбранное предложение
    -  private selectedOffer: Offer;
    -  // Создаёт заказ
    -  private createOrder() {
    -    const order = await api
    -      .createOrder(this.selectedOffer);
    -    // Действия после создания заказа
    -    …
    -  }
       …
     }
     
    -

    В данном фрагменте кода налицо полный хаос с уровнями абстракции, и заодно сделано множество неявных предположений:

    -
      -
    • единственный способ выбрать предложение — клик по элементу списка;
    • -
    • единственный способ сделать заказ — клик внутри элемента «панель предложения»;
    • -
    • заказ не может быть сделан, если предложение не было предварительно выбрано.
    • -
    -

    Из поставленных нами задач при такой декомпозиции мы можем условно решить только вторую (замена списка на карту), потому что к решению первой проблемы (замена иконки в кнопке заказа) мы не продвинулись вообще, а третью (кнопки быстрых действий в списке заказов) можно решить только следующим образом:

    -
      -
    • сделать панель заказа невидимой / перенести её за границы экрана;
    • -
    • после события "click" на кнопке создания заказа дождаться окончания отрисовки невидимой панели и сгенерировать на ней фиктивное событие "click".
    • -
    -

    Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки сделать этот интерфейс расширяемым?

    -

    Первый очевидный шаг заключается в том, чтобы SearchBox перестал реагировать на низкоуровневые события типа click, а стал только лишь контекстом для нижележащих сущностей и работал в терминах своего уровня абстракции. А для этого нам нужно в первую очередь установить, что же он из себя представляет логически, какова его область ответственности как компонента?

    -

    Предположим, что мы определим SearchBox концептуально как конечный автомат, находящийся в одном из трёх состояний:

    -
      -
    1. Пуст (ожидает запроса пользователя и получения списка предложений).
    2. -
    3. Показан список предложений по запросу.
    4. -
    5. Показано конкретное предложение пользователю.
    6. -
    7. Создаётся заказ.
    8. -
    -

    Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, SearchBox должен ждать не события click, а событий типа selectOffer и createOrder:

    -
    this.offerList.on(
    -  'selectOffer', 
    -  (event) => {
    -    this.selectedOffer =
    -      event.offer;
    -    this.offerPanel.show(
    -      this.selectedOffer
    +

    Код выглядит более разумно написанным, но никак не уменьшает связность: использовать OfferList без OfferPanel мы всё ещё не можем.

    +

    Во всех вышеприведённых фрагментах кода налицо полный хаос с уровнями абстракции: OfferList инстанцирует OfferPanel и управляет ей напрямую. При этом OfferPanel приходится перепрыгивать через уровни, чтобы создать заказ. Мы можем попытаться разомкнуть эту связь, если начнём маршрутизировать потоки команд через сам SearchBox, например, так:

    +
    class SearchBox() {
    +  constructor() {
    +    this.offerList = new OfferList(…);
    +    this.offerPanel = new OfferPanel(…);
    +    this.offerList.events.on(
    +      'offerSelect', function (offer) {
    +        this.offerPanel.show(offer);
    +      }
         );
    -  });
    -this.offerPanel.on(
    -  'createOrder', () => {
    -    this.createOrder();
    +    this.offerPanel.events.on(
    +      'close', function () {
    +        this.offerList
    +          .resetSelectedOffer()
    +      }
    +    )
       }
    -)
    -
    -

    Возможности по кастомизации субкомпонентов расширились: теперь нет нужды эмулировать 'click' для выбора предложения, есть семантический способ сделать это через событие selectOffer; аналогично, какие события обрабатывает панель предложения для бросания события createOrder — больше не забота самого SearchBox-а.

    -

    Однако описанный выше пример — с заказом свайпом по элементу списка — всё ещё реализуется «костыльно» через открытие невидимой панели, поскольку вызов offerPanel.show всё ещё жёстко вшит в сам SearchBox. Мы можем сделать ещё один шаг, и сделать связность ещё более слабой: пусть SearchBox не вызывает напрямую методы субкомпонентов, а только извещает об изменении собственного состояния:

    -
    this.offerList.on(
    -  'selectOffer', 
    -  (event) => {
    -    this.selectedOffer =
    -      event.offer;
    -    this.emit('stateChange', {
    -      selectedOffer: this.
    -        selectedOffer
    -    });
    -  });
    -this.offerPanel.on(
    -  'createOrder', () => {
    -    const order = await api.createOrder();
    -    this.state = 'orderCreated';
    -    this.emit('stateChange', {
    -      order
    -    });
    -  }
    -)
    -
    -

    Тем самым мы даём имплементациям компонентов бо́льшую свободу действий. offerPanel не обязана «открываться», если такого состояния в ней нет и может просто проигнорировать изменения состояния на offerSelected. Наконец, мы могли бы полностью абстрагироваться от нижележащего UI, если сделаем SearchBox транслятором несущественного для него события "selectOffer":

    -
    // Имплементация SearchBox
    -class SearchBox {
    -  …
    -  public onMessage(message) {
    -    switch (message.type) {
    -      case 'selectOffer':
    -        this.emit('stateChange', {
    -          selectedOffer: message.offer
    -        });
    -        break;
    -      …
    -    }
    -  }
    -  …
    -};
    -// Имплементация OfferList
    -class OfferList {
    -  public context: SearchBox;
    -  onOfferClick(offer) {
    -    // Компонент-список предложений
    -    // инициирует выбор конкретного
    -    // предложения через нотификацию
    -    // родительского контекста
    -    this.context.onMessage({
    -        type: 'selectOffer',
    -        offer
    -    });
    -  }
    -  …
     }
     
    -

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

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

    Или, если сформулировать другими словами, наш SearchBox не «перекидывает мостик», не сближает два программных контекста (высокоуровневый SearchBox и низкоуровневую имплементацию, скажем, offerPanel-а). Пусть, например, разработчик хочет сделать не сложную замену UX, а очень простую вещь: сменить дизайн кнопки «Заказать» на какой-то другой. Проблема заключается в том, что альтернативная кнопка не бросает никаких событий 'createOrder' — она генерирует самый обычный 'click'. А значит, разработчику придётся написать эту логику самостоятельно.

    -
    class MyOfferPanel implements IOfferPanel {
    -  protected parentSearchBox;
    +

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

    +
    class SearchBox() {
    +  constructor() {
    +    …
    +    this.offerList.events.on(
    +      'offerSelect', function (event) {
    +        this.events.emit('offerSelect', {
    +          offer: event.selectedOffer
    +        });
    +      }
    +    );
    +  }
    +}
    +
    +

    То есть заставить SearchBox транслировать события, возможно, с преобразованием данных. Мы даже можем заставить SearchBox транслировать любые события дочерних компонентов, и, таким образом, прозрачным образом расширять функциональность, добавляя новые события. Но это совершенно очевидно не ответственность высокоуровневого компонента — состоять, в основном, из кода трансляции событий. К тому же, в этих цепочках событий очень легко запутаться. Как, например, должна быть реализована функциональность выбора следующего предложения в offerPanel (п. 3 в нашем списке улучшений)? Для этого необходимо, чтобы OfferList не только генерировал сам событие offerSelect, но и прослушивал это событие на родительском контексте и реагировал на него. В этом коде легко можно организовать бесконечный цикл:

    +
    class OfferList {
    +  constructor(searchBox, …) {
    +    …
    +    searchBox.events.on(
    +      'offerSelect',
    +      this.selectOffer
    +    )
    +  }
     
    -  render() {
    -    this.button = new CustomButton();
    -    this.button.on('click', () => {
    -      this.parentSearchBox.notify(
    -        'createOrder'
    +  selectOffer(offer) {
    +    …
    +    this.events.emit(
    +      'offerSelect', offer
    +    )
    +  }
    +}
    +
    +class SearchBox {
    +  constructor() {
    +    …
    +    this.offerList.events.on(
    +      'offerSelect', function (offer) {
    +        …
    +        this.events.emit(
    +          'offerSelect', offer
    +        )
    +      }
    +    )
    +  }
    +}
    +
    +

    Во избежание таких циклов мы можем разделить события:

    +
    class SearchBox {
    +  constructor() {
    +    …
    +    // `OfferList` сообщает о низкоуровневых
    +    // событиях, а `SearchBox` — о высокоуровневых
    +    this.offerList.events.on(
    +      'click', function (target) {
    +        …
    +        this.events.emit(
    +          'offerSelect',
    +          target.dataset.offer
    +        )
    +      }
    +    )
    +  }
    +}
    +
    +

    Но тогда код станет окончательно неподдерживаемым: для того, чтобы открыть панель предложения, нужно будет сгенерировать click на инстанции класса offerList.

    +

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

    +

    Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:

    +
      +
    1. +

      SearchBox отвечает за предоставление общего интерфейса. Он является точкой входа и для пользователя, и для разработчика. Если мы спросим себя: какой максимально абстрактный компонент мы всё ещё готовы называть SearchBox-ом? Очевидно, некоторый UI для ввода поискового запроса и его отображения, а также какое-то абстрактное создание заказа по предложениям.

      +
    2. +
    3. +

      OfferList выполняет функцию показа пользователю какого-то списка предложений кофе. Пользователь может взаимодействовать со списком — просматривать его и «активировать» предложения (т.е. выполнять какие-то операции с конкретным элементом списка).

      +
    4. +
    5. +

      OfferPanel представляет одно конкретное предложение и отображает всю значимую информацию для пользователя. Панель предложения всегда ровно одна. Пользователь может взаимодействовать с панелью, активируя различные действия, связанные с этим конкретным предложением (включая создание заказа).

      +
    6. +
    +

    Следует ли из определения SearchBox необходимость наличия суб-компонента OfferList? Никоим образом: мы можем придумать самые разные способы показа пользователю предложений. OfferListчастный случай, каким образом мы могли бы организовать работу SearchBox-а по предоставлению UI к результатами поиска.

    +

    Следует ли из определения SearchBox и OfferList необходимость наличия суб-компонента OfferPanel? Вновь нет: даже сама концепция существования какой-то краткой и полной информации о предложении (первая показана в списке, вторая в панели) никак не следует из определений, которые мы дали выше. Аналогично, ниоткуда не следует и наличие действия «выбор предложения» и вообще концепция того, что OfferList и OfferPanel выполняют разные действия и имеют разные настройки. На уровне SearchBox вообще не важно, как результаты поисква представлены пользователю и в каких состояниях может находиться соответствующий UI.

    +

    Всё это приводит нас к простому выводу: мы не можем декомпозировать SearchBox просто потому, что мы не располагаем достаточным количеством уровней абстракции и пытаемся «перепрыгнуть» через них. Нам нужен «мостик» между SearchBox, который не зависит от конкретной имплементации UI работы с предложениями и OfferList/OfferPanel, которые описывают конкретную концепцию такого UI. Введём дополнительный уровень абстракции (назовём его, скажем, «composer»), который позволит нам модерировать потоки данных.

    +
    class SearchBoxComposer implements ISearchBoxComposer {
    +  // Ответственность `composer`-а состоит в:
    +  // 1. Создании собственного контекста
    +  // для дочерних компонентов
    +  constructor(searchBox, container, options) {
    +    …
    +    // Контекст состоит из показанного списка
    +    // предложений (возможно, пустого) и
    +    // выбранного предложения (возможно, пустого)
    +    this.offerList = null;
    +    this.currentOffer = null;
    +    // 2. Создании конкретных суб-компонентов
    +    // и трансляции опций для них
    +    this.offerList = this.buildOfferList();
    +    this.offerPanel = this.buildOfferPanel();
    +    // 2. Управлении состоянием и оповещении
    +    // суб-компонентов о его изменении
    +    this.searchBox.events.on(
    +      'offerListChange', this.onOfferListChange
    +    );
    +    // 3. Прослушивании событий дочерних
    +    // компонентов и вызове нужных действий
    +    this.offerListComponent.events.on(
    +      'offerSelect', this.selectOffer
    +    );
    +    this.offerPanelComponent.events.on(
    +        'action', this.performAction
    +    );
    +  }
    +
    +  buildOfferList() {
    +    return new OfferList(
    +      this,
    +      this.offerListContainer,
    +      this.generateOfferListOptions()
    +    );
    +  }
    +
    +  buildOfferPanel() {
    +    return new OfferPanel(
    +      this,
    +      this.offerPanelContainer,
    +      this.generateOfferPanelOptions()
    +    );
    +  }
    +
    +

    Мы можем придать SearchBoxComposer-у функциональность трансляции любых контекстов. В частности:

    +
      +
    1. Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что offerList показывает краткую информацию о предложений, а offerPanel — полную, и предоставить (потенциально переопределяемые) методы генерации нужных срезов данных: +
      class SearchBoxComposer {
      +  …
      +  onContextOfferListChange(offerList) {
      +    …
      +    // `SearchBox` транслирует событие
      +    // `offerListChange` как `offerPreviewListChange`
      +    // специально для компонента `OfferList`,
      +    // таким образом, исключая возможность 
      +    // зацикливания, и подготавливает данные
      +    this.events.emit('offerPreviewListChange', {
      +      offerList: this.generateOfferPreviews(
      +        this.offerList,
      +        this.contextOptions
             )
           });
         }
       }
       
      -

      В нашем примере это не выглядит чем-то сложным (но это только потому, что наш конечный автомат очень прост и содержит очень мало данных), но трудно не согласиться с тем, что необходимость писать подобный код совершенно неоправдана: почти любая альтернативная реализация кнопки генерирует именно событие 'click'.

      -

      Другая очень большая проблема состоит в том, что с подобным «плоским» интерфейсом (любой актор может отправить события selectOffer / createOrder) мы фактически просто перевернули дырявую изоляцию абстракций с ног на голову: раньше SearchBox должен был знать о низкоуровневых объектах и их поведении — теперь низкоуровневые объекты должны знать о логике работы SearchBox. Такая перевёрнутая пирамида лучше прямой (нам хотя бы не приходится эмулировать клики на скрытых объектах), но далеко не идеальна с точки зрения архитектуры конкретного приложения. Написанный в этой парадигме код практически невозможно использовать повторно (приведённый выше пример MyOfferPanel нельзя использовать для каких-либо других целей, потому что действие по клику на кнопку всегда одно и то же — создание заказа), что приводит к необходимости копипастинга кода со всеми вытекающими проблемами.

      -

      Мы можем решить и эту проблему, если искусственным образом «перекинем мостик» — введём дополнительный уровень абстракции (назовём его, скажем, «арбитром»), который позволяет транслировать контексты:

      -
      class Arbiter implements IArbiter {
      -  protected currentSelectedOffer;
      -
      -  constructor(
      -    searchBox: ISearchBox,
      -    offerPanel: IOfferPanel
      -  ) {
      -    // Панель показа предложений
      -    // должна быть каким-то образом
      -    // привязана к арбитру
      -    offerPanel.setArbiter(this);
      -    
      -    searchBox.on('stateChange', (event) => {
      -      // Арбитр переформулирует события
      -      // `searchBox` в требования к
      -      // панели предложений.
      -
      -      // Если выбрано новое предложение
      -      if (this.currentSelectedOffer !=
      -        event.offer) {
      -        // Запоминаем предложение
      -        this.currentSelectedOffer =
      -          event.offer;
      -        // Даём команду на открытие панели
      -        this.emit('showPanel', {
      -          content: this.generateOfferContent(
      -            this.currentSelectedOffer
      -          )
      -        });
      -      }
      -    });
      -
      -    // Если же от кнопки создания заказа
      -    // пришло событие 'click'
      -    this.offerPanel.createOrderButton.on(
      -      'click',
      -      () => {
      -        this.searchBox.notify('createOrder');
      -      }
      -    );
      -  }
      -
      -  protected generateOfferContent(offer) {
      -    // Формирует контент панели
      +
    2. +
    3. Логику управления собственным состоянием (в нашем случае полем currentOffer): +
      class SearchBoxComposer {
      +  …
      +  onContextOfferListChange(offerList) {
      +    // Если в момент ввода нового поискового
      +    // запроса пользователем показано какое-то
      +    // предложение, его необходимо скрыть
      +    if (this.currentOffer !== null) {
      +      this.currentOffer = null;
      +      // Специальное событие для
      +      // компонента `offerPanel`
      +      this.events.emit(
      +        'offerFullViewToggle', 
      +        { offer: null }
      +      );
      +    }
           …
         }
       }
       
      -

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

      -

      Более того, мы можем пойти дальше и сделать два уровня арбитров — между SearchBox и панелью предложений и между панелью предложений и кнопкой создания заказа. Тогда у нас пропадёт требование к IOfferPanel иметь поле createOrderButton, и мы сможем свободно комбинировать разные варианты: альтернативный способ подтверждения заказа (не по кнопке), альтернативная реализация панели с сохранением той же кнопки и т.д.

      -

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

      Глава 45. MV*-фреймворки

      Глава 46. Backend-Driven UI

      Глава 47. Разделяемые ресурсы и асинхронные блокировки

      Глава 48. Вычисляемые свойства

      Глава 49. В заключение

      Раздел VI. API как продукт

      Глава 50. Продукт API 

      +
    4. +
    5. Логику преобразования действий пользователя на одном из субкомпонентов в события или действия над другими компонентами или родительским контекстом: +
      class SearchBoxComposer {
      +  …
      +  public performAction({
      +    action, offerId
      +  } {
      +    switch (action) {
      +      case 'createOrder':
      +        // Действие «создать заказ»
      +        // нужно оттранслировать `SearchBox`-у
      +        this.createOrder(offerId);
      +        break;
      +      case 'close':
      +        // Действие «закрытие панели предложения»
      +        // нужно оттранслировать `OfferList`-у
      +        if (this.currentOffer != null) {
      +          this.currentOffer = null;
      +          this.events.emit(
      +            'offerFullViewToggle', { offer: null }
      +          );
      +        }
      +        break;
      +      …
      +    }
      +  }
      +
      +
    6. +
    +

    Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них:

    +
      +
    1. Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного OfferList-а нам нужно переопределить метод buildOfferList так, чтобы он создавал наш кастомный компонент с картой.
    2. +
    3. Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный ISearchBoxComposer. Но мы при этом сможем использовать стандартный OfferList, поскольку Composer управляет и подготовкой данных для него, и реакцией на действия пользователей.
    4. +
    5. Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный SearchBoxComposer и OfferList), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые SearchBoxComposer транслирует с панели предложения.
    6. +
    +

    Ценой этой гибкости является чрезвычайное усложнение взаимодейсвтия. Все события и потоки данных должны проходить через цепочку таких Composer-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.

    +

    Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:

    +

    Глава 45. MV*-фреймворки

    Глава 46. Backend-Driven UI

    Глава 47. Разделяемые ресурсы и асинхронные блокировки

    Глава 48. Вычисляемые свойства

    Глава 49. В заключение

    Раздел VI. API как продукт

    Глава 50. Продукт API 

    Когда мы говорим об API как о продукте, необходимо чётко зафиксировать два важных тезиса.

    1. diff --git a/src/css/style.css b/src/css/style.css index b1f7664..7654c84 100644 --- a/src/css/style.css +++ b/src/css/style.css @@ -189,7 +189,7 @@ h6 { color: darkgray; text-align: center; padding: 0; - margin: 0 1em 0 2em; + margin: 0 1em 1.2em 1em; font-weight: normal; } diff --git a/src/img/mockups/08.png b/src/img/mockups/08.png index 533a8ac..2c2301c 100644 Binary files a/src/img/mockups/08.png and b/src/img/mockups/08.png differ diff --git a/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md b/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md index 09be696..07a9484 100644 --- a/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md +++ b/src/ru/clean-copy/06-[В разработке] Раздел V. SDK и UI-библиотеки/04.md @@ -22,7 +22,7 @@ * кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения; * кнопка «позвонить» показывается динамически, если номер кофейни известен, и, таким образом, требует возможности определения списка показываемых кнопок динамически, отдельно для каждого конкретного предложения. - Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов; + Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов. [![APP](/img/mockups/08.png "Панель предложения с дополнительными кнопками и иконками")]()