Галоўная / Артыкулы / Tokenы ін’екціі ў NestJS: чаму метод useClass можа тыха копіюваць екземпляры класа Singleton

Tokenы ін’екціі ў NestJS: чаму метод useClass можа тыха копіюваць екземпляры класа Singleton

З'ясавайце, што такое типы, токены і прадастрыкі ў NestJS DI, чаму інтэфейсы не можна інжектаваць, і як useExisting запобегае стварэнню двух экземпляраў таго ж сервіса з станам.

1672 слоў

У NestJS ін’екцыя залежнасцей спачатку выглядае простаю: задаеце параметр канструктара, і фрамворк дае вам готовы экземпляр. У гэйманцы ўкрыта супарабоўка между типамі TypeScript, токенамі часа выканання і вызначэннямі прадастальнікаў, якая, якщо ёй неправильна зрозумець, прыводзіць да багоў, якія важкая адзначыць. Гэта можа выражацца ў дуплікаванні кэшоў, зайвых басэнах дадзенняў або мокі, якія ніколі не рашаюцца. У гэтым артыкуле ствараецца точны ментальны модэль таго, як NestJS рашае залежнасці, пояснюецца, чаму інтэрфейсы не можа служыць ключамі для ін’екцыі, і паказваецца, як адна настройка прадастальніка вялічыць, чы рэзультатам будзе адны экземпляр службы чы два.

Ад автаматычнага підключэння да явных токенаў

Тыповы першы кантакт з ін’екцыяй залежнасцей у NestJS — это канструктар такога типу:

constructor(
  private readonly emailService: EmailService,
) {}

Для выкарыстоўвання залежнасцяў класаў NestJS чытае метаданы, якія выдае TypeScript пра типы параметраў канстрактара (увімкнутыя за дапамогай emitDecoratorMetadata), і выкарыстоўвае сам клас для пошуку адпаведнага прадастальніка. Не трэба додатковых анотацый.

Большыя кодавыя базы часта выглядаюць інакш:

constructor(
  @Inject('EMAIL_SERVICE')
  private readonly emailService: EmailService,
) {}

Якщо параметр уже мае тип, што дадае @Inject() і чаму змена useClass на useExisting може змяніць тое, чы гэта будзе адна інстанцыя чы два? Ёсць канчатковая адказ на гэтае пытанне, для чаго трэба раз'ясніць тры панявы, якія часта маюць аднаковую назву.

Тып, токен і прадастальнік — гэта тры разныя рычы.

Пачатківцы часта спрыягледзяюць іх як адну паняву, таму што ў простым случае у яных аднаковы ідэнтыфікатор:

  • Тып: тое, што выкарыстоўвае компіляр TypeScript для пераканалення вашага коду. Ён існуе толькі пад час компіляцыі.
  • Token: ключ часу выконання, які викорыстоўвае NestJS для пошуку прадаўцы ў сваём контейнере.
  • Provider: рэгістрацыя, якая паведамляе NestJS, як стварыць значэнне для токена — чыраз інстанціюванням класа, падзеяннем іншага прадаўцы, вярненням фіксаванага значэння чы роўнымна вызваннем фабрыкі.
  • За дапамогою звычайнага вводу на адной класе, одна класа выпалюе два завадзіны: як тип у 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.
  • Для кожнай інжэкцыі задаеце тры пытанні: што такое токен, якая рэалізацыя яго падтрымлівае, і чы ён аднародная рэўістрацыя чы аліяс?