NestJS-Injektionstoken: Warum useClass Singletone stillschweigend duplizieren kann
Verstehen Sie Typen, Tokens und Anbieter in NestJS DI, warum Interfaces nicht injiziert werden können, und wie useExisting das Erstellen von zwei Instanzen desselben zustandsbehafteten Services vermeidet.
NestJS-Abhängigkeitsinjektion scheint anfangs mühelos zu sein: Man deklariert einen Konstruktorparameter, und das Framework liefert eine fertige Instanz. Hinter dieser Einfachheit verbirgt sich jedoch eine Zuordnung zwischen TypeScript-Typen, Laufzeit-Token und Provider-Definitionen, die bei Fehlverständnis zu schwer erkennbaren Fehlern führen kann. Dazu gehören doppelte Caches, zusätzliche Datenbankpools oder Mocks, die sich nie auflösen. Dieser Artikel entwickelt ein präzises mentales Modell dafür, wie NestJS Abhängigkeiten löst, erklärt, warum Interfaces nicht als Injektionschlüssel dienen können, und zeigt, wie eine einzige Provider-Einstellung darüber entscheidet, ob ein Service mit einer oder zwei Instanzen arbeitet.
Von automatischer Verkabelung zu expliziten Tokenen
Die typische erste Begegnung mit der DI in NestJS ist ein Konstruktor wie dieser:
constructor(
private readonly emailService: EmailService,
) {}
Für eine Klassenzuverlässigkeit liest NestJS die von TypeScript ausgegebenen Metadaten zu den Typen der Konstruktorparameter (aktiviert durch emitDecoratorMetadata) und verwendet die Klasse selbst, um den passenden Anbieter zu finden. Es sind keine zusätzlichen Annotationen erforderlich.
Größere Codebasen sehen oft anders aus:
constructor(
@Inject('EMAIL_SERVICE')
private readonly emailService: EmailService,
) {}
Falls der Parameter bereits typisiert ist, was fügt @Inject() hinzu, und warum kann das Umstellen von useClass auf useExisting dazu führen, dass entweder eine oder zwei Instanzen bereitgestellt werden? Um dies zu beantworten, müssen drei Konzepte unterschieden werden, die in der Regel denselben Namen teilen.
Typ, Token und Anbieter sind drei verschiedene Dinge
Anfänger betrachten diese oft als ein und dasselbe Konzept, weil sie im einfachen Fall denselben Identifikator haben:
- Typ: das, was der TypeScript-Compiler verwendet, um Ihren Code zu überprüfen. Er existiert nur zur Kompilierzeit.
Mit der herkömmlichen klassenbasierten Injektion übernimmt eine Klasse gleichzeitig die Rolle des TypeScript-Typs und des NestJS-Tokens:
CustomLoggerService
↓
NestJS Token
+
TypeScript Type
Ein benutzerdefiniertes Token trennt diese Rollen voneinander. Im folgenden Konstruktor dienen der String und die Typangabe völlig unterschiedlichen Zwecken:
constructor(
@Inject('logger')
private readonly logger: CustomLoggerService,
)
Im Einzelnen sehen die Verantwortlichkeiten so aus:
@Inject('logger')
↓
NestJS Token (Finds the provider in memory)
: CustomLoggerService
↓
TypeScript Type (Gives you IDE autocomplete)
Das Token ist das, was NestJS zur Suche nach der Instanz zur Laufzeit verwendet. Die Typangabe gibt dem Compiler und Ihrem Editor lediglich Informationen über seine Struktur für die Typüberprüfung und das Autocomplete. Eine nützliche Abkürzung: Das Token lokalisiert die Instanz, der Typ beschreibt deren Inhalt. Beachten Sie, dass NestJS nicht überprüft, ob beides übereinstimmt; wenn der Anbieter hinter 'logger' etwas anderes zurückgibt, wird TypeScript dies nicht erkennen.
Warum man keine Schnittstelle injizieren kann
Eine häufige Anfrage von Entwicklern, die mit NestJS neu sind, ist es, sich auf eine Schnittstelle zu stützen, um den Code entkoppelt zu halten – beispielsweise indem ein Parameter als IMailService definiert wird. Der natürliche erste Versuch scheitert:
// Won't work at runtime
constructor(
private readonly mailService: IMailService,
) {}
Zur Startzeit meldet NestJS Nest kann die Abhängigkeiten von UserService (?) nicht auflösen. Das Fragezeichen kennzeichnet den Parameter, den es nicht identifizieren konnte.
Der Grund ist, dass in JavaScript keine Interfaces existieren. Wenn tsc Ihren Code kompiliert, werden alle Interface-Definitionen sowie alle reinen Typangaben entfernt. NestJS setzt auf Metadaten, die im laufenden Programm erhalten bleiben, und eine Interface hinterlässt nichts, was es lesen könnte; die erzeugten Metadaten für diesen Parameter reduzieren sich auf das generische Object, das keinen passenden Anbieter findet.
Klassen sind anders: Sie werden zu echten JavaScript-Konstruktorfunktionen kompiliert, sodass sie auch zur Laufzeit weiterhin vorhanden sind und als Schlüssel dienen können.
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
Wenn man also mit einer Abstraktion programmiert, muss man explizit ein Laufzeit-Token bereitstellen, wie zum Beispiel @Inject('MAIL_SERVICE'), und einen Anbieter unter diesemselben Token registrieren. Eine abstrakte Klasse ist eine weitere Alternative, die man kennen sollte: Da sie zu einer echten Funktion kompiliert wird, kann sie ohne @Inject() sowohl als Typ als auch als Token fungieren.
Die Falle des doppelten Singleton: useClass versus useExisting
Sobald eigene Token zum Einsatz kommen, muss jedes Modul NestJS mitteilen, wie diese gelöst werden sollen – und genau hier schlüpft häufig ein subtiler Fehler ein. Betrachten wir dieses Modul:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useClass: CustomLoggerService, // The trap
},
],
})
export class CommonModule {}
Auf den ersten Blick scheint 'APP_LOGGER' nur ein weiterer Name für CustomLoggerService zu sein. Das ist nicht der Fall. Das Modul enthält nun zwei unabhängige Anbieterregistrierungen, und dank des standardmäßigen Singleton-Scope erhält jede davon ihre eigene Instanz:
- Der Klassentoken
CustomLoggerServicewird durch den Aufbau der Klasse gelöst, wodurch Instanz A entsteht. - Der Zeichentoken
'APP_LOGGER'wird erneut durch den Aufbau der Klasse gelöst, wodurch Instanz B entsteht.
Das Problem tritt nur bei Zuständen auf. Wenn der Logger einen In-Memory-Puffer speichert, eine Warteschlange führt, Anfragen zur Geschwindigkeitsbegrenzung zählt oder eine Verbindung besitzt, kommunizieren die Konsumierenden, die die Klasse injizieren, sowie diejenigen, die den Zeichentoken injizieren, mit unterschiedlichen Objekten, die niemals die Daten des anderen sehen.
Die Lösung: ein Alias mit useExisting
Falls es darum geht, einem bereits registrierten Anbieter einen zusätzlichen Namen zu geben, sollte useExisting verwendet werden. Damit weist man NestJS an, nichts Neues zu erstellen und den Token stattdessen auf die bereits vorhandene Instanz zu beziehen:
@Module({
providers: [
CustomLoggerService,
{
provide: 'APP_LOGGER',
useExisting: CustomLoggerService, // Points to the existing singleton
},
],
})
export class CommonModule {}
Eine einfache Möglichkeit, den Unterschied darzustellen, sind Kästchen und Namensschilder:
useClasserstellt eine zweite Box. Das Klassentoken kennzeichnet Box A, das Zeichentexttoken kennzeichnet Box B.useExistingerstellt eine einzige Box und fügt beide Nametags dazu hinzu.
Sobald der Alias vorhanden ist, erhält ein Verbraucher, der CustomLoggerService injiziert, sowie ein anderer, der 'APP_LOGGER' injiziert, dasselbe Objekt; daher ergibt ein strenger Gleichheitscheck zwischen ihnen true.
useClass bleibt die richtige Wahl, wenn man tatsächlich eine separate Instanz benötigt oder wenn das Token die einzige Möglichkeit ist, die Klasse zu registrieren, wie im nächsten Abschnitt erläutert.
Tokens als architektonischer Trennpunkt
Für einen kleinen CRUD-Dienst können benutzerdefinierte Token übertrieben erscheinen. Ihr Nutzen zeigt sich, wenn eine Implementierung voraussichtlich geändert werden muss. Angenommen, Dutzende von Controllern nutzen einen auf Winston basierenden Dienst zur Protokollierung. Anstatt in jedem Controller WinstonLoggerService einzubinden, verlassen sie sich lediglich auf ein Token und eine Schnittstelle:
constructor(
@Inject('LOGGER') private readonly logger: LoggerInterface
) {}
Das Modul entscheidet, welche Implementierung hinter diesem Token steckt:
{
provide: 'LOGGER',
useClass: WinstonLoggerService,
}
Der Wechsel zu einem Cloud-Logging-Backend wie Google Cloud Logging oder AWS CloudWatch bedeutet dann nur eine einzige Änderung im Modul, ohne dass irgendwelche Verbraucher-Module angepasst werden müssen:
{
provide: 'LOGGER',
useClass: CloudLoggerService,
}
Dieselbe Struktur macht das Testen einfach, denn ein Testmodul kann 'LOGGER' an einen Stub binden, ohne den getesteten Code anzufassen.
Eine praktische Verbesserung: Einfache Zeichenketten lassen sich leicht falsch eingeben und können zwischen Modulen aufeinandertreffen. Durch die Definition von Tokens als exportierte Konstanten oder als Symbol-Werte bleibt ihre Konsistenz gewahrt, und der Compiler kann Tippfehler erkennen.
Provider-Muster im Überblick
Die vier am häufigsten vorkommenden Provider-Formen sowie die Situationen, in denen jede davon geeignet ist:
- Klasse-Provider (
useClass): Erstellt eine Instanz einer Klasse für ein Token. Verwenden Sie ihn, um ein Abstraktions-Token mit einer konkreten Implementierung zu verknüpfen oder die Implementierungen je nach Umgebung auszutauschen. Jede Registrierung erzeugt ihre eigene Instanz. - Alias-Provider (
useExisting): Weist ein Token auf einen bereits vorhandenen Provider hin. Nutzen Sie ihn, um eine Instanz unter mehreren Namen bereitzustellen, ohne den Zustand zu duplizieren.
useValue): gibt einen festen Wert zurück, wie beispielsweise ein Konfigurationsobjekt, eine Konstante oder ein Mock in Tests.useFactory): berechnet den Wert mithilfe einer Funktion, gegebenenfalls mit in inject aufgeführten injizierten Abhängigkeiten. Verwenden Sie ihn, wenn die Erstellung von der Konfiguration abhängt oder asynchron ist, wie beispielsweise bei Datenbankverbindungen.Vergessen Sie die Exports nicht
Ein klassisches Problem ist ein korrekt in CommonModule konfigurierter Token, den UserModule dennoch nicht auflösen kann. Der providers-Array steuert, was ein Modul für seine eigene Verwendung registriert. Der exports-Array bestimmt, was es Modulen zur Verfügung stellt, die es importieren. Ein benutzerdefinierter Token wie 'LOGGER' muss im exports-Array erscheinen (und das importierende Modul muss CommonModule importieren), bevor externe Komponenten ihn einfügen können. Wenn Sie einen mit useExisting erstellten Alias exportieren, überprüfen Sie, ob die Nutzer auch auf den Provider zugreifen können, auf den er verweist – oder exportieren Sie beide Tokens.
Kernpunkte
- Ein Typ existiert nur zur Kompilierzeit, ein Token ist der Schlüssel zur Laufzeit, und ein Provider definiert, wie dieser Schlüssel erfüllt wird; halten Sie diese drei Aspekte beim Lesen von DI-Code getrennt.
@Inject() benötigen oder eine abstrakte Klasse, die zur Laufzeit vorhanden ist.useClass entstehen zwei Singleton-Instanzen; verwenden Sie useExisting, wenn Sie nur einen Alias möchten.exports und imports.