Токени ін’єкції в 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 versus 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.