Токены инъекции в NestJS: почему useClass может тихо создавать дубликаты синглтонов
Понимание типов, токенов и поставщиков в системе инъекций зависимостей NestJS, причины, по которым интерфейсы не могут быть внедрены, и то, как функция useExisting предотвращает создание двух экземпляров одного состоянийного сервиса.
Внедрение зависимостей в NestJS с первого взгляда кажется простым: достаточно указать параметр конструктора, и фреймворк предоставит готовый экземпляр. Однако за этой простотой скрывается соответствие между типами TypeScript, токенами во время выполнения и определениями поставщиков, которое при неправильном понимании приводит к ошибкам, трудным для обнаружения. К таким проблемам могут относиться дублирование кэшей, избыточное количество пулов баз данных или моки, которые так и не решаются. В этой статье создается точная модель того, как NestJS разрешает зависимости, объясняется, почему интерфейсы не могут служить ключами для внедрения, и показано, как один параметр поставщика определяет, будет ли у сервиса один экземпляр или два.
Типичный первый пример использования внедрения зависимостей в NestJS — это конструктор, похожий на этот:
constructor(
private readonly emailService: EmailService,
) {}
Для обеспечения зависимостей между классами NestJS считывает метаданные, генерируемые TypeScript относительно типов параметров конструктора (включаемые с помощью emitDecoratorMetadata), и использует сам класс для поиска соответствующего поставщика. Дополнительных аннотаций не требуется.
В более крупных проектах ситуация часто отличается:
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailService: EmailService,
) {}
Если параметр уже имеет тип, что добавляет @Inject(), и почему смена параметра useClass на useExisting может повлиять на то, получится ли один экземпляр или два? Чтобы ответить на это, необходимо разделить три понятия, которые обычно носят одинаковое название.
Тип, токен и поставщик — это три разных понятия
Новички часто рассматривают их как единое понятие, поскольку в простых случаях у них один идентификатор:
- Тип: то, что использует компилятор TypeScript для проверки кода. Он существует только во время компиляции.
При обычной инъекции на основе классов один класс выполняет двойную роль — он является как типом в TypeScript, так и токеном NestJS:
CustomLoggerService
↓
NestJS Token
+
TypeScript Type
Собственный токен разделяет эти роли. В следующем конструкторе строка и аннотация типа выполняют совершенно разные функции:
constructor(
@Inject('logger')
private readonly logger: CustomLoggerService,
)
Если разложить обязанности по пунктам, они выглядят так:
@Inject('logger')
↓
NestJS Token (Finds the provider in memory)
: CustomLoggerService
↓
TypeScript Type (Gives you IDE autocomplete)
Токен — это то, что использует NestJS для поиска экземпляра во время выполнения. Аннотация типа предоставляет компилятору и вашему редактору информацию о его структуре только для проверки типов и автодополнения. Полезное упрощение: токен указывает на объект, а тип описывает его содержимое. Обратите внимание, что NestJS не проверяет соответствие этих двух элементов; если провайдер, находящийся за 'logger', возвращает что-то другое, TypeScript этого не обнаружит.
Почему нельзя вставлять интерфейс
Частой просьбой у разработчиков, только начинающих работать с NestJS, является использование интерфейса для декуплирования кода, например путем указания параметра как IMailService. Однако первая попытка так сделать проваливается:
// Won't work at runtime
constructor(
private readonly mailService: IMailService,
) {}
При запуске NestJS выводит сообщение Nest can't resolve dependencies of the UserService (?). Знак вопроса обозначает параметр, который не удалось идентифицировать.
Причина в том, что интерфейсы в JavaScript отсутствуют. Когда tsc компилирует ваш код, все interface и аннотации типов удаляются. NestJS зависит от метаданных, которые сохраняются в работающей программе, но интерфейс не оставляет после себя ничего, что можно было бы прочитать; генерируемые метаданные для такого параметра сводятся к обычному объекту Object, который не соответствует ни одному поставщику.
Классы отличаются: они компилируются в настоящие функции-конструкторы JavaScript, поэтому они продолжают существовать во время выполнения и могут служить ключами.
class CustomLoggerService
↓
exists at runtime
↓
can be used as a DI token
interface IMailer
↓
erased during compilation
↓
cannot be used as a runtime DI token
Когда вы программируете с использованием абстракции, необходимо явно указать токен во время выполнения, например @Inject('MAIL_SERVICE'), и зарегистрировать соответствующего поставщика под этим же токеном. Абстрактный класс представляет собой альтернативу, которую стоит знать: поскольку он компилируется в реальную функцию, он может служить как типом, так и токеном без использования @Inject().
Ловушка дублирующихся синглтонов: useClass против useExisting
Как только начинают использоваться пользовательские токены, каждый модуль должен указать NestJS, как их разрешать, и именно здесь часто возникают скрытые ошибки. Рассмотрим этот модуль:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useClass: CustomLoggerService, // The trap
},
],
})
export class CommonModule {}
На первый взгляд кажется, что 'APP_LOGGER' — это просто другое название для CustomLoggerService. На самом деле это не так. Теперь в модуле содержатся две независимые регистрации поставщиков, и благодаря стандартному диапазону синглтонов у каждого из них есть свой собственный экземпляр:
- Токен класса
CustomLoggerServiceразрешается путем создания экземпляра класса, в результате чего формируется объект A. - Токен-строка
'APP_LOGGER'разрешается путем повторного создания класса, в результате чего формируется объект B.
Проблема возникает только при наличии состояния. Если логгер хранит буфер в памяти, управляет очередью, подсчитывает запросы для ограничения скорости или имеет подключение, то потребители, вставляющие класс, и те, кто вставляет токен-строку, общаются с разными объектами, которые никогда не видят данные друг друга.
Решение: алиас с использованием useExisting
Когда нужно дать дополнительное имя уже зарегистрированному поставщику, используйте useExisting. Это указывает NestJS не создавать ничего нового, а вместо этого разрешить токен через существующий экземпляр:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useExisting: CustomLoggerService, // Points to the existing singleton
},
],
})
export class CommonModule {}
Простой способ представить разницу — это коробки и ярлыки с названиями:
useClassсоздаёт второй контейнер. Токен класса обозначает контейнер A, а токен-строка — контейнер B.useExistingсоздаёт один контейнер и привязывает к нему оба тега с именами.
При наличии псевдонима потребитель, вставляющий CustomLoggerService, и потребитель, вставляющий 'APP_LOGGER', получают один и тот же объект, поэтому проверка строгого равенства между ними возвращает true.
useClass по-прежнему является правильным выбором, когда действительно нужен отдельный экземпляр или когда токен — единственный способ регистрации класса, как будет показано в следующем разделе.
Токены как архитектурный разделитель
Для небольшого сервиса типа CRUD использование пользовательских токенов может показаться излишним. Однако оно оправдано, когда существует вероятность изменения реализации. Предположим, десятки контроллеров ведут логирование через сервис на основе Winston. Вместо того чтобы импортировать WinstonLoggerService в каждом из них, контроллеры зависят только от токена и интерфейса:
constructor(
@Inject('LOGGER') private readonly logger: LoggerInterface
) {}
Модуль определяет, какая реализация находится за этим токеном:
{
provide: 'LOGGER',
useClass: WinstonLoggerService,
}
Переход на облачное решение для логирования, такое как Google Cloud Logging или AWS CloudWatch, приводит к изменению в модуле всего в одной строке, без необходимости редактирования кода любых потребителей:
{
provide: 'LOGGER',
useClass: CloudLoggerService,
}
Та же самая архитектура облегчает тестирование, поскольку тестовый модуль может связать 'LOGGER' с имитацией, не затрагивая код, который проверяется.
Практическое усовершенствование: обычные строки легко писать неправильно, и они могут сталкиваться между разными модулями. Определение токенов один раз в качестве экспортируемых констант или как значений Symbol обеспечивает их единообразие и позволяет компилятору выявлять опечатки.
Основные шаблоны поставщиков в одном взгляде
Четыре типа поставщиков, с которыми вы столкнетесь чаще всего, и когда каждый из них подходит:
- Поставщик классов (
useClass): создает экземпляр класса для токена. Используется для связывания абстрактного токена с конкретной реализацией или для замены реализаций в зависимости от среды. Каждая регистрация создает свой собственный экземпляр. - Поставщик псевдонимов (
useExisting): направляет токен на уже существующего поставщика. Используется для предоставления одного экземпляра под несколькими именами без дублирования состояния.
useValue): возвращает фиксированное значение, такое как объект конфигурации, константа или мок в тестах.useFactory): вычисляет значение с помощью функции, при необходимости с использованием внедряемых зависимостей, указанных в inject. Используйте его, когда создание зависит от конфигурации или происходит асинхронно, например при подключении к базе данных.Не забывайте об экспортах
Классической проблемой является токен, правильно настроенный в CommonModule, но который UserModule по-прежнему не может разрешить. Массив providers определяет, что регистрирует модуль для собственного использования. Массив exports определяет, что он делает доступным для модулей, которые его импортируют. Пользовательский токен, такой как 'LOGGER', должен присутствовать в exports (а модуль-использователь должен импортировать CommonModule), прежде чем кто-либо извне сможет его внедрить. Когда вы экспортируете псевдоним, созданный с помощью useExisting, убедитесь, что модули-использователи также могут обратиться к провайдеру, на который он указывает, либо экспортируйте оба токена.
Основные выводы
- Тип существует только на этапе компиляции, токен — это ключ во время выполнения, а провайдер определяет способ удовлетворения этого ключа; при анализе кода DI следует разделять эти три элемента.
@Inject() или абстрактный класс, существующий во время выполнения.useClass создает два синглтона; используйте useExisting, когда вам нужен лишь псевдоним.exports и imports.