Aufbau eines Decorator-gesteuerten Dependency Injection Containers in TypeScript
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Einleitung
Moderne Webanwendungen, insbesondere solche, die mit Frameworks wie Angular oder NestJS erstellt wurden, setzen stark auf Modularität und Testbarkeit. Ein entscheidendes Muster, das diese Eigenschaften ermöglicht, ist Dependency Injection (DI – Abhängigkeitsinjektion). Während Frameworks oft hochentwickelte DI-Systeme out-of-the-box bieten, kann das Verstehen und Implementieren eines eigenen Systems, insbesondere mit der Macht von TypeScript-Decorators, unglaublich aufschlussreich sein. Es bietet ein tieferes Verständnis dafür, wie diese Systeme funktionieren, und ermöglicht eine Feinabstimmung bei der Erstellung kleinerer, spezialisierterer Anwendungen oder Bibliotheken. Dieser Artikel führt Sie durch den Prozess der Erstellung eines einfachen, aber leistungsstarken automatisierten Dependency Injection Containers mithilfe von TypeScript-Decorators und zeigt, wie Sie eine saubere, wartbare und testbare Codebasis erzielen.
Die Kernkonzepte verstehen
Bevor wir uns mit der Implementierung befassen, wollen wir ein gemeinsames Verständnis der wichtigsten damit verbundenen Begriffe schaffen:
- Dependency Injection (DI): Ein Entwurfsmuster, bei dem Komponenten ihre Abhängigkeiten von einer externen Quelle erhalten, anstatt sie selbst zu erstellen. Dies fördert lose Kopplung, wodurch Komponenten unabhängiger, wiederverwendbarer und testbarer werden.
- IoC Container (Inversion of Control Container): Oft synonym mit einem DI-Container, ist es ein Framework oder eine Bibliothek, die die Instanziierung und den Lebenszyklus von Objekten innerhalb einer Anwendung verwaltet. Es "invertiert" die Kontrolle der Objekterstellung von den Komponenten selbst auf den Container.
- Decorator: Eine spezielle Art von Deklaration, die an eine Klassen-, Methoden-, Accessor-, Eigenschafts- oder Parameterdeklaration angehängt werden kann. Decorators verwenden die Form
@expression
, wobeiexpression
zu einer Funktion ausgewertet werden muss, die zur Laufzeit mit Informationen über die dekorierte Deklaration aufgerufen wird. In TypeScript bieten sie eine leistungsstarke Möglichkeit, Metadaten hinzuzufügen oder das Verhalten von Klassen und ihren Mitgliedern deklarativ zu ändern. - Service/Injectable: Eine Klasse oder ein Objekt, das eine bestimmte Aufgabe ausführt und in andere Komponenten injiziert werden kann. Im Kontext von DI sind dies die Komponenten, deren Instanzen der Container verwalten wird.
- Provider: Ein Mechanismus, um dem DI-Container mitzuteilen, wie eine Instanz einer bestimmten Abhängigkeit erstellt werden soll. Dies kann eine Klasse, ein Wert oder eine Factory-Funktion sein.
Das Grundprinzip unseres Decorator-gesteuerten DI-Containers besteht darin, Decorators zu verwenden, um Klassen als injizierbar zu markieren und ihre Konstruktorabhängigkeiten zu identifizieren. Der Container verwendet diese Metadaten dann zur Laufzeit, um die benötigten Dienste automatisch aufzulösen und zu instanziieren.
Implementierung des Dependency Injection Containers
Unser DI-Container besteht aus einigen Schlüsselkomponenten: einem Decorator zur Markierung injizierbarer Klassen, einem Decorator zur Angabe der Injektion von Abhängigkeiten und dem Container selbst, der für die Verwaltung von Instanzen verantwortlich ist.
1. Der @Injectable
-Decorator
Dieser Decorator dient zwei Zwecken: Erstens zur Markierung einer Klasse als Dienst, den unser Container verwalten kann, und zweitens zum Speichern von Metadaten über ihre Abhängigkeiten.
// reflect-metadata ist ein erforderliches Polyfill, damit Decorators mit Typinformationen funktionieren import 'reflect-metadata'; // Ein Symbol zum Speichern von Konstruktorparametertypen const INJECT_METADATA_KEY = Symbol('design:paramtypes'); /** * Markiert eine Klasse als injizierbar durch den DI-Container. * Dieser Decorator speichert Metadaten über die Konstruktorparameter der Klasse * und ermöglicht es dem Container, ihre Abhängigkeiten aufzulösen. * @returns Ein Klassen-Decorator */ function Injectable(): ClassDecorator { return (target: Function) => { // Hier ist keine explizite Aktion erforderlich, da TypeScript's // emitDecoratorMetadata das Speichern von design:paramtypes // für Konstruktorparameter übernimmt. Wir müssen nur sicherstellen, dass es ausgeführt wird. }; }
Erklärung: Der Injectable
-Decorator selbst tut in seinem Körper nicht viel direkt. Seine Hauptaufgabe besteht darin, die emitDecoratorMetadata
-Funktion von TypeScript auszulösen (die in tsconfig.json
aktiviert sein muss). Wenn emitDecoratorMetadata
wahr ist, gibt TypeScript automatisch Metadaten über die Typen der Parameter des Konstruktors einer Klasse aus und speichert sie mithilfe des design:paramtypes
-Schlüssels (ein bekanntes Symbol) über das reflect-metadata
-Polyfill. Unser Container liest diese Metadaten später.
2. Der Container-Kern
Hier geschieht die Magie – die Container
-Klasse verwaltet unsere Dienste.
import 'reflect-metadata'; // Stellen Sie sicher, dass dies einmal global importiert wird type Constructor<T> = new (...args: any[]) => T; /** * Ein einfacher Dependency Injection Container zur Verwaltung von Service-Instanzen. */ class Container { private static instance: Container; private readonly providers = new Map<Constructor<any>, any>(); // Speichert Singleton-Instanzen oder Factory-Funktionen private constructor() {} /** * Ruft die Singleton-Instanz des Containers ab. */ public static getInstance(): Container { if (!Container.instance) { Container.instance = new Container(); } return Container.instance; } /** * Registriert eine Klasse als Provider im Container. * Standardmäßig wird sie als Singleton registriert. * @param target Der Klassenkonstruktor, der registriert werden soll. */ public register<T>(target: Constructor<T>): void { if (this.providers.has(target)) { console.warn(`Service ${target.name} ist bereits registriert.`); return; } // An dieser Stelle registrieren wir nur den Konstruktor selbst. // Die Instanz wird bei der ersten Auflösung erstellt. this.providers.set(target, target); } /** * Löst eine Instanz der gegebenen Klasse vom Container auf. * Sie kümmert sich um die rekursive Auflösung von Abhängigkeiten. * @param target Der Klassenkonstruktor, der aufgelöst werden soll. * @returns Eine Instanz der angeforderten Klasse. */ public resolve<T>(target: Constructor<T>): T { // Wenn bereits eine Instanz erstellt wurde (Singleton), geben Sie sie zurück. // Der Einfachheit halber implementieren wir hier direkt ein einfaches Singleton-Muster. // Fortgeschrittenere Container könnten abweichende Lebenszyklusmanagement-Flags haben. if (this.providers.has(target) && (this.providers.get(target) instanceof target)) { return this.providers.get(target); } // Holen Sie sich die Konstruktorparametertypen (Abhängigkeiten) mit reflect-metadata const paramTypes: Constructor<any>[] = Reflect.getMetadata('design:paramtypes', target) || []; const dependencies = paramTypes.map(paramType => { if (!paramType) { // Dies kann passieren, wenn ein primitiver Typ injiziert wird oder wenn emitDecoratorMetadata deaktiviert ist throw new Error(`Kann Abhängigkeit für ${target.name} nicht auflösen. ` + `Stellen Sie sicher, dass 'emitDecoratorMetadata' in tsconfig.json auf true gesetzt ist ` + `und alle Abhängigkeiten ebenfalls @Injectable sind.`); } // Rekursiv Abhängigkeiten auflösen return this.resolve(paramType); }); // Erstellen Sie eine neue Instanz der Zielklasse mit ihren aufgelösten Abhängigkeiten const instance = new target(...dependencies); // Speichern Sie die neu erstellte Singleton-Instanz this.providers.set(target, instance); return instance; } } // Exportieren Sie eine praktische Instanz const container = Container.getInstance(); export { container, Injectable };
Erklärung:
Container.getInstance()
: Implementiert das Singleton-Muster für unseren Container. Wir benötigen nur eine zentrale Instanz, um alle Dienste zu verwalten.register(target: Constructor<T>)
: Diese Methode ermöglicht die explizite Registrierung von Klassen beim Container. Obwohl unsereresolve
-Methode Abhängigkeiten implizit finden kann, ist die vorherige Registrierung für eine klare Konfiguration nützlich. Für dieses Basisbeispiel speichertregister
nur den Konstruktor selbst, und die eigentliche Instanz wird beim erstenresolve
erstellt.resolve(target: Constructor<T>): T
: Dies ist der Kern des DI-Containers.- Es prüft zuerst, ob eine Instanz von
target
bereits existiert (Implementierung eines einfachen Singleton-Verhaltens). - Anschließend verwendet es
Reflect.getMetadata('design:paramtypes', target)
, um die Typen der Konstruktorparameter abzurufen. Hier kommen dasreflect-metadata
-Polyfill undemitDecoratorMetadata
ins Spiel. - Es ruft rekursiv
this.resolve()
für jeden Parametertyp auf, um Instanzen der Abhängigkeiten zu erhalten. - Schließlich instanziiert es die
target
-Klasse mit demnew
-Operator und den aufgelösten Abhängigkeiten als Konstruktorargumenten. - Die neu erstellte Instanz wird dann für nachfolgende Anfragen im
providers
-Map gespeichert und fungiert als Singleton.
- Es prüft zuerst, ob eine Instanz von
3. Nutzungsbeispiel
Lassen Sie uns die Verwendung unseres Containers mit einigen Beispielservices veranschaulichen.
// services.ts import { container, Injectable } from './container'; // Angenommen, container.ts befindet sich im selben Verzeichnis @Injectable() class LoggerService { log(message: string): void { console.log(`[Logger]: ${message}`); } } @Injectable() class DataService { constructor(private logger: LoggerService) {} // LoggerService ist eine Abhängigkeit getData(): string { this.logger.log('Fetching data...'); return 'Hello from DataService!'; } } @Injectable() class ApplicationService { constructor(private dataService: DataService, private logger: LoggerService) {} // DataService und LoggerService sind Abhängigkeiten run(): void { this.logger.log('Application starting...'); const data = this.dataService.getData(); this.logger.log(`Received data: ${data}`); this.logger.log('Application finished.'); } } // main.ts import { container } from './container'; import { ApplicationService } from './services'; // Registrieren Sie alle Dienste beim Container (optional, aber gute Praxis zur Verdeutlichung) // In einer größeren Anwendung könnten Sie ein Modulsystem oder automatische Erkennung verwenden container.register(LoggerService); container.register(DataService); container.register(ApplicationService); // Lösen Sie den obersten Application-Service auf const app = container.resolve(ApplicationService); app.run(); // Singleton-Verhalten überprüfen const anotherLogger = container.resolve(LoggerService); const firstLogger = container.resolve(LoggerService); console.log('Sind Logger die gleiche Instanz?', anotherLogger === firstLogger); // Sollte true sein
Um diesen Code auszuführen, stellen Sie Folgendes sicher:
reflect-metadata
installiert:npm install reflect-metadata
emitDecoratorMetadata
undexperimentalDecorators
in Ihrertsconfig.json
aktiviert:{ "compilerOptions": { "target": "es2016", "module": "commonjs", "emitDecoratorMetadata": true, "experimentalDecorators": true, "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }
- Ihr TypeScript kompiliert:
tsc
- Das kompilierte JavaScript ausgeführt:
node dist/main.js
Sie sollten eine Ausgabe ähnlich dieser sehen:
[Logger]: Application starting...
[Logger]: Fetching data...
[Logger]: Received data: Hello from DataService!
[Logger]: Application finished.
Sind Logger die gleiche Instanz? true
Dies zeigt, wie unser ApplicationService
automatisch Instanzen von DataService
und LoggerService
in seinem Konstruktor erhält, ohne sie explizit zu erstellen. Darüber hinaus erhält DataService
ebenfalls LoggerService
. Alle Dienste werden von unserem Container als Singletons verwaltet.
Anwendungsbereiche
- Microservices und API-Gateways: Verwalten und injizieren Sie einfach Service-Clients, Authentifizierungsanbieter und gängige Utility-Dienste über verschiedene Microservices hinweg.
- Kommandozeilen-Tools: Erstellen Sie komplexe CLI-Anwendungen, bei denen verschiedene Befehle oder Module spezifische Konfigurationen oder Hilfsklassen benötigen.
- Erstellung eigener Frameworks/Bibliotheken: Bieten Sie eine schlanke DI-Lösung für Ihre eigenen Bibliotheken, die es den Nutzern ermöglicht, Komponenten einfacher zu erweitern und zu integrieren.
- Testbarkeit: Der bedeutendste Vorteil. Da Komponenten ihre Abhängigkeiten erhalten, können Sie diese Abhängigkeiten während des Unit-Testings leicht mocken oder ersetzen, was zu isolierten und effizienten Tests führt.
Fazit
Wir haben erfolgreich einen grundlegenden, aber funktionsfähigen Dependency Injection Container in TypeScript aufgebaut, indem wir die Leistungsfähigkeit von Decorators genutzt haben. Durch die Markierung von Klassen mit @Injectable
und die Übertragung der Abhängigkeitsauflösung an den Container erzielen wir eine hochgradig modulare, lose gekoppelte und testbare Codebasis. Dieser Ansatz verbessert die Codeorganisation und Wartbarkeit erheblich und ermöglicht sauberere Anwendungsarchitekturen. Die Fähigkeit, Abhängigkeiten automatisch zu verwalten, ist ein unverzichtbarer Vorteil für die moderne JavaScript-Entwicklung.