Расшифровка доклада Сергея Нестерова с конференции 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.
Автор: Сергей Нестеров