Опубликовано с большого одобрения автора и согласия портала infoq.com. Надеюсь, мои языковые навыки оправдают оказанное автором доверие.
Худшее в моей работе на сегодняшний день, это проектирование API для front-end разработчиков. Диалог с ними неизбежно разворачивается следующим образом:
Dev — итак, на этом экране нужны данные x, y и z. Не мог бы ты сделать API, которое вернет данные в формате {x:, y:, z: }
Я — ok
Я больше даже не спорю с ними. Проекты заканчивается ворохом различных API, привязанных к экранам, которые меняются очень часто, что “by design” требует изменений в API. Вы и глазом моргнуть не успеваете, а у вас уже куча API и для каждого необходимо поддерживать множество форматов и платформ. Сэм Ньюман даже начал формализовывать этот подход как BFF Pattern, предполагающий, что это нормально — разрабатывать отдельное API для каждого типа устройства, платформы и, естественно, каждой версии вашего приложения. Дэниэл Якобсон рассказывал, что Netflix был вынужден начать использовать новую характеристику для своего “Experience API”: эфемерность. Ох…
Пару месяцев назад я начал свой путь в поисках ответа на вопрос, почему все так сложилось и что с этим делать. Этот путь привел меня к сомнениям в прочнейшей из догм создания архитектуры приложений — MVC. И, когда я столкнулся с истинной силой реактивного и функционального программирования, я сосредоточился на простоте и уходе от раздувания, в создании которого наша отрасль так преуспела. Я думаю, вам могут быть интересны сделанные мной выводы.
Паттерн, который мы используем при разработке каждого экрана, MVC — Model-View-Controller. MVC был изобретен в те дни, когда Web еще не существовал и архитектура приложений была, в лучшем случае, толстым клиентом, который обращался напрямую к единственной базе данных по примитивной сети. И все же, спустя десятилетия, MVC до сих пор бессменно используется для создания Omni-Сhannel приложений.
В связи с предстоящим выходом Angular2, возможно, сейчас самое время для пересмотра использования паттерна MVC и, следовательно, той ценности, которую MVC фреймворки привносят в архитектуру приложений.
Впервые я столкнулся с MVC в 1990-м году, когда NeXT выпустил Interface Builder (здорово осознавать, что это ПО до сих пор актуально в наши дни). В то время Interface Builder и MVC представлялись как большой шаг вперед. В конце 90-х паттерн MVC был приспособлен к работе через HTTP (помните Struts?) и теперь MVC это краеугольный камень в архитектуре любых приложений, на все случаи жизни.
Даже React.js, казалось бы, далеко ушедший от догмы MVC, использовал этот эвфемизм, когда они представляли свой фреймворк: “React это лишь View в MVC”.
В прошлом году, когда я начал использовать React, я чувствовал что это что-то совершенно иное — вы где-то изменяете часть данных и, в тот же момент, без явного взаимодействия View и Model, весь UI изменяется (не только значения в полях и таблицах). Из-за этого я так быстро разочаровался в модели разработки React, и, очевидно, я был не одинок. Я поделюсь мнением Андре Медейроса:
React разочаровал меня по нескольким причинам. В основном из-за плохо спроектированного API, которое вынуждает пользователя [..] смешивать в одном компоненте несколько concerns.
Как проектировщик серверного API, я пришел к выводу, что не существует хорошего способа добавить вызовы API во front-end реализованный на React, именно потому, что React сосредоточен только на View, и в его модели разработки вообще нет понятия Controller.
Facebook до настоящего момента сопротивляется исправлению этого пробела на уровне фреймворка. Команда React разработала паттерн Flux, который так же разочаровывает, и теперь Dan Abramov продвигает еще один паттерн, Redux, который движется, в целом, в правильном направлении, но — я продемонстрирую это далее — не обеспечивает надлежащего способа работы с API из front-end.
Вы можете подумать, что, разработав GWT, Android SDK и Angular, инженеры Google имеют четкое представление (на английском языке звучит как каламбур — strong view) о том, какова наилучшая архитектура для front-end. Но, если вы почитаете некоторые соображения об архитектуре Angular2, вы не обязательно испытаете теплое чувство, что, уж хотя бы в Google, люди понимают, что они делают:
Angular 1 не был построен на идее компонентов. Вместо этого мы прикрепляем контроллеры к различным [элементам] страницы по нашему усмотрению. Области видимости присоединялись или растекались (flow through) в зависимости от того, как наши директивы инкапсулируют сами себя (изолированные scope, кто-нибудь?)
Выглядит ли построенный на компонентах Angular2 намного проще? Не совсем. Основной пакет Angular2 сам по себе содержит 180 определений, а весь фреймворк доходит до ошеломляющие почти 500 определений, и это все поверх HTML5 и CSS3. У кого есть время изучить и совершенствоваться в такого рода фреймворке для построения web-приложений? Что будет, когда выйдет Angular3?
Поработав с React и увидев, что представляет из себя Angular2, я впал в уныние — эти фреймворки методично вынуждают меня использовать паттерн BFF Screen Scraping, в котором каждое серверное API подгоняется под набор данных, необходимых на экране, до мелочей.
Это был мой момент “Да пошло все к черту”. Я буду просто создавать приложения, без React, без Angular, без всяких MVC-фреймворков, и посмотрю, возможно мне удастся найти более удачный способ соединить View и нижележащее API.
Что мне действительно понравилось в React, так это взаимодействие между Model и View. То, что React не основан на шаблонах и что View сам по себе не может обратиться за данными, чувствовалось как разумное направление для изучения (вы можете только передать данные во View).
Достаточно поработав с React вы понимаете, что его единственное назначение — декомпозиция View на набор чистых (pure) функций и JSX-разметку:
<V params={M}/>
Это не что иное как:
V = f(M)
К примеру, web-сайт одного из проектов, над которым я сейчас работаю, Gliiph, построен при помощи функций подобных этой:
Рис.1 функция, отвечающая за генерацию HTML для компонента слайдера.
Эта функция наполняется из модели:
Рис. 2. Модель, работающая со слайдерами.
Когда вы понимаете, что обычные JavaScript-функции могут прекрасно выполнить нужную работу, вашим следующим вопросом будет: “зачем вообще я должен использовать React?”.
Virtual dom? Если вам кажется, что вам он нужен (и я не уверен, что для многих это так), то есть варианты, и я думаю, что будут разработаны и другие.
GraphQL? Вообще-то нет. Не обманывайтесь доводами, что если Facebook использует это повсеместно, то это подойдет вам. GraphQL это не более чем декларативный способ создания View Model. Когда вы вынуждены формировать Model в соответствии с View это проблема, а не решение. Как вообще команда React (в том смысле, что он reactive) может полагать, что это нормально, запрашивать данные при помощи Client Specified Queries:
GraphQL полностью управляется требованиями View и front-end разработчиками, которые их написали [...] С другой стороны, GraphQL-запрос возвращает в точности то, что запрошено клиентской стороной и не более
Что команда GraphQL, кажется, упускает — маленькое изменение, которое скрывается JSX-синтаксисом — что функции изолируют Model от View. В отличии от шаблонов или “запросов, написанных front-end разработчиками” функции не требуют, чтобы Model подходил ко View.
Когда View создается при помощи функции (в противовес шаблону или запросу) вы можете преобразовать Model так, как вам нужно, и наилучшим образом реализовать View без добавления искусственных ограничений в Model.
К примеру, если View отображает значение v и графический индикатор, показывающий, является ли это значение отличным, хорошим или плохим, вам не нужно добавлять значение индикатора в Model — функция должна просто посчитать значение индикатора из значения v, которое предоставил Model.
На данный момент, это не особо хорошая идея, напрямую встраивать подобные вычисления во view. Но совсем не трудно так же сделать view-Model чистой функцией, и, следовательно, нет необходимости использовать GraphQL когда вам нужен четко сформулированный ViewModel:
V = f( vm(M) )
Как ветеран MDE (Model-driven engineering — прим. переводчика), я могу заверить вас, что писать код бесконечно лучше, чем метаданные, будь то шаблон или сложный язык запросов наподобие GraphQL.
Подобный функциональный подход имеет несколько важных преимуществ. Во-первых, так же как React, он позволяет вам декомпозировать ваши View на компоненты. Естественный интерфейс, который они создают, позволяет вам реализовать темы для вашего web-приложения или web-сайта или выполнить рендеринг View при помощи других технологий (нативных, к примеру). Также, реализация при помощи функций может усовершенствовать способы реализации responsive-дизайна, которые мы используем.
Я не удивлюсь, к примеру, если в ближайшие несколько месяцев разработчики начнут создавать темы для HTML5 реализованные как компоненты на основе JavaScript-функций. На данный момент это то, как я делаю все мои web-сайты. Я беру шаблон и сразу же оборачиваю его JavaScript-функциями. Я больше не использую WordPress — я могу получить лучшее от технологий HTML5 и CSS3 с тем же уровнем затрат (или меньшим).
Также, такой подход призывает к новому способу взаимодействия между дизайнерами и разработчиками. Кто угодно может написать подобные JavaScript-функции, тем более дизайнер шаблонов. Здесь нет “binding” синтаксиса, который необходимо изучать, нет JSX, нет шаблонов Angular, только простые JavaScript-функции.
Что интересно, с точки зрения reactive data flow, эти функции могут работать там, где это имеет больше смысла — на сервере или на клиенте.
Но, что наиболее важно, данный подход позволяет View декларировать минимальный контракт взаимодействия с Model и оставляет Model-у решать, каким образом лучше всего предоставит нужные данные View. Такие вопросы как caching, lazy loading, orchestration, consistency под полным контролем Model. В отличие от шаблонов или GraphQL, никогда не возникает необходимость делать прямой запрос, составленный с точки зрения View.
Теперь, когда у нас есть способ отвязать друг от друга Model и View, следующий вопрос в том, как создать полновесную модель приложения из этого? Как должен выглядеть “Controller”? Для ответа на этот вопрос, давайте вернемся к MVC.
Apple знает кое что об MVC, так как они “украли” этот паттерн из Xerox SPARC в начале 80-х и с того момента религиозно следуют ему:
Рис. 3. Паттерн MVC.
Ключевой вопрос здесь в том, и Андре Медейрос очень красноречиво объяснил это, что паттерн MVC “интерактивный” (в противоположность реактивному). В традиционном MVC Action (Controller) вызывает метод обновления у Model и в случае успеха (или ошибки) решает, каким образом обновить View. Как он (Андре Медейрос — прим. переводчика) замечает, Controller не обязан действовать именно таким образом. Есть другой, не менее разумный, reactive, способ, смысл которого заключается в том, что Action должен просто передавать значения в Model, независимо от результата, вместо того, чтобы принимать решение о том, как должен быть обновлен Model.
Ключевым вопросом тогда становится: “как вы интегрируете Action-ы в reactive flow?” Если вы хотите разбираться в том, что такое Action-ы, вам может захотеться взглянуть на TLA+. TLA расшифровывается как “Temporal Logic of Actions”, формулировка изобретенная доктором Лампортом, получившим премию Тьюринга за это. Согласно TLA+ Action-ы это чистые функции:
data’ = A (data)
Мне очень нравится основная идея TLA+, поскольку она подтверждает тот факт, что функции это лишь преобразования заданного набора данных.
С учетом этого, реактивный MVC может выглядеть примерно как:
V = f( M.present( A(data) ) )
Данное выражение ставит условием что Action, когда он срабатывает, высчитывает набор данных из набора входных параметров (таких как пользовательский ввод), которые передаются в Model, который, в свою очередь, решает, когда и как себя обновить. Когда обновление выполнено, View рендерится из нового состояния Model. Реактивная петля замыкается. Способ, которым Model сохраняет и извлекает данные, не имеет отношения к reactive flow, и никогда, абсолютно никогда, не должен быть “написан front-end разработчиками”. Никаких оправданий.
Еще раз, Action это чистые функции, без состояния и без side effect-ов (из уважения к Model, исключаем logging, к примеру).
Реактивный паттерн MVC интересен, поскольку, за исключением Model (разумеется), все остальное это чистые функции. Говоря по совести, Redux реализует именно это паттерн, но с ненужными церемониями в отношении React и несколько избыточной связанностью между Model и Action-ами внутри reducer. Интерфейс между Action-ами и Model это лишь передача сообщений.
Исходя из вышесказанного, паттерн Reactive MVC, в том виде как он есть, не завершен. Он не масштабируется под реальные приложения как Дэн любит утверждать. Давайте рассмотрим простой пример, объясняющий почему.
Предположим, нам необходимо реализовать приложение, которое управляет пусковой установкой для ракет: как только мы начали отчет, система убавляет счетчик и, когда он достигает нуля, если все свойства Model имеют корректные значения, инициируется запуск ракеты.
Данное приложение имеет простую машину состояний:
Рис. 4. Машина состояний приложения для запуска ракет.
Как уменьшение (decrement), так и запуск (launch), являются “автоматическими” действиями. То есть каждый раз, когда мы вносим (или вносим повторно) состояние отсчета, условия перехода будут оценены и, если состояние счетчика больше нуля, будет выполняться действие decrement. А когда значение достигнет нуля — будет вызвано действие launch. Действие отмена (abort) может быть вызвано в любой момент, что приведет систему в отмененное состояние.
В MVC такая логика будет реализована в Controller, и, вероятно, вызываться по таймеру из View.
Этот параграф очень важен, поэтому прочтите его внимательно. Мы увидели, что в TLA+ Action не имеет side effects-ов и результирующее состояние вычисляется как только Model обрабатывает результат вызова Action и обновляет сам себя. Это существенное отступление от традиционной семантики машин состояний, где Action определяет результирующее состояние. Иными словами, результирующее состояние не зависит от Модели. В TLA+, Action-ы, которые разрешены, и, следовательно, доступны для вызова в state representation (то есть во View) не связаны напрямую с Action-ами, которые вызывают изменения состояния. Другими словами, машины состояния не должны быть кортежами, соединяющими два состояния (S1, A, S2) каковыми они обычно являются. Они являются, скорее, кортежами форм (Sk, Ak1, Ak2,...) которые определяют все разрешенные Actions для состояния Sk с результирующим состоянием вычисляемым после того, как Action был применен к системе и Model обработал изменения.
Семантика TLA+ предоставляет превосходный способ концептуализации системы, при котором вы представляете объект “состояние”, отделенный от Action и View (которые являются лишь отображением состояния).
Model в нашем приложении выглядит следующим образом:
model = {
counter:,
started:,
aborted:,
launched:
}
Четыре (управляющих) состояния системы ассоциированы со следующими значениями в Model:
ready = {counter: 10, started: false, aborted: false, launched: false }
counting = {counter: [0..10], started: true, aborted: false, launched: false }
launched = {counter: 0, started: true, aborted: false, launched: true}
aborted = {counter: [0..10], started: true, aborted: true, launched: false}
Model описывается свойствами системы и их возможными значениями, в то время как cостояние определяет Action-ы, доступные для заданного набора значений. Бизнес-логика подобного вида должна быть где-то реализована. Мы не можем ожидать, что пользователю можно доверить выбор, какие действия возможны, а какие нет. Это условие, которое невозможно обойти. Тем не менее, подобную бизнес-логику сложно писать, отлаживать и сопровождать, особенно когда вам недоступна семантика для ее описания, например, в MVC.
Давайте напишем немного кода для нашего примера с запуском ракеты. С точки зрения TLA+, next-action предикат логически определяется из отображаемого состояния. Как только текущее состояние представлено, следующим шагом будет выполнение next-action предиката, который вычисляет и выполняет следующий Action, если таковой имеется. Оно же, в свою очередь предоставит данные Model, который инициирует рендеринг нового state representation и так далее.
Рис. 5. Реализация приложения для запуска ракет.
Обратите внимание, что в случае клиент-серверной архитектуры нам придется использовать протокол наподобие WebSocket (или polling, если WebSocket недоступен) для корректного отображения состояния после того, как автоматический Action выполнится.
Я написал очень маленькую open source библиотеку на Java и JavaScript, которая строит состояние объекта с верной, с точки зрения TLA+, семантикой и привел примеры, которые используют WebSocket, Polling и Queueing для реализации клиент-серверного взаимодействия. Как вы видите по примеру с приложением для запуска ракет, вы не обязаны использовать эту библиотеку. Реализация состояния достаточно проста в написании когда вы понимаете, что нужно написать.
Я считаю, что теперь у нас есть все элементы для того, чтобы формально описать новый шаблон, как альтернативу MVC — SAM pattern (State-Action-Model), реактивный, функциональный паттерн, уходящий корнями к React.js и TLA+.
Паттерн SAM может быть описан в виде следующего выражения:
V = S( vm( M.present( A(data) ) ), nap(M))
которое ставит условием, что View V системы может быть вычислено как чистая функция от Model, после применения Action-а A.
В SAM, A (Actions), vm (ViewModel), nap (next-action predicate) и S (state representation) являются и должны являться чистыми функциями. В случае SAM, то, что мы обычно называем “Состояние” (значения свойств системы) полностью ограничивают Model и логика, которая изменяет эти значения, недоступна за пределами Model.
В качестве примечания, предикат next-action, или nap() это callback, вызываемый как только state representation был создан и его предстоит отрендерить пользователю.
Рис. 6. Паттерн State-Action-Model (SAM).
Паттерн как таковой не зависит от каких-либо протоколов (и может быть с легкостью реализован через HTTP) и каких-либо клиентских/серверных технологий.
SAM не подразумевает, что вы всегда должны использовать семантику машины состояний дабы получить содержимое View. Когда вызов Action выполняется только из View, next-action предикат это null-функция. Хотя, это может быть хорошей привычкой — четко выделять управляющие состояния нижележащей машины состояний, поскольку View может выглядеть по разному в зависимости от того или иного (управляющего) состояния.
С другой стороны, если ваша машина состояний включает автоматические Action-ы, ни ваши Action-ы, ни ваш Model не будут чистыми без next-action предиката: либо некоторые Action-ы станут stateful, либо Model будет вызывать Action-ы, что не входит в ее задачи. Между прочим, и это неожиданно, объект состояния не содержит какое-либо “состояние”, это тоже чистая функция, которая рендерит View и вычисляет next-action предикат, делая и то и другое на основе значений свойств Model.
Ключевое преимущество это нового паттерна в том, что он четко отделяет CRUD операции от Action-ов. Model сам отвечает за свой persistence, который будет реализован при помощи CRUD операций, недоступных из View. В частности, View никогда не будет находиться в положении для “извлечения”данных, единственное, что View может делать это запрашивать текущий state representation системы и инициировать reactive flow за счет вызова действий.
Action-ы представляют собой всего лишь канал для внесения изменений в Model. Они, сами по себе, не имеют side effect-а (для Model). Когда необходимо, Action-ы могут вызывать сторонние API (еще раз, без side effect для Model). Например, действие по изменению адреса может вызвать сервис для его проверки и передать в Model адрес, возвращенный данным сервисом.
Вот как действие “Смена Адреса”, вызывающее API для проверки адреса, будет реализовано:
Рис. 7. Реализация действия “Смена Адреса”
Составные элементы паттерна, действия и модели, могут быть составлены (composed) свободно:
Композиция на основе функций:
data’ = A(B(data))
Одноуровневая композиция (один и тот же набор данных передается двум моделям):
M1.present(data’)
M2.present(data’)
Композиция “Родительский-дочерний” (родительский Model управляет набором данных, передаваемым дочернему):
M1.present(data’,M2)
function present(data, child) {
// perform updates
…
// synch models
child.present(c(data))
}
Композиция посредством Publish/Subscribe:
M1.on(“topic”, present )
M2.on(“topic”, present )
Или
M1.on(“data”, present )
M2.on(“data”, present )
Для архитекторов, которые мыслят в терминах Систем Записей и Систем Участия (прочитать что это такое можно здесь — прим. переводчика) данный паттерн помогает определить интерфейс между двумя слоями (рис. 8) с Model, отвечающим за все взаимодействия с Системой Записей.
Рис. 8. Модель композиции SAM.
Паттерн composable сам по себе и вы можете реализовать экземпляр SAM запускающийся в браузере для поддержки поведения в стиле wizard-ов (к примеру, ToDo application), взаимодействующий с экземпляром SAM на сервере:
Рис. 9. Композиция экземпляров SAM.
Пожалуйста, обратите внимание, что внутренний экземпляр SAM представлен как часть state representation, сгенерированного внешним экземпляром SAM.
Восстановление сессии должно предшествовать срабатыванию Action-a (рис. 10). SAM делает возможной интересную композицию, когда View может вызывать сторонний Action, предоставляющее токен и callback, указывающий на Action системы, которое авторизует и верифицирует вызов, перед тем как предоставлять данные для Model.
Рис. 10. Управление сессиями в SAM.
С точки зрения CQRS, паттерн не делает каких-либо различий между запросами и командами, хотя нижележащая реализация и требует делать это различие. Action поиска или запроса данных просто передает набор параметров в Model. Мы можем перенять конвенцию (к примеру, префикс в виде нижнего подчеркивания) для отделения запросов от команд, или мы можем использовать два отдельных способа представления в Model.
{ _name : ‘/^[a]$/i’ } // Names that start with A or a
{ _customerId: ‘123’ } // customer with id = 123
Model может выполнять необходимые операции для сопоставления с запросом, обновлять свое содержимое и инициировать рендеринг View. Схожий набор конвенций может быть использован для создания, обновления или удаления элементов Model. Есть целый ряд способов которые могут быть реализованы для передачи результата работы Action-ов в Model (data sets, events, actions). У каждого подхода есть свои достоинства и недостатки и, в конечном счете, подход может определяться исходя из предпочтений. Я предпочитаю подход с использованием data sets.
С точки зрения исключений (exceptions), так же как и в React, ожидается, что Model будет содержать информацию о соответствующем исключении как значения свойств (будь то исключение от Action или возвращенное CRUD-операцией). Данные значения будут использованы при рендере state representation чтобы отобразить исключение.
С точки зрения кэширования, SAM позволяет выполнять его на уровне state representation. Ожидаемо, кэширование результатов этих функций state representation должно привести к более высокому hit rate, раз мы теперь инициируем кэширование на уровне компонента/состояния вместо уровня Action/Response.
Реактивная и функциональная структура паттерна делает легким воспроизведение и unit-тестирование.
Паттерн SAM полностью меняет парадигму front-end архитектуры, поскольку, основываясь на TLA+, бизнес-логика может быть четко разделена на:
- Action-ы как чистые функции
- CRUD-операции в Model
- Состояния, которые управляют автоматическими Action-ами
С моей точки зрения, как разработчика API, паттерн сдвигает ответственность за проектирование API обратно на сервер, с минимально возможным контрактом между View и Model.
Action-ы, как чистые функции, могут быть повторно использованы между моделями если Model принимает возвращаемый результат от Action. Мы можем ожидать, что библиотеки Action-ов, темы (state representation) и, возможно, библиотеки моделей будут расцветать, раз они теперь композируются независимо.
С SAM микросервисы естественным образом помещаются за Model. Фреймворки наподобие Hivepod.io могут быть подключенына этом уровне, по-большому счету, as-is.
Что наиболее важно, данный паттерн, подобно React, не требует какого-либо data binding или шаблонов.
Я ожидаю, что со временем SAM поспособствует тому, что virtual-dom будет повсеместно реализован в браузерах и новые state representation будут напрямую обрабатываться через соответствующее API.
Я нахожу данное исследование преобразующим: времена существовавшего десятилетия подхода Object Orientation похоже, почти прошли. Я не могу больше мыслить в терминах иных чем реактивный или функциональный. Те вещи, которые я реализовал с помощью SAM и скорость с которой я могу их реализовать беспрецендентны. И еще один момент. Я теперь могу сосредоточиться на проектировании API и сервисов, которые не следуют паттерну “screen scrapping”.
Я хочу поблагодарить и выразить признательность людям, которые любезно согласились проверить эту статью: Prof. Jean Bezivin, Prof. Joëlle Coutaz, Braulio Diez, Adron Hall, Edwin Khodabackchian, Guillaume Laforge, Pedro Molina, Arnon Rotem-Gal-Oz.
Об авторе:
Жан-Жак Дюбре является основателем xgen.io и gliiph. Он занимается созданием Service Oriented Architectures и платформ API последние 15 лет. Является бывшим членом исследовательской команды в HRL и получил докторскую степень в University of Provence (Luminy campus) (ныне Aix-Marseille University — прим. переводчика), доме языка Prolog. Изобретатель методологии BOLT.
Автор: fshchudlo