Иерархическое внедрение зависимостей в React и MobX State Tree в качестве доменной модели
Довелось мне как-то после нескольких проектов на React поработать над приложением под Angular 2. Прямо скажем, не впечатлило. Но один момент запомнился — управление логикой и состоянием приложения с помощью Dependency Injection. И я задался вопросом, удобно ли управлять состоянием в React используя DDD, многослойную архитектуру, и внедрение зависимостей?
Если интересно, как это сделать, а главное, зачем — добро пожаловать под кат!
Если быть честным, то даже на бекэнде DI редко где используется на полную катушку. Разве что в реально больших приложениях. А в маленьких и средних, даже при наличии DI, у каждого интерфейса обычно только одна реализация. Но внедрение зависимостей все равно имеет свои преимущества:
- Код лучше структурирован, а интерфейсы выступают в качестве явных контрактов.
- Упрощается создание заглушек в юнит-тестах.
Но современные библиотеки тестирования для JS, такие как Jest, позволяют писать моки просто на основе модульной системы ES6. Так что здесь от DI мы особого профита не получим.
Остается второй момент — управление областью видимости и временем жизни объектов. На сервере время жизни обычно привязывается ко всему приложению (Singleton), или к запросу. А на клиенте основной единицей кода является компонент. К нему мы и будем привязываться.
Если нам необходимо использовать состояние на уровне приложения, проще всего завести переменную на уровне модуля ES6 и импортировать ее там, где надо. А если состояние нужно только внутри компонента — мы просто поместим его в this.state
. Для всего остального есть Context
. Но Context
— слишком низкоуровневый:
- Мы не можем использовать контекст вне дерева компонентов React. Например, в слое бизнес-логики.
- Мы не можем использовать более одного контекста в
Class.contextType
. Чтобы определить зависимость от нескольких разных сервисов, нам придется построить «пирамиду ужаса» на новый лад:
Новый Hook useContext()
слегка исправляет ситуацию для функциональных компонентов. Но мы никак не избавимся от множества
. Пока не превратим наш контекст в Service Locator, а его родительский компонент в Composition Root. Но тут уже и до DI недалеко, поэтому приступим!
Эту часть можно пропустить и перейти сразу к описанию архитектуры
Реализация механизма DI
Для начала нам понадобится React Context:
export const InjectorContext= React.createContext(null);
Поскольку React использует конструктор компонента для своих нужд, мы будем использовать Property Injection. Для этого определим декоратор @inject
, который:
- задает свойство
Class.contextType
, - получает тип зависимости,
- находит объект
Injector
и разрешает зависимость.
import "reflect-metadata";
export function inject(target, key) {
// задаем static cotextType
target.constructor.contextType = InjectorContext;
// получаем тип зависимости
const type = Reflect.getMetadata("design:type", target, key);
// определяем property
Object.defineProperty(target, key, {
configurable: true,
enumerable: true,
get() {
// получаем Injector из иерархии компонентов и разрешаем зависимость
const instance = getInstance(getInjector(this), type);
Object.defineProperty(this, key, {
enumerable: true,
writable: true,
value: instance
});
return instance;
},
// settet для присваивания в обход Dependency Injection
set(instance) {
Object.defineProperty(this, key, {
enumerable: true,
writable: true,
value: instance
});
}
});
}
Теперь мы можем задавать зависимости между произвольными классами:
import { inject } from "react-ioc";
class FooService {}
class BarService {
@inject foo: FooService;
}
class MyComponent extends React.Component {
@inject foo: FooService;
@inject bar: BarService;
}
Для тех, кто не приемлет декораторы, определим функцию inject()
с такой сигнатурой:
type Constructor = new (...args: any[]) => T;
function inject(target: Object, type: Constructor | Function): T;
export function inject(target, keyOrType) {
if (isFunction(keyOrType)) {
return getInstance(getInjector(target), keyOrType);
}
// ...
}
Это позволит определять зависимости в явном виде:
class FooService {}
class BarService {
foo = inject(this, FooService);
}
class MyComponent extends React.Component {
foo = inject(this, FooService);
bar = inject(this, BarService);
// указываем явно
static contextType = InjectorContext;
}
А что же насчет функциональных компонентов? Для них мы можем реализовать Hook useInstance()
import { useRef, useContext } from "react";
export function useInstance(type) {
const ref = useRef(null);
const injector = useContext(InjectorContext);
return ref.current || (ref.current = getInstance(injector, type));
}
import { useInstance } from "react-ioc";
const MyComponent = props => {
const foo = useInstance(FooService);
const bar = useInstance(BarService);
return ;
}
Теперь определим, как может выглядеть наш Injector
, как его найти, и как разрешить зависимости. Инжектор должен содержать ссылку на родителя, кэш объектов для уже разрешенных зависимостей и словарь правил для еще не разрешенных.
type Binding = (injector: Injector) => Object;
export abstract class Injector extends React.Component {
// ссылка на вышестоящий Injector
_parent?: Injector;
// настройки разрешения зависимостей
_bindingMap: Map;
// кэш для уже созданных экземпляров
_instanceMap: Map;
}
Для React-компонентов Injector
доступен через поле this.context
, а для классов-зависимостей мы можем временно поместить Injector
в глобальную переменную. Чтобы ускорить поиск инжектора для каждого класса, будем кэшировать ссылку на Injector
в скрытом поле.
export const INJECTOR =
typeof Symbol === "function" ? Symbol() : "__injector__";
let currentInjector = null;
export function getInjector(target) {
let injector = target[INJECTOR];
if (injector) {
return injector;
}
injector = currentInjector || target.context;
if (injector instanceof Injector) {
target[INJECTOR] = injector;
return injector;
}
return null;
}
Чтобы найти конкретное правило привязки, нам нужно пробежаться вверх по дереву инжекторов с помощью функции getInstance()
export function getInstance(injector, type) {
while (injector) {
let instance = injector._instanceMap.get(type);
if (instance !== undefined) {
return instance;
}
const binding = injector._bindingMap.get(type);
if (binding) {
const prevInjector = currentInjector;
currentInjector = injector;
try {
instance = binding(injector);
} finally {
currentInjector = prevInjector;
}
injector._instanceMap.set(type, instance);
return instance;
}
injector = injector._parent;
}
return undefined;
}
Перейдем, наконец, к регистрации зависимостей. Для этого нам понадобится HOC provider()
, который принимает массив привязок зависимостей к их реализациям, и регистрирует новый Injector
через InjectorContext.Provider
export const provider = (...definitions) => Wrapped => {
const bindingMap = new Map();
addBindings(bindingMap, definitions);
return class Provider extends Injector {
_parent = this.context;
_bindingMap = bindingMap;
_instanceMap = new Map();
render() {
return (
);
}
static contextType = InjectorContext;
static register(...definitions) {
addBindings(bindingMap, definitions);
}
};
};
А также, набор функций привязок, которые реализуют различные стратегии создания экземпляров зависимостей.
export const toClass = constructor =>
asBinding(injector => {
const instance = new constructor();
if (!instance[INJECTOR]) {
instance[INJECTOR] = injector;
}
return instance;
});
export const toFactory = (depsOrFactory, factory) =>
asBinding(
factory
? injector =>
factory(...depsOrFactory.map(type => getInstance(injector, type)))
: depsOrFactory
);
export const toExisting = type =>
asBinding(injector => getInstance(injector, type));
export const toValue = value => asBinding(() => value);
const IS_BINDING = typeof Symbol === "function" ? Symbol() : "__binding__";
function asBinding(binding) {
binding[IS_BINDING] = true;
return binding;
}
export function addBindings(bindingMap, definitions) {
definitions.forEach(definition => {
let token, binding;
if (Array.isArray(definition)) {
[token, binding = token] = definition;
} else {
token = binding = definition;
}
bindingMap.set(token, binding[IS_BINDING] ? binding : toClass(binding));
});
}
Теперь мы сможем зарегистрировать привязки зависимостей на уровне произвольного компонента в виде набора пар [<Интерфейс>, <Реализация>]
.
import { provider, toClass, toValue, toFactory, toExisting } from "react-ioc";
@provider(
// привязка к классу
[FirstService, toClass(FirstServiceImpl)],
// привязка к статическому значению
[SecondService, toValue(new SecondServiceImpl())],
// привязка к фабрике
[ThirdService, toFactory(
[FirstService, SecondService],
(first, second) => ThirdServiceFactory.create(first, second)
)],
// привязка к уже зарегистрированному типу
[FourthService, toExisting(FirstService)]
)
class MyComponent extends React.Component {
// ...
}
Или в сокращенной форме для классов:
@provider(
// [FirstService, toClass(FirstService)]
FirstService,
// [SecondService, toClass(SecondServiceImpl)]
[SecondService, SecondServiceImpl]
)
class MyComponent extends React.Component {
// ...
}
Поскольку время жизни сервиса определяется компонентом-провайдером, в котором он зарегистрирован, для каждого сервиса мы можем определить метод очистки .dispose()
. В нем мы можем отписаться от каких-то событий, закрыть сокеты и т.д. При удалении провайдера из DOM, он вызовет .dispose()
на всех созданных им сервисах.
export const provider = (...definitions) => Wrapped => {
// ...
return class Provider extends Injector {
// ...
componentWillUnmount() {
this._instanceMap.forEach(instance => {
if (isObject(instance) && isFunction(instance.dispose)) {
instance.dispose();
}
});
}
// ...
};
};
Для разделения кода и ленивой загрузки нам может понадобиться инвертировать способ регистрации сервисов в провайдерах. С этим нам поможет декоратор @registerIn()
export const registrationQueue = [];
export const registerIn = (getProvider, binding) => constructor => {
registrationQueue.push(() => {
getProvider().register(binding ? [constructor, binding] : constructor);
});
return constructor;
};
export function getInstance(injector, type) {
if (registrationQueue.length > 0) {
registrationQueue.forEach(registration => {
registration();
});
registrationQueue.length = 0;
}
while (injector) {
// ...
}
import { registerIn } from "react-ioc";
import { HomePage } from "../components/HomePage";
@registerIn(() => HomePage)
class MyLazyLoadedService {}
Вот так, за 150 строк и 1 KB кода, можно реализовать практически полноценный иерархический DI-контейнер.
Архитектура приложения
Наконец, перейдем к главному — как организовать архитектуру приложения. Здесь возможны три варианта в зависимости от размера приложения, сложности предметной области и нашей лени.
1. The Ugly
У нас же Virtual DOM, а значит он должен быть быстрым. По крайней мере под этим соусом React подавался на заре карьеры. Поэтому просто запомним ссылку на корневой компонент (например, с помощью декоратора @observer
). И будем вызывать на нем .forceUpdate()
после каждого действия, затрагивающего общие сервисы (например, с помощью декоратора @action
)
export function observer(Wrapped) {
return class Observer extends React.Component {
componentDidMount() {
observerRef = this;
}
componentWillUnmount() {
observerRef = null;
}
render() {
return ;
}
}
}
let observerRef = null;
export function action(_target, _key, descriptor) {
const method = descriptor.value;
descriptor.value = function() {
let result;
runningCount++;
try {
result = method.apply(this, arguments);
} finally {
runningCount--;
}
if (runningCount === 0 && observerRef) {
observerRef.forceUpdate();
}
return result;
};
}
let runningCount = 0;
class UserService {
@action doSomething() {}
}
class MyComponent extends React.Component {
@inject userService: UserService;
}
@provider(UserService)
@observer
class App extends React.Component {}
Это даже будет работать. Но… Вы сами понимаете:-)
2. The Bad
Нас не устраивает рендеринг всего на каждый чих. Но мы все еще хотим использовать почти обычные объекты и массивы для хранения состояния. Давайте возьмем MobX!
Заводим несколько хранилищ данных со стандартными действиями:
import { observable, action } from "mobx";
export class UserStore {
byId = observable.map();
@action
add(user: User) {
this.byId.set(user.id, user);
}
// ...
}
export class PostStore {
// ...
}
Бизнес-логику, I/O и прочее выносим в слой сервисов:
import { action } from "mobx";
import { inject } from "react-ioc";
export class AccountService {
@inject userStore userStore;
@action
updateUserInfo(userInfo: Partial) {
const user = this.userStore.byId.get(userInfo.id);
Object.assign(user, userInfo);
}
}
И распределяем их по компонентам:
import { observer } from "mobx-react";
import { provider, inject } from "react-ioc";
@provider(UserStore, PostStore)
class App extends React.Component {}
@provider(AccountService)
@observer
class AccountPage extends React.Component{}
@observer
class UserForm extends React.Component {
@inject accountService: AccountService;
}
import { action } from "mobx";
import { inject } from "react-ioc";
export class AccountService {
userStore = inject(this, UserStore);
updateUserInfo = action((userInfo: Partial) => {
const user = this.userStore.byId.get(userInfo.id);
Object.assign(user, userInfo);
});
}
import { observer } from "mobx-react-lite";
import { provider, useInstance } from "react-ioc";
const App = provider(UserStore, PostStore)(props => {
// ...
});
const AccountPage = provider(AccountService)(observer(props => {
// ...
}));
const UserFrom = observer(props => {
const accountService = useInstance(AccountService);
// ...
});
Получается классическая трехуровневая архитектура.
3. The Good
Иногда предметная область становится настолько сложной, что с ней уже неудобно работать используя простые объекты (или анемичную модель в терминах DDD). Особенно это заметно, когда данные имеют реляционную структуру с множеством связей. В таких случаях нам приходит на помощь библиотека MobX State Tree, позволяющая применить принципы Domain-Driven Design в архитектуре фронтенд-приложения.
Проектирование модели начинается с описания типов:
// models/Post.ts
import { types as t, Instance } from "mobx-state-tree";
export const Post = t
.model("Post", {
id: t.identifier,
title: t.string,
body: t.string,
date: t.Date,
rating: t.number,
author: t.reference(User),
comments: t.array(t.reference(Comment))
})
.actions(self => ({
voteUp() {
self.rating++;
},
voteDown() {
self.rating--;
},
addComment(comment: Comment) {
self.comments.push(comment);
}
}));
export type Post = Instance;
import { types as t, Instance } from "mobx-state-tree";
export const User = t.model("User", {
id: t.identifier,
name: t.string
});
export type User = Instance;
import { types as t, Instance } from "mobx-state-tree";
import { User } from "./User";
export const Comment = t
.model("Comment", {
id: t.identifier,
text: t.string,
date: t.Date,
rating: t.number,
author: t.reference(User)
})
.actions(self => ({
voteUp() {
self.rating++;
},
voteDown() {
self.rating--;
}
}));
export type Comment = Instance;
И типа хранилища данных:
// models/index.ts
import { types as t } from "mobx-state-tree";
export { User, Post, Comment };
export default t.model({
users: t.map(User),
posts: t.map(Post),
comments: t.map(Comment)
});
Типы-сущности содержат в себе состояние модели предметной области и основные операции с ней. Более сложные сценарии, включая I/O, реализуются в слое сервисов.
import { Instance, unprotect } from "mobx-state-tree";
import Models from "../models";
export class DataContext {
static create() {
const models = Models.create();
unprotect(models);
return models;
}
}
export interface DataContext extends Instance {}
import { observable } from "mobx";
import { User } from "../models";
export class AuthService {
@observable currentUser: User;
}
import { inject } from "react-ioc";
import { action } from "mobx";
import { Post } from "../models";
export class PostService {
@inject dataContext: DataContext;
@inject authService: AuthService;
async publishPost(postInfo: Partial) {
const response = await fetch("/posts", {
method: "POST",
body: JSON.stringify(postInfo)
});
const { id } = await response.json();
this.savePost(id, postInfo);
}
@action
savePost(id: string, postInfo: Partial) {
const post = Post.create({
id,
rating: 0,
date: new Date(),
author: this.authService.currentUser.id,
comments: [],
...postInfo
});
this.dataContext.posts.put(post);
}
}
Главной особенностью MobX State Tree является эффективная работа со снапшотами данных. В любой момент времени мы можем получить сериализванное состояние любой сущности, коллекции или даже всего состояния приложения с помощью функции getSnapshot()
. И точно так же мы можем применить снапшот к любой части модели используя applySnapshot()
. Это позволяет нам в несколько строчек кода инициализировать состояние с сервера, загружать из LocalStorage или даже взаимодействовать с ним через Redux DevTools.
Поскольку мы используем нормализованную реляционную модель, для загрузки данных нам понадобится библиотека normalizr. Она позволяет переводить древовидный JSON в плоские таблицы объектов, сгруппированных по id
, согласно схеме данных. Как раз в тот формат, что нужен MobX State Tree в качестве снапшота.
Для этого определим схемы объектов, загружаемых с сервера:
import { schema } from "normalizr";
const UserSchema = new schema.Entity("users");
const CommentSchema = new schema.Entity("comments", {
author: UserSchema
});
const PostSchema = new schema.Entity("posts", {
// определяем только поля-связи
// примитивные поля копируются без изменений
author: UserSchema,
comments: [CommentSchema]
});
export { UserSchema, PostSchema, CommentSchema };
И загрузим данные в хранилище:
import { inject } from "react-ioc";
import { normalize } from "normalizr";
import { applySnapshot } from "mobx-state-tree";
export class PostService {
@inject dataContext: DataContext;
// ...
async loadPosts() {
const response = await fetch("/posts.json");
const posts = await response.json();
const { entities } = normalize(posts, [PostSchema]);
applySnapshot(this.dataContext, entities);
}
// ...
}
[
{
"id": 123,
"title": "Иерархическое внедрение зависимостей в React",
"body": "Довелось мне как-то после нескольких проектов на React...",
"date": "2018-12-10T18:18:58.512Z",
"rating": 0,
"author": { "id": 12, "name": "John Doe" },
"comments": [{
"id": 1234,
"text": "Hmmm...",
"date": "2018-12-10T18:18:58.512Z",
"rating": 0,
"author": { "id": 12, "name": "John Doe" }
}]
},
{
"id": 234,
"title": "Lorem ipsum",
"body": "Lorem ipsum dolor sit amet...",
"date": "2018-12-10T18:18:58.512Z",
"rating": 0,
"author": { "id": 23, "name": "Marcus Tullius Cicero" },
"comments": []
}
]
Наконец, зарегистрируем сервисы в соответствующих компонентах:
import { observer } from "mobx-react";
import { provider, inject } from "react-ioc";
@provider(AuthService, PostService, [
DataContext,
toFactory(DataContext.create)
])
class App extends React.Component {
@inject postService: PostService;
componentDidMount() {
this.postService.loadPosts();
}
}
Получается все та же трехслойная архитектура, но с возможностью сохранения состояния и рантайм-проверкой типов данных (в DEV-режиме). Последнее позволяет быть уверенным, что если не возникло исключения, то состояние хранилища данных соответствует спецификации.
Для тех, кому было интересно, ссылка на github и демо.