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

в 8:29, , рубрики: backbone, flux, front-end, javascript, mvc, mvp, React, ReactJS, redux, sofware achitecture, больше тегов богу тегов

image

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

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

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

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), которая эту исходную схему взаимодействия значительно упрощала:

image

Поскольку разговор у нас идет именно о веб-интерфейсах, то здесь использован еще один довольно важный элемент, который обычно сопровождает реализацию данных паттернов — роутер (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 обычно реализовывается следующим образом:

image

const templates = {
  'users/profile': `
    <div class="user-profile">
      <h2>{{ name}}</h2>
      <p>E-mail: {{ email }}</p>
      <p>
        Projects: 
        {{#each projects}}
          <a href="/projects/{{id}}">{{name}}</a>
        {{/each}}
      </p>
      <a href=/user-profile/1/edit>Edit</a>
    </div>
  `
};

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 заслуживает нашего внимания:

image

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(`
    <h2><%= name %></h2>
      <p>E-mail: <%= email %></p>
      <p>
        Projects: 
        <% _.each(projects, project => { %>
           <a href="/projects/<%= project.id %>"><%= project.name %></a>
        <% }) %>
      </p>
      <button class="edit">Edit</button>
  `),
  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 (
      <div className="user-profile">
        <h2>{name}</h2>
        <p>E-mail: {email}</p>
      <p>
        Projects: 
        {
          projects.map(project => <a href="/projects/{project.id}">{project.name}</a>) 
        }
      </p>
      <button onClick={this.handleEdit}>Edit</button>
      </div>
    );
  }
}

В использовании 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 может быть представлена следующим образом:

image

За отображение данных по-прежнему отвечают 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. Повышение взаимозаменяемости библиотек

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

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

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

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

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

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

Автор: Alexey Oganezov

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js