Создаем модульную архитектуру для большого React-приложения
Від редакції: матеріал готувався до повномасштабного вторгнення росії на територію України. Привет, меня зовут Антон Пинкевич, я Front-end Tech Lead в Universe, продуктовой компании из экосистемы бизнесов Genesis. Front-end разработкой я занимаюсь уже больше восьми лет. Много времени отдал архитектуре проектов на React: мне всегда было интересно искать варианты ее улучшения. В этой статье хочу рассказать о своём опыте создания и внедрения нового архитектурного паттерна в edtech-платформе компании Universe. Это огромный продукт, и важно наладить внедрение большого количества функций малыми усилиями. Поэтому моей задачей было сделать так, чтобы при росте системы время разработки нового функционала оставалось линейным. Материал будет интересен, в первую очередь, всем, кто пишет на React, и Middle+ разработчикам, которые хотят улучшить свои приложения.
Ссылка на репозиторий с примером: github.com/...ych/modular-react-example (будет дополняться)
Идея
React — отличный, простой фреймворк, но на мой взгляд, ему не хватает структуры. Разработчики этого фреймворка советуют использовать context’ы для инкапсуляции бизнес-логики. Это помогает, но есть нюанс: при обновлении любого свойства в одном из контекстов, обновляется всё дерево компонентов, что негативно влияет на производительность приложений. С другой стороны, есть Redux, он ставит всё на рельсы, но даже с ним сложно работать в приложении из-за многочисленных связей между компонентами и обилия скрытой логики в middlewares. При росте системы всё это превращается в большой комок грязи (big ball of mud) и распутать эти взаимосвязи становится очень сложно. На создание текущей архитектуры меня вдохновил RIBs (router-interactor-builder), разработанный компанией Uber для своих мобильных приложений. У них хорошо реализована инверсия зависимостей и поток данных. Именно эту архитектуру я немного доработал, и в итоге получилось нечто вроде Angular для React. Основная цель заключалась в том, чтобы сделать систему максимально гибкой на любых масштабах. Мы не делали свой фреймворк, а просто договорились о том, какие файлы будут в системе и как система будет из этих блоков выстраиваться. Это позволило делиться логикой между командами и «общаться» на одном языке.
Преимущества нашей архитектуры
- Она позволяет развернуть зависимости приложения и сделать так, чтобы бизнес-логика отвечала за презентацию, а не наоборот, как обычно бывает в веб-приложениях.
- Разделяет приложение на независимые модули, позволяя переиспользовать большую часть написанного кода, миксуя модули между собой.
- Обладает высокой тестируемостью кода. Каждый модуль разделен на дополнительные унифицированные компоненты, поэтому можно писать тесты только для бизнес-логики, игнорируя инфраструктуру.
- В нашем случае унифицирует написание React-кода, чтобы вся команда работала по одинаковым шаблонам.
Далее расскажу подробней обо всех элементах системы, которые можно использовать для расширения в разные стороны. Преимущество модульности в том, что можно начать с малого и добавлять новые файлы по необходимости. Эту архитектуру можно использовать с любым фреймворком для React: create-react-app, nextjs и другими.
Абстрактная аналогия
Для более легкого восприятия информации возьмем аналогию с процессом работы небольшого ресторана. Далее расскажу как это переложить на React. Итак, давайте представим себе ресторан. У нас есть:
- три сотрудника (хостес, официант, повар);
- список столиков;
- терминал для приёма платежей;
- кухня;
- список заказов, которые находятся в работе;
- место выдачи заказов с кухни.
Задача
К нам приходит клиент и его нужно провести по всем этапам взаимодействия:
- Посадить за столик.
- Принять заказ.
- Приготовить заказ.
- Подать заказ.
- Получить от клиента оплату.
Посадка за столик
- Клиент сообщает хостес свои пожелания.
- Хостес находит нужный столик.
- Проводит к нему клиента.
Прием заказа
- Далее официант видит клиента за столиком.
- Принимает заказ.
- Отправляет заказ в очередь на приготовление.
Приготовление заказа
- Повар видит новый заказ в списке.
- Готовит его на кухне.
- Как только заказ готов, ставит его на место выдачи.
Подача заказа
- Официант видит приготовленный заказ.
- Забирает и относит его клиенту.
Приём платежа за заказ
- Как только клиент закончил, официант это видит и рассчитывает клиента.
- Для этого он использует сервис приёма платежей.
Теперь давайте отсортируем сущности, которые у нас были похожи по свойствам:
- Модули — сотрудники.
- Хранилища — список столиков, список заказов и место выдачи заказа с кухни.
- Сервисы — кухня и терминал для приема платежей.
Из текущего примера видно, что есть клиент, запросы которого наша система должна обработать. И для обработки этих запросов существуют работники (модули), они ограждают клиента от прямого взаимодействия с системой ресторана. При этом модули могут использовать сервисы и хранилища так, как им это нужно. Ответственность за работу всегда лежит в модуле. Он мониторит изменение состояния хранилищ и использует сервисы для выполнения того, что ему нужно. Получается, что главная единица системы — модуль. Если углубиться, то каждый модуль имеет несколько свойств:
- Он может самостоятельно принимать решения (interactor).
- Знает о том, где взять нужную ему информацию (index).
- Имеет определенный внешний вид (router).
Последнее необязательно, к примеру, повару в некоторых ресторанах неважно как он выглядит для клиента, потому что тот его никогда не увидит. Для таких модулей можно опускать роутер и оставлять только логическую часть модуля. А теперь усложним. Давайте отбросим абстракции и рассмотрим всё то же самое, только с технической точки зрения.
Модуль (Module)
Основной строительный блок системы — Модуль (Module). Это независимая единица, которая содержит некоторое инкапсулированное поведение. При этом модуль может существовать без визуальной презентации. Это позволяет развернуть стандартные зависимости приложения и сделать так, чтобы View не управлял всем приложением, а перестраивался в зависимости от нужного поведения. В нашей архитектуре мы описываем, что должно быть в системе. По умолчанию, логика лежит возле каждого модуля. При этом не все модули содержат визуальную презентацию: жёлтым выделены модули, которые выполняют логику, но не отображаются пользователю в интерфейсе. Это важное изменение позволяет нам развернуть зависимости. Теперь приложение зависит не от визуального слоя, а от логического. Кроме того, это делает нашу архитектуру «кричащей», как завещал дядюшка Боб.
Из чего состоит модуль
- Базовый модуль состоит из двух файлов: Index, Interactor.
- Если в модуле нужна визуализация — добавляем Router.
- Если модуль состоит из сложной визуализации, к примеру есть несколько А/B тестов, которые отличаются только визуально, но имеют общую логику — добавляем View (или несколько, по необходимости).
На схеме стрелками обозначены зависимости между компонентами модуля
Interactor
Этот файл содержит бизнес-логику. Желательно структурировать систему так, чтобы интерактор был изолирован от внешнего мира и получал нужные зависимости через пропсы. Также хорошей практикой считается делать его хуком.
type Payload = { authenticationService: IAuthenticationService authenticationStore: IAuthenticationStore router: IRouter } interface IUserSignupByEmailInteractor { redirectToSignin: () => void passwordRecoveryUrl: string children: { signupByEmail: boolean signupByGoogle: boolean } } const useSignupPageInteractor = ({ authenticationService, router, authenticationStore }: Payload): IUserSignupPageInteractor => { // logic implementation here return { redirectToSignin: () => router.redirect('/signin'), passwordRecoveryUrl: '/password-recovery', children: { signupByEmail: true, signupByGoogle: canUserSignupByGoogle, } } }
Так мы получаем нужное хранилище и сервис из пропсов. Сначала обозначаем, какие дочерние модули может рендерить данный модуль. Затем выполняем проверки и возвращаем булевые значения для каждого дочернего модуля. Также добавляем другие пропсы при необходимости. Рендер реактовских компонентов будет выполняться уже в роутере.
Router
Файл, который связывает бизнес-логику и визуализацию. Другими словами, прокидывает props в компоненты, вызывает нужные коллбэки и расставляет дочерние модули в layout’e.
interface IProps { signupByEmail: React.ReactNode signupByGoogle: React.ReactNode interactor: IUserSignupByEmailInteractor } const SignupPageRouter: React.FC = ({ signupByEmail, signupByGoogle, interactor }) => ( <> {interactor.children.signupByEmail && signupByEmail} {interactor.children.signupByGoogle && signupByGoogle} </> )
Роутер получает все нужные зависимости через пропсы, и рендерит нужные модули и компоненты. Содержит только логику проверок if else чтобы понять, нужно ли рендерить тот или иной модуль.
View
Задача View — группировать разные «глупые» компоненты (dumb components).
interface IProps { link: string } // classNames опущены для лучшей читабельности const ForgotPassword: React.FC = ({ link }) => (
)
Index
Собирает нужные зависимости для интерактора и роутера. В нём мы вызываем все useContext, загружаем нужные сервисы и хранилища.
const SignupByEmail = () => { con†st { authenticationService } = useServices() const { router } = useUtils() const { authenticationStore } = useStores() const interactor = useSignupByEmail({ authenticationService, router, authenticationStore }) return ( } signupByGoogle={} interactor={interactor} /> ) }
Основные файлы для построения модуля — Index и Interactor. Остальные добавляем по необходимости. Для удобства модули без UI можно назвать Activity, а те, что с UI, оставить как и есть — Module. До тех пор, пока нам не нужно расширение системы, можем хранить состояние прямо внутри модулей через useState. Для общих данных можно создать базовый createContext. О том, как правильно хранить данные для масштабирования, расскажу дальше. Для получения данных из внешних источников (API и другие сервисы) можно писать небольшие функции прямо в index файлах. Минимальное приложение может выглядеть вот так:
Сервисы (Services)
Если нужно использовать логику получения данных из внешних источников в разных модулях, выносим ее в отдельные сервисы. Сервис — это простой request-response механизм. Он может хранить внутреннее состояние, но только то, которое ему нужно для успешного выполнения запросов. Это может быть закэшированное состояние или токен. О самом механизме кэширования много информации в интернете. Для него также можно использовать библиотеки по типу react-query. Примеры сервисов:
- Authentication — проверяет credentials пользователя, позволяет зарегистрироваться/залогиниться, восстановить пароль и т. д.
- Analytics — отправляет аналитику.
- Payment — обрабатывает платежи.
Реализовать сервис можно абсолютно разными способами. Я советую разбивать сервисы на небольшие логические блоки при проектировании и реализовывать их в классах. Это упрощает их использование. Приложение с подключенными сервисами:
Хранилища (Stores)
Данные нужно не только получить, но и хранить. В текущей системе каждый модуль может хранить данные внутри себя, но если нужно получить общее состояние, то такие данные лучше выносить в хранилище. Примеры хранилищ:
- Authentication — хранит данные аутентификации, такие как token, refreshToken и другие.
- User — хранит данные пользователя. Имя, email и другие.
Для реализации хранилищ мы используем Mobx. Он простой, быстрый и позволяет проектировать необходимую систему без сложной структуры (в отличии от Redux). Но для каждого проекта лучше подбирать свою систему хранения. Приложение с подключенными хранилищами: Сервисы и хранилища позволяют реализовать флоу любой сложности. К примеру, нам нужно получить и сохранить токен пользователя. Для этого создаём модуль Authentication, который может ходить в сервис аутентификации, запрашивать у него токены и сохраняет их в authentication store. Для того, чтобы добавить сохранять хранилища локально, можно добавить ещё один модуль PersistStores, который будет отвечать за то, чтобы сохранять хранилища при изменении и загружать их во время загрузки приложения.
Адаптеры/Шлюзы (Adapters/Gateways)
При росте системы сервисы и хранилища могут использовать разные адаптеры. К примеру, analytics service изначально может использовать только Google Analytics для отправки данных, но позже могут добавиться Facebook Pixel, Amplitude, Mixpanel и другие. Для того, чтобы не писать новый сервис каждый раз, достаточно просто передать нужный адаптер в сервис. Таким образом, в сервисе появляется новая зависимость — analytics adapter. Интерфейс этого адаптера описывается в сервисе, а реализуется уже внешними адаптерами. Например:
export interface IAnalyticsAdapter { sendEvent: (eventName: string, eventData: unknown) => Promise } export class AnalyticsService { private adapter: IAnalyticsAdapter constructor(adapter: IAnalyticsAdapter) { this.adapter = adapter } // ... implementation }
И далее делаем нужные адаптеры
class AnalyticsAdapter implements IAnalyticsAdapter { private adapters: IAnalyticsAdapter[] = [] constructor(adapters: IAnalyticsAdapter[]) { this.adapters = adapters } sendEvent: IAnalyticsAdapter['sendEvent'] = (eventName, eventData) => { this.adapters.forEach((adapter) => adapter.sendEvent(eventName, eventData)) } } class GoogleAnalyticsAdapter implements IAnalyticsAdapter { sendEvent: IAnalyticsAdapter['sendEvent'] = (eventName, eventData) => { // google analytics implementation } } class FacebookPixelAdapter implements IAnalyticsAdapter { sendEvent: IAnalyticsAdapter['sendEvent'] = (eventName, eventData) => { // facebook pixel implementation } } export const analyticsAdapter = new AnalyticsAdapter([ new GoogleAnalyticsAdapter(), new FacebookPixelAdapter(), ])
И используем в сервисе:
const analyticsService = new AnalyticsService(analyticsAdapter)
Приложение с адаптерами:
Внедрение зависимостей (Dependency Injection)
Сервисы и хранилища необходимо как-то связать с модулями. Для этого проще всего использовать контексты React. Например:
interface IServices { authenticationService: IAuthenticationService analyticsService: IAnalyticsService } const ServicesContext = createContext(null) export const useServices = (): IServices => useContext(ServicesContext) export const ServicesProvider: React.FC = ({ children }) => { // initialize services return ( {children} ) }
Пример того, как это выглядит в дереве файлов: Оборачиваем модуль, или страницу (в случае nextjs можно _app.tsx) максимально выше по дереву компонентов в данные контексты и позже используем в коде, как показано в примере index файла в модуле.
Приложение с внедрением зависимостей: В нашей системе сервисы и хранилища это обычные классы, которые не обновляются при изменении состояния (ссылка остаётся постоянной). Таким образом у нас нет никаких лишних обновлений в системе.
Кейсы использования (Use cases)
При росте количества модулей будет появляться общая логика. Чтобы не копировать ее из одного места в другое, можно выносить эту логику в Use cases. К примеру, нам нужно реализовать покупку, которую совершает пользователь. Обычно она состоит из нескольких шагов:
- Запросить цену на продукт по ID.
- Загрузить данные для шлюза оплаты.
- Отобразить шлюз оплаты пользователю в модальном окне.
- Подождать ввода данных от пользователя.
- Верифицировать покупку.
- Открыть доступ к нужному продукту пользователю (обновить данные в хранилищах).
Чтобы каждый раз не писать эти шаги заново, мы выносим логику в processPaymentUseCase. Use case’ы могут использовать и сервисы, и хранилища через контексты, о которых я упоминал ранее. Они априори не могут использоваться вне модулей, поэтому можно сказать, что сервисы и хранилища будут всегда доступны. Приложение с кейсами использования:
Модели (Models)
Если полученные из внешних источников объекты нужно использовать в нескольких модулях и к ним могут применяться однотипные действия, их можно выделить в отдельную сущность «Модель». Это позволит валидировать данные, применять к ним общую логику и хранить в одном месте. ⚠️ Важно. Модели в этой архитектуре являются изолированными и не имеют доступа к внешним источникам. Если нужно сохранить модель в базе данных, используем modelNameService и т. д. Это нужно для создания юнит-тестов для всех моделей. Примеры моделей:
- Product.
- Course.
- Lesson.
Приложение с моделями:
Утилиты (Utils)
Без них невозможно комфортно работать. Сюда относится всё, что помогает в разработке, например:
- Token parsers.
- Date formatters.
- Device type detection.
Утилиты — это все, чему не нужно иметь доступ к внешним источникам. При этом их можно использовать как в сервисах, так и в модулях.
Обработка ошибок/исключений (Exceptions handling)
⚠️ Важно. Все ошибки должны обрабатываться в модулях. Сервисы просто возвращают исключения. Это нужно для того, чтобы логика обработки ошибок была в одном месте. Для типизации мы используем тип Result, который возвращается при вызове методов в сервисах.
export type Result<R, E extends Error> = R | E
Пример использования:
class MyError1 extends Error {} class MyError2 extends Error {} // example-service.ts class ExampleService { example = (): Result<string, MyError1 | MyError2> => { // implementation } } // example-module/interactor.ts const useExampleInteractor: React.FC = ({ exampleService }) => { useEffect(() => { exampleService.example() .then((result) => { // typeof result = string | MyError1 | MyError 2 if (result instanceof MyError1) {} // typeof result = string | MyError2 if (result instanceof MyError2) {} // typeof result = string // do whatever you want with the pure result type }) }, []) return {} }
Итоги
Модули — это связующее звено между получением данных (сервисы) и их хранением (хранилища). Для упрощения взаимодействий модули используют кейсы (use cases) и утилиты. Разбивка по зонам позволяет сделать так, чтобы каждая зона системы была ответственна только за одну задачу (single responsibility principle). Пример структуры полного приложения: И пример файловой структуры: Данная архитектура веб-приложения позволяет гибко растить проект во все стороны, при этом логика остаётся инкапсулированной и понятной. Не нужно вчитываться в код, чтобы понять, как разные компоненты системы взаимодействуют между собой. Вероятно, покрыты не все проблемы, которые могут появиться в работе над проектом, но текущий подход позволяет нашей команде двигаться быстро в разработке и постоянно добавлять новые функции, при этом не трогая старые.
22 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів