Strona główna / Artykuły / Tokeny iniekcji w NestJS: Dlaczego useClass może potajemnie kopiować instancje singleton

Tokeny iniekcji w NestJS: Dlaczego useClass może potajemnie kopiować instancje singleton

Zrozum typy, tokeny i dostawców w NestJS DI, dlaczego interfejsy nie mogą być iniektowane oraz jak useExisting zapobiega utworzeniu dwóch instancji tego samego usługi z pamięcią stanu.

1672 słów

Wstrzykiwanie zależności w NestJS na początku wydaje się proste: wystarczy zadeklarować parametr konstruktora, a framework dostarcza gotową instancję. Ta łatwość ukrywa mapowanie między typami TypeScript, tokenami w czasie wykonywania a definicjami dostawców, które w przypadku błędnego zrozumienia powodują trudne do wykrycia błędy. Mogą to być np. duplikowane cache’y, dodatkowe zasoby bazy danych lub mocki, które nigdy się nie rozwiązują. Ten artykuł tworzy precyzyjny model mentalny tego, jak NestJS rozwiązuje zależności, wyjaśnia, dlaczego interfejsy nie mogą służyć jako klucze do wstrzykiwania, oraz pokazuje, jak jedna opcja dostawcy decyduje o tym, czy usługa będzie miała jedną instancję, czy dwie.

Zwykłe pierwsze spotkanie z wstrzykiwaniem zależności w NestJS polega na użyciu konstruktora w takim wyglądzie:

constructor(
  private readonly emailService: EmailService,
) {}

Dla zależności klasowej NestJS odczytuje metadane wytwarzane przez TypeScript dotyczące typów parametrów konstruktora (włączone za pomocą emitDecoratorMetadata) i korzysta z samej klasy, aby znaleźć odpowiadającego dostawcę. Nie jest potrzebna żadna dodatkowa adnotacja.

Bardziej złożone bazy kodu często wyglądają inaczej:

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

Jeśli parametr jest już ztypowany, co dodaje @Inject(), i dlaczego zmiana useClass na useExisting może wpłynąć na to, czy otrzymamy jedną instancję, czy dwie? Odpowiedź na to pytanie wymaga rozróżnienia trzech pojęć, które zwykle mają to samo nazwanie.

Typ, token i dostawca to trzy różne rzeczy

Początkujący często traktują je jako jeden koncept, ponieważ w prostych przypadkach mają ten sam identyfikator:

  • Typ: to, co kompilator TypeScript używa do sprawdzania kodu. Istnieje tylko w czasie kompilacji.
  • Token: klucz używany w czasie wykonywania przez NestJS do znajdowania dostawcy w jego kontenerze.
  • Dostawca: rejestracja, która informuje NestJS, jak wygenerować wartość dla tokena – poprzez instanciowanie klasy, ponowne użycie innego dostawcy, zwrócenie stałej wartości lub wywołanie fabryki.
  • Przy zwykłej iniekcji opartej na klasach jedna klasa pełni podwójną rolę – zarówno jako typ w TypeScript, jak i token w NestJS:

    CustomLoggerService
           ↓
      NestJS Token
           +
     TypeScript Type
    

    Customowy token rozdziela te role. W poniższym konstruktorze ciąg znaków i adnotacja typu służą zupełnie różnym celom:

    constructor(
      @Inject('logger')
      private readonly logger: CustomLoggerService,
    )
    

    Rozłożone na części obowiązki wyglądają w ten sposób:

    @Inject('logger')
           ↓
      NestJS Token (Finds the provider in memory)
    
    : CustomLoggerService
           ↓
      TypeScript Type (Gives you IDE autocomplete)
    

    Tok jest tym, co NestJS używa do znajdowania instancji w czasie wykonywania. Adnotacja typu służy jedynie kompilatorowi i edytorowi do poznania jej struktury w celach sprawdzania typów i autodopowiadania. Praktyczne ujęcie: tok wskazuje na obiekt, a typ opisuje jego zawartość. Należy pamiętać, że NestJS nie sprawdza, czy te dwa elementy są zgodne; jeśli dostawca za 'logger' zwróci coś innego, TypeScript tego nie wykryje.

    Dlaczego nie można wstrzykiwać interfejsu

    Częstym żądaniem programistów nowo przychodzących do NestJS jest korzystanie z interfejsu w celu rozdzielenia kodu, na przykład poprzez określenie parametru jako IMailService. Pierwsza próba kończy się niepowodzeniem:

    // Won't work at runtime
    constructor(
      private readonly mailService: IMailService,
    ) {}
    

    Podczas uruchamiania NestJS wyświetla komunikat Nest can't resolve dependencies of the UserService (?). Znak zapytania oznacza parametr, którego nie udało się zidentyfikować.

    Powodem jest to, że interfejsy nie istnieją w JavaScript. Gdy tsc kompiluje twój kod, każdy interface oraz każda adnotacja typu jest usuwana. NestJS polega na metadanych, które przetrwają w działającym programie, a interfejs nie pozostawia nic, co mogłoby je odczytać; wygenerowane metadane dla tego parametru sprowadzają się do ogólnego typu Object, który nie pasuje do żadnego dostawcy.

    Klasy są inne: kompilują się do rzeczywistych funkcji konstruktorów w JavaScript, więc nadal istnieją w czasie wykonywania i mogą pełnić rolę kluczy.

    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
    

    Gdy programujesz z wykorzystaniem abstrakcji, musisz wyraźnie podać token w czasie wykonywania, na przykład @Inject('MAIL_SERVICE'), oraz zarejestrować dostawcę pod tym samym tokenem. Klasa abstrakcyjna to alternatywa, którą warto znać: ponieważ kompiluje się do rzeczywistej funkcji, może pełnić rolę zarówno typu, jak i tokena bez użycia @Inject().

    Pułapka dublowanych singletonów: useClass versus useExisting

    Gdy już używamy własnych tokenów, każdy moduł musi poinformować NestJS, jak je rozwiązywać, i właśnie w tym miejscu często pojawia się subtelny błąd. Rozważmy ten moduł:

    @Module({
      providers: [
        CustomLoggerService,
        {
          provide: 'APP_LOGGER',
          useClass: CustomLoggerService, // The trap
        },
      ],
    })
    export class CommonModule {}
    

    Na pierwszy rzut oka wydaje się, że 'APP_LOGGER' to po prostu inna nazwa dla CustomLoggerService. Tak nie jest. Moduł zawiera teraz dwa niezależne rejestracje dostawców, a dzięki domyślnemu zakresowi singletona każdy z nich otrzymuje własną instancję:

    • Tok klasowy CustomLoggerService jest rozstrzygany poprzez utworzenie klasy, co skutkuje powstaniem instancji A.
    • Tok ciągu znaków 'APP_LOGGER' jest rozstrzygany ponownym utworzeniem klasy, co skutkuje powstaniem instancji B.

    Problem pojawia się tylko w przypadku stanu. Jeśli logger przechowuje bufor pamięciowy, ma kolejkę, liczy żądania w celu ograniczenia szybkości lub posiada połączenie, to konsumenci wstrzykujący klasę oraz ci wstrzykujący token ciągu znaków komunikują się z różnymi obiektami, które nigdy nie widzą danych drugiej strony.

    Rozwiązanie: alias z użyciem useExisting

    Gdy chodzi o dodatkową nazwę dla dostawcy już zarejestrowanego, należy użyć useExisting. Informuje to NestJS, aby nie tworzyło nic nowego, lecz rozstrzygnęło token w odniesieniu do istniejącej instancji:

    @Module({
      providers: [
        CustomLoggerService,
        {
          provide: 'APP_LOGGER',
          useExisting: CustomLoggerService, // Points to the existing singleton
        },
      ],
    })
    export class CommonModule {}
    

    Prostym sposobem na zilustrowanie tej różnicy są pudełka i etykiety z nazwami:

    • useClass tworzy drugą ramkę. Token klasy oznacza ramkę A, a token ciągu znaków oznacza ramkę B.
    • useExisting tworzy jedną ramkę i dołącza do niej oba tagi nazwy.

    Gdy alias jest już ustawiony, konsument, który injectuje CustomLoggerService, oraz ten, który injectuje 'APP_LOGGER', otrzymują ten sam obiekt, więc sprawdzenie ścisłej równości pomiędzy nimi daje wynik true.

    useClass pozostaje właściwym wyborem, gdy naprawdę chcesz oddzielną instancję lub gdy token jest jedynym sposobem rejestracji klasy, jak opisano w następnej sekcji.

    Tokeny jako element architektury

    Dla małej usługi CRUD używanie specjalnych tokenów może wydawać się zbędne. Ich zalety stają się widoczne, gdy istnieje ryzyko zmian w implementacji. Załóżmy, że dziesiątki kontrolerów loguje się za pośrednictwem usługi opartej na Winstonie. Zamiast importować WinstonLoggerService w każdym z nich, kontrolery polegają jedynie na tokenie i interfejsie:

    constructor(
      @Inject('LOGGER') private readonly logger: LoggerInterface
    ) {}
    

    Moduł decyduje, która implementacja znajduje się za tym tokenem:

    {
      provide: 'LOGGER',
      useClass: WinstonLoggerService,
    }
    

    Przejście na chmurowy backend do logowania, takiego jak Google Cloud Logging lub AWS CloudWatch, polega wtedy na jednej zmianie w module, bez konieczności edytowania żadnego z klientów:

    {
      provide: 'LOGGER',
      useClass: CloudLoggerService,
    }
    

    To rozwiązanie ułatwia również testowanie, ponieważ moduł testowy może powiązać 'LOGGER' z mockiem bez konieczności modyfikowania testowanego kodu.

    Praktyczna poprawka: zwykłe ciągi znaków są podatne na błędy wprowadzania i mogą się kolidować pomiędzy modułami. Definiowanie tokenów raz jako eksportowanych stałych lub jako wartości Symbol zapewnia ich spójność i umożliwia kompilatorowi wykrywanie błędów pisowni.

    Wzory dostawców w pigułce

    Cztery typy dostawców, z którymi najczęściej się spotkasz, oraz sytuacje, w których każdy z nich jest odpowiedni:

    • Dostawca klas (useClass): tworzy instancję klasy dla danego tokena. Służy do powiązania tokenu abstrakcji z konkretną implementacją lub do zmiany implementacji w zależności od środowiska. Każda rejestracja tworzy własną instancję.
    • Dostawca aliasów (useExisting): wskazuje token na już istniejącego dostawcę. Służy do udostępniania jednej instancji pod kilkoma nazwami bez duplikowania stanu.
  • Dostawca wartości (useValue): zwraca stałą wartość, taką jak obiekt konfiguracji, stałą lub obiekt symulacyjny w testach.
  • Dostawca fabryki (useFactory): oblicza wartość za pomocą funkcji, opcjonalnie z zależnościami wprowadzanymi przez inject. Używaj go wtedy, gdy tworzenie zależy od konfiguracji lub jest asynchroniczne, jak w przypadku połączeń z bazą danych.
  • Nie zapomnij o eksportach

    Klasycznym problemem jest token prawidłowo skonfigurowany w CommonModule, którego UserModule nadal nie może rozwiązać. Tablica providers kontroluje to, co moduł rejestruje do własnego użycia. Tablica exports określa to, co udostępnia modułom, które go importują. Taki specjalny token jak 'LOGGER' musi pojawić się w exports (a moduł go używający musi importować CommonModule), zanim ktoś z zewnątrz będzie mógł go wstawić. Gdy eksportujesz alias utworzony za pomocą useExisting, sprawdź, czy użytkownicy mogą również uzyskać dostęp do dostawcy, na który wskazuje, albo eksportuj oba tokeny.

    Główne wnioski

    • Typ istnieje tylko w czasie kompilacji, token jest kluczem w czasie wykonywania, a dostawca określa, w jaki sposób ten klucz jest spełniany; przy analizie kodu DI trzymaj te trzy elementy oddzielnie.
  • Interfejsy są usuwane podczas kompilacji, dlatego wymagają wyraźnego tokena za pomocą @Inject() lub abstrakcyjnej klasy istniejącej w czasie wykonywania.
  • Zarejestrowanie tej samej klasy pod dwoma tokenami za pomocą useClass tworzy dwa singletony; użyj useExisting, gdy chcesz tylko aliasu.
  • Tokeny abstrakcji umożliwiają zamianę implementacji i mocków z jednego miejsca; definiuj je jako wspólne stałe lub symbole.
  • Gdy token nie może zostać rozwiązany w innym module, najpierw sprawdź exports i imports.
  • Dla każdej iniekcji zadaj sobie trzy pytania: jaki jest token, jaką implementację on wspiera oraz czy chodzi o oddzielną rejestrację, czy alias?
  • Literatura pokrewna

  • Jak Angular Dependency Injection rozwiązuje usługi za pomocą inject() — Zrozum, w jaki sposób Angular DI dostarcza usługi poprzez funkcję inject(), providerów oraz hierarchicznych injectorów, a także jak określać zakres instancji i unikać błędów związanych z brakującymi providerami.
  • SOLID jako pytanie o zmianę: Refaktoryzacja usługi Spring Boot — Naucz się stosować każdą z zasad SOLID w backendzie Spring Boot, zadając pytania o to, co może ulec zmianie, oraz jak unikać nadmiernego projektowania, do którego często prowadzą te zasady.
  • Laravel czy NestJS? Porównanie architektury, szybkości i dopasowania do zespołu — Praktyczne porównanie Laravel i NestJS obejmujące architekturę, ORM-y, domyślne zasady bezpieczeństwa, szybkość działania i dostarczania aplikacji, krzywą uczenia się oraz sytuacje, w których każdy z nich jest odpowiedni.
  • Sześć zasad DDD dla strukturyzowania domen w aplikacjach NestJS — Przyswoj sześć praktycznych zasad projektowania napędzanego domeną do organizacji modułów, entytetów i zdarzeń w NestJS, aby funkcje pozostawały izolowane i łatwe do utrzymania.
  • Projektowanie aplikacji w Angular dla nieudanych żądań: stany, interceptorzy i ponawiania prób — Dowiedz się, jak klasyfikować błędy HTTP w Angularze, oczyszczać stan ładowania, centralizować obsługę w interceptorach, bezpiecznie próbować ponownie oraz wyświetlać komunikaty, na które użytkownicy mogą zareagować.
  • Zrozumienie zasad SOLID poprzez praktyczne przykłady kodu — Ten przewodnik wyjaśnia wszystkie pięć zasad SOLID za pomocą konkretnych przykładów kodu, pokazując, jak są one stosowane w rzeczywistych projektach oraz aplikacjach React.
  • Nauczanie Claude Code Twojego monorepo w NestJS: routowanie, zasady i umiejętności — Jak skonfigurować CLAUDE.md, zasady, umiejętności i uprawnienia, aby Claude Code umieszczał kod we właściwej usłudze NestJS i przestrzegał konwencji zespołu.