Подборка полезных публикаций для веб-мастеров и заказчиков

Архитектура веб-интерфейсов: деревянное прошлое, странное настоящее и светлое будущее

Современное сообщество разработчиков сейчас как никогда подвержено моде и трендам, и особенно сильно это касается мира фронтент-разработки. Фреймворки и новые практики у нас являются главной ценностью, и из их перечисления состоит большая часть резюме, вакансий и программ конференций. И хотя само по себе развитие идей и инструментария не является чем-то негативным, но из-за постоянного стремления разработчиков следовать неуловимым трендам мы стали забывать о важности общих теоретических знаний об архитектуре приложений.

Преобладание ценности тулинга над знанием теории и лучших практик привело к тому, что большинство новых проектов сегодня имеют крайне низкий уровень сопровождаемости, тем самым создавая существенные неудобства как для разработчиков (стабильно высокая сложность изучения и модификации кода), так и для заказчиков (низкие темпы и высокая стоимость разработки).

Для того, чтобы хоть как-то повлиять на сложившуюся ситуацию, сегодня я хотел бы рассказать вам о том, что же из себя представляет хорошая архитектура, как она применима к веб-интерфейсам, и самое главное — как она эволюционирует с течением времени.

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 (

{name}

E-mail: {email}

Projects: { projects.map(project => {project.name}) }

); }}


В использовании 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. Повышение взаимозаменяемости библиотек

Полагаю, что со временем сообщество фронтенд-разработчиков осознает выгоды от максимальной взаимозаменяемости библиотек и перестанет замыкать себя в своих маленьких экосистемах. Все составные части приложений — роутеры, стэйт-контейнеры и прочее — они должны быть предельно универсальны, и их замена не должна требовать массового рефакторинга или переписывания приложения с нуля.
 

К чему все это?


Если попытаться обобщить представленную выше информацию и свести ее к более простой и короткой форме, то мы получим несколько достаточно общих тезисов:

— Для успешной разработки приложений недостаточно знания языка и фреймворка, следует уделять внимание и общим теоретическим вещам: архитектуре приложений, лучшим практикам и паттернам проектирования.

— Единственное постоянное — это изменение. Тулинг и подходы к разработке продолжат изменяться, так что большие и долгоживующие проекты должны уделять соответствующее внимание архитектуре — без нее внедрение новых инструментов и практик будет чрезвычайно затруднено.

И на этом, наверное, у меня все. Большое спасибо всем, кто нашел в себе силы прочитать статью до конца. Если у вас остались какие-либо вопросы или замечания — приглашаю вас в комментарии.

сохранить ссылку