Иерархическое внедрение зависимостей в 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() слегка исправляет ситуацию для функциональных компонентов. Но мы никак не избавимся от множества <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 и демо.

    • +14
    • 6.1k
    • 1
    Share post

    Similar posts

    Comments 1

      0
      Правильно ли я понял, что при использовании react-ioc компоненты-классы всегда требуют InjectorContext, и собственный static contextType в компоненте уже использовать не получится?

      Еще не очень понятен момент регистрации пар [<Интерфейс>, <Реализация>]. Что такое в данном случае интерфейс? TypeScript-интерфейсы ведь не существуют в рантайме, это артефакт времени компиляции. Или тут интерфейс это просто какой-то произвольный объект-маркер?

      Спасибо, что нашли время написать статью :)

      Only users with full accounts can post comments. Log in, please.