mirror of
https://github.com/twirl/The-API-Book.git
synced 2025-01-05 10:20:22 +02:00
refactoring
This commit is contained in:
parent
7bdcb7b64a
commit
40ff86511e
@ -2,7 +2,7 @@
|
||||
|
||||
Аббревиатура «SDK» («Software Development Kit»), как и многие из обсуждавшихся ранее терминов, не имеет конкретного значения. Считается, что SDK отличается от API тем, что помимо программных интерфейсов содержит и какое-то количество готовых инструментов для работы с ними. Определение это, конечно, лукавое, поскольку почти любая технология сегодня идёт в комплекте со своим набором инструментов.
|
||||
|
||||
Тем не менее, у термина SDK есть и более узкое значение, в котором он часто используется: это клиентская библиотека, которая предоставляет нативный интерфейс для работы с некоторой нижележащей платформой (и в частности с клиент-серверным API). Чаще всего речь идёт о библиотеках для мобильных платформ или веб-платформы, которые работают поверх нижележащего HTTP API сервиса.
|
||||
Тем не менее, у термина SDK есть и более узкое значение, в котором он часто используется: это клиентская библиотека, которая предоставляет высокоуровневый (обычно, нативный) интерфейс для работы с некоторой нижележащей платформой (и в частности с клиент-серверным API). Чаще всего речь идёт о библиотеках для мобильных платформ или веб-платформы, которые работают поверх нижележащего HTTP API сервиса.
|
||||
|
||||
Среди подобных клиентских SDK особо выделяются те из них, которые предоставляют не только программные интерфейсы для работы с API, но также и готовые визуальные компоненты, которые разработчик может использовать. Классический пример такого SDK — это библиотеки карточных сервисов; в силу исключительной сложности самостоятельной реализации движка работы с картами (особенно векторными) вендоры API карт предоставляют и «обёртки» к HTTP API (например, поисковому), и готовые библиотеки для работы с географическими сущностями, которые часто включают в себя и визуальные компоненты общего назначения — кнопки, метки, контекстные меню — которые могут применяться и совершенно самостоятельно вне контекста API (SDK) как такового.
|
||||
|
||||
@ -12,4 +12,4 @@
|
||||
|
||||
Во избежание нагромождения подобных оборотов мы будем называть первый тип библиотеки просто «SDK», а второй — «UI-библиотеки».
|
||||
|
||||
**NB**: вообще говоря, UI-библиотека может как включать в себя обёртку над клиент-серверным API, так и предоставлять чистый API к графическому движку. В рамках этой книги мы будем говорить, в основном, о первом варианте, поскольку с точки зрения дизайна API второго вида существенно проще и не требует отдельной главы. Тем не менее, многие паттерны дизайна SDK, которые мы опишем далее, применимы и к «чистым» библиотекам без клиент-серверной составляющей.
|
||||
**NB**: вообще говоря, UI-библиотека может как включать в себя обёртку над клиент-серверным API, так и предоставлять чистый API к графическому движку. В рамках этой книги мы будем говорить, в основном, о первом варианте, поскольку API второго вида существенно проще с точки зрения дизайна и не требует отдельной главы. Тем не менее, многие паттерны дизайна SDK, которые мы опишем далее, применимы и к «чистым» библиотекам без клиент-серверной составляющей.
|
@ -5,7 +5,7 @@
|
||||
Некоторые проблемы лежат на поверхности:
|
||||
1. Протоколы клиент-серверных API, как правило, разрабатываются так, что не зависят от конкретного языка программирования и, таким образом, без дополнительных действий полученные из API данные будут представлены в не самом удобном формате. Например в JSON нет типа данных «дата и время», и его приходится передавать в виде строки; или, скажем, поддержка (де)сериализации хэш-таблиц в протоколах общего назначения отсутствует.
|
||||
|
||||
2. Большинство языков программирования императивные (и чаще всего — объектно-ориентированные), в то время как большинство форматов данных — декларативные. Работать с сырыми данными, полученными из API, таким образом почти всегда неудобно с точки зрения написания кода.
|
||||
2. Большинство языков программирования императивные (и чаще всего — объектно-ориентированные), в то время как большинство форматов данных — декларативные. Работать с сырыми данными, полученными из API, таким образом почти всегда неудобно с точки зрения написания кода, программистам зачастую было бы удобнее работать с полученными из API данными как с объектами.
|
||||
|
||||
3. Разные языки программирования предполагают разный стиль кодирования (кейсинг, организация неймспейсов и т.п.), в то время как концепция API не предполагает адаптацию форматирования под запрашивающую платформу.
|
||||
|
||||
|
@ -2,13 +2,13 @@
|
||||
|
||||
Как мы убедились в предыдущей главе, список задач, стоящих перед разработчиком SDK (если, конечно, его целью является качественный продукт) — очень и очень значительный. Учитывая, что под каждую целевую платформу необходим отдельный SDK, неудивительно, что многие вендоры API стремятся полностью или частично заменить ручной труд машинным.
|
||||
|
||||
Одно из основных направлений такой автоматизации — кодогенерация, то есть разработка технологии, которая позволяет по спецификации API сгенерировать готовый код SDK на целевом языке программирования на целевой платформе. Многие современные стандарты обмена данными (в частности, gRPC) поставляются в комплекте с генераторами готовых клиентов на различных языках; к другим технологиям (в частности, OpenAPI/Swagger) такие генераторы пишутся энтузиастами.
|
||||
Одно из основных направлений такой автоматизации — кодогенерация, то есть разработка технологии, которая позволяет по спецификации API сгенерировать готовый код SDK на целевом языке программирования для целевой платформы. Многие современные стандарты обмена данными (в частности, gRPC) поставляются в комплекте с генераторами готовых клиентов на различных языках; к другим технологиям (в частности, OpenAPI/Swagger) такие генераторы пишутся энтузиастами.
|
||||
|
||||
Генерация кода позволяет решить типовые проблемы, описанные в предыдущей главе: стиль кодирования, обработка исключений, (де)сериализацию сложных типов — словом все те задачи, которые зависят не от предметной области, а от особенностей конкретной платформы. Относительно недорого разработчик API может дополнить такой автоматизированный «перевод» правильными настройками используемых системных средств: обеспечить автоматические перезапросы для идемпотентных эндпойнтов (с реализацией какой-то политики), кэширование результатов, сохранение данных (например, токенов авторизации) в системном хранилище и т.д. Такой сгенерированный SDK часто называют термином «клиент к API».
|
||||
|
||||
Удобство использования и функциональные возможности кодогенерации столь привлекательны, что многие вендоры API только ей и ограничиваются, предоставляя свои SDK в виде сгенерированных клиентов.
|
||||
|
||||
**NB**: напомним, что кодогенерация по спецификации, при всех её достоинствах, имеет один очень существенный недостаток: она существенно искажает понятие обратной совместимости, поскольку вводит ещё одну прослойку между спецификацией и кодом, который пишет разработчик. В общем случае, гарантировать, что обратно-совместимое изменение спецификации не приведёт к обратно-несовместимому изменению клиента к API [т.е. к тому, что написанный когда-то разработчиком код поверх кодогенерированного клиента будет корректно работать с новой версией клиента] — достаточно нетривиальная задача, равно как такая гарантия отсутствует и при переходе от одной версии библиотеки кодогенерации к другой. Как минимум это означает, что сгенерированные клиенты должны интенсивно тестироваться с целью выявления непредвиденных ошибок.
|
||||
**NB**: напомним, что кодогенерация по спецификации, при всех её достоинствах, имеет один очень существенный недостаток: она искажает понятие обратной совместимости, поскольку вводит ещё одну прослойку между спецификацией и кодом, который пишет разработчик. В общем случае, гарантировать, что обратно-совместимое изменение спецификации не приведёт к обратно-несовместимому изменению клиента к API [т.е. к тому, что написанный когда-то разработчиком код поверх кодогенерированного клиента будет корректно работать с новой версией клиента] — достаточно нетривиальная задача, равно как такая гарантия отсутствует и при переходе от одной версии библиотеки кодогенерации к другой. Как минимум это означает, что сгенерированные клиенты должны интенсивно тестироваться с целью выявления непредвиденных ошибок.
|
||||
|
||||
Как мы, однако, видим из предыдущей главы, проблемы более высокого порядка — получение серверных событий, обработка ошибок в бизнес-логике и т.п. — никак не может быть покрыта кодогенерацией, во всяком случае — стандартным модулем без его доработки применительно к конкретному API. В случае нетривиальных API со сложным основным циклом работы очень желательно, чтобы SDK решал также и высокоуровневые проблемы, иначе вы просто получите множество разработанных поверх API приложений, раз за разом повторяющие одни и те же «детские ошибки». Тем не менее, это не повод отказываться от кодогенерации полностью — её можно использовать как базис, на котором будет разработан высокоуровневый SDK.
|
||||
|
||||
|
@ -16,6 +16,18 @@
|
||||
|
||||
С одной стороны нам может показаться, что наш UI — это просто надстройка над клиент-серверным `search`, визуализирующая результаты поиска. Увы, это не так; перечислим проблемы, с которыми мы никогда не сталкивались при разработке API без визуальных компонент.
|
||||
|
||||
##### Объединение в одном объекте разнородной функциональности
|
||||
|
||||
Посмотрим на панель действий с предложениями. Допустим, мы размещаем на ней три кнопки — «заказать», «показать на карте» и «отменить». Эти кнопки выглядят одинаково и реагируют на действия пользователя одинаково — но при этом осуществляют абсолютно не имеющие ничего общего друг с другом действия.
|
||||
|
||||
Допустим, мы предоставили программисту возможность добавить свои кнопки действий на панель, для чего предоставим в составе SDK класс `Button`. Достаточно быстро мы выясним, что этой функциональностью будут пользоваться в двух основных диаметрально противоположных сценариях:
|
||||
* для размещения на панели дополнительных кнопок, ну скажем, «позвонить в кафе», *выполненных в том же дизайне, что и стандартные*;
|
||||
* для изменения дизайна стандартных кнопок в соответствии с фирменным стилем заказчика, *сохраняя ту же самую функциональность в неизменном виде*.
|
||||
|
||||
Более того, возможен и третий сценарий: разработчики заходят сделать кнопку «позвонить», которая будет и выглядеть иначе, и программно выполнять другие действия, но при этом будет *наследовать UX* кнопки — т.е. нажиматься при клике, располагаться в ряд с другими кнопками и так далее.
|
||||
|
||||
С точки зрения разработчика SDK это означает, что класс `Button` должен позволять независимо переопределять и внешний вид кнопки, и реакцию на действия пользователя, и элементы UX — или, иначе говоря, каждая из трёх подсистем может быть заменена альтернативной имплементацией так, чтобы для двух других подсистем ничего не изменилось (интерфейс взаимодействия сохранился).
|
||||
|
||||
##### Разделяемые ресурсы
|
||||
|
||||
Предположим, что мы хотим разрешить разработчику подставить в наш `SearchBox` свой поисковый запрос — например, чтобы дать возможность разместить в приложении баннер «найти лунго рядом со мной», по нажатию на который происходит переход к нашему компоненту с введённым запросом «лунго». Программно, разработчику потребуется показать соответствующий экран и вызвать метод `SeachBox.search`.
|
||||
@ -29,18 +41,6 @@
|
||||
|
||||
Любая асинхронная операция в UI-компонентах, особенно если она индицируется визуально (с помощью анимации или другого длящегося действия), может помешать любой другой визуальной операции — в том числе вследствие действий пользователя.
|
||||
|
||||
##### Сильная связность бизнес-логики и отображения
|
||||
|
||||
Посмотрим теперь на панель действий с предложениями. Допустим, мы размещаем на ней три кнопки — «заказать», «показать на карте» и «отменить». Эти кнопки выглядят одинаково и реагируют на действия пользователя одинаково — но при этом осуществляют абсолютно не имеющие ничего общего друг с другом действия.
|
||||
|
||||
Допустим, мы предоставили программисту возможность добавить свои кнопки действий на панель, для чего предоставим в составе SDK класс `Button`. Достаточно быстро мы выясним, что этой функциональностью будут пользоваться в двух основных диаметрально противоположных сценариях:
|
||||
* для размещения на панели дополнительных кнопок, ну скажем, «позвонить в кафе», *выполненных в том же дизайне, что и стандартные*;
|
||||
* для изменения дизайна стандартных кнопок в соответствии с фирменным стилем заказчика, *сохраняя ту же самую функциональность в неизменном виде*.
|
||||
|
||||
Более того, возможен и третий сценарий: разработчики заходят сделать кнопку «позвонить», которая будет и выглядеть иначе, и программно выполнять другие действия, но при этом будет *наследовать UX* кнопки — т.е. нажиматься при клике, располагаться в ряд с другими кнопками и так далее.
|
||||
|
||||
С точки зрения разработчика SDK это означает, что класс `Button` должен позволять независимо переопределять и внешний вид кнопки, и реакцию на действия пользователя, и элементы UX.
|
||||
|
||||
##### Множественная иерархия подчинения сущностей
|
||||
|
||||
Предположим, что разработчик хочет обогатить дизайн списка предложений иконками сетей кофеен. Если изображение известно, оно должно быть показано всюду, где происходит работа с предложением конкретной кофейни.
|
||||
@ -59,7 +59,7 @@
|
||||
const searchBox = new SearchBox({
|
||||
// Предположим, что мы разрешили
|
||||
// переопределять поисковую функцию
|
||||
searchApi: function (params) {
|
||||
searchFunction: function (params) {
|
||||
const res = await api.search(params);
|
||||
res.forEach((item) {
|
||||
item.checkoutButtonIconUrl =
|
||||
|
@ -1,72 +1,222 @@
|
||||
### Вычисляемые свойства
|
||||
### Декомпозиция UI-компонентов. MV*-подходы
|
||||
|
||||
Наличие множественных линий наследования усложняет кастомизация компонентов, поскольку подразумевает, что они могут наследовать важные свойства по любой из вертикалей (иконка кнопки может быть задана и в визуальных настройках компонента, и в данных, и в родительском классе) и, более того, любое поддерево иерархии может быть вообще полностью заменено (например, вместо использования системной кнопки мы можем отрисовать её напрямую как графический примитив). При этом корректность взаимодействия с нашей кастомизированной кнопкой по всем остальным иерархиям должна сохраняться:
|
||||
* при изменении дерева визуальных объектов (например, если мы сделаем свою отдельную всегда видимую кнопку «заказать» для каждого элемента в списке) ничего не должно измениться ни в работе с данными, ни в UX кнопки;
|
||||
* если разработчик подменит источник данных (например, реализовав свой алгоритм поиска), кнопка должна продолжать работать как в смысле UX (реагировать на действия пользователя) так и в плане бизнес-логики (создавать заказ);
|
||||
* если будет выбрана альтернативная реализация самого рендеринга кнопки, с точки зрения обработки данных, бизнес-логики и UX ничего измениться не должно.
|
||||
|
||||
Вернёмся к проблеме, которую мы описали в предыдущей главе. Пусть у нас имеется кнопка, которая получает одно и то же свойство `iconUrl` по двум вертикалям — из данных и из настроек отображения:
|
||||
Перейдём к более предметному разговору и попробуем объяснить, почему требование возможности замены одной из подсистем компонента (внешнего вида, бизнес-логики или поведения) приводит к кратному усложнению интерфейсов. Рассмотрим имплементацию функциональности создания заказа по клику на соответствующую кнопку. Внутри нашего класса `SearchBox` мы могли бы написать такой код:
|
||||
|
||||
```
|
||||
const button = new Button({
|
||||
model: {
|
||||
iconUrl: <URL#1>
|
||||
class SearchBox {
|
||||
// Список предложений
|
||||
public offerList: OfferList;
|
||||
// Панель отображения
|
||||
// выбранного предложения
|
||||
public offerPanel: OfferPanel;
|
||||
|
||||
// Инициализация
|
||||
init() {
|
||||
// Подписываемся на клик по
|
||||
// предложению
|
||||
this.offerList.on(
|
||||
'click',
|
||||
(event) => {
|
||||
this.selectedOffer =
|
||||
event.target.offer;
|
||||
this.offerPanel.show(
|
||||
this.selectedOffer
|
||||
);
|
||||
});
|
||||
this.offerPanel.on(
|
||||
'click', () => {
|
||||
this.createOrder();
|
||||
}
|
||||
)
|
||||
}
|
||||
);
|
||||
button.view.options.iconUrl = <URL#2>;
|
||||
```
|
||||
|
||||
При этом мы можем легко представить себе, что по обеим иерархиям свойство `iconUrl` было получено от кого-то из родителей — например, данные могут быть сгруппированы по бренду, и иконка будет задана для всей группы. Также возможно, что мы разрешим переопределять иконку вообще всем кнопкам через задание свойств по умолчанию для всего SDK.
|
||||
// Ссылка на текущее выбранное предложение
|
||||
private selectedOffer: Offer;
|
||||
// Создаёт заказ
|
||||
private createOrder() {
|
||||
const order = await api
|
||||
.createOrder(this.selectedOffer);
|
||||
// Действия после создания заказа
|
||||
…
|
||||
}
|
||||
|
||||
В этой ситуации у нас возникает вопрос: каким образом задавать приоритеты, какой из возможных вариантов опции будет выбран.
|
||||
|
||||
Современные графические SDK в зависимости от выбранного подхода делятся на две категории: построенные по образу и подобию CSS и все остальные.
|
||||
|
||||
#### Приоритеты наследования
|
||||
|
||||
Простой подход «в лоб» к этому вопросу — либо зафиксировать приоритеты в точности (скажем, заданное в опциях отображения значение всегда важнее заданного в модели, и они оба всегда важнее любого унаследованного свойства), либо попросту запретить наследование и заставить разработчика копировать все нужные ему свойства. То есть в нашем примере сам разработчик должен написать что-то типа:
|
||||
|
||||
```
|
||||
const button = new Button(…);
|
||||
if (button.model.checkoutButtonIconUrl) {
|
||||
button.view.iconUrl =
|
||||
button.model.checkoutButtonIconUrl;
|
||||
} else if (
|
||||
button.model.parentCategory.iconUrl
|
||||
) {
|
||||
button.view.iconUrl =
|
||||
button.model.parentCategory.iconUrl;
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
(В достаточно сложном API оба этих подхода приведут к одинаковому результату. Если приоритеты фиксированы, то это рано или поздно приведёт к необходимости написать код, подобный вышеприведённому, так как разработчик не сможет добиться нужного результата иначе.)
|
||||
В данном фрагменте кода налицо полный хаос с уровнями абстракции, и заодно сделано множество неявных предположений:
|
||||
* единственный способ выбрать предложение — клик по элементу списка;
|
||||
* единственный способ сделать заказ — клик внутри элемента «панель предложения»;
|
||||
* заказ не может быть сделан, если предложение не было предварительно выбрано.
|
||||
|
||||
Достоинства простого решения очевидны — разработчик сам имплементирует ту логику, которая ему нужна. Недостатки тоже очевидны — во-первых, это лишний код; во-вторых, разработчик быстро запутается в том, какие правила он реализовал и почему.
|
||||
Такой код вполне может работать, если вы не собираетесь предоставлять возможно что-то кастомизировать в бизнес-логике или поведении компонента — потому что с таким кодом что-либо кастомизировать невозможно. Единственный работающий способ выбрать какое-то предложение для показа — эмулировать бросание события `'click'` в списке предложений. При этом в панель предложения невозможно добавить никаких кликабельных элементов, поскольку любой клик рассматривается как создание заказа. Если разработчик захочет, например, чтобы сделать заказ можно было свайпом по предложению в списке, то ему придётся:
|
||||
* по свайпу сгенерировать фиктивное событие `'click'` на `offerList`,
|
||||
* переопределить метод `offerPanel.show` так, чтобы он показывал панель с кнопкой где-то в невидимой части экрана и тут же генерировал `'click'` на этой фантомной кнопке.
|
||||
|
||||
Альтернативный подход — это предоставить возможность декларативно задавать правила, каким образом для конкретной кнопки определяется её иконка, либо напрямую в виде CSS, либо предоставив какие-то похожие механизмы типа:
|
||||
Думаем, излишне уточнять, что подобные решения ни в каком случае не могут считать разумным API для UI-компонента. Но как же нам всё-таки *сделать* этот интерфейс расширяемым?
|
||||
|
||||
Первый очевидный шаг заключается в том, чтобы `SearchBox` перестал реагировать на низкоуровневые события типа `click`, а стал только лишь контекстом для нижележащих сущностей и работал в терминах своего уровня абстракции. А для этого нам нужно в первую очередь установить, что же он из себя представляет *логически*, какова его область ответственности как компонента?
|
||||
|
||||
Предположим, что мы определим `SearchBox` концептуально как конечный автомат, находящийся в одном из четырёх состояний:
|
||||
1. Пуст [это состояние, а также порядок перехода из него в другие состояния нас в рамках данной главы не интересует].
|
||||
2. Показан список предложений по запросу.
|
||||
3. Показано конкретное предложение пользователю.
|
||||
4. Создаётся заказ.
|
||||
|
||||
Допустимые переходы в рамках состояний 2-4 таковы: 2 → 3, 3 → 3 (выбрано другое предложение), 3 → 4, 3 → 2, 4 → 3. Соответствующие интерфейсы должны быть и предъявлены субкомпонентам: они должны нотифицировать об одном из переходов. Соответственно, `SearchBox` должен ждать не события `click`, а событий типа `selectOffer` и `createOrder`:
|
||||
|
||||
```
|
||||
api.options.addRule(
|
||||
// Читать примерно так: кнопки
|
||||
// типа `checkout` значение `iconUrl`
|
||||
// берут из поля `iconUrl` своей модели
|
||||
'button[@type=checkout].iconUrl',
|
||||
'model.iconUrl'
|
||||
this.offerList.on(
|
||||
'selectOffer',
|
||||
(event) => {
|
||||
this.selectedOffer =
|
||||
event.offer;
|
||||
this.offerPanel.show(
|
||||
this.selectedOffer
|
||||
);
|
||||
});
|
||||
this.offerPanel.on(
|
||||
'createOrder', () => {
|
||||
this.createOrder();
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Думаем, излишне уточнять, что разработка своей CSS-подобной системы — огромное количество работы, и к тому же изобретение велосипеда. Использование настоящего CSS, если оно возможно — более разумный подход, однако и он зачастую совершенно избыточен, и только весьма ограниченный набор возможностей системы будет реально использоваться разработчиками.
|
||||
Возможности по кастомизации субкомпонентов расширились: теперь нет нужды эмулировать `'click'` для выбора предложения, есть семантический способ сделать это через событие `selectOffer`; аналогично, какие события обрабатывает панель предложения для бросания события `createOrder` — больше не забота самого `SearchBox`-а.
|
||||
|
||||
#### Вычисленные значения
|
||||
|
||||
Очень важно не забыть предоставить разработчику не только способы задать приоритеты параметров, но и возможность узнать, какой же из вариантов значения был применён. Для этого мы должны разделить заданные и вычисленные значения:
|
||||
Однако описанный выше пример — с заказом свайпом по элементу списка — всё ещё реализуется «костыльно» через открытие невидимой панели, поскольку вызов `offerPanel.show` всё ещё жёстко вшит в сам `SearchBox`. Мы можем сделать ещё один шаг, и сделать связность ещё более слабой: пусть `SearchBox` не вызывает напрямую методы субкомпонентов, а только извещает об изменении собственного состояния:
|
||||
|
||||
```
|
||||
// Задаём значение в процентах
|
||||
button.view.width = '100%';
|
||||
// Получаем реально применённое
|
||||
// значение в пикселях
|
||||
button.view.computedStyle.width;
|
||||
this.offerList.on(
|
||||
'selectOffer',
|
||||
(event) => {
|
||||
this.selectedOffer =
|
||||
event.offer;
|
||||
this.state = 'offerSelected';
|
||||
this.emit('stateChange', {
|
||||
selectedOffer: this.
|
||||
selectedOffer
|
||||
});
|
||||
});
|
||||
this.offerPanel.on(
|
||||
'createOrder', () => {
|
||||
this.state = 'orderCreating';
|
||||
this.emit('stateChange');
|
||||
const order = await api.createOrder();
|
||||
this.state = 'orderCreated';
|
||||
this.emit('stateChange', {
|
||||
order
|
||||
});
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Не менее важна и возможность получать оповещения об изменении вычисленных значений.
|
||||
Тем самым мы фактически предоставляем доступ к описанному нами автомату состояний, и даём альтернативным имплементациям полную свободу действий. `offerPanel` не обязана «открываться», если такого состояния в ней нет и может просто проигнорировать изменения состояния на `offerSelected`. Наконец, мы могли бы полностью абстрагироваться от нижележащего UI, если бы прослушивали события `selectOffer` и `createOrder` не на конкретных субкомпонентах `offerList` и `offerPanel`, а позволили бы любому актору присылать их:
|
||||
|
||||
```
|
||||
this.onMessage((event) => {
|
||||
switch (event.type) {
|
||||
case 'selectOffer':
|
||||
this.selectedOffer =
|
||||
event.offer;
|
||||
this.state = 'offerSelected';
|
||||
this.emit('stateChange', {
|
||||
selectedOffer: this.
|
||||
selectedOffer
|
||||
});
|
||||
break;
|
||||
case 'createOrder':
|
||||
this.state = 'orderCreating';
|
||||
this.emit('stateChange');
|
||||
const order = await api.createOrder();
|
||||
this.state = 'orderCreated';
|
||||
this.emit('stateChange', {
|
||||
order
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Это решение выглядит достаточно общим и в своём роде идеальным (`SearchBox` сведён к своей чистой функциональности конечного автомата с хранением небольшого набора данных в виде `selectedOffer` и `order`), но при этом является, увы, очень ограниченно применимым:
|
||||
* он содержит очень мало функциональности, которая реально помогала бы программисту в его работе;
|
||||
* он заставляет программиста досконально разобраться в механике работы каждого субкомпонента и имплементировать её полностью, если необходима альтернативная реализация.
|
||||
|
||||
Или, если сформулировать другими словами, наш `SearchBox` не «перекидывает мостик», не сближает два программных контекста (высокоуровневый `SearchBox` и низкоуровневую имплементацию, скажем, `offerPanel`-а). Пусть, например, разработчик хочет сделать не сложную замену UX, а очень простую вещь: сменить дизайн кнопки «Заказать» на какой-то другой. Проблема заключается в том, что альтернативная кнопка не бросает никаких событий `'createOrder'` — она генерирует самый обычный `'click'`. А значит, разработчику придётся написать эту логику самостоятельно.
|
||||
|
||||
```
|
||||
class MyOfferPanel implements IOfferPanel {
|
||||
protected parentSearchBox;
|
||||
|
||||
render() {
|
||||
this.button = new CustomButton();
|
||||
this.button.on('click', () => {
|
||||
this.parentSearchBox.notify(
|
||||
'createOrder'
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
В нашем примере это не выглядит чем-то сложным (но это только потому, что наш конечный автомат очень прост и содержит очень мало данных), но трудно не согласиться с тем, что необходимость писать подобный код совершенно неоправдана: почти любая альтернативная реализация кнопки генерирует именно событие `'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) {
|
||||
// Формирует контент панели
|
||||
…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Таким образом, мы убрали сильную связность компонентов: можно отдельно переопределить класс кнопки создания заказа (достаточно, чтобы он генерировал событие `'click'`) и даже саму панель целиком. Вся *специфическая* логика, относящаяся к работе панели показа приложений, теперь собрана в арбитре — саму панель можно переиспользовать в других частях приложения.
|
||||
|
||||
Более того, мы можем пойти дальше и сделать два уровня арбитров — между `SearchBox` и панелью предложений и между панелью предложений и кнопкой создания заказа. Тогда у нас пропадёт требование к `IOfferPanel` иметь поле `createOrderButton`, и мы сможем свободно комбинировать разные варианты: альтернативный способ подтверждения заказа (не по кнопке), альтернативная реализация панели с сохранением той же кнопки и т.д.
|
||||
|
||||
Единственной проблемой остаётся потрясающая сложность и неочевидность имплементации такого решения со всеми слоями промежуточных арбитров. Таков путь.
|
||||
|
@ -1,146 +1,86 @@
|
||||
### Разделяемые ресурсы и асинхронные блокировки
|
||||
### MV*-фреймворки
|
||||
|
||||
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении разработчик решил открывать экран предложения с анимацией. Для этого он воспользуется объектом offerPanel, который мы реализуем в составе searchBox:
|
||||
Очевидным способом сделать менее сложными многослойные схемы, подобные описанным в предыдущей главе, является ограничение возможных путей взаимодействия между компонентами. Как мы описывали в главе «Слабая связность», мы могли бы упростить код, если бы разрешили нижележащим субкомпонентам напрямую вызывать методы вышестоящих сущностей. Например, так:
|
||||
|
||||
```
|
||||
// По событию выбора предложения
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
// Показываем выбранное предложение
|
||||
// в панели, но размещаем её
|
||||
// за границей экрана
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
// Анимируем положение панели
|
||||
searchBox.offerPanel.view.animate(
|
||||
'left', 0, '1s'
|
||||
);
|
||||
});
|
||||
```
|
||||
class Arbiter implements IArbiter {
|
||||
protected currentSelectedOffer;
|
||||
|
||||
Возникает вопрос: а что должно произойти, если, например, пользователь пытается прокрутить список предложений, если сейчас происходит анимация панели? Логически, мы должны эту операцию запретить, поскольку в ней нет никакого смысла — выезжающая панель всё равно не даст просмотреть новые элементы списка. Мы можем изменить *состояние* компонента, выставив флаг «происходит анимация»:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
|
||||
searchBox.state.isAnimating = true;
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
searchBox.state.isAnimating = false;
|
||||
});
|
||||
|
||||
searchBox.on('scroll', (event) => {
|
||||
// Если сейчас происходит анимация
|
||||
if (searchBox.state.isAnimating) {
|
||||
// Запретить действие
|
||||
return false;
|
||||
constructor(
|
||||
searchBox: ISearchBox,
|
||||
offerPanel: IOfferPanel
|
||||
) {
|
||||
…
|
||||
this.offerPanel.createOrderButton.on(
|
||||
'click',
|
||||
() => {
|
||||
// Вместо оповещения о событии,
|
||||
// напрямую вызываем родительский
|
||||
// метод
|
||||
this.searchBox.createOrder();
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Но этот код очень плох по множеству причин:
|
||||
* непонятно, как его модифицировать, если у нас появятся разные виды анимации, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
|
||||
* этот код просто плохо читается: совершенно непонятно, почему флаг `isAnimating` влияет на обработку события `scroll`;
|
||||
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект;
|
||||
* если при выполнении анимации произойдёт какая-то ошибка, флаг `isAnimating` не будет сброшен, и прокрутка будет заблокирована навсегда.
|
||||
|
||||
Корректное решение первых двух проблемы — это абстрагирование от самого факта анимации и переформулирование проблемы в высокоуровневых терминах. Почему мы запрещаем прокрутку во время анимации? Потому что появление панели предложения как бы «захватывает» эту область экрана. Пользователь не может работать с другими объектами в этой области во время анимации (или, скорее, нет разумного сценария использования, при котором пользователю может понадобиться это делать). Следовательно, именно такой флаг нам и надо объявить — признак «разделяемая область на экране заблокирована»:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = true;
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = false;
|
||||
});
|
||||
|
||||
searchBox.on('scroll', (event) => {
|
||||
// Если сейчас происходит анимация
|
||||
if (searchBox.state
|
||||
.isInnerAreaLocked) {
|
||||
// Запретить действие
|
||||
return false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Такой подход улучшает семантику операций, но не помогает с проблемами параллельного доступа и ошибочно неснятых флагов. Чтобы решить их, нам нужно сделать ещё один шаг: не просто ввести флаг, но и процедуру его *захвата* (вполне классическим образом по аналогии с управлением разделяемыми ресурсами в системном программировании):
|
||||
|
||||
```
|
||||
try {
|
||||
const lock = await searchBox
|
||||
.state.acquireLock('innerArea', '2s');
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
lock.release();
|
||||
} catch (e) {
|
||||
// Какая-то логика обработки
|
||||
// невозможности захвата ресурса
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: вторым параметром в `acquireLock` мы передали время жизни блокировки — 2 секунды. Если в течение двух секунд блокировка не снята, она будет отменена автоматически.
|
||||
|
||||
В таком подходе мы можем реализовать не только блокировки, но и программируемые прерывания и реакцию на на них:
|
||||
Кроме того, мы можем использовать арбитр только для передачи сигналов от UI, а с данными взаимодействовать напрямую:
|
||||
|
||||
```
|
||||
const lock = await searchBox
|
||||
.state.acquireLock(
|
||||
'innerArea',
|
||||
'2s', {
|
||||
// Добавляем описание,
|
||||
// кто и зачем пытается
|
||||
// выполнить блокировку
|
||||
reason: 'selectOffer',
|
||||
offer
|
||||
}
|
||||
);
|
||||
class OfferPanel {
|
||||
protected currentSelectedOffer;
|
||||
|
||||
lock.on('lost', () => {
|
||||
// Если у нас забрали блокировку,
|
||||
// отменяем анимацию
|
||||
searchBox.offerPanel.view
|
||||
.cancelAnimation();
|
||||
})
|
||||
|
||||
// Если другой актор пытается
|
||||
// перехватить блокировку
|
||||
lock.on('tryLock', (sender) => {
|
||||
// Если это другое предложение
|
||||
// разрешааем перехват и отменяем
|
||||
// текущую блокировку
|
||||
if (sender.reason == 'selectOffer') {
|
||||
lock.release();
|
||||
} else {
|
||||
// Иначе запрещаем перехват
|
||||
return false;
|
||||
constructor(
|
||||
searchBox: ISearchBox,
|
||||
) {
|
||||
// Панель показа предложений
|
||||
// сама получает нужные
|
||||
// ей данные
|
||||
searchBox.on('stateChange', (event) => {
|
||||
if (this.currentSelectedOffer !=
|
||||
event.offer) {
|
||||
this.currentSelectedOffer =
|
||||
event.offer;
|
||||
this.show({
|
||||
content: this.generateOfferContent(
|
||||
this.currentSelectedOffer
|
||||
)
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
lock.release();
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: хотя пример выше выглядит крайне переусложнённым, в нём не учтено ещё множество нюансов:
|
||||
* `offerPanel` тоже должен стать разделяемым ресурсом, и его точно так же надо захватывать;
|
||||
* при перехвате блокировки должна останавливаться не анимация вообще, а та конкретная операция, запущенная в рамках конкретной блокировки.
|
||||
Тем самым мы утратили возможность переиспользовать саму панель (она содержит логику, опирающуюся на получение конкретного вида данных), но сохранили возможность свободно использовать альтернативные реализации компонентов панели (мы всё ещё превращаем `'click'` на кнопке создания заказа в действие на уровне арбитра, не модифицируя код самой кнопки). Как бонус мы получили отсутствие двусторонних взаимодействий между тремя нашими сущностями: кнопка *читает* состояние `SearchBox`-а, но не модифицирует его; контроллер *читает* состояние `OfferPanel`, но не модифицирует его; сам `SearchBox` вообще никак не взаимодействует ни с тем, ни с другим — только лишь инстанцирует в нужный момент.
|
||||
|
||||
Упражнение «найти все разделяемые ресурсы и дополнить пример корректной работой с ними» мы оставим читателю.
|
||||
Сделав подобное упрощение, мы фактически получили компонент, следующий методологии [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller): `OfferPanel` — это view, который непосредственно взаимодействует с пользователем и оповещает об этом событиями, `Arbiter` — это controller, который получает события от view и модифицирует модель (сам `SearchBox`). Если мы выберем другие направления редукции полного взаимодействия, мы получим другие варианты MV*-фреймворков (Model—View—View model, Model—View—Presenter и т.д.) Все они, в конечно счёте, предлагают реализовать следующий подход:
|
||||
|
||||
1. Выделить сущность *модель*, отвечающую за данные, поверх которых построен компонент.
|
||||
2. Потребовать, чтобы внешний вид и поведение компонента полностью определялись его моделью.
|
||||
3. Построить механизм изменений внешнего вида компонента через изменение модели.
|
||||
4. Запретить взаимодействия, которые не соответствуют выбранному способу разделения компонента (например, в MVC view запрещено изменять состояние модели напрямую).
|
||||
|
||||
(схемы MVC, MVP, MVVP)
|
||||
|
||||
Достоинством MV*-подходов и иных способов ограничения сложности субкомпонентного взаимодействия (например, Redux также является одним из подходов, хотя он оставляет реализацию view за скобками) является строгое насаждение уровней абстракции, которое упрощает дизайн API и вообще оставляет меньше возможностей сделать в нём ошибку. Один очевидный недостаток всех этих фреймворков мы упомянули — это фиксация явных интерфейсов (в нашем случае — контроллер напрямую вызывает методы `SearchBox`-а), что означает ограничение возможности замены составляющих на альтернативные имплементации. Второй очевидный недостаток мы рассмотрим ниже.
|
||||
|
||||
#### Паттерн «модель»
|
||||
|
||||
Общая черта, объединяющая все MV*-фреймворки — это выделение сущности «модель» (некоторого набора данных), которая детерминировано *определяет* внешний вид и состояние UI-компонента. Изменения в модели порождают и изменения в отображении компонента (или дерева компонентов; модель может быть одной на всё приложение, и полностью определять весь интерфейс).
|
||||
|
||||
У этого подхода есть несколько очень важных свойств:
|
||||
* он позволяет восстанавливать состояние в случае ошибок (например, сбоя в рендеринге или перезагрузки приложения);
|
||||
* любые переходы между состояниями можно рассматривать как применение изменения (патча) к модели, что, помимо прочего, позволят «возвращаться в прошлое», т.е. отменять последние действия.
|
||||
|
||||
Один из частных случаев использование модели — это сериализация её в виде URL (или App Links в случае мобильных приложений). Тогда URL полностью определяет состояние приложения, и любые изменения состояния отражаются в виде изменений URL. Этот подход чрезвычайно удобен тем, что можно сгенерировать специальные ссылки, открывающие нужный экран в приложении.
|
||||
|
||||
Недостатки этого подхода, увы, также очевидны:
|
||||
* если задаться целью *полностью* описать состояние компонента, то мы обязаны внести в него и такие данные, как выполняющиеся сейчас анимации и даже процент их выполнения — очевидно, на таком уровне детализации ни о каком хранении модели в виде набора патчей речи идти не может, так как нам придётся сохранить каждый шаг анимации;
|
||||
* таким образом, модель обязана будет содержать в себе все данные всех уровней абстракции (причём, зачастую, дублирующие друг друга — например, контент панели предложения является производной от показанного предложения), и, более того, каким-то образом включать в себя две или более иерархии подчинения (по семантической и визуальной иерархиям, а так же, возможно, все селекторы и правила, как мы описали их в главе «Вычисляемые свойства»).
|
||||
|
||||
В нашем примере это означает, например, что модель должна будет хранить `currentSelectedOffer` для `SearchPanel`, иначе в коде панели невозможно будет определить, изменилось ли выбранное предложение.
|
||||
|
||||
Подобная полная модель представляет собой проблему не только теоретически и семантически (перемешивание в одной сущности разнородных данных), но и в практическом смысле — сериализация таких моделей окажется ограничена рамками конкретной версии API или приложения. Если мы в следующей версии изменим реализацию панели, то старые ссылки перестанут работать (либо нам потребуется держать слой совместимости, описывающий, как интерпретировать модели предыдущих версий).
|
||||
|
||||
В практическом смысле, разумеется, разработчики визуальных SDK и не пытаются строить такие модели, описывающие до последней запятой состояние каждого субкомпонента в дереве; модели состоят из *важных* полей, а всё «неважное» остаётся на откуп другим частям системы. (Что, по факту, означает следующее: UI-библиотеки никогда не реализуют MV*-подходы *строго*, поскольку это попросту невозможно. View-компоненты всегда имеют свои скрытые состояния-субмодели, которые лишь частично согласованы с основной моделью — в том числе потому, что далеко не всегда возможно это согласование произвести синхронно.)
|
||||
|
@ -1,149 +1,44 @@
|
||||
### Декомпозиция UI-компонентов. MV*-подходы
|
||||
### Backend-Driven UI
|
||||
|
||||
Продолжим рассматривать пример с анимацией панели, и отметим неприятную проблему в нашем коде (для простоты опустим часть с получением доступа к разделяемому ресурсу):
|
||||
Другой способ обойти сложность «переброса мостов» между несколькими предметными областями, которые нам приходится сводить в рамках одного UI-компонента — это убрать одну из них. Как правило, речь идёт о бизнес-логике: мы можем разработать компоненты полностью абстрактными, и скрыть все трансляции UI-событий в полезные действия вне контроля разработчика.
|
||||
|
||||
В такой парадигме код открытия панели предложений (которая должна перестать быть панелью предложений и стать просто «панелью чего-то») выглядел бы так:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = true;
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = false;
|
||||
});
|
||||
```
|
||||
|
||||
В данном фрагменте кода налицо неполное разделение уровней абстракций и сильная связность: обработчик *логического* события `selectOffer`, сформулированного в терминах предметной области высокого уровня («выбрано предложение»), запускает такой *низкоуровневый* процесс как анимация. Практическое следствие этой проблемы следующее: если разработчик захочет изменить способ показа предложения (использовать какую-то другую сущность, не `offerPanel` или, скажем, заменить имплементацию `offerPanel` через, например, отрисовку анимации на уровне GL-контекста), этот код перестанет работать. Соответственно, если мы хотим включить вышеприведённое поведение (анимацию показа предложения по его выбору) как часть UX нашего компонента (т.е. сделать поведением по умолчанию), писать код в таком виде мы не можем. (Строго говоря, и часть про блокирование `innerArea` тоже может оказаться ненужной, если мы дадим возможность показывать предложение где-то в другой части экрана, но этот случай мы опустим).
|
||||
|
||||
Как мы описывали в главе «Слабая связность», чтобы избежать этих проблем, необходимо ввести промежуточные сущности между разноуровневыми контекстами и необязывающие взаимодействия между ними. В нашем случае, введём некоторый объект-арбитр, который перехватывает и переформулирует события:
|
||||
|
||||
```
|
||||
class Arbiter implements IArbiter {
|
||||
constructor(
|
||||
searchBox: ISearchBox,
|
||||
offerPanel: IOfferPanel
|
||||
) {
|
||||
// Панель показа предложений
|
||||
// должна быть каким-то образом
|
||||
// привязана к арбитру
|
||||
offerPanel.setArbiter(this);
|
||||
|
||||
searchBox.on('selectOffer', (event) {
|
||||
// Арбитр переформулирует события
|
||||
// `searchBox` в требования к
|
||||
// панели предложений.
|
||||
|
||||
// 1. Необходимо задать контент панели
|
||||
this.emit(
|
||||
'setContent',
|
||||
// Какая-то, возможно переопределяемая
|
||||
// функция получения визуального
|
||||
// контента панели по offer-у
|
||||
this.generateContent(offer.event)
|
||||
);
|
||||
// 2. Необходимо инициализировать
|
||||
// положение панели
|
||||
this.emit(
|
||||
'setInitialPosition',
|
||||
// Мы только указываем панели,
|
||||
// что она должна быть в состоянии
|
||||
// «не видна», не предписывая
|
||||
// конкретной механики скрытия
|
||||
{ "state": "hidden" }
|
||||
);
|
||||
// 3. Необходимо оповестить сам SearchBox
|
||||
// о том, что `innerArea` заблокирована
|
||||
this.emit('innerAreaLocked');
|
||||
// 4. Сообщаем панели, что необходимо
|
||||
// анимировать показ контента
|
||||
this.emit(
|
||||
'animateContentShow',
|
||||
// Желаемая длительность анимации
|
||||
{'duration': '1s'}
|
||||
);
|
||||
// 5. Как только контент панели
|
||||
// полностью готов и показан,
|
||||
// разблокировать `innerArea`
|
||||
offerPanel.on('contentReady', () => {
|
||||
this.emit('innerAreaUnlocked')
|
||||
})
|
||||
});
|
||||
class SearchBox {
|
||||
selectOffer: (offer) => {
|
||||
this.offerPanel.setContent(
|
||||
await api
|
||||
.getOfferPanelContent(item);
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Важно, что интерфейсы `ISearchBox` и `IContentPanel` (как и интерфейс IArbiter) состоят *только* из событий (плюс метод связывания всех трёх сущностей, в нашем случае — `IContentPanel.setArbiter`) и, таким образом, не являются обязывающими для всех трёх сторон. Панель предложений может быть заменена на реализацию, которая вообще не поддерживает никаких анимаций и просто сразу генерирует сообщение `contentReady` после получения `setContent` и отрисовки переданного контента.
|
||||
|
||||
Аналогичным образом, через подобные арбитры, мы можем выстроить взаимодействие между любыми составляющими визуального компонента, где нам требуется «перебросить мостик» через уровни абстракций — но, разумеется, это *чрезвычайно* дорого в имплементации. Чем больше у нас таких суб-компонентов, чем дальше логическая дистанция между их предметными областями — тем больше арбитров, событий и возможностей сделать ошибку в дизайне.
|
||||
|
||||
Вновь обратимся к главе «Слабая связность»: мы могли бы упростить взаимодействие и снизить сложность коде, если бы разрешили нижележащим субкомпонентам напрямую вызывать методы вышестоящих сущностей. Например, так:
|
||||
Или даже так:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (event) {
|
||||
this.emit(
|
||||
'setContent',
|
||||
this.generateContent(offer.event)
|
||||
);
|
||||
// Вместо бросания события,
|
||||
// напрямую модифицируем состояние
|
||||
// родительского компонента
|
||||
searchBox.lockInnerArea();
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Сделав подобное упрощение, мы фактически получили компонент, следующий методологии [MVC](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller): `OfferPanel` — это view, который непосредственно взаимодействует с пользователем и оповещает об этом событиями, `Arbiter` — это controller, который получает события от view и модифицирует модель (сам `SearchBox`). Если мы выберем другие направления редукции полного взаимодействия, мы получим другие варианты MV*-фреймворков (Model—View—View model, Model—View—Presenter и т.д.) Все они, в конечно счёте, предлагают реализовать следующий подход:
|
||||
|
||||
1. Выделить сущность *модель*, отвечающую за данные, поверх которых построен компонент.
|
||||
2. Потребовать, чтобы внешний вид и поведение компонента полностью определялись его моделью.
|
||||
3. Построить механизм изменений внешнего вида компонента через изменение модели.
|
||||
4. Запретить взаимодействия, которые не соответствуют выбранному способу разделения компонента (например, в MVC view запрещено изменять состояние модели напрямую).
|
||||
|
||||
(схемы MVC, MVP, MVVP)
|
||||
|
||||
Достоинством MV*-подходов и иных способов ограничения сложности субкомпонентного взаимодействия (например, Redux также является одним из подходов, хотя он оставляет реализацию view за скобками) является строгое насаждение уровней абстракции, которое упрощает дизайн API и вообще оставляет меньше возможностей сделать в нём ошибку. Один очевидный недостаток всех этих фреймворков — это фиксация явных интерфейсов (в нашем случае — контроллер напрямую вызывает методы `SearchBox`-а), что означает ограничение возможности замены составляющих на альтернативные имплементации. Вторую очевидный недостаток мы рассмотрим ниже.
|
||||
|
||||
#### Паттерн «модель»
|
||||
|
||||
Общая черта, объединяющая все MV*-фреймворки — это выделение сущности «модель» (некоторого набора данных), которая детерминировано *определяет* внешний вид и состояние UI-компонента. Изменения в модели порождают и изменения в отображении компонента (или дерева компонентов; модель может быть одной на всё приложение, и полностью определять весь интерфейс).
|
||||
|
||||
У этого подхода есть несколько очень важных свойств:
|
||||
* он позволяет восстанавливать состояние в случае ошибок (например, сбоя в рендеринге или перезагрузки приложения);
|
||||
* любые переходы между состояниями можно рассматривать как применение изменения (патча) к модели, что, помимо прочего, позволят «возвращаться в прошлое», т.е. отменять последние действия.
|
||||
|
||||
Один из частных случаев использование модели — это сериализация её в виде URL (или App Links в случае мобильных приложений). Тогда URL полностью определяет состояние приложения, и любые изменения состояния отражаются в виде изменений URL. Этот подход чрезвычайно удобен тем, что можно сгенерировать специальные ссылки, открывающие нужный экран в приложении.
|
||||
|
||||
Недостатки этого подхода, увы, также очевидны:
|
||||
* если задаться целью *полностью* описать состояние компонента, то мы обязаны внести в него и такие данные, как выполняющиеся сейчас анимации и даже процент их выполнения — очевидно, на таком уровне детализации ни о каком хранении модели в виде набора патчей речи идти не может, так как нам придётся сохранить каждый шаг анимации;
|
||||
* таким образом, модель обязана будет содержать в себе все данные всех уровней абстракции (причём, зачастую, дублирующие друг друга — например, контент панели предложения является производной от показанного предложения), и, более того, каким-то образом включать в себя две или более иерархии подчинения (по семантической и визуальной иерархиям, а так же, возможно, все селекторы и правила, как мы описали их в главе «Вычисляемые свойства»).
|
||||
|
||||
В нашем примере с анимацией панели предложения из предыдущей главы мы должны составить полную модель в таком виде:
|
||||
|
||||
```
|
||||
{
|
||||
"searchBox": {
|
||||
"isInnerAreaLocked": true,
|
||||
"offerPanel": {
|
||||
"content",
|
||||
"animations": [{
|
||||
"left": {
|
||||
"begin": "100%",
|
||||
"end": "0",
|
||||
"progress": 0.23
|
||||
}]
|
||||
}
|
||||
},
|
||||
class SearchBox {
|
||||
stateChange: (patch) => {
|
||||
// Получаем от сервера
|
||||
// список действий, которые
|
||||
// необходимо выполнить при
|
||||
// запрошенном изменении
|
||||
const actions = await api
|
||||
.getActions(
|
||||
this.model,
|
||||
patch
|
||||
);
|
||||
// Применяем действия
|
||||
…
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Подобная полная модель представляет собой проблему не только теоретически и семантически (перемешивание в одной сущности разнородных данных), но и в практическом смысле — сериализация таких моделей окажется ограничена рамками конкретной версии API или приложения. Если мы в следующей версии изменим анимацию (панель станет выезжать справа), то старые ссылки перестанут работать (либо нам потребуется держать слой совместимости, описывающий, как интерпретировать модели предыдущих версий).
|
||||
(Примером реализации этой идеи можно считать т.н. «Web 1.0» — сервер присылает готовый контент страницы, а вся интерактивность сводится к переходам по ссылкам.)
|
||||
|
||||
В практическом смысле, разумеется, разработчики визуальных SDK и не пытаются строить такие огромные модели, описывающие до последней запятой состояние каждого субкомпонента в дереве; модели состоят из *важных* полей, а всё «неважное» остаётся на откуп другим частям системы. (Что, по факту, означает следующее: UI-библиотеки никогда не реализуют MV*-подходы *строго*, поскольку это попросту невозможно. View-компоненты всегда имеют свои скрытые состояния-субмодели, которые лишь частично согласованы с основной моделью — в том числе потому, что далеко не всегда возможно это согласование произвести синхронно.)
|
||||
Этот подход, безусловно, является крайне привлекательным с двух сторон:
|
||||
* возможность с сервера регулировать поведение клиента, в том числе устранять возможные ошибки в реальном времени;
|
||||
* возможность сэкономить на разработке консистентной и читабельной номенклатуры сущностей публичного SDK, ограничившись минимальным набором доступной функциональности.
|
||||
|
||||
Тем не менее, мы не можем не отметить: при том, что любая крупная IT-компания проходит через эту фазу — разработки Backend-Driven UI (они же — «тонкие клиенты») для своих приложений или своих публичных SDK — мы не знаем ни одного заметного на рынке API, разработанного в этой парадигме (кроме протоколов удалённых терминалов), хотя во многих случаях возникающими сетевыми задержками вполне можно было бы пренебречь. Нам сложно выделить конкретные причины, почему так происходит, но мы рискнём предположить, что разработать серверный код управления UI ничуть не проще, нежели клиентский — даже если вы не должны документировать каждый метод и абстрагировать каждую сущность — и игра, в конечном счёте, не стоит свеч.
|
@ -1,38 +1,152 @@
|
||||
### Backend-Driven UI
|
||||
### Разделяемые ресурсы и асинхронные блокировки
|
||||
|
||||
Другой способ обойти сложность «переброса мостов» между несколькими предметными областями, которые нам приходится сводить в рамках одного UI-компонента — это убрать одну из них. Как правило, речь идёт о бизнес-логике: мы можем разработать компоненты полностью абстрактными, и скрыть все трансляции UI-событий в полезные действия вне контроля разработчика.
|
||||
|
||||
В такой парадигме код открытия панели предложений (которая должна перестать быть панелью предложений и стать просто «панелью чего-то») выглядел бы так:
|
||||
Другой важный паттерн, который мы должны рассмотреть — это доступ к общим ресурсам. Предположим, что в нашем учебном приложении разработчик решил открывать экран предложения с анимацией. Для этого он воспользуется объектом offerPanel, который мы реализуем в составе searchBox:
|
||||
|
||||
```
|
||||
searchBox.on('selectItem', (item) => {
|
||||
this.isInnerAreaLocked = true;
|
||||
this.innerAreaContent = await api
|
||||
.getInnerAreaContent(
|
||||
this.model, item
|
||||
class OfferPanel {
|
||||
constuctor(searchBox) {
|
||||
searchBox.on(
|
||||
'selectOffer',
|
||||
(event) => {
|
||||
// Показываем выбранное предложение
|
||||
// в панели, но размещаем её
|
||||
// за границей экрана
|
||||
this.render(event.offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
// Анимируем положение панели
|
||||
this.animate(
|
||||
'left', 0, '1s'
|
||||
);
|
||||
}
|
||||
);
|
||||
this.isInnerAreaLocked = false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Возникает вопрос: а что должно произойти, если, например, пользователь пытается прокрутить список предложений, и сейчас происходит анимация панели? Логически мы должны эту операцию запретить, поскольку в ней нет никакого смысла — выезжающая панель всё равно не даст просмотреть новые элементы списка. Мы можем изменить *состояние* компонента, выставив флаг «происходит анимация»:
|
||||
|
||||
```
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
|
||||
searchBox.state.isAnimating = true;
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
searchBox.state.isAnimating = false;
|
||||
});
|
||||
|
||||
searchBox.on('scroll', (event) => {
|
||||
// Если сейчас происходит анимация
|
||||
if (searchBox.state.isAnimating) {
|
||||
// Запретить действие
|
||||
return false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Или даже так:
|
||||
Но этот код очень плох по множеству причин:
|
||||
* непонятно, как его модифицировать, если у нас появятся разные виды анимации, причём некоторые из них будут требовать блокировки прокрутки, а некоторые — нет;
|
||||
* этот код просто плохо читается: совершенно непонятно, почему флаг `isAnimating` влияет на обработку события `scroll`;
|
||||
* сложно предсказать, что произойдёт, если каким-то образом (например, программно) будет открыто другое предложение — оба процесса будут «драться» за один флаг и один объект;
|
||||
* если при выполнении анимации произойдёт какая-то ошибка, флаг `isAnimating` не будет сброшен, и прокрутка будет заблокирована навсегда.
|
||||
|
||||
Корректное решение первых двух проблемы — это абстрагирование от самого факта анимации и переформулирование проблемы в высокоуровневых терминах. Почему мы запрещаем прокрутку во время анимации? Потому что появление панели предложения как бы «захватывает» эту область экрана. Пользователь не может работать с другими объектами в этой области во время анимации (или, скорее, нет разумного сценария использования, при котором пользователю может понадобиться это делать). Следовательно, именно такой флаг нам и надо объявить — признак «разделяемая область на экране заблокирована»:
|
||||
|
||||
```
|
||||
searchBox.on('selectItem', (item) => {
|
||||
// Получаем от сервера набор
|
||||
// команд, которые необходимо выполнить
|
||||
// по событию selectItem
|
||||
const reaction = await api.onSelectItem(
|
||||
this.model, this.state, item
|
||||
searchBox.on('selectOffer', (offer) {
|
||||
searchBox.offerPanel.render(offer, {
|
||||
left: screenWidth
|
||||
});
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = true;
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
searchBox.state.
|
||||
isInnerAreaLocked = false;
|
||||
});
|
||||
|
||||
searchBox.on('scroll', (event) => {
|
||||
// Если сейчас происходит анимация
|
||||
if (searchBox.state
|
||||
.isInnerAreaLocked) {
|
||||
// Запретить действие
|
||||
return false;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Такой подход улучшает семантику операций, но не помогает с проблемами параллельного доступа и ошибочно неснятых флагов. Чтобы решить их, нам нужно сделать ещё один шаг: не просто ввести флаг, но и процедуру его *захвата* (вполне классическим образом по аналогии с управлением разделяемыми ресурсами в системном программировании):
|
||||
|
||||
```
|
||||
try {
|
||||
const lock = await searchBox
|
||||
.state.acquireLock('innerArea', '2s');
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
lock.release();
|
||||
} catch (e) {
|
||||
// Какая-то логика обработки
|
||||
// невозможности захвата ресурса
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
**NB**: вторым параметром в `acquireLock` мы передали время жизни блокировки — 2 секунды. Если в течение двух секунд блокировка не снята, она будет отменена автоматически.
|
||||
|
||||
В таком подходе мы можем реализовать не только блокировки, но и программируемые прерывания и реакцию на на них:
|
||||
|
||||
```
|
||||
const lock = await searchBox
|
||||
.state.acquireLock(
|
||||
'innerArea',
|
||||
'2s', {
|
||||
// Добавляем описание,
|
||||
// кто и зачем пытается
|
||||
// выполнить блокировку
|
||||
reason: 'selectOffer',
|
||||
offer
|
||||
}
|
||||
);
|
||||
this.exec(reaction);
|
||||
});
|
||||
|
||||
lock.on('lost', () => {
|
||||
// Если у нас забрали блокировку,
|
||||
// отменяем анимацию
|
||||
searchBox.offerPanel.view
|
||||
.cancelAnimation();
|
||||
})
|
||||
|
||||
// Если другой актор пытается
|
||||
// перехватить блокировку
|
||||
lock.on('tryLock', (sender) => {
|
||||
// Если это другое предложение,
|
||||
// разрешааем перехват и отменяем
|
||||
// текущую блокировку
|
||||
if (sender.reason == 'selectOffer') {
|
||||
lock.release();
|
||||
} else {
|
||||
// Иначе запрещаем перехват
|
||||
return false;
|
||||
}
|
||||
})
|
||||
|
||||
await searchBox.offerPanel
|
||||
.view.animate('left', 0, '1s');
|
||||
|
||||
lock.release();
|
||||
```
|
||||
|
||||
(Реализацией этой идеи можно также считать «Web 1.0» — сервер присылает готовый контент страницы, а вся интерактивность сводится к переходам по ссылкам.)
|
||||
**NB**: хотя пример выше выглядит крайне переусложнённым, в нём не учтено ещё множество нюансов:
|
||||
* `offerPanel` тоже должен стать разделяемым ресурсом, и его точно так же надо захватывать;
|
||||
* при перехвате блокировки должна останавливаться не анимация вообще, а та конкретная операция, которая была запущена в рамках конкретной блокировки.
|
||||
|
||||
Этот подход, безусловно, является крайне привлекательным с двух сторон:
|
||||
* возможность с сервера регулировать поведение клиента, в том числе устранять возможные ошибки в реальном времени;
|
||||
* возможность сэкономить на разработке консистентной и читабельной номенклатуре сущностей публичного SDK, ограничившись минимальным набором доступной функциональности.
|
||||
|
||||
Тем не менее, мы не можем не отметить: при том, что любая крупная IT-компания проходит через эту фазу — разработки Backend-Driven UI (они же — «тонкие клиенты») для своих приложений или своих публичных SDK — мы не знаем ни одного заметного на рынке API, разработанного в этой парадигме (кроме протоколов удалённых терминалов), хотя во многих случаях возникающими сетевыми задержками вполне можно было бы пренебречь. Нам сложно выделить конкретные причины, почему так происходит, но мы рискнём предположить, что разработать серверный код управления UI ничуть не проще, нежели клиентский — даже если вы не должны документировать каждый метод и абстрагировать каждую сущность — и игра, в конечном счёте, не стоит свеч.
|
||||
Упражнение «найти все разделяемые ресурсы и дополнить пример корректной работой с ними» мы оставим читателю.
|
@ -1,9 +1,69 @@
|
||||
### В заключение
|
||||
### Вычисляемые свойства
|
||||
|
||||
Предыдущие восемь глав были написаны нами, чтобы раскрыть две очень важные мысли:
|
||||
* разработка качественной UI-библиотеки — это отдельная и весьма непростая инженерная задача;
|
||||
* и эта задача не сводится к автоматической генерации SDK по спецификации.
|
||||
Наличие множественных линий наследования усложняет кастомизация компонентов, поскольку подразумевает, что они могут наследовать важные свойства по любой из вертикалей (иконка кнопки может быть задана и в визуальных настройках компонента, и в данных, и в родительском классе).
|
||||
|
||||
Автор этой книги в течение 10 лет разрабатывал подобную сложную интерактивную визуальную систему, позволяющую кастомизировать компоненты вплоть до замены технологии рендеринга, и может уверенно констатировать: не знакомые с проблематикой разработки такого SDK программисты и менеджеры склонны считать проблемы UI несущественными и не заслуживающими большого внимания, и это само по себе быстро становится проблемой. (Впрочем, то же самое мы можем сказать и про разработку API в целом.)
|
||||
Вернёмся к проблеме, которую мы описали в предыдущей главе. Пусть у нас имеется кнопка, которая получает одно и то же свойство `iconUrl` по двум вертикалям — из данных и из настроек отображения:
|
||||
|
||||
Оглядываясь на всё написанное, мы с трудом можем сказать, что нашли лучшие примеры и самые понятные слова для описания такой сложной предметной области. Мы, тем не менее, надеемся, что сделали вашу жизнь — и жизнь ваших пользователей — чуточку проще. Спасибо за ваше внимание!
|
||||
```
|
||||
const button = new Button({
|
||||
model: {
|
||||
iconUrl: <URL#1>
|
||||
}
|
||||
);
|
||||
button.view.options.iconUrl = <URL#2>;
|
||||
```
|
||||
|
||||
При этом мы можем легко представить себе, что по обеим иерархиям свойство `iconUrl` было получено от кого-то из родителей — например, данные могут быть сгруппированы по бренду, и иконка будет задана для всей группы. Также возможно, что мы разрешим переопределять иконку вообще всем кнопкам через задание свойств по умолчанию для всего SDK.
|
||||
|
||||
В этой ситуации у нас возникает вопрос: каким образом задавать приоритеты, какой из возможных вариантов опции будет выбран.
|
||||
|
||||
Современные графические SDK в зависимости от выбранного подхода делятся на две категории: построенные по образу и подобию CSS и все остальные.
|
||||
|
||||
#### Приоритеты наследования
|
||||
|
||||
Простой подход «в лоб» к этому вопросу — либо зафиксировать приоритеты в точности (скажем, заданное в опциях отображения значение всегда важнее заданного в данных, и они оба всегда важнее любого унаследованного свойства), либо попросту запретить наследование и заставить разработчика копировать все нужные ему свойства. То есть в нашем примере сам разработчик должен написать что-то типа:
|
||||
|
||||
```
|
||||
const button = new Button(…);
|
||||
if (button.data.checkoutButtonIconUrl) {
|
||||
button.view.iconUrl =
|
||||
button.data.checkoutButtonIconUrl;
|
||||
} else if (
|
||||
button.data.parentCategory?.iconUrl
|
||||
) {
|
||||
button.view.iconUrl =
|
||||
button.data.parentCategory.iconUrl;
|
||||
}
|
||||
```
|
||||
|
||||
(В достаточно сложном API оба этих подхода приведут к одинаковому результату. Если приоритеты фиксированы, то это рано или поздно приведёт к необходимости написать код, подобный вышеприведённому, так как разработчик не сможет добиться нужного результата иначе.)
|
||||
|
||||
Достоинства простого решения очевидны — разработчик сам имплементирует ту логику, которая ему нужна. Недостатки тоже очевидны — во-первых, это лишний код; во-вторых, разработчик быстро запутается в том, какие правила он реализовал и почему.
|
||||
|
||||
Альтернативный подход — это предоставить возможность декларативно задавать правила, каким образом для конкретной кнопки определяется её иконка, либо напрямую в виде CSS, либо предоставив какие-то похожие механизмы типа:
|
||||
|
||||
```
|
||||
api.options.addRule(
|
||||
// Читать примерно так: кнопки
|
||||
// типа `checkout` значение `iconUrl`
|
||||
// берут из поля `iconUrl` своей модели
|
||||
'button[@type=checkout].iconUrl',
|
||||
'model.iconUrl'
|
||||
)
|
||||
```
|
||||
|
||||
Думаем, излишне уточнять, что разработка своей CSS-подобной системы — огромное количество работы, и к тому же изобретение велосипеда. Использование настоящего CSS, если оно возможно — более разумный подход, однако и он зачастую совершенно избыточен, и только весьма ограниченный набор возможностей системы будет реально использоваться разработчиками.
|
||||
|
||||
#### Вычисленные значения
|
||||
|
||||
Очень важно не забыть предоставить разработчику не только способы задать приоритеты параметров, но и возможность узнать, какой же из вариантов значения был применён. Для этого мы должны разделить заданные и вычисленные значения:
|
||||
|
||||
```
|
||||
// Задаём значение в процентах
|
||||
button.view.width = '100%';
|
||||
// Получаем реально применённое
|
||||
// значение в пикселях
|
||||
button.view.computedStyle.width;
|
||||
```
|
||||
|
||||
При этом необходимо предоставить доступ не только к вычисляемым значениям, но и к событию изменения этого значения.
|
9
src/ru/drafts/06-Раздел V. SDK и UI-библиотеки/10.md
Normal file
9
src/ru/drafts/06-Раздел V. SDK и UI-библиотеки/10.md
Normal file
@ -0,0 +1,9 @@
|
||||
### В заключение
|
||||
|
||||
Предыдущие восемь глав были написаны нами, чтобы раскрыть две очень важные мысли:
|
||||
* разработка качественной UI-библиотеки — это отдельная и весьма непростая инженерная задача;
|
||||
* и эта задача не сводится к автоматической генерации SDK по спецификации.
|
||||
|
||||
Автор этой книги в течение 10 лет разрабатывал подобную сложную интерактивную визуальную систему, позволяющую кастомизировать компоненты вплоть до замены технологии рендеринга, и может уверенно констатировать: не знакомые с проблематикой разработки такого SDK программисты и менеджеры склонны считать проблемы UI несущественными и не заслуживающими большого внимания, и это само по себе быстро становится проблемой. (Впрочем, то же самое мы можем сказать и про разработку API в целом.)
|
||||
|
||||
Оглядываясь на всё написанное, мы с трудом можем сказать, что нашли лучшие примеры и самые понятные слова для описания такой сложной предметной области. Мы, тем не менее, надеемся, что сделали вашу жизнь — и жизнь ваших пользователей — чуточку проще. Спасибо за ваше внимание!
|
Loading…
Reference in New Issue
Block a user