Нативная инверсия зависимостей в TypeScript и React

в 7:48, , рубрики: architecture, composition root, constructor injection, dependency injection, inversify, javascript, React, service locator, solid, TypeScript, Блог компании ДоксВижн, Разработка веб-сайтов

Когда я задумался о внедрении зависимостей в TypeScript, то первое, что мне посоветовали — inversify. Я посмотрел эту и другие библиотеки, реализующие паттерн Service Locator, и даже сделал свою собственную — typedin.

Но когда я работал над версией typedin 2.0, то в конце концов понял, что вообще никакой библиотеки не нужно. В TypeScript есть все необходимое.

Нативная инверсия зависимостей в TypeScript и React - 1

Sevice Locator это антипаттерн

Уже давно известно, что Service Locator это антипаттерн. В первую очередь потому, что он создает неявные зависимости. Если вы просто передаете service сontainer в класс, и в коде класса произвольным образом получаете сервисы, то единственный способ узнать зависимости такого класса — изучить его код.

// Пример из inversify
var ninja = kernel.get<INinja>("INinja");

Конечно, можно чуточку улучшить это обстоятельство, если внедрять зависимости через свойства. Например, вот так это делается в typedin (для inversify тоже есть декоторы):

 class SomeComponent {
     @inject logService: ILogService;
 }

Объявлением такого свойства мы якобы объявляем в интерфейсе класса его зависимость. Но это все еще плохо — мы можем спокойно создать экземпляр класса, не передав ему нужные зависимости, и получить ошибку времени исполнения. IDE нам не подсказывает, как правильно использовать класс.

Нативная инверсия зависимостей в TypeScript и React - 2

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

По всем этим причинам самым лучшим способом внедрения зависимостей является constructor injection совместно с composition root.

 class SomeComponent {
     constrcutor(private logService: ILogService) {
     }
 }

Сложности с constructor injection

Внедрение зависимостей через конструктор лишено всех перечисленных выше недостатков. Мы явно объявляем зависимости, так что пользователь просто не сможет создать экземпляр класса, не передав нужные сервисы. При этом компилятор полностью контролирует наш код и сообщит об ошибке сразу. Однако этот подход довольно неудобен в "сыром" виде. Каждый раз при создании экземпляра класса нам нужно передавать в него все необходимые сервисы.

 var some = new SomeComponent(logService)

А если у нас дерево компонентов, то код передачи зависимостей нужно писать во всей цепочке.

 class SomeWrapperComponent {
     constructor(private logService: ILogService) {
        var some = new SomeComponent(logService)
     }
 }

При изменении списка сервисов в SomeComponent придется менять код SomeWrapperComponent и далее всех, кто его использует. Особенно это печально, когда количество сервисов становится сколько-нибудь значительным.

Тем не менее, как показал нам Angular, благодаря декораторам в TypeScript можно автоматически внедрять зависимости, перечисленные в параметрах конструктора.

// Пример внедрения зависимостей через конструктор в Angular
@Injectable()
export class HeroService {     
  constructor(private logger: Logger) {  }
}

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

Однако такой подход проблематично реализовать в React. Аналогом конструктора для React-компонентов являются props. То есть, constructor injection в React должен выглядеть примерно так:

render() {
    return <SomeComponent logService={this.logService} />
}

К сожалению, props — это всего лишь интерфейс, и никакие декораторы не позволят нам сделать автоматическую инъекцию зависимостей, как в Angular.

export interface SomeComponentProps {
    logger: Logger
}
export class SomeComponent extends React.Component<SomeComponentProps, {}> {
}

Это проблема не только React. Во многих фреймворках мы не контролируем создание компонентов через конструктор. Например, в том же Vue. На самом деле, в Angular тоже никто не создает компоненты через конструктор, так что там тоже это все актуально.

Нативная инъекция зависимостей средствами TypeScript

Я долго думал, как бы все это совместить, работая над typedin v2.0. Хотелось сохранить явный характер передачи зависимостей, как в constructor injection, но при этом сократить количество бойлерплейта и сделать это совместимым с React.

Постепенно у меня начал появляться прототип такого решения. Я шаг за шагом улучшал код, выкидывал все лишнее до тех пор, пока в один прекрасный момент от библиотеки typedin не осталось совсем ничего. Оказалось, что все, что нужно, уже есть в TypeScript, так что, можно сказать, данная статья — это и есть typedin v2.0.

Итак, все, что нам нужно сделать — добавить одно объявление типа $Logger рядом с объявлением сервиса.

export class Logger {
    log(msg: string) { console.info(msg); }
}
export type $Logger = { logger: Logger; };

Добавим еще один сервис, чтобы было интереснее:

export class LocalStorage {
    setItem(key: string, value: string) { localStorage.setItem(key, value); } 
    getItem(key: string) { return localStorage.getItem(key); } 
}
export type $LocalStorage = { localStorage: LocalStorage }

Объявляем наш компонент, которому требуются зависимости Logger и LocalStorage.

export interface SomeComponentProps {
    services: $Logger & $LocalStorage;
}
export class SomeComponent extends React.Component<SomeComponentProps, {}> {
    constructor(props) {
        super(props);

        // Обращаемся к зависимостям
        let habrGreeting = props.services.localStorage.getItem("Habrahabr");
        props.services.logger.log("Native TypeScript DI! " + habrGreeting);
    )
}

Давайте еще объявим другой сервис, который также нуждается во внедрении зависимостей.

export class HeroService {     
  constructor(private services: $Logger) {
    services.logger.log("Constructor injection is awesome!");
  }
}

Осталось собрать все это вместе. В каком-то месте приложения, мы инициализируем все наши сервисы, согласно паттерну composition root:

let logger = new Logger();
export var services = {
    logger: logger,
    localStorage: new LocalStorage(),
    heroService: new HeroService({ logger }) // Обратите внимание!
};

Теперь можно просто передать этот объект в наш компонент:

render() {
    return <SomeComponent services={services} />
}

Вот и все! Настоящий чистый универсальный constructor injection без бойлерплейта!

Как все это работает

Я обожаю TypeScript за этот оператор & применительно типам. Именно благодаря нему все это выглядит так просто и изящно. При объявлении сервиса Logger мы дополнительно объявили тип $Logger. Если Вас смущает конструкция type, альтернативый вариант такой:

export interface $Logger {
    logger: Logger;
}

Буквально, мы объявляем интерфейс некоторого контейнера, содержащего сервис Logger в переменной logger. И так делает каждый сервис — $LocalStorage, $HeroService. В компоненте нам нужно несколько сервисов, поэтому мы просто объединяем два интерфейса:

services: $Logger & $LocalStorage;

Данная конструкция равносильна примерно следующему:

interface SomeComponentDependecies extends $Logger, $LocalStorage {
     logger: Logger;
     localStorage: LocalStorage;
}
services: SomeComponentDependecies;

То есть мы говорим, что компоненту SomeComponent нужно передать контейнер, содержащий сервисы Logger и LocalStorage. И это все! Каким образом компоненту передадут соответствующий контейнер, откуда он возьмется и как будет создан — это уже не так важно. Можно импортировать какой-то глобальный объект services, созданный в одном месте в composition root. Можно передавать этот объект через цепочку родительских компонентов. Можно создавать его динамически по требованию. Все зависит от условий конкретного приложения.

Заключение

InversifyJS содержит порядка 100кб кода и документацию из порядка 40 разделов, не самых простых для понимания. Тем не менее, ее пакет npm загружают около 100 тысяч раз месяц, пишут для нее множество плагинов и расширений. Из этого можно сделать два вывода:

  1. Внедрение зависимостей набирает популярность в фронтенд-мире
  2. Фронтенд-сообщество еще не успело осознать, что Service Locator — это антипаттерн

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

Автор: PFight77

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js