Это глава 44 раздела «SDK и UI-библиотеки» моей книги «API». Второе издание книги будет содержать три новых раздела: «Паттерны API», «HTTP API и REST», «SDK и UI‑библиотеки». Если эта работа была для вас полезна, пожалуйста, оцените книгу на GitHub, Amazon или GoodReads. English version on Substack.
Очевидным способом сделать менее сложными многослойные схемы, подобные описанным в предыдущей главе, является ограничение возможных путей взаимодействия между компонентами. Как мы описывали в главе «Слабая связность», мы могли бы упростить код, если бы разрешили нижележащим субкомпонентам напрямую вызывать методы вышестоящих сущностей. Например, так:
class SearchBoxComposer
implements ISearchBoxComposer {
…
protected context: ISearchBox;
…
public createOrder(offerId: string) {
const offer = this.findOfferById(offerId);
if (offer !== null) {
// Вместо вызова
// this.events.emit(
// 'createOrder', { offer });
this.context
.createOrder(offer);
}
}
}
Кроме того, мы можем убрать Composer
из цепочки подготовки данных так так, чтобы подчинённые компоненты напрямую получали нужные поля напрямую из SearchBox
:
class OfferListComponent
implements IOfferListComponent {
…
protected context: SearchBox;
…
constructor () {
…
// Список заказов напрямую
// получает нужные данные
// и оповещения из `SearchBox`
this.context.events.on(
'offerListChange',
() => {
this.show(
context.getOfferList()
);
}
);
}
…
}
Тем самым мы утратили возможность подготавливать данные для их показа в списке и, тем самым, возможность встраивать их в любого родителя через соответствующий Composer
. Но при этом мы сохранили возможность свободно использовать альтернативные реализации компонентов панели, поскольку реакция на взаимодействие пользователя с интерфейсом находится всё ещё находится под контролем Composer
-а. Как бонус мы получили отсутствие двусторонних взаимодействий между тремя нашими сущностями:
субкомпоненты читают состояние
SearchBox
-а, но не модифицируют его;Composer
получает оповещения о взаимодействии пользователя с UI, но никак не влияет на сам UI;наконец,
SearchBox
никак не взаимодействует ни с тем, ни с другим — только лишь предоставляет контекст, методы его изменения и нотификации.
Сделав подобное упрощение, мы фактически получили компонент, следующий методологии «Model-View-Controller» (MVC)1: OfferList
и OfferPanel
(а также код показа поля ввода) — это различные view, которые непосредственно наблюдает пользователь и взаимодействует с ними; Composer
— это controller, который получает события от view и модифицирует модель (сам SearchBox
).
NB: следуя букве подхода, мы должны выделить из SearchBox
компонент-модель, который отвечает только за данные. Это упражнение мы оставим читателю.
Если мы выберем другие направления редукции полного взаимодействия, мы получим другие варианты MV*-фреймворков (Model-View-Viewmodel, Model-View-Presenter и т.д.) Все они, в конечно счёте, основаны на паттерне «модель», который мы обсудим ниже.
Паттерн «модель»
Общая черта, объединяющая все MV*-фреймворки — это требование к сущности «модель» (некоторого набора данных) полностью детерминировано определять внешний вид и состояние UI-компонента. Изменения в модели порождают и изменения в отображении компонента (или дерева компонентов; в некоторых подходах модель может быть одной на всё приложение и полностью определять весь интерфейс). В то же время визуальные представления не могут влиять на модель напрямую, так как им разрешено взаимодействовать только с контроллером.
SDK, реализованный в MV*-парадигмах, в теории получает несколько важных свойств:
Принудительное разделение уровней абстракции, поскольку постулируется (но далеко не всегда выполняется, см. ниже), что модель содержит семантичные высокоуровневые данные.
-
Практически исключены циклы в обработке событий, поскольку контроллер должен реагировать только на взаимодействие пользователя или разработчика с view, но не на изменения модели.
Дополнительно, события изменения состояния модели обычно генерируются только в том случае, если состояние действительно изменилось (новое значение поля не совпадает со старым), и, таким образом, чтобы зациклить обработку события, система должна бесконечно осциллировать между двумя разными состояниями, что достаточно сложно допустить случайно.
controller транслирует низкоуровневые события (взаимодействие пользователя с view) в высокоуровневые, тем самым предоставляя нужную глубину уровней абстракции и позволяя полностью менять UI при сохранении бизнес-логики;
-
Данные модели полностью определяют состояние системы, что очень удобно при реализации такой сложной функциональности как восстановление приложения в случае сбоя, совместное редактирование, отмена последних действий и т.д.
Один из частных случаев использования этого свойства — сериализация модели в виде URL (или App Links в случае мобильных приложений). Тогда URL полностью определяет состояние приложения, и любые изменения состояния отражаются в виде изменений URL. Этот подход чрезвычайно удобен тем, что можно сгенерировать специальные ссылки, открывающие нужный экран в приложении.
Иными словами, MV*-фреймворки представляют собой жёсткий шаблон, который помогает писать качественный код и не запутываться в потоках данных.
Однако эта жёсткость влечёт за собой недостатки. Если задаться целью полностью описать состояние компонента, то мы обязаны внести в него и такие данные, как выполняющиеся сейчас анимации и даже процент их выполнения. Таким образом, модель обязана будет содержать в себе все данные всех уровней абстракции и, более того, каким-то образом включать в себя две или более иерархии подчинения (по семантической и визуальной иерархиям, а так же, возможно, вычисляемые значения опций). В нашем примере это означает, например, что модель должна будет хранить и currentSelectedOffer
для OfferPanel
, и список показанных кнопок, и даже вычисленные значения иконок для кнопок.
Подобная полная модель представляет собой проблему не только теоретически и семантически (перемешивание в одной сущности разнородных данных), но и в практическом смысле — сериализация таких моделей окажется ограничена рамками конкретной версии API или приложения (поскольку они содержат все внутренние переменные, включая непубличные). Если мы в следующей версии изменим реализацию субкомпонентов, то старые ссылки и закэшированные состояния перестанут работать (либо нам потребуется держать слой совместимости, описывающий, как интерпретировать модели предыдущих версий).
Другая идеологическая проблема подхода — организация вложенных контроллеров. В системе с дочерними субкомпонентами все те проблемы, которые решил MV*-подход, возвращаются на новом уровне: нам придётся разрешить вложенным контроллерам либо перепрыгивать через уровни абстракции и модифицировать корневую модель, либо вызывать методы контроллеров родительских компонент. Оба подхода влекут за собой сильную связность сущностей и требуют очень аккуратного проектирования, иначе переиспользование компонентов окажется затруднено.
Если мы внимательно посмотрим на современные UI библиотеки, выполненные в MV*-парадигмах, то увидим, что они следуют парадигме весьма нестрого и лишь заимствуют из неё основной принцип: модель полностью определяет внешний вид компонента, и любые визуальные изменения инициируются через изменение модели контроллером. Дочерние компоненты при этом обычно имеют собственные модели (часто в виде подмножества родительской модели, дополненного собственным состоянием компонента), и в глобальной модели приложения находится только ограниченный набор полей. Этот подход адаптирован во многих современных UI-фреймворках, даже тех, которые от MV*-парадигм открещиваются (например, React2 3).
На эти же проблемы MVC-подхода обращает внимание в своём эссе Мартин Фаулер в своём эссе «Архитектуры GUI»4, и предлагает решение в виде фреймворка Model-View-Presenter (MVP), в котором место controller-а занимает сущность-presenter, в обязанности которой входит не только трансляция событий, но и подготовка данных для view, что позволяет разделить уровни абстракции (модель хранит только семантичные данные, описывающие предметную область, presenter — низкоуровневые данные, описывающие UI, в терминологии Фаулера — application model или presentation model).
Предложенная Фаулером парадигма во многом схожа с концепцией Composer
-а, которую мы обсуждали в предыдущей главе, но с одним заметным различием. По мысли Фаулера собственного состояния у presenter-а нет (за исключением, возможно, кэшей и замыканий), он только вычисляет данные, необходимые для показа визуального интерфейса, из данных модели. Если необходимо манипулировать каким-то низкоуровневым свойством, например, цветом текста в интерфейсе, то нужно разработать модель так, чтобы цвет текста вычислялся presenter-ом из какого-то высокоуровневого поля в модели (возможно, искусственно введённого), что ограничивает возможности альтернативных имплементаций субкомпонентов.
NB: на всякий случай уточним, что автор этой книги не предлагает Composer
как альтернативную MV*-методологию. Идея предыдущей главы состоит в том, что сложные сценарии декомпозиции UI-компонентов решаются только искусственным введением мостиков-уровней абстракции. Неважно, как мы этот мостик назовём и какие правила для него придумаем.
Примечания
[1] MVC
[3] Mattiazzi, R. How React and Redux brought back MVC and everyone loved it