Расшифровка доклада Сергея Нестерова с конференции FrontendLive 2020.
Привет! Меня зовут Сергей, уже больше двух лет я работаю в группе компаний Тинькофф. Моя команда занимается разработкой системы для анализа качества обслуживания клиентов в Тинькофф, и, как вы, наверное, догадались, мы используем React в своем приложении. Не так давно мы внедрили в свой проект архитектурный паттерн Dependency Injection совместно с IoC-контейнерами. Сделали мы это не просто так: это позволило нам решить ряд проблем, которые тормозили разработку нового функционала.
Непосредственно переход на новую архитектуру длился три-четыре месяца. Здесь нужно учитывать, что такого рода задачи являются техническим долгом, которые постоянно отодвигаются на задний план из-за разработки новых бизнес-фич.
Сегодня я расскажу про Dependency Injection в React-приложении. Рассмотрим, что из себя представляет этот архитектурный паттерн, как мы к нему пришли и какую проблему он решает. На примерах покажу, как внедрить Dependency Injection в ваш проект, какие есть плюсы и минусы.
Начну вот с такой формулы:
Frontend + DI ≠ ♥
Идея этого доклада родилась из-за того, что архитектурный паттерн Dependency Injection, который, хоть и появился очень давно, к сожалению, до сих пор не очень широко используется в мире фронтенда и не встречается в реальных приложениях. Хотя в последние годы Dependency Injection набирает обороты и если еще не стал трендом фронтенд-разработки, то, как мне кажется, точно им станет в ближайшее время. Кстати, о трендах и технологиях, которые будут лидировать в следующем году, рассказал в своем докладе мой коллега, Филипп Нехаев.
Давайте посмотрим, где на сегодняшний день есть Dependency Injection. Он присутствует в таких современных и часто используемых фреймворках, как Angular и Nest.js (используется для написания бэкенда на NodeJS). И если в Angular Dependency Injection идет из коробки, то в React-приложениях и в самом React ничего подобного нет.
Цель моего доклада — прийти к такому уравнению:
Frontend + DI = ♥
и показать, как можно подружить ваше React-приложение с Dependency Injection. Но перед тем как начать, давайте познакомимся с нашим проектом.
Наш технологический стек
Погружу вас в наш технологический стек. Мы используем в своем проекте React и MobX. У нас по классической схеме есть какое-то одно глобальное хранилище, которое инициализируется в корне проекта и при помощи React-контекста передается вниз по дереву компонентов. В этом хранилище мы регистрируем все необходимые модели и сервисы, передаем зависимости и потихоньку начинаем строить наше приложение.
Мы разрабатываем систему для анализа качества обслуживания клиентов, и у нас основными сущностями являются звонки, чаты и прочие подобные коммуникации, существующие внутри компании. У нас есть таблицы с коммуникациями, в которых можно, например, посмотреть список звонков оператора и, допустим, оставить комментарий к звонку или оценить работу оператора в конкретном разговоре с клиентом. Конечно же, в зависимости от прав доступа к конкретному звонку, могут быть доступны и другие действия. Это выглядит так:
У нас есть карточка звонка с основной информацией и есть различные действия: например, оценить, прослушать эту коммуникацию или оставить комментарий. Это должно выглядеть как таблица с пагинацией, в которой происходит загрузка и отображение 30 коммуникаций на страницу. У нас есть чаты, письма, есть встречи, а можно взять и оценить оператора без какой-либо коммуникации. Само собой, такая таблица — это переиспользуемый компонент, в котором отличаются только отображение карточек и их функционал.
Каждый элемент этой таблицы выполняет различные действия, что так или иначе приводит к большому количеству сущностей, и у этих сущностей — большое количество связей. Мы это реализовали и получили решение с существенным количеством недостатков.
Очень большое глобальное хранилище. Мы пошли по классической схеме: инициализируем одно глобальное хранилище в корне приложения и начинаем с ним работать. Это приводит к тому, что все объекты состояния грузятся при запуске приложения, а зависимости, которые не влияют на отображение нашей страницы со звонками, все равно подгружаются. То есть объекты в глобальном хранилище, предназначенные для страницы чатов, подгружаются и для страницы со звонками.
Большое количество пропсов по дереву компонентов. Нынешний контекст React появился в версии 16.2 или 16.3. Раньше, если и пользовались старым API, то все же склонялись к прокидыванию пропсов внутрь компонентов. Из-за того, что у нас вся логика отличалась на нижнем уровне (на уровне карточек), по дереву компонентов прокидывалось большое количество пропсов и при этом дерево было с глубокой вложенностью — так называемый props hell.
Из-за того, что на каждой карточке отличался функционал, у нас получилось еще и большое количество опциональных пропсов, которые прокидываются по этому дереву. Из-за того, что большая доля логики закладывается именно в карточках, у нас получились еще к тому же и сильно связанные сервисы. В конце концов, инициализация сложной зависимости выглядела не сильно привлекательно и не поддавалась рефакторингу. Вдобавок вынести все это добро в независимый модуль стало невозможно, что мешало дальнейшему переиспользованию кода.
Столкнувшись с этими проблемами, мы начали искать пути решения и пришли к архитектурному паттерну Dependency Injection. Тут стоит начать немного издалека — с пяти основных принципов проектирования в объектно-ориентированном программировании, обозначаемых аббревиатурой SOLID.
SOLID
Что это за принципы?
Принцип единственной ответственности.
Принцип открытости/закрытости.
Принцип подстановки Барбары Лисков.
Принцип разделения интерфейса.
Принцип инверсии зависимостей.
В рамках моего доклада нас интересует только последний принцип — принцип инверсии зависимостей. О чем он говорит?
Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций.
Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Чтобы разобраться, что все это значит, давайте взглянем на пример с кодом:
Я буду пользоваться двумя сущностями: сущностью соковыжималки (класс Juicer) и яблока (класс Apple). У класса Juicer есть внешняя зависимость от класса Apple. Что это значит? Это значит, что сейчас у нас очень сильно связаны два класса: Juicer и Apple. У этого решения есть ряд минусов:
Внешняя зависимость от класса Apple.
Сложность тестирования. Чтобы протестировать класс Juicer, нам нужно залезть внутрь него и посмотреть, как на самом деле устроен класс Apple.
Нет возможности для повторного использования. Сейчас наша «соковыжималка» может работать только с «яблоками», и это, наверное, плохо. Хотелось бы получить более универсальную «соковыжималку».
Соблюдая принцип инверсии зависимостей, мы эту связанность между классами убираем, добавляем абстракцию в виде интерфейса IFruit и разворачиваем нашу зависимость так, что теперь «соковыжималка» не полагается на конкретный класс, а зависит от какой-то абстракции.
В то же время наш класс Apple — в этом месте как раз произошла инверсия зависимостей — теперь полагается только на интерфейс, то есть на абстракцию. Снова взглянем на пример:
Теперь класс Juicer, во-первых, не инициализирует внутри конструктора необходимую зависимость, а ждет на вход какой-то фрукт с интерфейсом IFruit. Класс Apple имплементирует этот интерфейс и передается извне в конструктор класса Juicer. Мы делаем это для того, чтобы можно было повторно использовать этот класс.
Например, теперь мы можем взять апельсин и снова воспользоваться нашей соковыжималкой! А еще это легче тестировать, потому что теперь мы можем передать нашей соковыжималке не конкретный класс, а просто реализовать какой-то объект, который будет имплементацией нашего интерфейса.
Таким образом, мы добились низкой связанности классов друг с другом, то есть теперь мы можем работать с любыми фруктами.
Мы воспользовались архитектурным паттерном Dependency Injection. Он позволяет создавать зависимые объекты за пределами класса, которые его будут использовать и передавать его при помощи трех различных методов:
Constructor injection.
Property Injection.
Setter Injection.
Constructor injection. По большому счету, мы передаем внешнюю зависимость через конструктор класса. Это решение считается самым правильным, поскольку позволяет заключить явный контракт: мы видим, что для работы соковыжималки нужен какой-то фрукт. Это легко тестировать, передав объект, имплементированный от абстракции:
Property Injection. Здесь уже нет передачи зависимости через конструктор класса. Мы добавляем в property класса необходимую нам зависимость. Этот метод лучше не использовать, потому что, во-первых, это сокрытие зависимостей — чтобы понять, с чем работает соковыжималка, нужно залезть внутрь нее:
Во-вторых, это сложно тестировать, потому что нужно переопределять поля класса. В-третьих, как я указал в комментарии на примере выше, возможно, тут будет какой-нибудь декоратор. Мы должны закладываться на реализацию какого-то IoC-контейнера, чтобы он понимал, какую зависимость и куда нужно предоставить в этот класс.
Setter Injection. В сущности, он похож на Property Injection, но вместо предоставления зависимости напрямую в property класса, у нас есть сеттер, в котором мы передаем необходимую нам зависимость. Этот метод стоит использовать только для опциональных зависимостей. То есть наша соковыжималка должна уметь работать без предоставленной зависимости. Здесь, как и в случае с Property Injection, присутствует сокрытие зависимостей (неявный контракт), и нам нужно смотреть на конкретную реализацию:
Подведем итог:
Constructor Injection — круто. Берем, используем.
Property Injection — не используем.
Setter Injection — используем только для опциональных зависимостей. Inversion of Control-контейнеры.
Выше я упоминал IoC-контейнеры, давайте немного остановимся на них.
Что это. IoC-контейнер — это, как правило, библиотека или фреймворк, берущие на себя часть логики вашего приложения и отвечающие за создание инстансов классов: в каком порядке поднимать, какому классу нужна какая зависимость, поднимать ли на каждый запрос необходимой зависимости из контейнера новый инстанс класса или, допустим, брать уже поднятый.
Как это выглядит. IoC-контейнер — это своего рода коробка, в которую мы складываем классы — классы яблока и соковыжималки из нашего примера. На выходе мы можем из этого контейнера получить уже готовый инстанс класса, в который будут переданы зависимости, необходимые ему для работы.
Готовые решения для работы с React
Уже есть react-simple-di, react-ioc, typescript-ioc, inversifyJS.
Для нашего проекта мы выбрали inversifyJS, потому что он не зависит от конкретного фреймворка или библиотеки. Его можно использовать не только с React. Допустим, можно даже не пользоваться Dependency Injection Angular, а воспользоваться inversifyJS.
Он хорошо документирован и предоставляет мощные devtools. Это значит, что у него есть много методов для реализации крутых фич и он позволяет удобно дебажить код — у него очень понятные сообщения об ошибках. То есть, когда у вас что-то упало, inversifyJS подскажет, что и где пошло не так:
Рассмотрим наш пример. У нас есть класс Juicer, и у него в конструкторе инициализируется зависимость от класса Apple. При использовании inversifyJS, чтобы сложить в контейнер, мы добавляем injectable-декоратор, который добавляет метаданные о представлении класса.
Далее мы добавляем inject-декоратор в конструктор класса и инжектим класс Apple, который нам нужен в качестве зависимости. Далее — инициализируем наш контейнер из inversifyJS, закладываем туда необходимые нам объекты и биндим их по ключам этих классов, ну а потом можем доставать готовые инстансы из этого контейнера.
Вы можете сказать: «Это не круто, у нас здесь до сих пор конкретные классы, а хотелось бы работать именно с интерфейсами для уменьшения зависимости!» Ну что ж, давайте переделаем примеры. Вернемся к нашим интерфейсам:
Что мы теперь делаем? Мы имплементируем класс Apple от интерфейса IFruit. В конструктор класса мы передаем @inject по этому интерфейсу и затем регистрируем в контейнере по ключу необходимый нам класс Apple.
Что мы получим? Мы получим IFruit is not defined — ошибку ReferenceError.
Почему так произошло? Думаю, вы знаете, что в runtime TypeScript — обычный JavaScript и интерфейсов там нет. В момент, когда у нас запускается приложение, InversifyJS попытается инициализировать зависимость по интерфейсу, который на самом деле не определен.
Что мы можем сделать с этой ошибкой, чтобы пользоваться нашими интерфейсами? На самом деле, здесь только одно решение — использовать строковые ключи или токены:
Мы добавляем строковую константу и говорим, что мы хотим получить в конструктор класса зависимость под ключом FruitKey. Далее — в контейнере указываем, что класс Apple теперь будет относиться к этому ключу. Таким образом мы можем использовать интерфейсы, придерживаться архитектурного паттерна Dependency Injection и применять инверсию зависимостей.
Reflect-metadata
Reflect-metadata — это библиотека, которая добавляет метаданные (данные о данных) о классах непосредственно в сам класс. Давайте посмотрим на примере, как это работает:
У нас есть класс Juicer, у него — injectable- и inject-декораторы. Мы хотим понять, как же все-таки inversify-контейнер понимает, что внутрь класса Juicer нужно передать зависимость в виде фрукта. Давайте посмотрим, какие метаданные добавляет reflect-metadata к классу Juice.
Воспользуемся командой console.log(Reflect.getMetadataKeys) от нашего класса. Она выведет три ключа:
design:paramtypes;
inversify:tagged;
inversify:paramtypes.
Итак, мы хотим разобраться, как же inversifyJS понимает, что нужно предоставить в конструктор класса зависимость фрукта. Давайте посмотрим значение ключа inversify:tagged:
Снова выполняем console.log(Reflect.getMetadata) по ключу inversify:tagged и видим, что в метаданных класса Juicer присутствует запись о том, что первым параметром в конструктор класса нужно передать зависимость с ключом FruitKey. Именно так inversifyJS и работает: на основе метаданных понимает, какую зависимость и куда передать.
Dependency Injection+React
Перейдем к самому интересному — к внедрению Dependency Injection в React-приложение. Стоит отметить, что в React внедрение в конструктор класса невозможно, потому что React использует конструктор класса по своему назначению. Здесь приходится добавлять обертки, чтобы связать наши компоненты с контейнером. Разобраться, как это работает, вам поможет демо. Вы можете его использовать, оно готово к работе. Просто добавляйте свои страницы и состояния.
import React from 'react';
import { interfaces } from 'inversify';
const context = React.createContext<interfaces.Container | null>(null);
export default context;
Давайте рассмотрим пример. Чтобы хранить контейнеры, конечно же, мы воспользуемся контекстом React. Здесь все достаточно просто: как обычно, мы вызываем функцию React.createContext и передаем ему первоначальное значение null. У inversifyJS есть типы, с помощью которых можно легко и понятно типизировать и при этом получать минимальное количество ошибок.
Что нужно для того, чтобы передать контекст в компонент? Нужно реализовать DiProvider — контекст-провайдер, который позволит передавать вниз по дереву созданный нами контекст. Мы реализуем функцию, которая на вход будет принимать два параметра: наш контейнер и дочерние элементы (children) из родительского React-компонента, у которых будет доступ к зависимостям из контейнера:
type Props = {
container: interfaces.Container;
children: ReactNode;
};
export function DiProvider({ container, children }: Props) {
return <Context.Provider value={container}>{children}</Context.Provider>;
}
Дальше нам нужно реализовать High-Order-компонент, который будет помогать передавать контекст вниз. Для этого мы реализуем High-Order-компонент, который назовем, допустим, withProvider, и у него будут два параметра на вход — компонент и контейнер, который мы инициализируем:
export function withProvider<P, C>(
component: JSXElementConstructor<P> & C,
container: interfaces.Container
) {
class ProviderWrap extends Component<Props> {
public static contextType = Context;
public static displayName = `diProvider(${getDisplayName(component)})`;
public constructor(props: Props, context?: interfaces.Container) {
super(props);
this.context = context;
if (this.context) {
container.parent = this.context;
}
}
public render() {
const WrappedComponent = component;
return (
<DiProvider container={container}>
<WrappedComponent {...(this.props as any)} />
</DiProvider>
);
}
}
return ProviderWrap as ComponentClass<Props>;
}
В моем примере довольно много кода. Но большая его часть предназначена для корректной работы Typescript, который будет подсказывать, какие параметры можно передать в получившиеся High-Order-компоненты и отсеивать пропсы, получаемые из нашего контейнера. Мы реализовали функцию, которая оборачивает переданный компонент DiProvider функцией и передает в контекст контейнер, оставляя пропсы этого компонента без изменений.
В конструкторе класса мы ищем родительский контекст тех же самых контейнеров, чтобы реализовать иерархическую структуру DI-контейнеров. Если у нас уже есть контекст с контейнером по дереву компонент выше, то мы записываем в parent-поле дочернего контейнера ссылку на него. Как я упоминал ранее, для иерархических контейнеров это дает крутую фичу, к которой вернусь подробнее чуть позже.
Теперь перейдем к компоненту, который будет получать из нашего контейнера необходимые данные в виде пропсов. Для этого мы реализуем еще один High-Order-компонент, который будет принимать на вход сам компонент и зависимости, которые он хочет получить в качестве пропсов. Эти зависимости передаются в виде объекта, названия свойств которого будут соответствовать названиям пропсов компонента, а значения — специальным Dependence-классам.
В конструктор Dependence-класса передается ключ необходимой зависимости или класс, у которого есть статическое поле с этим ключом. Вторым параметром можно передать объект с опциями. Это нужно для того, чтобы воспользоваться такими фишками inversify, как именованный binding (то есть по имени), tagged binding и функцией трансформации — чтобы из класса достать уже конкретное свойство.
Здесь, как и в случае с предыдущим компонентом высшего порядка, много разных типов для того, чтобы TypeScript подсказывал, что не так и какие значения нужно передавать. По большому счету, мы возвращаем исходный компонент с уже переданными в него зависимостями из контейнера, которые мы запросили во втором параметре нашей обертки.
Все эти зависимости мы получаем при помощи функции inject. В ней мы проверяем, что у нас есть контекст с DI-контейнером, а затем достаем необходимые зависимости из него при помощи метода resolve, предварительно собрав ключи этих зависимостей. Получившийся результат складываем в новый объект, свойства которого мы вернем в компонент в виде пропсов.
Все, что нужно, мы сделали и теперь можем воспользоваться нашими High-Order-компонентами для стандартного компонента React, у которого мы хотим получить зависимость.
Мой пример написан на Next.js, чтобы был серверный рендеринг. Да и вообще Next.js легко собрать: то есть npm install, npm run dev — все запустится. Сначала мы оборачиваем pages-компонент в HOC withProvider и передаем туда контейнер, который хотим использовать на уровне нашей страницы.
Чтобы получить необходимую зависимость из нашего контейнера, мы должны воспользоваться HOC diInject, передать туда компонент и указать внутри объекта, что мы хотим получить зависимость по такому-то строковому ключу.
Например: мы зарегистрировали в контейнере по строковому ключу зависимость ListModel и говорим, что она будет inSingletonScope, потому что мы хотим, чтобы эта зависимость закэшировалась и на каждый get-метод из контейнера мы получали тот же самый инстанс нашей зависимости. Дальше для типизации указываем в Props компонента, что у нас должна быть передана зависимость booksListModel из контейнера, и указываем ее тип. А inversifyJS в React-приложении даст нам поддержку иерархических контейнеров, повторное использование кода, низкую связанность и простоту тестирования.
Если последние два пункта исходят из того, что мы придерживаемся архитектурного паттерна Dependency Injection, то первые два — это про плюшки, которые дает inversifyJS.
Давайте рассмотрим пример, иллюстрирующий иерархическую структуру наших контейнеров:
У нас SPA-приложение и есть входная точка. При помощи React Router мы перекидываем пользователя на конкретную страницу. В корне нашего проекта добавляем дефолтный контейнер, в который складываем зависимости для работы приложения: это сервисы типа fetch-сервиса, юзер-сервиса для работы с авторизованными данными пользователя и fetch для работы с бэкендом — то есть все те вещи, которые использует каждая страница.
Далее, уже на уровне конкретной страницы, регистрируем дочерние контейнеры, в которых мы будем хранить состояние и сервисы конкретной страницы и они не будут переплетаться между собой. Теперь, когда мы загрузим страницу 1, она не будет ничего знать о странице 2.
При иерархической структуре нам не нужно указывать общие сервисы, потому что наш дочерний контейнер будет знать о родительском, но при этом родительский ничего не будет знать о дочерних. То есть мы можем пользоваться моделями и сервисами родительского контейнера, тогда как родительский не имеет доступа к дочерним и тем более контейнер конкретной страницы ничего не знает о контейнере других страниц.
В этом есть много плюсов, потому что теперь у нас страницы — это такие модули, которые, во-первых, не могут управлять состоянием друг друга, а во-вторых, мы отдаем пользователю только тот js, который ему нужен на этой странице.
Поговорим о повторном использовании кода и для этого вернемся к моему примеру:
У нас есть карточка звонка, которую мы хотим переиспользовать. У нее есть различные внешние зависимости: CommentService, CommentModel и так далее.
Чтобы все время не регистрировать эти сервисы и не передавать их в контейнер, мы можем воспользоваться фишкой inversifyJS и взять Container Module, в который мы складываем все необходимые зависимости, а затем на конкретной странице просто подгружаем в этот контейнер необходимый нам модуль. Собственно, на этом все. Теперь мы можем всю нашу страницу разбить на независимые модули и подгружать конкретный модуль в контейнер, только когда он нам нужен.
Теперь хочу рассказать про две ключевые фишки inversifyJS, которыми мы пользуемся у себя в проекте. Первая из них — tagged bindings. Для чего они нужны? Давайте вернемся к примеру с соковыжималкой, апельсином и яблоком:
Теперь мы скажем, что соковыжималка должна работать только с цитрусовыми фруктами. Для этого импортируем из inversifyJS декоратор tagged и внутри конструктора класса говорим, что по ключу FruitKey (то есть по ключу фруктов) хотим получить цитрусовый фрукт. Далее — в контейнере регистрируем, что теперь по одному ключу FruitKey у нас два класса — яблоко и апельсин, которым говорим, что яблоко у нас — не цитрусовое, а апельсин — цитрусовый. Чтобы различать эти два вида зависимостей, используем whenTargetTagged.
Вторая фишка, о которой я расскажу, — named bindings:
Итак, у нас есть соковыжималка и мы хотим добавить еще один класс с внешней зависимостью в виде соковыжималки, в которой используются яблоки, — класс Store, магазин. Чтобы получить соковыжималку с яблоками, в конструкторе класса мы указываем, что ее необходимо получить по ключу JuicerKey c дополнительным параметром AppleJuicer. Для этого воспользуемся декоратором named из inversifyJS.
В контейнере регистрируем наши фрукты и при помощи метода whenAnyAncestorNamed указываем, что у яблок будет дополнительный ключ AppleJuicer, а у апельсинов — OrangeJuicer.
Обратите внимание, что в классе Juicer мы берем зависимость по ключу FruitKey и, по большому счету, здесь мы не знаем, что нам придет на вход — апельсин или яблоко. Но при этом в родительской зависимости Store мы можем это определить.
Минусы Dependency Injection+React
Какие есть минусы DI в связке с React? Самый большой и, пожалуй, единственный минус — это использование строковых ключей, которые не сопоставляются с типами. То есть для того, чтобы мы могли работать с абстракциями в виде интерфейсов, нам приходится добавлять строковые ключи, так как в runtime у нас обычный JavaScript, в котором нет интерфейсов:
Если посмотрите на предпоследнюю строчку с кодом, то увидите, что мы биндим Store по ключу StoreKey. Если в моем примере поменять местами Store и, допустим, ту же самую соковыжималку, то в приложении получим ошибку и TypeScript не скажет, что здесь что-то пошло не так.
Когда мы берем из контейнера какую-то зависимость или когда биндим какую-то зависимость в контейнер, лучше указывать, какой класс или интерфейс мы хотим получить или зарегистрировать в контейнере.
container.bind<Store>("StoreKey").to(Store);
Это единственный минус, который мы заметили за время внедрения и использования получившейся архитектуры.
Вывод
Мы получили новую модульную и гибкую архитектуру, которая легко поддается изменениям и легко расширяется. Придерживаясь архитектурного паттерна Dependency Injection, мы уменьшили связанность между компонентами системы и в целом пишем меньше кода. У нас больше нет одного глобального хранилища. Страницы стали полностью независимыми друг от друга и загружают только необходимые для конкретной страницы данные.
На этом все. Вы можете перейти по двум ссылкам: первая — это playground для того, чтобы побаловаться с inversifyJS на NodeJS, вторая — пример внедрения в React-приложение. Вы можете забрать себе эти High-Order-компоненты и контейнеры и начать строить свое приложение уже с React и inversifyJS.