Делюсь одной из своих библиотек которая называется First DI. Она уже много лет помогает мне решить проблему внедрения зависимостей в браузерных приложениях для таких библиотек как React, Preact, Mithril и другие. При написании First DI за основу была взята идеология DI библиотек языков C# и Java, такие как autofac, java spring autowired, ninject и другие. И точно так как библиотеки из этих языков First DI работает опираясь на рефлексию и интерфейсы Typescript.

Для чего нужен DI?


Если говорить коротко, то Dependecy Injection (далее DI) является одной из основных частей для построения Чистой Архитектуры. Которая позволяет подменять любую часть программы на другую реализацию этой части программы. А DI является инструментом внедрения и подмены частей программы.

В частности он позволяет:

  • Подменять реализации логики индивидуально для каждой платформы. Например в браузере показывать уведомления через Notifications API, а при сборке мобильных приложений подменять на уведомления через API мобильной платформы.
  • Подменять реализацию логики в различных окружениях. Например в продуктовом контуре может использоваться платёжный шлюз или система рассылки уведомлений недоступная с локальной машины разработчика. Тем самым вместо реальных платежей или рассылки на этапе разработке логику можно заменить заглушкой.
  • Подменять источник получения данных. Например вместо реального запроса на сервер подсунуть заранее подготовленные данные для тестирования бизнес логики или верстки на соответствие макетам.
  • и еще много полезных применений, но сейчас не об этом...

Для начало подготовим инструменты к работе.

Подготовка к использованию


Для использования необходимо сделать всего 3 простых вещи:

  1. Подключить полифил reflect-metadata.
  2. В файле tsconfig включить опции emitDecoratorMetadata и experimentalDecorators. Первая позволяет генерировать рефлексию, вторая включает поддержку декораторов
  3. Создать любой пустой декоратор, например const reflection = (..._params: Object[]): void => {} или воспользоваться готовым из библиотеки.

Теперь продемонстрирую использование в самом просто виде.

Простое использование


Для примера возьмем программу написанную с использованием Чистой Архитектуры:

import { autowired, override, reflection } from "first-di";

@reflection // typescript сгенерирует рефлексию
class ProdRepository { // реализация для продакшена

    public async getData(): Promise<string> {
        return await Promise.resolve("production");
    }

}

@reflection
class MockRepository { // реализация для тестов с тем же интерфейсом

    public async getData(): Promise<string> {
        return await Promise.resolve("mock");
    }

}

@reflection
class ProdService {

    constructor(private readonly prodRepository: ProdRepository) { }

    public async getData(): Promise<string> {
        return await this.prodRepository.getData();
    }

}

class ProdController { // компонент в React, Preact, Mithril и др.

    @autowired() // внедрение зависимости
    private readonly prodService!: ProdService;

    // constructor используют библиотеки, такие как React, Preact, Mithril
    // поэтому инъекция через конструктор не производится

    public async getData(): Promise<string> {
        return await this.prodService.getData();
    }

}

if (process.env.NODE_ENV === "test") { // переопределение реализаций для тестов
    override(ProdRepository, MockRepository);
}

const controllerInstance = new ProdController(); // создание экземпляра фреймворком
const data = await controllerInstance.getData();

if (process.env.NODE_ENV === "test") {
    assert.strictEqual(data, "mock"); // тестовые данные
} else {
    assert.strictEqual(data, "production"); // продуктовые данные
}

В Чистую Архитектуру надо добавить всего 2 строчки кода, autowired и override для того что бы DI начал работать. И вот программа уже имеет реализацию для продакшена и реализацию для тестирования.

Так как First DI писался в первую очередь для клиентский приложений, то по умолчанию зависимости будут реализованы в виде Singleton. Для того, что бы глобально поменять это поведение, существуют опции по умолчанию, а также параметры для декоратора autowired и метода override().

Поменять можно следующим образом:

// Вариант 1 глобально
import { DI, AutowiredLifetimes } from "first-di";
DI.defaultOptions.lifeTime = AutowiredLifetimes.PER_INSTANCE;

// Вариант 2 в декораторе
import { autowired, AutowiredLifetimes } from "first-di";
@autowired({lifeTime: AutowiredLifetimes.PER_INSTANCE})
private readonly prodService!: ProdService;

// Вариант 3 при переопределении
import { override, AutowiredLifetimes } from "first-di";
override(ProdRepository, MockRepository, {lifeTime: AutowiredLifetimes.PER_INSTANCE});

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

Профессиональное использование


Для профессионального использования заменим конкретные реализации на интерфейсы. Но typescript не генерирует код для работы интерфейсов в рантайме. К счастью есть простое решение, для понимания необходимо вспомнить теорию… что такое интерфейс? Это полностью абстрактный класс!

К счастью typescript поддерживает полностью абстрактные классы, генерирует код для рантайма, и позволяет использовать абстрактные классы для описания типов.

Воспользуемся этой информацией и напишем энтерпрайзную программу:

import { autowired, override, reflection } from "first-di";

abstract class AbstractRepository { // абстрактный класс вместо интерфейса

    abstract getData(): Promise<string>;

}

@reflection
class ProdRepository implements AbstractRepository {

    public async getData(): Promise<string> {
        return await Promise.resolve("production");
    }

}

@reflection
class MockRepository implements AbstractRepository {

    public async getData(): Promise<string> {
        return await Promise.resolve("mock");
    }

}

abstract class AbstractService { // абстрактный класс вместо интерфейса

    abstract getData(): Promise<string>;

}

@reflection
class ProdService implements AbstractService {

    private readonly prodRepository: AbstractRepository;

    constructor(prodRepository: AbstractRepository) {
        this.prodRepository = prodRepository;
    }

    public async getData(): Promise<string> {
        return await this.prodRepository.getData();
    }

}

class ProdController { // компонент в React, Preact, Mithril и др.

    @autowired()
    private readonly prodService!: AbstractService;

    // constructor используют библиотеки, такие как React, Preact, Mithril
    // поэтому инъекция через конструктор не производится

    public async getData(): Promise<string> {
        return await this.prodService.getData();
    }

}

override(AbstractService, ProdService);

if (process.env.NODE_ENV === "test") {
    override(AbstractRepository, MockRepository);
} else {
    override(AbstractRepository, ProdRepository);
}

const controllerInstance = new ProdController();
const data = await controllerInstance.getData();

if (process.env.NODE_ENV === "test") {
    assert.strictEqual(data, "mock");
} else {
    assert.strictEqual(data, "production");
}

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

Вот так просто можно внедрить DI в ваше приложение. А те кто писал на C# и Java смогут пользоваться уже имеющимся опытом и в веб разработке.

Другие возможности


Использование нескольких копий DI:

import { DI } from "first-di";
import { ProductionService } from "../services/ProductionService";

const scopeA = new DI();
const scopeB = new DI();

export class Controller {

    @scopeA.autowired()
    private readonly serviceScopeA!: ProductionService;

    @scopeB.autowired()
    private readonly serviceScopeB!: ProductionService;

    // constructor используют библиотеки, такие как React, Preact, Mithril
    // поэтому инъекция через конструктор не производится

    public async getDataScopeA(): Promise<string> {
        return await this.serviceScopeA.getData();
    }

    public async getDataScopeB(): Promise<string> {
        return await this.serviceScopeB.getData();
    }

}

Расширяемость, можно написать свой DI:

import { DI } from "first-di";

class MyDI extends DI {
    // extended method
    public getAllSingletons(): IterableIterator<object> {
        return this.singletonsList.values();
    }
}

И еще много интересных возможностей описаны в репозитории на GitHub.

Почему написан свой, а не использован имеющийся?


Причина простая — когда писался этот DI альтернатив не было. Это было время когда angular 1 уже был не актуален, а angular 2 еще не собирался выходить. В Javascript только появились классы, а в typescript рефлексия. Кстати говоря появление рефлексии стало основным толчком к действию.

InversifyJS — даже в текущем виде меня не устраивает. Слишком много бойлерплейта. К тому же регистрация по строке или символу на проч убивает возможность рефакторинга зависимостей.

Понравилось?


Если вам понравился этот DI помогите сделать его более популярным. Экспериментируйте с ним, присылайте реквесты, ставьте звездочки. Репозиторий на GitHub.