На этой странице

Пошаговое руководство · API-справочник

@zirion/ioc

Практическое руководство по @zirion/ioc для Node.js: регистрация сервисов, время жизни значений, ошибки настройки и тесты.

Обновлено · ·

Зачем передавать зависимости?

Сервису часто нужны другие объекты, например база данных или отправитель писем. Внедрение зависимостей (сокращённо DI) означает, что эти объекты передают сервису через конструктор, а не создают внутри него. Желающим разобраться глубже в основах предлагаю к ознакомлению множество статей на тему принципов SOLID, где D - Dependency Injection.

Рассмотрим простой пример. Здесь BillingService сам создаёт базу данных и инстанс отправителя писем:

class BillingService {
  private database = new PostgresDatabase(process.env.DATABASE_URL!);
  private mailer = new SmtpMailer(process.env.SMTP_URL!);
}

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

Проще передать готовые объекты в конструктор:

class BillingService {
  constructor(
    private readonly database: Database,
    private readonly mailer: Mailer,
  ) {}
}

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

@zirion/ioc помогает собрать эти объекты в общий граф. Вы указываете зависимости в поле inject, а контейнер создаёт значения по мере надобности. Он умеет ждать асинхронные фабрики, легковесен и не имеет зависимостей.

Установка

Выполните команду используя ваш менеджер пакетов:

npm install @zirion/ioc

Нужен Node.js 20 или новее. Примеры ниже рассчитаны на @zirion/ioc@2.0.0. В пакете есть сборки CommonJS и ES modules, а также типы TypeScript.

Основные экспорты пакета:

import {
  Container,
  InjectScope,
  DependencyInjectionError,
  ErrorCode,
  type IOnInitialized,
  type IOnFinalized,
  type ILogger,
} from '@zirion/ioc';

Первый контейнер

Зарегистрируйте базу данных и сервис, соберите контейнер, затем запросите сервис:

import { Container } from '@zirion/ioc';

class Database {
  findUser(id: number) {
    return { id, name: 'Ada' };
  }
}

class UserService {
  constructor(private readonly database: Database) {}

  greet(id: number) {
    return `Hello, ${this.database.findUser(id).name}!`;
  }
}

const container = await new Container()
  .add(Database)
  .add(UserService, { inject: [Database] })
  .build();

const users = await container.getOrFail(UserService);
console.log(users.greet(1));

Обратите внимание на три вещи:

  1. Зарегистрируйте все зависимости до первого запроса сервиса. Порядок вызовов add() после завершения регистрации не важен.
  2. По умолчанию значение создаётся при первом запросе и затем используется повторно.
  3. Дожидайтесь build() и getOrFail() с помощью await.

Термины, которые встретятся дальше

Термин Значение в этом руководстве
Контейнер Хранит правила создания значений и выдаёт их по запросу
Selector Ключ для значения: класс, строка, symbol или объект
Provider Правило создания значения для конкретного selector
Зависимость Значение, нужное другому provider; указывается в inject
Получение значения Запрос значения у контейнера
Scope Правило повторного использования созданного значения
Context Объект, который обозначает один запрос или задачу
Место сборки Часть приложения, где регистрируются все providers

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

class OrdersService {
  constructor(private readonly repository: OrdersRepository) {}
}

Явно укажите зависимость для контейнера:

container.add(OrdersService, { inject: [OrdersRepository] });

Соберите настройки в одном месте

Разместите регистрации рядом с кодом запуска приложения. Такой файл часто называют местом сборки или composition root. Сами сервисы получают зависимости через конструктор.

import { Container } from '@zirion/ioc';

export const APP_CONFIG = Symbol('APP_CONFIG');

export interface AppConfig {
  databaseUrl: string;
}

export function createContainer(config: AppConfig) {
  return new Container()
    .add(APP_CONFIG, { valueFactory: () => config })
    .add(Database, { inject: [APP_CONFIG] })
    .add(UserRepository, { inject: [Database] })
    .add(UserService, { inject: [UserRepository] })
    .build();
}

Тогда код запуска остаётся коротким:

const container = await createContainer({
  databaseUrl: process.env.DATABASE_URL!,
});

const users = await container.getOrFail(UserService);

В тесте можно собрать отдельный контейнер с тестовыми фабриками. Сервисам не нужны особые ветки для тестов.

Передавайте полученный сервис обработчику HTTP, воркеру или другой точке входа. Не вызывайте контейнер по всему прикладному коду.

Выберите ключ для каждого значения

add(selector, options?) регистрирует значение. Тот же selector используется в inject, get() и getOrFail().

Класс как ключ

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

class Clock {
  now() {
    return new Date();
  }
}

container.add(Clock);
const clock = await container.getOrFail(Clock); // Clock

Для строк, symbols и объектов нужен valueFactory. Контейнер не знает, как создать их сам.

Строка как ключ

Строки удобны для простых настроек. В большом приложении два модуля могут случайно выбрать одну строку:

container.add('REGION', { valueFactory: () => 'eu-central-1' });
const region = await container.getOrFail('REGION');

Symbol как ключ

Symbol помогает избежать такого совпадения. Он подходит для интерфейса TypeScript или настройки:

const MAILER = Symbol('MAILER');

container
  .add(MAILER, { valueFactory: () => new SmtpMailer() })
  .add(NotificationService, { inject: [MAILER] });

Объект как ключ

Ключом может быть и объект. Используйте тот же самый объект, а не новый объект с такими же полями:

const metricsToken = { name: 'metrics' };

container.add(metricsToken, {
  valueFactory: () => new MetricsClient(),
});

const metrics = await container.getOrFail(metricsToken);

Для строки, symbol и объекта нужен valueFactory. Один selector нельзя зарегистрировать дважды. В v2.0.0 нельзя заменить регистрацию или привязать несколько providers к одному selector.

Передайте сервисы через конструктор

Порядок selectors в inject должен совпадать с порядком параметров конструктора:

class CheckoutService {
  constructor(
    private readonly inventory: Inventory,
    private readonly payments: Payments,
  ) {}
}

container
  .add(Inventory)
  .add(Payments)
  .add(CheckoutService, {
    inject: [Inventory, Payments],
  });

Не повторяйте selector в одном массиве inject. В v2.0.0 повторы схлопываются в один аргумент.

Зарегистрируйте все selectors до запроса CheckoutService. Если Payments отсутствует, контейнер выбросит DependencyInjectionError с кодом UNKNOWN_TARGET.

Сервис может работать с зависимостями, не зная о контейнере. Не запрашивайте их внутри самого сервиса:

// Не рекомендуется: поиск зависимости скрыт в бизнес-логике.
class CheckoutService {
  constructor(private readonly container: Container<any>) {}

  async checkout() {
    const payments = await this.container.getOrFail(Payments);
  }
}

Передайте Payments в конструктор. get() и getOrFail() вызывайте при запуске приложения или в другой точке входа.

Создавайте значения через фабрики

Используйте valueFactory для настроек, асинхронного запуска и нестандартного создания. Первый аргумент фабрики содержит массив готовых зависимостей. Для request scope второй аргумент содержит текущий context.

const DATABASE = Symbol('DATABASE');
const CONFIG = Symbol('CONFIG');

const container = await new Container()
  .add(CONFIG, {
    valueFactory: () => ({ databaseUrl: process.env.DATABASE_URL! }),
  })
  .add(DATABASE, {
    inject: [CONFIG],
    valueFactory: async ([config]) => {
      const database = new DatabaseClient(config.databaseUrl);
      await database.connect();
      return database;
    },
  })
  .build();

Контейнер дожидается асинхронной фабрики и повторно использует результат согласно scope. В v2.0.0 singleton-фабрика, вернувшая false, 0, '' или null, при следующем запросе вызовется снова. Если значение нужно создать один раз, оберните его в объект.

Фабрика может вернуть конструктор класса, который затем создаст контейнер. Обычно проще вернуть готовое значение.

Фабрика пригодится, чтобы:

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

Пусть одна фабрика создаёт одно значение. Так проще найти причину ошибки.

Выберите время жизни значения

В v2.0.0 есть два scope:

Scope Создание Повторное использование Типичный сценарий
InjectScope.SINGLETON При первом запросе Общий для всего контейнера Пул БД, общий сервис
InjectScope.REQUEST При первом запросе с context Общий только для того же объекта context Данные запроса, логгер запроса

По умолчанию используется singleton. Оба вызова вернут один объект:

container.add(UserRepository);

const first = await container.getOrFail(UserRepository);
const second = await container.getOrFail(UserRepository);
console.log(first === second); // true

Выберите request scope, если значение принадлежит одному запросу или задаче:

container.add(RequestState, {
  scope: InjectScope.REQUEST,
});

В v2.0.0 нет transient scope. Если каждый вызов должен создавать новый объект, внедрите функцию-фабрику или создавайте объект внутри использующего его сервиса.

Не храните данные запроса в singleton

Singleton создаётся один раз и обслуживает все запросы. Значение с InjectScope.REQUEST создаётся отдельно для каждого запроса. Например, RequestState может хранить ID текущего пользователя. Если бы singleton запомнил RequestState Алисы, то при запросе Боба он всё ещё видел бы данные Алисы.

Поэтому значение с InjectScope.REQUEST может использовать singleton, но singleton не может получать такое значение как зависимость. Если общему сервису нужен ID пользователя, передайте его в метод. Если сервис должен хранить стейт только в рамках одного запроса, сделайте его request-scoped.

Контейнер не даст получить singleton с такой зависимостью и выбросит ошибку ErrorCode.SINGLETONE_SCOPE_WRONG_CONTEXT. Это точное имя кода в API.

Один context на запрос

Передавайте объект, обозначающий запрос или задачу. Внутри запроса используйте именно этот объект. Новый объект с такими же полями создаст отдельный request scope.

type RequestContext = {
  requestId: string;
  userId?: string;
};

class RequestState {
  constructor(readonly context: RequestContext) {}
}

const container = await new Container()
  .add(RequestState, { scope: InjectScope.REQUEST })
  .build();

const context: RequestContext = { requestId: 'req-42' };
const first = await container.getOrFail(RequestState, context);
const second = await container.getOrFail(RequestState, context);
const third = await container.getOrFail(RequestState, { requestId: 'req-42' });

console.log(first === second); // true
console.log(first === third);  // false

Класс получает context последним аргументом конструктора. Фабрика получает его вторым аргументом:

const REQUEST_ID = Symbol('REQUEST_ID');

container.add(REQUEST_ID, {
  scope: InjectScope.REQUEST,
  valueFactory: (_dependencies, context?: RequestContext) => context?.requestId,
});

Если запросить request-scoped значение без объекта context, контейнер выбросит REQUEST_SCOPE_CONTEXT_REQUIRED.

Создавайте отдельный context для каждого запроса или задачи. Передавайте его во все верхнеуровневые вызовы get() и getOrFail() этой операции.

Когда создаются providers

Обычно регистрация provider не создаёт значение. Контейнер ждёт, пока вы его запросите.

После создания: onInitialized()

Если у созданного объекта есть onInitialized(), контейнер сразу вызывает этот метод:

class Cache implements IOnInitialized {
  onInitialized() {
    console.log('Cache created');
  }
}

В v2.0.0 контейнер не ждёт промис от onInitialized(). Обязательную асинхронную подготовку поместите в ожидаемую valueFactory. Этот hook подходит для синхронных действий.

При сборке: onFinalized()

build() вызывает finalize(). Метод создаёт зарегистрированные классы или объекты с onFinalized() и дожидается их hooks:

class Routes implements IOnFinalized {
  async onFinalized() {
    await this.compileRoutes();
  }

  private async compileRoutes() {}
}

const container = await new Container()
  .add(Routes)
  .build();

Класс с onFinalized() создаётся во время build(). Остальные классы создаются при запросе.

Не используйте onFinalized() для request-scoped класса. У build() нет context этого запроса.

Вызывайте build() один раз после регистрации. Повторный вызов ещё раз запустит hooks.

Получите сервис

Выберите один из двух методов:

const optional = await container.get(UserService);
// UserService | null

const required = await container.getOrFail(UserService);
// UserService или DependencyInjectionError

Используйте get(), если отсутствие значения допустимо. Для обязательного сервиса выбирайте getOrFail(): он выбросит ошибку, если selector не найден.

Оба метода принимают context вторым аргументом:

const handler = await container.getOrFail(RequestHandler, requestContext);

Получение значения асинхронно. Сначала контейнер создаёт зависимости, затем передаёт их в порядке inject.

Добавьте логгер

Логгер контейнера доступен через container.logger. По умолчанию методы error, warn, info, debug и trace пишут сообщения в консоль.

Можно передать готовый логгер, его класс или фабрику:

class AppLogger implements ILogger {
  error(message: unknown, ...args: unknown[]) {}
  warn(message: unknown, ...args: unknown[]) {}
  info(message: unknown, ...args: unknown[]) {}
  debug(message: unknown, ...args: unknown[]) {}
  trace(message: unknown, ...args: unknown[]) {}
}

const byClass = new Container({ logger: AppLogger });
const byFactory = new Container({ logger: () => new AppLogger() });
const byInstance = new Container({ logger: new AppLogger() });

byClass.logger.info('Container ready');

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

Разберитесь с ошибками настройки

При ошибке настройки или получения значения контейнер выбрасывает DependencyInjectionError. Поле code показывает причину. Ошибка может также содержать target и dependency; метод hasCode() проверяет код.

try {
  await container.getOrFail(UserService);
} catch (error) {
  if (
    error instanceof DependencyInjectionError &&
    error.hasCode(ErrorCode.UNKNOWN_TARGET)
  ) {
    console.error('Composition root собран не полностью');
  }
  throw error;
}
Код Значение Причина
UNKNOWN_SCOPE 1 Указан неподдерживаемый scope
UNKNOWN_TARGET 2 Обязательный selector не зарегистрирован
TARGET_TYPE_BAD_RESOLVER 3 Selector требует фабрику, но её нет
TARGET_NULL 4 Selector равен null или undefined
TARGET_DUPLICATE 5 Selector уже зарегистрирован
REQUEST_SCOPE_CONTEXT_REQUIRED 6 У request-scoped значения нет объекта context
SINGLETONE_SCOPE_WRONG_CONTEXT 7 Singleton зависит от request-scoped значения

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

Разложите код приложения по файлам

Храните настройку контейнера в одном файле, а прикладной код отдельно:

src/
├── application/
│   └── user-service.ts
├── domain/
│   └── user.ts
├── infrastructure/
│   ├── postgres-user-repository.ts
│   └── console-logger.ts
├── interfaces/
│   └── http-server.ts
└── container.ts       # composition root

Простое правило для каждой части:

  1. Передавайте зависимости прикладным классам через конструктор.
  2. Держите код базы данных, сети и других внешних систем в отдельных модулях.
  3. Регистрируйте конкретные классы и selectors в container.ts.
  4. При запуске запрашивайте у контейнера только сервер, воркер или другую точку входа.
  5. Для каждого запроса создавайте context и получайте request-scoped сервис.

У интерфейса нет значения во время выполнения. Используйте symbol как его selector:

export interface UserRepository {
  findById(id: string): Promise<User | null>;
}

export const USER_REPOSITORY = Symbol('USER_REPOSITORY');

Затем свяжите symbol с настоящей реализацией при настройке:

container
  .add(USER_REPOSITORY, {
    valueFactory: () => new PostgresUserRepository(),
  })
  .add(UserService, {
    inject: [USER_REPOSITORY],
  });

Пример: один HTTP-запрос

В этом примере для каждого HTTP-запроса создаётся свой context и передаётся контейнеру:

import { Container, InjectScope } from '@zirion/ioc';

type HttpContext = {
  requestId: string;
  request: Request;
};

class RequestLogger {
  constructor(readonly context: HttpContext) {}

  info(message: string) {
    console.info(`[${this.context.requestId}] ${message}`);
  }
}

class RequestHandler {
  constructor(
    private readonly users: UserService,
    private readonly logger: RequestLogger,
    readonly context: HttpContext,
  ) {}

  async handle() {
    this.logger.info('Handling request');
    return new Response('ok');
  }
}

const container = await new Container()
  .add(UserService)
  .add(RequestLogger, { scope: InjectScope.REQUEST })
  .add(RequestHandler, {
    inject: [UserService, RequestLogger],
    scope: InjectScope.REQUEST,
  })
  .build();

export async function handleRequest(request: Request) {
  const context: HttpContext = {
    request,
    requestId: crypto.randomUUID(),
  };

  const handler = await container.getOrFail(RequestHandler, context);
  return handler.handle();
}

UserService общий для контейнера. RequestLogger и RequestHandler повторно используются только с тем же объектом context. Когда context больше никому не нужен, связанные с ним значения могут быть удалены сборщиком мусора.

Проверьте сервисы и настройку

Для большинства unit-тестов создайте сервис напрямую и передайте подмену зависимости. Контейнер не нужен:

const repository: UserRepository = {
  async findById(id) {
    return { id, name: 'Test user' };
  },
};

const service = new UserService(repository);

Отдельным тестом проверьте, что регистрации контейнера согласованы:

import { describe, expect, it } from 'vitest';

it('builds the user graph', async () => {
  const container = await new Container()
    .add(USER_REPOSITORY, {
      valueFactory: () => ({
        findById: async (id: string) => ({ id, name: 'Test user' }),
      }),
    })
    .add(UserService, { inject: [USER_REPOSITORY] })
    .build();

  await expect(container.getOrFail(UserService)).resolves.toBeInstanceOf(UserService);
});

Создавайте новый контейнер для каждого теста. В v2.0.0 нельзя заменить регистрацию, а общий контейнер может сохранить singleton из другого теста.

Для request scope проверьте, что один context возвращает один объект, а другой context даёт другой объект:

const requestA = {};
const requestB = {};

expect(await container.getOrFail(RequestState, requestA))
  .toBe(await container.getOrFail(RequestState, requestA));
expect(await container.getOrFail(RequestState, requestA))
  .not.toBe(await container.getOrFail(RequestState, requestB));

Переход с v1 на v2

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

Паттерн v1 Замена в v2
Синхронный container.get(Service) await container.get(Service)
Синхронный container.getOrFail(Service) await container.getOrFail(Service)
value в настройках provider valueFactory: () => value
Фабрика возвращала только готовое значение Фабрика может вернуть значение или промис
Цепочка регистрации без типов selectors Каждый add() обновляет тип контейнера
Только finalize() build() тоже вызывает finalize()

Чтобы обновить приложение:

  1. Замените value у каждого provider на valueFactory.
  2. Добавьте await к get() и getOrFail(). При необходимости сделайте вызывающий код асинхронным.
  3. Дожидайтесь build() или finalize() при запуске.
  4. Проверьте фабрики: их первый аргумент содержит массив готовых зависимостей.
  5. Используйте Node.js 20 или новее.
  6. Добавьте небольшой тест, который получает каждый главный сервис.

Чего нет в v2.0.0

Учитывайте эти ограничения при разработке:

  • зависимости можно передать через конструктор или фабрику, но не через свойства и методы;
  • нет декораторов и автоматического поиска по метаданным TypeScript;
  • есть только singleton и request scope; transient scope отсутствует;
  • нельзя заменить регистрацию или привязать несколько providers к одному selector;
  • регистрируйте зависимости до получения значения; порядок остальных вызовов add() свободный;
  • повторный selector в одном inject превращается в один аргумент конструктора;
  • singleton-фабрика со значением false, 0, '' или null запускается снова;
  • циклические зависимости не обнаруживаются автоматически;
  • нет дочерних контейнеров и групп зависимостей;
  • onInitialized() вызывается, но его промис не ожидается;
  • контейнер не закрывает ресурсы автоматически;
  • логгер контейнера не записывает каждый запрос значения автоматически.

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

API-справочник

new Container(options?)

Создаёт пустой контейнер.

const container = new Container({ logger });

options.logger может быть объектом ILogger, классом или фабрикой без аргументов. Если его не передать, используется ConsoleLoggerImpl.

container.add(selector, options?)

Регистрирует один provider и возвращает контейнер. Тип контейнера в TypeScript пополняется новым selector.

container.add(Service, {
  inject: [DependencyA, DependencyB],
  scope: InjectScope.SINGLETON,
  valueFactory: async ([a, b], context) => new Service(a, b),
});
Опция По умолчанию Назначение
inject undefined Selectors, которые нужно получить перед созданием значения, в порядке аргументов
scope InjectScope.SINGLETON Значение общее для контейнера или связано с context
valueFactory undefined Функция создания значения; может быть асинхронной

Класс можно создать без фабрики. Для других selectors нужен valueFactory.

container.get(selector, context?)

Возвращает промис со значением или null, если selector неизвестен. При другой ошибке промис отклоняется.

container.getOrFail(selector, context?)

Возвращает промис со значением. Неизвестный selector вызывает DependencyInjectionError с кодом UNKNOWN_TARGET.

container.finalize() / container.build()

Запускает finalization hooks, дожидается их и возвращает контейнер. build() вызывает finalize().

container.logger

Логгер, переданный при создании контейнера.

InjectScope

enum InjectScope {
  SINGLETON = 'singleton',
  REQUEST = 'request',
}

Lifecycle-контракты

interface IOnInitialized {
  onInitialized(): void | Promise<void>;
}

interface IOnFinalized {
  onFinalized(): void | Promise<void>;
}

Другие экспортируемые типы

Пакет также экспортирует Type, MaybePromise, TSelector, TValueFactory, TTargetOptions, TStorageEntry, TContainerOptions, ILogFunction и ILogger. В прикладном коде чаще всего нужны TTargetOptions и ILogger; типы хранилища относятся в основном к внутреннему устройству.

Частые проблемы

get() возвращает null

Selector не зарегистрирован. Проверьте, что в add() и get() используется тот же класс, строка, symbol или объект. Для обязательного значения используйте getOrFail().

UNKNOWN_TARGET при разрешении зарегистрированного класса

В inject этого класса отсутствует один из selectors. Зарегистрируйте его до получения сервиса и проверьте, что модули импортируют один и тот же symbol.

TARGET_TYPE_BAD_RESOLVER

Строке, symbol или объекту нужен корректный valueFactory.

REQUEST_SCOPE_CONTEXT_REQUIRED

Передайте context вторым аргументом и используйте этот же объект в течение всего запроса:

await container.getOrFail(RequestHandler, requestContext);

SINGLETONE_SCOPE_WRONG_CONTEXT

Singleton зависит от request-scoped provider. Сделайте родительский provider request-scoped, передайте данные запроса в метод или внедрите фабрику, принимающую context.

Значение создаётся во время build()

Проверьте, есть ли у него onFinalized(). build() создаёт providers с этим hook, чтобы выполнить его.

Singleton-фабрика вызывается снова

В v2.0.0 результат false, 0, '' или null не сохраняется как singleton. Возвращайте объект, например { value: false }, или сделайте повторный вызов безопасным.

Асинхронная настройка в onInitialized() не успевает завершиться

Контейнер не дожидается этого hook. Перенесите обязательную асинхронную настройку в valueFactory.

Ресурсы остаются открытыми при завершении приложения

У контейнера нет шага завершения. Закрывайте серверы, пулы БД, очереди и другие долгоживущие ресурсы в обработчике завершения приложения.


Это руководство описывает @zirion/ioc v2.0.0. Детали реализации есть в репозитории, история версий на странице проекта.

Наверх