Pull to refresh

Иерархическое внедрение зависимостей в React и MobX State Tree в качестве доменной модели

Reading time12 min
Views20K

Довелось мне как-то после нескольких проектов на React поработать над приложением под Angular 2. Прямо скажем, не впечатлило. Но один момент запомнился — управление логикой и состоянием приложения с помощью Dependency Injection. И я задался вопросом, удобно ли управлять состоянием в React используя DDD, многослойную архитектуру, и внедрение зависимостей?


Если интересно, как это сделать, а главное, зачем — добро пожаловать под кат!


Если быть честным, то даже на бекэнде DI редко где используется на полную катушку. Разве что в реально больших приложениях. А в маленьких и средних, даже при наличии DI, у каждого интерфейса обычно только одна реализация. Но внедрение зависимостей все равно имеет свои преимущества:


  • Код лучше структурирован, а интерфейсы выступают в качестве явных контрактов.
  • Упрощается создание заглушек в юнит-тестах.

Но современные библиотеки тестирования для JS, такие как Jest, позволяют писать моки просто на основе модульной системы ES6. Так что здесь от DI мы особого профита не получим.


Остается второй момент — управление областью видимости и временем жизни объектов. На сервере время жизни обычно привязывается ко всему приложению (Singleton), или к запросу. А на клиенте основной единицей кода является компонент. К нему мы и будем привязываться.


Если нам необходимо использовать состояние на уровне приложения, проще всего завести переменную на уровне модуля ES6 и импортировать ее там, где надо. А если состояние нужно только внутри компонента — мы просто поместим его в this.state. Для всего остального есть Context. Но Context — слишком низкоуровневый:


  • Мы не можем использовать контекст вне дерева компонентов React. Например, в слое бизнес-логики.
  • Мы не можем использовать более одного контекста в Class.contextType. Чтобы определить зависимость от нескольких разных сервисов, нам придется построить "пирамиду ужаса" на новый лад:



Новый Hook useContext() слегка исправляет ситуацию для функциональных компонентов. Но мы никак не избавимся от множества <Context.Provider>. Пока не превратим наш контекст в Service Locator, а его родительский компонент в Composition Root. Но тут уже и до DI недалеко, поэтому приступим!


Эту часть можно пропустить и перейти сразу к описанию архитектуры

Реализация механизма DI


Для начала нам понадобится React Context:


export const InjectorContext= React.createContext(null);

Поскольку React использует конструктор компонента для своих нужд, мы будем использовать Property Injection. Для этого определим декоратор @inject, который:


  • задает свойство Class.contextType,
  • получает тип зависимости,
  • находит объект Injector и разрешает зависимость.

inject.js
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<T> = new (...args: any[]) => T;

function inject<T>(target: Object, type: Constructor<T> | Function): T;

inject.js
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()


hooks.js
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 <div />;
}

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


injector.js
type Binding = (injector: Injector) => Object;

export abstract class Injector extends React.Component {
  // ссылка на вышестоящий Injector
  _parent?: Injector;
  // настройки разрешения зависимостей
  _bindingMap: Map<Function, Binding>;
  // кэш для уже созданных экземпляров
  _instanceMap: Map<Function, Object>;
}

Для React-компонентов Injector доступен через поле this.context, а для классов-зависимостей мы можем временно поместить Injector в глобальную переменную. Чтобы ускорить поиск инжектора для каждого класса, будем кэшировать ссылку на Injector в скрытом поле.


injector.js
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()


injector.js
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


provider.js
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 (
        <InjectorContext.Provider value={this}>
          <Wrapped {...this.props} />
        </InjectorContext.Provider>
      );
    }

    static contextType = InjectorContext;

    static register(...definitions) {
      addBindings(bindingMap, definitions);
    }
  };
};

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


bindings.js
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() на всех созданных им сервисах.


provider.js
export const provider = (...definitions) => Wrapped => {
  // ...
  return class Provider extends Injector {
    // ...
    componentWillUnmount() {
      this._instanceMap.forEach(instance => {
        if (isObject(instance) && isFunction(instance.dispose)) {
          instance.dispose();
        }
      });
    }
    // ...
  };
};

Для разделения кода и ленивой загрузки нам может понадобиться инвертировать способ регистрации сервисов в провайдерах. С этим нам поможет декоратор @registerIn()


provider.js
export const registrationQueue = [];

export const registerIn = (getProvider, binding) => constructor => {
  registrationQueue.push(() => {
    getProvider().register(binding ? [constructor, binding] : constructor);
  });
  return constructor;
};

injector.js
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)


observer.js
export function observer(Wrapped) {
  return class Observer extends React.Component {
    componentDidMount() {
      observerRef = this;
    }

    componentWillUnmount() {
      observerRef = null;
    }

    render() {
      return <Wrapped {...this.props} />;
    }
  }
}

let observerRef = null;

action.js
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<number, User>();

  @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<User>) {
    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<User>) => {
    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<typeof Post>;

models/User.ts
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<typeof User>;

models/Comment.ts
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<typeof Comment>;

И типа хранилища данных:


// 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, реализуются в слое сервисов.


services/DataContext.ts
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<typeof Models> {}

services/AuthService.ts
import { observable } from "mobx";
import { User } from "../models";

export class AuthService {
  @observable currentUser: User;
}

services/PostService.ts
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<Post>) {
    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<Post>) {
    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);
  }
  // ...
}

posts.json
[
  {
    "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 и демо.

Tags:
Hubs:
Total votes 14: ↑14 and ↓0+14
Comments2

Articles