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.
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.
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
CustomLoggerServicejest 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:
useClasstworzy drugą ramkę. Token klasy oznacza ramkę A, a token ciągu znaków oznacza ramkę B.useExistingtworzy 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.
useValue): zwraca stałą wartość, taką jak obiekt konfiguracji, stałą lub obiekt symulacyjny w testach.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.
@Inject() lub abstrakcyjnej klasy istniejącej w czasie wykonywania.useClass tworzy dwa singletony; użyj useExisting, gdy chcesz tylko aliasu.exports i imports.Literatura pokrewna
- Diagnozowanie błędu NestJS Cannot Resolve Dependencies, przyczyny po przyczynie — Naucz się interpretować błędy rozwiązywania zależności w NestJS oraz naprawiać jego pięć najczęstszych przyczyn: brakujące dostawcy, nieeksporowane moduły, cykle, błędne tokeny oraz moduły testowe.
- Sześć reguł DDD dla strukturyzacji domen w aplikacjach NestJS — Poznaj sześć praktycznych reguł projektowania napędzanego domeną do organizacji modułów, entytetów i zdarzeń w NestJS, aby funkcjonalności pozostawały izolowane i łatwe do utrzymania.