Архитектура веб-интерфейсов: деревянное прошлое, странное настоящее и светлое будущее
Современное сообщество разработчиков сейчас как никогда подвержено моде и трендам, и особенно сильно это касается мира фронтент-разработки. Фреймворки и новые практики у нас являются главной ценностью, и из их перечисления состоит большая часть резюме, вакансий и программ конференций. И хотя само по себе развитие идей и инструментария не является чем-то негативным, но из-за постоянного стремления разработчиков следовать неуловимым трендам мы стали забывать о важности общих теоретических знаний об архитектуре приложений.
Преобладание ценности тулинга над знанием теории и лучших практик привело к тому, что большинство новых проектов сегодня имеют крайне низкий уровень сопровождаемости, тем самым создавая существенные неудобства как для разработчиков (стабильно высокая сложность изучения и модификации кода), так и для заказчиков (низкие темпы и высокая стоимость разработки).
Для того, чтобы хоть как-то повлиять на сложившуюся ситуацию, сегодня я хотел бы рассказать вам о том, что же из себя представляет хорошая архитектура, как она применима к веб-интерфейсам, и самое главное — как она эволюционирует с течением времени.
NB: В качестве примеров в статье будут использоваться только те фреймворки, с которыми непосредственно имел дело автор, и существенное внимание здесь будет уделено React и Redux. Но, несмотря на это, многие описываемые здесь идеи и принципы носят общий характер и могут быть более-менее успешно спроецированы на другие технологии разработки интерфейсов.
Архитектура для чайников
Для начала давайте разберемся с самим термином. Если говорить простыми словами, то архитектура любой системы — это определение ее составных частей и схемы взаимодействия между ними. Это своего рода концептуальный фундамент, поверх которого в последствии будет строиться реализация.
Задачей архитектуры является удовлетворение внешних требований к проектируемой системе. Эти требования варьируются от проекта к проекту и могут быть достаточно специфичными, но в общем случае они заключаются в облегчении процессов модификации и расширения разрабатываемых решений.
Что касается качества архитектуры, то обычно она выражается в следующий свойствах:
— Сопровождаемость: уже упомянутая предрасположенность системы к изучению и модификации (сложность обнаружения и исправления ошибок, расширения функциональности, адаптации решения к другой среде или условиям)
— Заменяемость: возможность изменения реализации любого элемента системы без затрагивания других элементов
— Тестируемость: возможность убедиться в корректности работы элемента (возможность управления элементом и наблюдения его состояния)
— Портируемость: возможность повторного использования элемента в рамках других систем
— Используемость: общая степень удобства системы при эксплуатации конечным пользователем
Отдельного упоминания также стоит один из самых ключевых принципов построения качественной архитектуры: принцип разделения ответственности (separation of concerns). Заключается он в том, что любой элемент системы должен отвечать исключительно за одну единственную задачу (применяется, кстати говоря, и к коду приложения: см. single responsibility principle).
Теперь, когда мы имеем представление о понятии архитектуры, давайте посмотрим, что в контексте интерфейсов нам могут предложить архитектурные паттерны проектирования.
Три самых важных слова
Одним из самых известных паттернов разработки интерфейсов является MVC (Model-View-Controller), ключевой концепцией которого является разделение логики интерфейса на три отдельные части:
1. Model — отвечает за получение, хранение и обработку данных
2. View — отвечает за визуализацию данных
3. Controller — осуществляет управление Model и View
Данный паттерн также включает в себя описание схемы взаимодействия между ними, но здесь эта информация будет опущена в связи с тем, что спустя определенное время широкой общественности была представлена улучшенная модификация этого паттерна под названием MVP (Model-View-Presenter), которая эту исходную схему взаимодействия значительно упрощала:
Поскольку разговор у нас идет именно о веб-интерфейсах, то здесь использован еще один довольно важный элемент, который обычно сопровождает реализацию данных паттернов — роутер (router). Его задача — это считывание URL и вызов ассоциированных с ним презентеров.
Работает представленная выше схема следующим образом:
1. Router считывает URL и вызывает связанный с ним Presenter
2–5. Presenter обращается к Model и получает из него необходимые данные
6. Presenter передает данные из Model во View, который осуществляет их визуализацию
7. При пользовательском взаимодействии с интерфейсом View уведомляет об этом Presenter, что возвращает нас ко второму пункту
Как показала практика, MVC и MVP не являются идеальной и универсальной архитектурой, но они все равно делают одну очень важную вещь — обозначают три ключевые области ответственности, без которых в том или ином виде не может быть реализован ни один интерфейс.
NB: По большому счету понятия Controller и Presenter обозначают одно и то же, а разница в их названии необходима только для дифференциации упомянутых паттернов, которые отличаются лишь в реализации коммуникаций.
MVC и серверный рендеринг
Несмотря на то, что MVC является паттерном для реализации клиента, он находит свое применение и на сервере. Более того, именно в контексте сервера проще всего продемонстрировать принципы его работы.
В случаях, когда мы имеем дело с классическими информационными сайтами, где в задачу веб-сервера входит генерация HTML-страниц для пользователя, MVC точно также позволяет нам организовать достаточно лаконичную архитектуру приложения:
— Router считывает данные из полученного HTTP-запроса (GET /user-profile/1) и вызывает связанный с ним Controller (UsersController.getProfilePage (1))
— Controller обращается к Model для получения необходимой информации из базы данных (UsersModel.get (1))
— Controller передает полученные данные во View (View.render ('users/profile', user)) и получает из него HTML-разметку, которую передает обратно клиенту
В данном случае View обычно реализовывается следующим образом:
const templates = {
'users/profile': `
`
};
class View {
render(templateName, data) {
const htmlMarkup = TemplateEngine.render(templates[templateName], data);
return htmlMarkup;
}
}
NB: Код выше намеренно упрощен для использования в качестве примера. В реальных проектах шаблоны выносятся в отдельные файлы и перед использованием проходят через этап компиляции (см. Handlebars.compile () или _.template ()).
Тут применяются так называемые шаблонизаторы, которые предоставляют нам средства для удобного описания текстовых шаблонов и механизмов подстановки в них реальных данных.
Подобный подход к реализации View не только демонстрирует идеальное разграничение ответственности, но и обеспечивает высокую степень тестируемости: для проверки корректности отображения нам достаточно выполнить сравнение эталонной строки со строкой, которую мы получили из шаблонизатора.
Таким образом, c помощью применения MVC мы получаем практически идеальную архитектуру, где каждый ее элемент имеет очень конкретное назначение, минимальную связанность, а также обладает высоким уровнем тестируемости и переносимости.
Что касается самого подхода с генерацией HTML-разметки средствами сервера, то в силу низкого UX этот подход постепенно начал вытесняться SPA.
Backbone и MVP
Одним из первых фреймворков, позволявших полностью вынести логику отображения на клиент, был Backbone.js. Реализация Router, Presenter и Model в нем достаточно стандартна, а вот новая реализация View заслуживает нашего внимания:
const UserProfile = Backbone.View.extend({
tagName: 'div',
className: 'user-profile',
events: {
'click .button.edit': 'openEditDialog',
},
openEditDialog: function(event) {
// ...
},
initialize: function() {
this.listenTo(this.model, 'change', this.render);
},
template: _.template(`
<%= name %>
E-mail: <%= email %>
Projects:
<% _.each(projects, project => { %>
<%= project.name %>
<% }) %>
`),
render: function() {
this.$el.html(this.template(this.model.attributes));
}
});
Очевидно, что реализация отображения существенно усложнилась — к элементарной шаблонизации добавилось прослушивание событий из модели и DOM, а также логика их обработки. Более того, для отображения изменений в интерфейсе крайне желательно выполнять не полный повторный рендеринг View, а осуществлять более тонкую работу с конкретными DOM-элементами (обычно средствами jQuery), что требовало написания большого количества дополнительного кода.
За общим усложнением реализации View усложнилось и его тестирование — поскольку теперь мы работаем непосредственно с DOM-деревом, то для тестирования нам необходимо использовать дополнительный инструментарий, предоставляющий или эмулирующий браузерное окружение.
И на этом проблемы с новой реализацией View не заканчивались:
В дополнение к вышесказанному здесь достаточно затруднено использование вложенных друг в друга View. Со временем эта проблема была разрешена с помощью Regions в Marionette.js, но до этого разработчикам приходилось изобретать свои собственные трюки для решения этой достаточно простой и часто возникающей задачи.
И последнее. Разработанные таким образом интерфейсы были предрасположены к рассинхронизации данных — поскольку все модели существовали изолировано на уровне различных презентеров, то при изменении данных в одной части интерфейса они обычно не обновлялись в другой.
Но, несмотря на перечисленные проблемы, данный подход оказался более чем жизнеспособным, а ранее упомянутое развитие Backbone в виде Marionette до сих пор может успешно применяться для разработки SPA.
React и пустота
Сложно в это поверить, но на момент своего первоначального релиза React.js вызывал большой скепсис у сообщества разработчиков. Скепсис этот был настолько велик, что на официальном сайте долгое время размещался следующий текст:
Give It Five Minutes
React challenges a lot of conventional wisdom, and at first glance some of the ideas may seem crazy.
И это при том, что в отличие от большинства своих конкурентов и предшественников React не являлся полноценным фреймворком и представлял из себя лишь небольшую библиотеку для облегчения отображения данных в DOM:
React is a JavaScript library for creating user interfaces by Facebook and Instagram. Many people choose to think of React as the V in MVC.
Главная концепция, которую нам предлагает React — это понятие компонента, который, собственно, и предоставляет нам новый способ реализации View:
class User extends React.Component {
handleEdit() {
// ..
}
render() {
const { name, email, projects } = this.props;
return (
);
}
}
В использовании React оказался невероятно приятен. В числе его неоспоримых преимуществ были и по сей день остаются:
1) Декларативность и реактивность. Больше нет необходимости в ручном обновлении DOM при изменении отображаемых данных.
2) Композиция компонентов. Построение и изучение дерева View стало совершенно элементарным действием.
Но, к сожалению, React обладает и рядом проблем. Одна из самых важных — это как раз тот факт, что React не является полноценным фреймворком и, следовательно, не предлагает нам ни какой-либо архитектуры приложения, ни полноценных средств для ее реализации.
Почему это записано в недостатки? Да потому, что сейчас React является наиболее популярным решением для разработки веб-приложений (пруф, еще пруф, и еще один пруф), он является точкой входа для новых фронтенд-разработчиков, но при этом совершенно не предлагает и не пропагандирует ни какую-либо архитектуру, ни какие-либо подходы и лучшие практики для построения полноценных приложений. Более того, он изобретает и продвигает свои собственные нестандартные подходы вроде HOC или Hooks, которые не имеют применения за пределами экосистемы React. Как результат — каждое приложение на React решает типовые проблемы как-то по-своему, и обычно делает это не самым правильным способом.
Продемонстрировать данную проблему можно с помощью одной из наиболее распространенных ошибок React-разработчиков, заключающуюся в злоупотреблении использованием компонентов:
If the only tool you have is a hammer, everything begins to look like a nail.
С их помощью разработчики решают совершенно немыслимый диапазон задач, далеко выходящий за пределы визуализации данных. Собственно, с помощью компонентов реализуют абсолютно все — от media queries из CSS до роутинга.
React и Redux
Наведению порядка в структуре React-приложений в значительной степени способствовало появление и популяризация Redux. Если React — это View из MVP, то Redux предложил нам достаточно удобную вариацию Model.
Главной идеей Redux является вынос данных и логики работы с ними в единое централизованное хранилище данных — так называемый Store. Данный подход полностью решает проблему дублирования и рассинхронизации данных, о которой мы говорили немного ранее, а также предлагает и множество других удобств, к которым среди прочего можно отнести легкость изучения текущего состояния данных в приложении.
Еще одной не менее важной его особенностью является способ коммуникации между Store и другими частями приложения. Вместо прямого обращения к Store или его данным нам предлагают использование так называемых Actions (простых объектов с описанием события или команды), которые обеспечивают слабый уровень связанности (loose coupling) между Store и источником события, тем самым существенно увеличивая степень сопровождаемости проекта. Таким образом Redux не только вынуждает разработчиков использовать более правильные архитектурные подходы, но еще и позволяет пользоваться различными преимуществами event sourcing — теперь в процессе дебага мы легко можем просматривать историю действий в приложении, их влияние на данные, а при необходимости вся эта информация может быть экспортирована, что также крайне полезно при анализе ошибок из «production».
Общая схема работы приложения с использованием React/Redux может быть представлена следующим образом:
За отображение данных по-прежнему отвечают React-компоненты. В идеале эти компоненты должны быть чистыми и функциональными, но при необходимости они вполне могут иметь локальное состояние и связанную с ним логику (к примеру, для реализации скрытия/отображения определенного элемента или базовой предобработки пользовательского действия).
При осуществлении пользователем какого-либо действия в интерфейсе компонент просто вызывает соответствующую функцию-обработчик, которую получает извне вместе с данными для отображения.
В качестве Presenter у нас выступают так называемые компоненты-контейнеры — именно они осуществляют контроль над компонентами отображения и их взаимодействие с данными. Создаются они с помощью функции connect, которая расширяет функциональность переданного в него компонента, добавляя к ним подписку на изменение данных в Store и позволяя нам определить, какие именно данные и обработчики событий следует в него передавать.
И если с данными здесь все понятно (просто осуществляем маппинг данных из хранилища на ожидаемые «props»), то на обработчиках событий хотелось бы остановиться немного подробнее — они не просто осуществляют отправку Actions в Store, но и вполне могут содержать дополнительную логику обработки события — к примеру, включать в себя ветвление, осуществлять автоматические редиректы и выполнять любую другую работу, свойственную презентеру.
Еще один важный момент, касающийся компонентов-контейнеров: в силу того, что они создаются через HOC, разработчики довольно часто описывают компоненты отображения и компоненты-контейнеры в рамках одного модуля и экспортируют исключительно контейнер. Это не самый правильный подход, так как для возможности тестирования и повторного использования компонента отображения он должен быть полностью отделен от контейнера и желательно вынесен в отдельный файл.
Ну и последнее, что мы еще не рассмотрели — это Store. Он служит для нас достаточно специфичной реализацией Model и состоит из нескольких составных частей: State (объект, содержащий все наши данные), Middleware (набор функций, осуществляющих предобработку всех полученных Actions), Reducer (функция, выполняющая модификацию данных в State) и какой-либо обработчик сайд-эффектов, отвечающий за исполнение асинхронных операций (обращение к внешним системам и т.п).
Больше всего вопросов здесь вызывает форма нашего State. Формально Redux не накладывает на нас никаких ограничений и не дает рекомендаций касательно того, что из себя должен представлять этот объект. Разработчики могут хранить в нем совершенно любые данные (в т.ч. состояние форм и информацию из роутера), данные эти могут иметь любой тип (не запрещается хранить даже функции и инстансы объектов) и иметь любой уровень вложенности. На деле это снова приводит к тому, что из проекта в проект мы получаем совершенно иной подход к использованию State, который через раз вызывает определенное недоумение.
Для начала согласимся с тем, что нам совсем не обязательно держать в State абсолютно все данные приложения — об этом явно говорит документация. Хранение части данных внутри состояния компонентов хоть и создает определенные неудобства при перемещении по истории действий в процессе дебага (внутреннее состояние компонентов всегда остается неизменным), но вынос этих данных в State создает еще больше трудностей — это значительно увеличивает его размер и требует создания еще большего количества Actions и редюсеров.
Что касается хранения в State каких-либо других локальных данных, то обычно мы имеем дело с какой-нибудь общей конфигурацией интерфейса, которая представляет из себя набор пар ключ-значение. В этом случае мы вполне можем обойтись одним простым объектом и редюсером для него.
А если речь идет о хранении данных из внешних источников, то исходя из того факта, что при разработке интерфейсов мы в подавляющем большинстве случаев имеем дело с классическим CRUD, то для хранения данных с сервера имеет смысл относиться к State как к РСУБД: ключи являются названием ресурса, а за ними хранятся массивы загруженных объектов (без вложенности) и опциональная информация к ним (к примеру, суммарное количество записей на сервере для создания пагинации). Общая форма этих данных должна быть максимально единообразной — это позволит нам упростить создание редюсеров для каждого типа ресурса:
const getModelReducer = modelName => (models = [], action) => {
const isModelAction = modelActionTypes.includes(action.type);
if (isModelAction && action.modelName === modelName) {
switch (action.type) {
case 'ADD_MODELS':
return collection.add(action.models, models);
case 'CHANGE_MODEL':
return collection.change(action.model, models);
case 'REMOVE_MODEL':
return collection.remove(action.model, models);
case 'RESET_STATE':
return [];
}
}
return models;
};
Ну и еще один момент, который хотелось бы обсудить в контексте применения Redux — это реализация сайд-эффектов.
В первую очередь полностью забудьте о Redux Thunk — предлагаемое им превращение Actions в функции с сайд-эффектами хоть и является рабочим решением, но оно перемешивает основные концепты нашей архитектуры и сводит ее преимущества на нет. Намного более правильный подход к реализации сайд-эффектов нам предлагает Redux Saga, хотя и к его технической реализации тоже есть некоторые вопросы.
Следующее — старайтесь максимально унифицировать ваши сайд-эффекты, осуществляющие обращения к серверу. Подобно форме State и редюсерам мы практически всегда можем реализовать логику создания запросов к серверу с помощью одного единого обработчика. К примеру, в случае с RESTful API этого можно добиться с помощью прослушивания обобщенных Actions вроде:
{
type: 'CREATE_MODEL',
payload: {
model: 'reviews',
attributes: {
title: '...',
text: '...'
}
}
}
… и создавая на на них такие же обобщенные HTTP-запросы:
POST /api/reviews
{
title: '...',
text: '...'
}
Осознанно следуя всем вышеперечисленным советам вы сможете получить если не идеальную архитектуру, то по крайней мере близкую к ней.
Светлое будущее
Современная разработка веб-интерфейсов действительно сделала значительный шаг вперед, и сейчас мы живем в то время, когда значительная часть основных проблем уже так или иначе решена. Но это совершенно не означает, что в будущем не случится новых революций.
Если попытаться заглянуть в будущее, то скорее всего там мы увидим следующее:
1. Компонентный подход без JSX
Концепция компоненов крайне успешно себя зарекомендовала, и, скорее всего, мы увидим еще большую их популяризацию. А вот сам JSX может и должен умереть. Да, он действительно достаточно удобен в использовании, но, тем не менее, он не является ни общепринятым стандартом, ни валидным JS-кодом. Библиотеки для реализации интерфейсов, как бы они не были хороши, не должны изобретать новые стандарты, которые потом раз за разом приходится реализовывать во всем возможном тулинге разработчиков.
2. Стэйт-контейнеры без Redux
Использование централизованного хранилища данных, предложенное Redux, тоже оказалось крайне удачным решением, и в будущем должно стать своеобразным стандартом в разработке интерфейсов, но его внутренняя архитектура и реализация вполне могут подвергуться определенным изменениям и упрощениям.
3. Повышение взаимозаменяемости библиотек
Полагаю, что со временем сообщество фронтенд-разработчиков осознает выгоды от максимальной взаимозаменяемости библиотек и перестанет замыкать себя в своих маленьких экосистемах. Все составные части приложений — роутеры, стэйт-контейнеры и прочее — они должны быть предельно универсальны, и их замена не должна требовать массового рефакторинга или переписывания приложения с нуля.
К чему все это?
Если попытаться обобщить представленную выше информацию и свести ее к более простой и короткой форме, то мы получим несколько достаточно общих тезисов:
— Для успешной разработки приложений недостаточно знания языка и фреймворка, следует уделять внимание и общим теоретическим вещам: архитектуре приложений, лучшим практикам и паттернам проектирования.
— Единственное постоянное — это изменение. Тулинг и подходы к разработке продолжат изменяться, так что большие и долгоживующие проекты должны уделять соответствующее внимание архитектуре — без нее внедрение новых инструментов и практик будет чрезвычайно затруднено.
И на этом, наверное, у меня все. Большое спасибо всем, кто нашел в себе силы прочитать статью до конца. Если у вас остались какие-либо вопросы или замечания — приглашаю вас в комментарии.