Внедрение зависимостей с TypeScript на практике

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

Один из приёмов, который помогает следовать этому принципу, — это внедрение зависимостей (dependency injection).

В этой статье мы разберём, что такое внедрение зависимостей, зачем оно нужно, а также какие инструменты могут помочь фронтендерам использовать этот шаблон по максимуму.

Что такое зависимости

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

Аналогия с функциями

Если немного отойти от академических определений, то зависимости можно сравнить с аргументами функции. И те, и другие как-то используются, ото всех зависит функциональность и работоспособность модуля.

// Функция не заработает без аргументов min и max:

function random(min, max) {
	if (typeof min === 'undefined' || typeof max === 'undefined') {
		throw new Error('All arguments are required');
	}

	return Math.random() * (max - min) + min;
}

В примере выше функция принимает два аргумента: min и max. Если какой-то забыть, то функция не заработает. Мы можем сказать, что она зависит от этих аргументов. Внимательные читатели заметят, что функция зависит не только от аргументов, но и от функции Math.random.

Мы можем сделать такой вывод, потому что если Math.random не будет определена, наша функция random тоже не заработает. То есть она зависит от функциональности другого модуля — Math. Этот модуль — тоже зависимость.

Мы даже можем показать это явно, сделав его аргументом:

function random(a, b, randomSource) {
	if (
		typeof min === 'undefined' ||
		typeof max === 'undefined' ||
		typeof randomSource === 'undefined'
	) {
		throw new Error('All arguments are required');
	}

	return randomSource.random() * (max - min) + min;
}

Теперь мы видим, что функция random на самом деле использует не только два числа, но ещё и какой-то генератор случайных чисел. Такую функцию мы будем вызывать так:

const randomBetweenTenAndTwenty = random(10, 20, Math);

Либо, если мы не хотим постоянно писать руками Math последним аргументом, мы можем сделать его значением по умолчанию для последнего аргумента в random:

function random(a, b, randomSource = Math) {
	// …
}

// …и вызывать функцию так:
const randomBetweenTenAndTwenty = random(10, 20);

Это и есть примитивное внедрение зависимостей

Оно, конечно, не каноническое, не по всем правилам, очень примитивное, но главная идея сохраняется. Мы передаём нашему модулю все необходимые для его работы зависимости.

Зачем это нужно

Пример с функцией random может показаться бессмысленным. Действительно, зачем выносить Math в аргумент и использовать его оттуда? Чем плохо просто использовать его внутри функции? На то есть две причины.

Тестируемость

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

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

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

// Мы можем создать фиктивный объект,
// который будет всегда возвращать 0.1 вместо случайного числа:

const fakeRandomSource = {
	random: () => 0.1
};

// Дальше мы вызовем нашу функцию,
// передав ей последним аргументом фиктивный объект:
const randomBetweenTenAndTwenty = random(10, 20, fakeRandomSource);

// Теперь, так как алгоритм внутри функции известен и детерминирован,
// мы в праве ожидать, что результат будет всегда одинаков:
randomBetweenTenAndTwenty === 11; // true

Замена зависимости на другую

Подменять зависимость во время тестов — это частный случай. В общем же мы можем захотеть поменять один модуль на другой по каким-то другим причинам.

Если новый модуль ведёт себя так же как старый, мы можем заменить один на другой:

// Если новый объект содержит метод random(),
// мы можем заменить им старую зависимость.

const otherRandomSource = {
	random() {
		// ...Другая реализация выбора случайного числа.
	}
};

const randomNumber = random(10, 20, otherRandomSource);

Но можем ли мы гарантировать, что новый модуль будет вести себя так же, как старый? Можем, для этого есть инструмент — интерфейсы.

Интерфейсы

Интерфейс — это контракт на поведение. Он фиксирует то, как объект должен себя вести, гарантирует какое-то поведение. В нашем случае, чтобы обеспечить метод random в каком-то объекте нам тоже может помочь интерфейс.

К сожалению в JavaScript интерфейсов нет, но они есть в TypeScript. Поэтому, начиная со следующего примера, весь код мы будем писать на нём. Если вы не знакомы с TypeScript, я могу посоветовать TypeScript Deep Dive.

Описываем поведение

Чтобы объявить, что какой-то объект обязан иметь метод random, который возвращает число, мы создадим интерфейс:

interface RandomSource {
	random(): number;
}

Чтобы объявить, что конкретный объект обязан иметь метод random, мы указываем, что этот объект реализует этот интерфейс:

// С помощью двоеточия мы говорим,
// что этот объект реализует интерфейс RandomSource,
// а значит — обязан вести себя так, как описано в этом интерфейсе.

const otherRandomSource: RandomSource = {
	random = () => {
		// Он должен вернуть число,
		// иначе TypeScript-компилятор укажет на ошибку.
		return 42;
	}
};

Теперь нам осталось в функции random указать, что последним аргументом мы ждём исключительно объект, который реализует интерфейс RandomSource.

function random(a: number, b: number, source: RandomSource): number {
	if (typeof min === 'undefined' || typeof max === 'undefined' || typeof source === 'undefined') {
		throw new Error('All arguments are required');
	}

	return source.random() * (max - min) + min;
}

Если теперь мы попробуем в эту функцию передать объект, который не реализует этот интерфейс, TypeScript укажет на ошибку:

const randomNumber1 = random(1, 10, Math);
// Ок, Math содержит метод random(), проблем нет.

const randomNumber2 = random(1, 10);
// Тоже ок, в нашей функции Math указан
// значением по умолчанию для последнего аргумента.

const randomNumber3 = random(1, 10, otherRandomSource);
// Тоже ок, otherRandomSource реализует требуемый интерфейс.

const otherObject = {
  otherMethod() {};
};

const randomNumber4 = random(1, 10, otherObject);
// Будет ошибка, код не скомпилируется,
// потому что otherObject не реализует требуемый интерфейс.

Зависимости от абстракций

На первый взгляд это может показаться переусложнением. Но на деле при таком подходе:

  • модули становятся менее зависимыми друг от друга,
  • мы вынуждены проектировать поведение до того, как начнём писать код.

Когда мы проектируем поведение заранее, мы используем абстрактные договорённости. Под эти договорённости мы уже проектируем собственные модули или адаптеры для сторонних. Это позволяет заменять части системы, не переписывая её полностью.

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

Сущности с внутренним состоянием

В TypeScript объект с внутренним состоянием можно создать с помощью замыкания или классов. Мы рассмотрим классы.

Простейший пример объекта с состоянием — это счётчик. В виде класса он бы выглядел так:

class Counter {
	private state: number = 0;

	public increase = (): void => {
		this.state++;
	};

	public decrease = (): void => {
		this.state--;
	};

	get stateOf(): number {
		return this.state;
	}
}

Методы дают возможность этим состоянием управлять:

const counter = new Counter();
counter.stateOf; // 0

counter.increase();
counter.stateOf; // 1

counter.decrease();
counter.stateOf; // 0

Жара начинается, когда одни объекты зависят от других. Представим, что наш счётчик должен не только хранить количество, но ещё и выводить его в консоль после каждого обновления.

class Counter {
	private state: number = 0;

	// Добавляем метод для логирования.
	private log = (): void => {
		console.log(this.state);
	};

	public increase = (): void => {
		// И теперь при изменении количества...
		this.state++;
		this.log();
	};

	public decrease = (): void => {
		// ...оно будет выводиться в консоль.
		this.state--;
		this.log();
	};

	get stateOf(): number {
		return this.state;
	}
}

Здесь мы натыкаемся на ту же проблему, что и раньше — мы используем не только внутреннее состояние экземпляра класса, но и другой модуль — console. По-хорошему нам нужно эту зависимость тоже сделать явной, внедрить.

Внедрение зависимостей в классах

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

Конструктор класса — это такой специальный метод, который вызывается при создании экземпляра. В конструкторе обычно описывают, как объект надо «приготовить» при его инициализации.

Например, если бы мы хотели при создании счётчика выводить приветствие в консоли, это можно было бы сделать как раз через конструктор.

class Counter {
	constructor() {
		console.log('Hello world!');
	}

	// ...Остальной код.
}

const counter = new Counter();
// В консоли выведется “Hello world!”

Именно через конструктор мы можем внедрить и все необходимые зависимости.

Простое внедрение

Мы хотим научить класс работать с зависимостями так же, как функции из примеров выше.

Наш класс Counter использует метод log объекта console. Это значит, что классу как зависимость нужно передать какой-то объект с методом log. Не обязательно это будет console — мы помним о тестируемости и заменяемости модулей.

Когда мы хотим зафиксировать поведение, мы используем интерфейсы. Значит, типом аргумента конструктора будет интерфейс, описывающий объекты с методом log.

interface Logger {
	log(message: string): void;
}

Используем этот интерфейс в коде, зафиксировав таким образом требования к объекту, от которого зависит класс Counter:

class Counter {
	// Приватное поле будет хранить «ссылку» на объект-зависимость...
	private logger: Logger;

	constructor(logger: Logger) {
		// ...который мы установим при создании объекта.
		this.logger = logger;
	}

	// ...Остальной код.
}

// Либо, используя автоприсвоение:

class Counter {
	// При такой записи аргумент, переданный в конструктор,
	// будет автоматически присвоен приватному полю this.logger.
	constructor(private logger: Logger) {}

	// ...Остальной код.
}

Инициализировать экземпляры такого класса мы тогда будем так:

const counter = new Counter(console);

И если мы захотим, например для тестов, заменить console на что-то другое, нам будет достаточно удостовериться, что новая зависимость реализует интерфейс Logger:

const customLogger: Logger = {
	log(message: string): void {
		alert(message);
	}
};

const counter = new Counter(customLogger);

Автоматическое внедрение и DI-контейнеры

Сейчас класс не использует неявных зависимостей. Это хорошо, но такое внедрение всё ещё неудобно: надо руками каждый раз добавлять ссылки на объекты, постараться не перепутать порядок, если их несколько, да и вообще…

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

Контейнер знает, какие зависимости какому модулю нужны, создаёт и внедряет их, когда это нужно. Мы освобождаем объекты от обязанности следить за своими зависимостями, контроль за этим переходит в другое место, как это и предполагают буквы S и D в SOLID.

На практике, чтобы это заработало, нам необходим слой абстракций — интерфейсы. (Поэтому мы используем TypeScript, а не JavaScript.) Интерфейсы будут связующим звеном между модулями.

Контейнер будет знать, объекты с какими интерфейсами требуются каждому из модулей. Также он будет знать, какие объекты эти интерфейсы реализуют. А при создании объектов, зависящих от таких интерфейсов, контейнер будет создавать их и давать к ним доступ автоматически.

В псевдокоде это выглядело бы так:

// На выдуманном языке мы бы написали:
// «Эй, контейнер, запомни, что когда у тебя просят объект, реализующий SomeInterface,
//  ты должен отдать экземпляр класса SomeClass.»
container.register(SomeInterface, SomeClass)

И этот псевдокод очень недалёк от реальности.

Инструменты для автоматического внедрения

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

import { DIContainer } from '@wessberg/di';

// Создаём контейнер:
const container = new DIContainer();

// ...Интерфейс:
interface Logger {
	log(message: string): void;
}

// ...И реализацию:
export class ConsoleLogger implements Logger {
	public log = (message: LogEntry): void => console.log(message);
}

// А теперь говорим контейнеру,
// что когда кто-то попросит у него
// объект, реализующий интерфейс Logger,
// он должен вернуть экземпляр класса ConsoleLogger:
container.registerSingleton<Logger, ConsoleLogger>();

Теперь если мы захотим в счётчике указать зависимость на логгер, мы это сделаем так:

// ...Предыдущий код.

class Counter {
	constructor(private logger: Logger) {}

	private log = (): void => {
		this.logger.log(this.state);
	};

	// ...Остальной код счётчика.
}

container.registerSingleton<Counter>();

Последней строчкой мы регистрируем счётчик в контейнере. Так контейнер узнает, что Counter может просить какие-то зависимости от него. А когда он увидит автоприсвоение в конструкторе, передаст все необходимые объекты.

Польза контейнера

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

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

// Новая реализация...
class CustomLogger implements Logger {
	public log = (message: LogEntry): void => alert(message);
}

// ...которой мы заменяем старый ConsoleLogger.
// Это происходит в одном месте, при регистрации:
container.registerSingleton<Logger, CustomLogger>();

Кроме этого, мы не передаём зависимости руками, нам не требуется соблюдать порядок перечисления в конструкторах, модули становятся более обособленными (менее сцепленными).

Особенный кайф конкретно этого контейнера в том, что он работает без декораторов (в отличие от того же Inversify). Регистрация на type-параметрах дженерик-функции позволяет разделить инфраструктурный и продакшен код, как советует Ганмер в «Паттернах отказоустойчивых приложений». Это повышает устойчивость, да и читать проще.

А что там за registerSingleton?

Singleton и transient здесь — это виды жизненного цикла объектов, которые контейнер создаст.

В общих чертах, registerSingleton создаёт один объект, который потом внедряет во все места, которые от него зависят. А registerTransient создаёт новый объект на каждый запрос.

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

Пример посерьёзнее

Я тут написал небольшое приложение, которое при клике выводит уникальный ID клика, время события и позицию на экране, где клик был совершён. А ещё раз в 5 секунд выводит в консоли “Hello world”. Да, оно тупое, но суть не в том, что оно делает 🙃 Им я хочу показать, как использовать DI на фронтенде по максимуму с помощью TypeScript.

(Для нетерпиливых сразу исходники.)

Инструменты

Я использую wessberg/DI в качестве контейнера и wessberg/DI-compiler, чтобы вся магия происходила во время компиляции. Мне не хочется тащить кучу инфраструктурного кода на клиент, а этот компилятор — лучшее, что мне довелось попробовать. Он увеличил мой бандл лишь на пару килобайт.

Входная точка

Само приложение простое:

export class AppInitiator {
	constructor(
		private dateTimeSource: DateTimeSource,
		private idGenerator: UuidGenerator,
		private clickHandler: EventHandler<MouseEvent>,
		private logger: Logger,
		private timer: Timer,
		private env: Window
	) {}

	private greet = (): void => this.logger.log('Hello world!');
	private setupTimer = (): void => this.timer.invokeEvery(this.greet, 5000);
	private registerClicks = (): void => this.clickHandler.on('click', this.handleClick);

	private handleClick = (e: MouseEvent): void => {
		const position = [e.pageX, e.pageY];
		const datetime = this.dateTimeSource.toString();
		const eventId = this.idGenerator.generate();
		this.env.alert(`${eventId}, ${datetime}: Mouse was clicked at ${position} `);
	};

	public init = (): void => {
		this.setupTimer();
		this.registerClicks();
	};
}

container.registerSingleton<AppInitiator>();

Всё интересное происходит в конструкторе — там я запрашиваю у контейнера все зависимости, который регистрирую в других модулях.

Зависимости первого уровня

Это зависимости, от которых зависит главный модуль:

  • DateTimeSource
  • UuidGenerator
  • EventHandler<MouseEvent>
  • Logger
  • Timer

DateTimeSource

Для доступа ко времени я использую адаптер BrowserDateTimeSource, который потом регистрирую как реализацию интерфейса DateTimeSource. Заметьте, что при запросе зависимости я указываю именно интерфейс — потому что всё должно зависеть от абстракций.

export class BrowserDateTimeSource implements DateTimeSource {
	get source() {
		return new Date();
	}

	public toString = (): UtcDateTimeString => this.source.toUTCString();
	public valueOf = (): TimeStamp => this.source.getTime();
}

container.registerSingleton<DateTimeSource, BrowserDateTimeSource>();

UuidGenerator

Генератор уникальных имён — это адаптер для nanoid. Обратите внимание, что явно nanoid я указываю лишь при регистрации интерфейса ThirdPartyGenerator и нигде больше.

export class IdGenerator implements UuidGenerator {
	constructor(private adaptee: ThirdPartyGenerator) {}
	generate = () => this.adaptee();
}

container.registerSingleton<ThirdPartyGenerator>(() => nanoid);
container.registerSingleton<UuidGenerator, IdGenerator>();

EventHandler<MouseEvent>

Обработчик событий реализует дженерик-интерфейс EventHandler<MouseEvent>. Важно, что при запросе зависимости я тоже указываю EventHandler<MouseEvent>. Если передать другой type-параметр, то контейнер будет искать реализацию интерфейса с другим параметром. Это удобно, при работе с похожими объектами.

export class ClickHandler implements EventHandler<MouseEvent> {
	constructor(private env: Window) {}

	public on = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
		this.env.addEventListener(event, callback);

	public off = (event: EventKind, callback: EventCallback<MouseEvent>): void =>
		this.env.removeEventListener(event, callback);
}

container.registerSingleton<EventHandler<MouseEvent>, ClickHandler>();

Logger

Логгер мы уже видели, он пишет логи в консоль 🙃

export class ConsoleLogger implements Logger {
	public log = (message: LogEntry): void => console.log(message);
}

container.registerSingleton<Logger, ConsoleLogger>();

Зависимости второго уровня

Зависимости второго уровня — это зависимости зависимостей. Как например, env в классе ClickHandler или adaptee в IdGenerator.

Удобство контейнера в том, что ему неважно, какой у зависимости уровень. Он подставит её сам, без труда. (Главное, чтобы не было циклических зависимостей, но об этом надо писать отдельную статью, пожалуй.)

// Например, для IdGenerator мы регистрировали зависимость так:
container.registerSingleton<ThirdPartyGenerator>(() => nanoid);

// А для ClickHandler (он требовал Window) так:
container.registerSingleton<Window>(() => window);

Минусы

Главный минус в том, что при использовании контейнера все зависимости надо регистрировать в нём. Иногда не хватает гибкости, иногда это не быстро.

Второй минус, что получать сервис входной точки приходится тоже через контейнер:

const service = container.get<AppInitiator>();
service.init();

По-другому доступ к нему не получить. Точнее получить можно, но без контейнера он не получит зависимостей и не будет работать.

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

Я написал пару проектов в «каноническо-ООПшном стиле», и в целом ничего, полёт нормальный. Это безусловно раздувает инфраструктуру, но удобство перевешивает. Особенно когда даже на фронте есть инструмент, который не увеличивает сверх меры количество кода, которое идёт на клиент.

Ссылки

SOLID и паттерны

DI, контейнеры, жизненный цикл

Википедия

TypeScript

Инструменты

Книги