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
так, чтобы он допускал следующие модификации:
-
-
Брендирование предложения и кнопки заказа иконкой сети кофеен:
+Замена списочного представления предложений, например, на представление в виде карты с подсвечиваемыми метками:
-- иллюстрирует проблемы неоднозначности иерархий наследования и разделяемых ресурсов (иконки кнопки), как описано в предыдущей главе;
+- иллюстрирует проблему полной замены одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы, а также сложности имплементации разделяемого состояния;
-
--
-
Замена списочного представления предложений, например, на представление в виде карты с метками:
-
-- иллюстрирует проблему изменения дизайна одного субкомпонента (списка заказов) при сохранении поведения и дизайна остальных частей системы;
-
-
Результаты поиска на карте. Нажмите для увеличения
+
-
-
Добавление кнопки быстрого заказа в каждое предложение в списке:
+Комбинирование краткого и полного описания предложения в одном интерфейсе (предложение можно развернуть прямо в списке и сразу сделать заказ)
-- иллюстрирует проблему разделяемых ресурсов (см. более подробные пояснения ниже) и изменения UX системы в целом при сохранении существующего дизайна, поведения и бизнес-логики.
+- иллюстрирует проблему полного удаления одного из субкомпонентов при сохранении бизнес-логики и UX:
-
Список результатов поиска с кнопками быстрых действий. Нажмите для увеличения
+
+
-
-
Динамическое добавление новой кнопки в панель создания заказа с дополнительной функцией (скажем, позвонить по телефону в кофейню):
+Манипуляция данными и доступными действиями для предложения через добавление новых кнопок (вперёд, назад, позвонить) и управление их содержимым. Отметим, что каждая из кнопок предоставляет свою проблему с точки зрения имплементации:
-- иллюстрирует проблему динамического изменения бизнес-логики компонента при сохранении внешнего вида и 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 и у нас не было бы задачи разрешать кастомизацию этих компонентов, подобная реализация была бы стопроцентно уместной. Попробуем, однако, представить, как мы будем решать описанные выше задачи:
+
+-
+
Показ списка предложений на карте: на первый взгляд, мы можем разработать альтернативный компонент показа списка предложений, скажем, 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.
+
+-
+
Полные описания и заказ в самом списке заказов — в этом случае всё достаточно очевидно: мы можем добиться нужной функциональности только созданием собственного компонента. Даже если мы предоставим метод переопределения внешнего вида элемента списка для стандартного компонента OfferList
, он всё равно продолжит создавать OfferPanel
и открывать его по выбору предложения.
+
+-
+
Для реализации новых кнопок мы можем только лишь предложить программисту реализовать свой список предложений (чтобы предоставить методы выбора предыдущего / следующего предложения) и свою панель предложений, которая эти методы будет вызывать. Даже если мы задаимся какой нибудь простой кастомизацией, например, текста кнопки «Сделать заказ», то в данном коде она фактически является ответственностью класса 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
+ …
+ })
+ }
+}
+
+
+
+Самая же неприятная особенность кода из п. 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
концептуально как конечный автомат, находящийся в одном из трёх состояний:
-
-- Пуст (ожидает запроса пользователя и получения списка предложений).
-- Показан список предложений по запросу.
-- Показано конкретное предложение пользователю.
-- Создаётся заказ.
-
-Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, 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-компонента в самых различных парадигмах, но так и не получили ни одного приемлемого решения. Вывод, который мы должны сделать, следующий: проблема не в конкретных интерфейсах и не в подходе к решению. В чём же она тогда?
+Давайте сформулируем, в чём состоит область ответственности каждого из наших компонентов:
+
+-
+
SearchBox
отвечает за предоставление общего интерфейса. Он является точкой входа и для пользователя, и для разработчика. Если мы спросим себя: какой максимально абстрактный компонент мы всё ещё готовы называть SearchBox
-ом? Очевидно, некоторый UI для ввода поискового запроса и его отображения, а также какое-то абстрактное создание заказа по предложениям.
+
+-
+
OfferList
выполняет функцию показа пользователю какого-то списка предложений кофе. Пользователь может взаимодействовать со списком — просматривать его и «активировать» предложения (т.е. выполнять какие-то операции с конкретным элементом списка).
+
+-
+
OfferPanel
представляет одно конкретное предложение и отображает всю значимую информацию для пользователя. Панель предложения всегда ровно одна. Пользователь может взаимодействовать с панелью, активируя различные действия, связанные с этим конкретным предложением (включая создание заказа).
+
+
+Следует ли из определения 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
-у функциональность трансляции любых контекстов. В частности:
+
+- Трансляцию данных и подготовку данных. На этом уровне мы можем предположить, что
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) {
- // Формирует контент панели
+
+- Логику управления собственным состоянием (в нашем случае полем
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
+
+- Логику преобразования действий пользователя на одном из субкомпонентов в события или действия над другими компонентами или родительским контекстом:
+
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;
+ …
+ }
+ }
+
+
+
+Если теперь мы посмотрим на кейсы, описанные в начале главы, то мы можем наметить стратегию имплементации каждого из них:
+
+- Показ компонентов на карте не меняет общую декомпозицию компонентов на список и панель. Для реализации альтернативного
OfferList
-а нам нужно переопределить метод buildOfferList
так, чтобы он создавал наш кастомный компонент с картой.
+- Комбинирование функциональности списка и панели меняет концепцию, поэтому нам необходимо будет разработать собственный
ISearchBoxComposer
. Но мы при этом сможем использовать стандартный OfferList
, поскольку Composer
управляет и подготовкой данных для него, и реакцией на действия пользователей.
+- Обогащение функциональности панели не меняет общую декомпозицию (значит, мы сможем продолжать использовать стандартный
SearchBoxComposer
и OfferList
), но нам нужно переопределить подготовку данных и опций при открытии панели, и реализовать дополнительные события и действия, которые SearchBoxComposer
транслирует с панели предложения.
+
+Ценой этой гибкости является чрезвычайное усложнение взаимодейсвтия. Все события и потоки данных должны проходить через цепочку таких Composer
-ов, удлиняющих иерархию сущностей. Любое преобразование (например, генерация опций для вложенного компонента или реакция на события контекста) должно быть параметризуемым. Мы можем подобрать какие-то разумные хелперы для того, чтобы пользоваться такими кастомизациями было проще, но никак не сможем убрать эту сложность из кода нашего SDK. Таков путь.
+Пример реализации компонентов с описанными интерфейсами и имплементацией всех трёх кейсов вы можете найти в репозитории настоящей книги:
+
+- исходный код доступен на www.github.com/twirl/The-API-Book/docs/examples
+
+- там же предложены несколько задач для самостоятельного изучения;
+
+
+- песочница с «живыми» примерами достпна на twirl.github.io/The-API-Book.
+
Глава 45. MV*-фреймворки
Глава 46. Backend-Driven UI
Глава 47. Разделяемые ресурсы и асинхронные блокировки
Глава 48. Вычисляемые свойства
Глава 49. В заключение
Раздел VI. API как продукт
Глава 50. Продукт API
Когда мы говорим об API как о продукте, необходимо чётко зафиксировать два важных тезиса.
-
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 @@
* кнопки навигации (вперёд/назад) требуют, чтобы информация о связности списка (есть предыдущий / следующий заказ) каким-то образом дошла до панели показа предложения;
* кнопка «позвонить» показывается динамически, если номер кофейни известен, и, таким образом, требует возможности определения списка показываемых кнопок динамически, отдельно для каждого конкретного предложения.
- Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов;
+ Пример иллюстрирует проблемы неоднозначности иерархий наследования и сильной связности компонентов.
[]()