TypeScript: Inversion Of Control und Dependency Injection selbst gemacht

geschrieben von:

Christian Illies

Christian Illies ist leidenschaftlicher Frontendentwickler und seit fast 3 Jahren bei der Finatix. Mit der Spezialisierung auf Angular und NodeJS, arbeitet er tagtäglich für verschiedene TypeScript-Projekte im Web und mit Apps.

Aus der Rubrik:

Fachbeiträge

Fachbeiträge

kobu-agency-ipARHaxETRk-unsplash

In diesem Fachartikel wird gezeigt, wie ein globaler Injector für Dependency Injection (kurz: DI) implementiert und verwendet werden kann. Angelehnt ist die Umsetzung dabei an die Dependency Injection des Angular Frameworks. Das hier vorgestellte Skript funktioniert sowohl im Browser als auch serverseitig mit NodeJS.

Vorgestellt werden die Konzepte hinter Dependency Injection und Inversion Of Control (kurz: IoC), die dafür sorgen, dass Code einfacher zu pflegen ist. Beide ergänzen sich gegenseitig. Mit IoC wird das Bereitstellen einer Dependency ausgelagert in einen sogenannten "IoC Container". Mit TypeScript wird in diesem Beitrag ein "Injector" (unser IoC Container) entwickelt, der verschiedene Dependencies bereitstellen kann. Die Klassen und Objekte in den folgenden Beispielen geben selbst an, welche Dependencies sie benötigen und erzeugen keine davon selbst, was dem Dependency Injection Konzept entspricht.

Was bedeutet Dependency Injection?

Dependency Injection und Inversion of Control sind Entwurfsmuster (engl.: Design Pattern), welche in vielen Frameworks, wie Spring und Angular, Verwendung finden. Sie lösen das Problem, dass Abhängigkeiten direkt in Klassen erzeugt werden müssen. Stattdessen werden die Abhängigkeiten definiert, aber nicht selbst erzeugt. Im Java Spring Framework fungiert der "ApplicationContext" als IoC Container (mehr zum Spring ApplicationContext unter baeldung.com). Der StaticInjector (früher ReflectiveInjector) kümmert sich im Angular Framework um die Dependency Injection. Das vereinfacht nicht nur die Wartung, sondern auch das Testen (speziell Unit-Tests).

export class MyClass { 
   // ohne IoC/DI: Abhängigkeiten werden selbst erzeugt
   private myDependency = new MyDependency();
   constructor() {}
}
Abb. 1: Beispiel, wie eine Klasse ohne IoC/DI aussehen könnte. Schwer zu testen, da MyDependency direkt instantiiert wird.

Für DI gibt es drei Methoden:

  • Constructor Injection
  • Field Injection
  • Setter Injection

Wie der Name vermuten lässt, werden Dependencies bei der Constructor Injection im Konstruktor einer Klasse festgelegt. So müssen alle Dependencies bereits beim Erstellen des Objekts definiert sein. Das hat den Vorteil, dass die Klasse sich nicht mehr selbst um die Instanziierung kümmern muss.
Bei Setter Injection, gibt es für jede Dependency eine eigene Setter-Methode, um die Dependency zu bedienen. Diese Methode kann dann direkt nach der Instanziierung aufgerufen werden. Mit Hilfe der Field Injection können bspw. im Spring Framework Dependencies direkt an Attribute einer Klasse übergeben werden.

// Klasse mit Inversion of Control
export class MyClass { 
   // Constructor Injection
   constructor(private myDependency: MyDependency) {}

   private anotherDependency: AnotherDependency;
   // Setter Injection
   public setAnotherDependency(anotherDependency: AnotherDependency) {
     this.anotherDependency = anotherDependency;
  }
}
Abb. 2: Beispiel für Dependency Injection und Inversion of Control.

Im Codebeispiel wird ausschließlich die Constructor Injection angewandt.

Provider und Injector

Unser Injector stellt bestimmte Provider, also Services, einfache Werte oder Objekte, bereit.
Ein Provider kann dabei selbst Abhängigkeiten haben. Außerdem können unterschiedliche Provider Typen genutzt werden. Meistens finden die "Type Provider" Anwendung, da sie einfach und intuitiv zu verwenden sind.

// Liste und Konfiguration aller Provider
const providers = [
 // 3 Type Provider
 MyClass, MyDependency, MyDependency2
];

const injector = Injector.createAndResolve(providers);
// Objekt von der Klasse "MyClass":
const obj = injector.get(MyClass);
Abb. 3: Verwendung von Provider und Injector

Singleton Entwurfsmuster in TypeScript

Mithilfe von statischen Methoden und dem Singleton Entwurfsmuster kann an jeder beliebigen Stelle im Code der Injector entsprechende Dependencies auflösen und bereitstellen. Ein Aufruf könnte bspw. wie folgt aussehen:

const myDependency = Injector.inject(MyDependency);

Das Entwurfsmuster stellt sicher, dass es nur eine einzige Instanz des Injectors zur Laufzeit der Anwendung gibt. Im TypeScript kann das Singleton Entwurfsmuster wie folgt implementiert werden:

export class MySingleton { 
   private static instance: MySingleton;
   // Erzeuge Singleton
   public static create(): MySingleton {
     if (!MySingleton.instance) { MySingleton.instance = new MySingleton(); }
     return MySingleton.instance;
  }
   // verbiete direkte Instantiierung:
   private constructor() {}
}

// Aufruf:
const singleton1 = MySingleton.create();
const singleton2 = MySingleton.create();

console.log(singleton1 === singleton2); // true
Abb. 4: Beispiel zur Implementierung des Singleton Entwurfsmusters

Was kann provided werden?

Im Prinzip ist der vorgestellte Injector ein Key-Value Store, der für jeden beliebigen Key einen beliebigen Wert zurückliefert. Das heißt es können nicht nur Klasseninstanzen, sondern auch einfache Objekte, Strings, Funktionen, Arrays usw. zur Verfügung gestellt werden. Oft werden Parameter, bspw. für eine Datenbank-Verbindung, ebenfalls über eine Dependency Injection geladen. Angular hat für solche Datentypen die Klasse "InjectionToken" bereitgestellt, die den Typ und eine Beschreibung des Providers erhält. InjectionTokens haben den Vorteil, dass sie als Konstante für einen Provider dienen. Alle Codebeispiele nutzen die InjectionToken-Klasse aus der injection-js Library.

import {InjectionToken} from "injection-js";
const MyToken = new InjectionToken<string>('a description for the simple string');
const providers = [
{ provide: MyToken: useValue: 'a simple string' }
];
const injector = Injector.createAndResolve(providers);
const aSimpleString = injector.get(MyToken);
console.log(aSimpleString); // 'a simple string'
Abb. 5: Verwendung von InjectionTokens.

Provider Konfiguration wie in Angular

Damit eine Anwendung funktioniert, muss an einer Stelle die Liste von verfügbaren Providern bereitgestellt werden. Im Angular Kontext geschieht dies in den Modulen selbst. In dem Codebeispiel wird der Injector-Klasse ein Array von Providern übergeben, woraufhin das Injector-Objekt als Ergebnis zurückgeliefert wird. Dieses Objekt kann nun bspw. in einer Bootstrap/Main-Funktion der Anwendung die Dependency-Injection nutzen.

class Application {
 constructor(
   private routing: Routing,
   private eventListener: EventListener,
   private core: CoreUtils
) {}

 init() {
   this.routing.start();
   this.eventListener.listen();
   this.core.log('init done');
}
}

// Die Provider Konfiguration der Anwendung:
const providerConfig = [
 Application, Routing, EventListener, CoreUtils
];

const injector = Injector.createAndResolve(providerConfig);
injector.inject(Application).init();
Abb. 6: Beispiel Initialisierung einer Anwendung mit DI.

Nach dem Initialisieren der Application, können alle in der Provider Konfiguration angegebenen Abhängigkeiten verwendet werden. Jeder Service kann dabei auch selbst weitere Provider als Abhängigkeit angeben.

Provider Typen für Dependency Injection

Bei der Konfiguration von Providern können verschiedene ProviderTypen verwendet werden. So wie im Angular Framework, gibt es auch hier die folgende Auswahl:

  • Class Provider (useClass): Für einen bestimmten Token wird eine Instanz von der in "useClass" angegebenen Klasse bereitgestellt.

  • Factory Provider (useFactory): Die Factory Methode erzeugt selbst zur Laufzeit den entsprechenden Provider und kann über das Feld "deps" Dependencies angeben.

  • Existing Provider (useExisting): Beim Existing Provider wird ein Alias zu einem bereits existierenden Providers erstellt.

  • Value Provider (useValue): Mit dem Value Provider können auch einfach Objekte, Strings oder andere Daten injiziert werden.

  • Type Provider: Analog zum Class Provider wird die Klasse selbst als Token und Value verwendet.

  • Multi Provider (multi): Ein Multi Provider stellt unter denselben Token ein Liste von unterschiedlichen Providern bereit.

Einen Unterschied machen die sog. Multi Provider, also bspw. ein einziger Token, der mehrere Provider gleichzeitig bereitstellt. Praktisches Beispiel hierfür ist der Multi Provider HTTP_INTERCEPTORS im Angular Framework zum Hinzufügen verschiedener Http-Interceptors (siehe hierzu ein Beispiel zu HttpInterceptors auf dev.to). So können verschiede Interceptors unterschiedliche Aufgaben lösen, wie Auth-Header setzen, Fehler Dialoge anzeigen bei Status 500 oder einfaches Logging. Darüber hinaus können Multi Provider sinnvoll für Initialisierungsservices (Angular APP_INITIALIZER) sein.

Die Quellcode Abbildungen in diesem Beitrag enthalten keine Multi-Provider Unterstützung, um nicht so viele Zeilen zu beanspruchen. Im Codebeispiel auf GitHub werden Multi-Provider berücksichtigt.

Inversion of Control Container implementieren

Um einen Injector (unser IoC Container) zur erzeugen, benötigt dieser die Provider Konfiguration. Nur mit der Provider Konfiguration kann der Injector alle Abhängigkeiten entsprechend auflösen.

Vor dem Erstellen des Injector-Objekts muss also die gesamte Provider Konfiguration eingelesen und entsprechende Factory-Methoden generiert werden. Die Factory Methoden erzeugen später zur Laufzeit die einzelnen Objekte (bspw. Class und Type Provider) oder geben die Werte direkt zurück (bspw. bei Value Provider). In der Abb. 7 wird die gesamte Provider Konfiguration eingelesen und verarbeitet. Die createFactory-Methode ist in Abb. 8 beschrieben. Dort werden die Provider Typen unterschieden.

export class Injector {
 // ... singleton Logik ... 
 // ... 
 static resolveAndCreate(providerConfiguration: Provider[], parent?: Injector): Injector {
       const injector = parent ?? Injector.getInstance();
       
       for (const provider of providerConfiguration) {
           // Erzeuge dynamische Factory Methoden für jeden Provider:
           const factory = this.createFactory(provider, injector);

           // Eindeutige Id/Token für Provider
           const id: any = this.getToken(provider);
injector.factories.set(id, factory);
      }

       return injector;
  }
 
   private static getToken(provider: Provider) {
       return "provide" in provider ? provider.provide : provider;
  }
 
   private static createFactory(provider: Provider, injector: Injector) {
     // ... siehe unten
  }

   // Map mit *allen* Provider Factory Methoden
   private readonly factories: Map<any, any> = new Map();

   // ... weitere Logik ... 
}
Abb. 7: Ausschnitt der statischen Injector Funktion "resolveAndCreate".

Die Factory-Methoden unterscheiden sich, abhängig von den Provider Typen, und erzeugen die Instanzen erst zur Laufzeit, wenn die Provider benötigt werden. Würden diese direkt erzeugt werden, könnten Laufzeitfehler durch (noch) unbekannte Provider entstehen.

Die createFactory-Methode ist die Kernfunktion zur Bereitstellung aller Abhängigkeiten. Mit Hilfe der Provider Konfiguration werden je nach Provider Typ die notwendigen Dependencies erkannt und entsprechend injiziert. Beim Class und Type Provider bspw. direkt in den Konstruktor (Code: (...deps: any[]) => new clazz(...deps)). Factory Provider beinhalten in ihrer Konfiguration bereits eine Liste der Dependencies. Für die Class und Type Provider wurde ein statisches Klassenattribut deps verwendet, welches ähnlich wie das deps-Attribut bei Factory Provider funktioniert, da in diesem Beitrag auf Reflection (siehe Abschnitt weiter unten) explizit verzichtet wird. Die Dependencies werden dann zur Laufzeit mit dem Code resolvedDeps = deps.map((dep: any) => injector.get(dep)); geladen und an die Provider entsprechend übergeben.

export class Injector {
// ...
   private static createFactory(provider: Provider, injector: Injector) {
       // Existing Provider nutzen einfach den "bekannten" Provider
       if ("useExisting" in provider && provider.useExisting) {
           return () => {
               return injector.get(provider.useExisting);
          };
      }
       // Value Provider, geben den Wert einfach zurück ohne Instantiierung
       if ("useValue" in provider && provider.useValue) {
           return () => provider.useValue;
      }

       let deps: any[] = [];
       let fac: Function;

       if ("useFactory" in provider && provider.useFactory) {
           deps = provider.deps ?? [];
           fac = provider.useFactory;
      } else {
           let clazz: any = provider;
           if ("useClass" in provider && provider.useClass) {
               clazz = provider.useClass;
          }
           if ("deps" in clazz) {
               // Da keine Reflection verwendet wird,
               // brauchen wir irgendwo die Dependencies im JavaScript.
               deps = clazz.deps ?? [];
          }
           fac = (...deps: any[]) => new clazz(...deps);
      }

       return () => {
           // Hier werden die Dependencies *zur Laufzeit* geladen 
           // und der Factory Methode übergeben ...
           const resolvedDeps = deps.map((dep: any) => injector.get(dep));
           return fac(...resolvedDeps)
      };
  }
// ...
}
Abb. 8: Erzeugen von Factory Methoden

In Abb. 9 ist zu sehen, wie die Instanziierung der Objekte zur Laufzeit abläuft. Zuerst wird geprüft, ob der Token überhaupt bekannt ist. Wenn ja, wird versucht mit Hilfe der Factory-Methode eine Instanz zu erzeugen. Treten dabei keine Fehler auf, wird die Instanz gespeichert und zurückgeliefert. Die nächste Klasse, die denselben Token verwendet, wird dann direkt die Instanz erhalten, da auch hier das Singleton Entwurfsmuster angewandt wird. Jedes erzeugte Objekt existiert im Arbeitsspeicher also nur exakt einmal.

Flowchart für Dependency Injection Beispiel
Abb. 9: Ablauf zum Erzeugen eines Provider Objekts

Nun fehlt nur noch die Implementierung der inject-Methode. Hier kann eine statische Methode helfen, um die Verwendung zu erleichtern. Mit der inject-Methode wird ein Objekt eines bestimmten Typs zurückgegeben. Der Typ steckt im Provider selbst, bspw. im InjectionToken.

export class Injector {
// ...
   // Alias für `Injector.getInstance().inject<T>(...)`
   static inject<T>(provider: InjectionToken<T> | Type<T>): T {
       return Injector.getInstance().inject<T>(provider);
  }
 
   // Erzeuge Instanzen, falls nötig, und gib diese zurück
   inject<T>(providerId: InjectionToken<T> | Type<T>): T {
       if (!this.factories.has(providerId)) {
           throw new Error('Could not resolve provider');
      }

       // Singleton: Es wird immer nur eine Provider Instanz erzeugt.
       if (!this.instances.has(providerId)) {
           const factory = this.factories.get(providerId);
           const instance: any = this.createProviderByFactory(factory);
           this.instances.set(providerId, instance);
      }

       return this.instances.get(providerId) as T;
  }

   private createProviderByFactory<T>(factory: () => T): T {
       try {
           return factory();
      } catch (err) {
           throw new Error(`Could not create provider: ${err.message}`);
      }
  }
// ...
}  
Abb. 10: Inject-Methode, die zur Laufzeit die Provider erzeugt.

Reflection in Typescript

Mittels Reflection können u. a. die Typen von Objekten bestimmt werden. Für viele Programmiersprachen gibt es eigene Lösungen für Reflection. Für TypeScript gibt es bspw. reflection, reflect-metadata oder auch core-js. Im Angular Framework werden (bzw. wurden) mit Hilfe von Reflection die Typinformationen von Konstruktor-Parametern erkannt, damit die entsprechenden Provider später injiziiert werden können (siehe JavaScript Reflection in ES6). Deswegen werden Dekoratoren (oder auch Annotations), wie @Injectable, @Component usw. benötigt, damit die Typinformationen automatisch an diese Klassen angefügt und zur Laufzeit ausgelesen werden können.

Im Codebeispiel wird auf jegliche Reflection verzichtet, da das das Beispiel unnötig komplizierter machen würde. Das Klassenattribut "deps" wird verwendet, um entsprechende Dependencies festzulegen. Für TypeScript bleibt es zusätzlich erforderlich die Typen auch in den Konstruktoren anzugeben.

export class Application {
   // für die Dependency Injection ohne Reflection notwendig
   static deps = [Routing, DB_CONNECT_TOKEN];
 
   // TypeScript benötigt trotzdem die Typen der Constructor Parameter:
   constructor(
       private routing: Routing,
       private dbConnection: any,
  ) {}
}
Abb. 11: Beispiel Klasse mit "deps" Attribut statt Reflection

Übrigens erzeugt das Angular Framework mit dem StaticInjector diese statischen Informationen bei der Kompilierung automatisch (siehe dazu einen Medium Beitrag von Alexey Zuev).

Mehr Informationen zu Reflections gibt es in der reflect-metadata library.

TypeScript Codebeispiel mit Depency Injection

Das Codebeispiel wurde auf GitHub zum Ausprobieren hochgeladen. Einfach auschecken und NPM Pakete installieren. Mit der Startdatei index.ts wird die Anwendung und die Dependency Injection konfiguriert und initialisiert. Wie oben geschrieben, wird die Provider Konfiguration dabei an den Injector übergeben, damit dieser alle Abhängigkeiten später auflösen kann. Die Application Klasse ist selbst ein Provider und wird mit Hilfe des Injectors geladen (inkl. Dependencies) und im Anschluss die init Methode aufgerufen. Wenn das Projekt nun gebaut und erfolgreich gestartet wurde (npm run build && npm start), sollte folgende Ausgabe erscheinen:

> node out/index.js

createDbConnection with dbConfig: jdbc://mysql:123@321
Routing started
EventListener listens
Routing events 0
App -> dbConnection.query: true
Abb. 12: Ausgabe des Beispielcodes

Die nach JavaScript kompilierte Injector-Klasse funktioniert aber auch im Webbrowser, wie Abb. 13 zeigt. Hierzu können die kompilierten JavaScript Klassen einfach nacheinander in den Browser kopiert werden. Ein JavaScript Bundler (wie Webpack, Parcel o. ä.), der den Code für Webbrowser optimiert, wurde in dem Beispiel nicht integriert.

Abb. 13: Ausführung der JavaScript Klassen im Webbrowser.

Unittesting mit Jest

Nun wurden mit dem Beispiel-Projekt die DI und IoC Entwurfsmuster umgesetzt und ein Injector, der sowohl im Browser als auch im NodeJS funktioniert, implementiert. Doch ein wichtiges Thema steht noch aus: das Testen. Mit Hilfe des Injectors lassen sich sämtliche Provider leicht testen, da die Abhängigkeiten einfach durch Mocks ersetzt werden können. Das Beispiel nutzt das Jest-Framework zum Ausführen der Tests.

describe('Application', () => {
   let injector: Injector;
   let app: Application;

   beforeEach(() => {
       injector = Injector.resolveAndCreate([
           Application,
          {
               provide: Routing,
               useValue: {
                   start: jest.fn(),
              }
          },
           // ...
      ]);

       app = injector.inject(Application);
  });

   test('should start routing when initialising', () => {
       expect(injector.get(Routing).start.mock.calls.length).toEqual(0);
       app.init();
       expect(injector.get(Routing).start.mock.calls.length).toEqual(1);
  });
});

Abb. 14: Tests werden durch Dependency Injection deutlich einfacher.

Jeder Unit-Test baut also eine eigene Provider Konfiguration zusammen, um so die entsprechenden Mocks zu nutzen. So ähnlich funktioniert das Testen auch im Angular Framework. Dort können mit TestBed.configureTestingModule({...}) entsprechende Provider und Mocks definiert werden.

Fazit: Dependency Injection erleichtert alles

Mit diesem Artikel wurden die Konzepte Dependency Injection und Inversion of Control etwas näher beleuchtet, so dass die Funktionsweise dahinter klarer wird. Komplexe Anwendungen können mit Hilfe der Provider und Injector deutlich leichter getestet, weiterentwickelt und gepflegt werden. Das ist auch der Grund, warum so viele Software Frameworks auf dieses Entwurfsmuster aufbauen.
Für einen produktiven Einsatz, ist allerdings die injection-js Library zu empfohlen.

Weiterführende Links und Quellen