Ein Beispiel für eine Dependency-Injektion

Learning by Doing: Wie man eine Dependency Injection implementiert
Keine Kommentare

Um etwas zu verstehen, sollte man es selbst implementieren. Das klappt auch mit dem Konzept der Dependency Injections und dem Modulsystem von NestJS: Ein Blick unter die Haube.

Ich halte sehr viel von dem Sprichwort „Learning by Doing“. Um ein bestimmtes Problem oder Thema besser zu verstehen, Paradigmen oder neue Libraries kennen zu lernen, hilft es, das tatsächlich selbst zu implementieren. Oft reicht es auch aus, erst einmal einfach drüber nachzudenken, wie eine Implementierung aussehen könnte. Manchmal muss man es aber auch aufschreiben.

Mit Dependency Injections (DI) beschäftige ich mich seit einigen Monaten, als wir in meinem vorherigen Job damit anfingen, damit in Form von NestJS zu arbeiten. Mich hat insbesondere folgende Frage beschäftigt: Warum kommt NestJS mit einem eigenen Modulsystem daher, wenn JavaScript bereits Module hat und die NestJS-Module, je nach Projektgröße, immer nur einen Service/Provider enthalten. Das Konzept war mir nicht sofort verständlich. Ich habe es erst durchschaut, als ich mir überlegt habe, wie ich selbst eine DI-Lösung implementieren würde.

Dependency Injection: Das Konzept

Das Konzept hinter DI ist eigentlich sehr simpel. In meinen eigenen Worten ausgedrückt geht es darum, das Was vom Wie zu entkoppeln.

Auf diese Weise können wir auch über die beweglichen Teile nachdenken. Der zentrale Teil in DI wird Container genannt. Man fragt dann diesen Container an, damit er das ausgibt, was man benötigt. Das ist im Wesentlichen nur ein Datentype, meistens der der selbst implementierten Services, aber man kann auch primitive Datentypes wie string oder number verwenden, wenn man Konfigurationen über DI managen möchte.

typedi nennt dies einen Token:

// Die Klasse ist leer, ihr einziger Sinn besteht darin, den Typ `T` zu enthalten, das **Was**.
class Token<T> {}

interface Service {
  foo: string;
}

// Ein paar explizite Beispiele:
const MyConfig = new Token<string>();
const MyService = new Token<Service>();

Wie aber konstruieren wir das, was wir haben wollen, das Wie? Der DI-Container selbst muss nicht wirklich wissen, wie diese Dinge entstehen. Er delegiert das einfach an eine beliebige Funktion, die Provider genannt wird.

type Provider<T> = (container: Container) => T;

In meinem Beispiel möchte ich die Dinge aus der Sicht des Containers so einfach wie möglich halten, was bedeutet, dass es in der Verantwortung des Providers liegt, die folgenden Dinge zu machen:

  • Jede Dependency zu initialisieren und
  • Dinge zu cachen/memoisieren. Dadurch kommen wir zu diesem sehr einfachen Container:
class Container {
  /** Das Registry beinhaltet die Provider, die jeweils einem Token zugeordnet sind. */
  private registry = new Map<Token<any>, Provider<any>>();

  /** Registrieren eines neuen Providers für ein Token, das **Wie**. */
  public register<T>(token: Token<T>, provider: Provider<T>): this {
    this.registry.set(token, provider);
    return this;
  }

  /** Folgender Code gibt das *Was* aus; **Wie** braucht uns nicht zu kümmern. */ 
  public get<T>(token: Token<T>): T {
    return this.registry.get(token)!(this); // Hinweis: Das kann Fehler ausgeben.
  }
}

Dies ist im Wesentlichen ein sehr einfacher, aber funktionierender DI-Container in 12 Zeilen Code. So verwenden wir ihn:

class ConcreteService {
  constructor(public foo: string) {}
}

const container = new Container();

// Wir können einen statischen Wert benutzen
container.register(MyConfig, () => "my config value");

// Hier konstruiert unser Provider einen neuen Wert, der zum "Service" Interface passt
// und benutzt den DI Container um alle Dependency-Werte zu erhalten.
// Weder der DI-Container noch der Nutzer müssen sich darum kümmern.
container.register(
  MyService,
  // Wenn wir ein Singleton wollen, können wir diese Funktion mit einer Art von 
  // `memoize` wrappen, was als Aufgabe für den Leser bleibt.
  container => new ConcreteService(container.get(MyConfig))
);

// Dies wird wenn erforderlich eine neue Instanz erstellen:
const myService = container.get(MyService);

Auf dem TypeScript Playground kann man auch mit dem vollständigen Beispiel experimentieren.

Es nützlicher machen

Das Beispiel ist offensichtlich auf Einfachheit hin optimiert und bringt einige Probleme mit:

  • Wie bereits im Kommentar erwähnt, wird die Verwendung von get, ohne vorher einen Provider zu registrieren, einen Fehler auswerfen.
  • Es wird zu unendlicher Rekursion kommen, wenn man zirkuläre Abhängigkeiten hat. Ich würde dafür plädieren, zirkuläre Abhängigkeiten generell zu vermeiden. Sie funktionieren nur unter ganz bestimmten Umständen und werden uns das Haus in die Luft jagen und niederbrennen, wenn wir nicht sehr gut aufpassen.
  • Wenn nötig können wir dies auch leicht async machen.
  • Es könnte eine gute Idee sein, mehr Details über Abhängigkeiten in den Container selbst zu packen, um komplexere Abhängigkeiten besser zu optimieren.
  • Im Beispiel fehlen auch Dinge wie Scopes und Vererbung völlig.
  • Möglicherweise möchten man einen Service sowohl als Token als auch als Provider verwenden können.

Aber auch wenn man es so implementiert, zeigt sich sehr deutlich das Hauptargument für DI: Weder wir als Programmierer noch einer unserer Provider müssen wissen, wie andere Werte konstruiert sind. Es funktioniert einfach. Das macht es sehr einfach, einen unserer Provider zu überschreiben, um ein Mock-Objekt für unsere Unit-Tests zu konstruieren oder an zwei verschiedene spezifische Implementierungen eines Dienstes zu delegieren, abhängig von unserer Konfiguration, etc.

Zurück zu den Modulen

Zurück zu meiner ursprünglichen Frage über Module. In diesem sehr einfachen Beispiel sehen wir sie nicht. Denken wir also ein wenig darüber nach, was passiert, wenn wir anfangen, das zu skalieren und viel mehr Provider haben, um die wir uns kümmern müssen, Dutzende oder sogar Hunderte davon.

Wir müssten die register-Funktion unseres Containers für jeden einzelnen aufrufen, was sehr schnell lästig wird. Außerdem wollen wir sowohl eine Art von Kapselung haben und uns nicht darum kümmern müssen, welche spezifischen Provider vorhanden sind.

Wie können wir das also vereinfachen? Indem wir eine sehr einfache Modul-Definition hinzufügen und unsere register– Methode darauf erweitern:

// Token und Provider gehören zusammen, wir bezeichnen das als Definition. 
interface Definition<T> {
  token: Token<T>;
  provider: Provider<T>;
}

// Ein Modul ist grundsätzlich nur eine Liste von Definitionen.
type Module = Array<Definition<any>>;

class Container {
  public register<T>(token: Token<T>, provider: Provider<T>): this;
  public register(module: Module): this;
  public register<T>(modOrToken: Module | Token<T>, provider?: Provider<T>) {
    if (Array.isArray(modOrToken)) {
      for (const def of modOrToken) {
        this.registry.set(def.token, def.provider);
      }
    } else {
      this.registry.set(token, provider);
    }
    return this;
  }
}

// Wir können dann ein Modul erstellen und benutzen:
const ConfigModule = [
  { token: MyConfig, provider: () => "module config value" }
];
container.register(ConfigModule);

Hier ist ein weiterer Playground-Link.

Na also! Hier ist ein Modul nur ein undurchsichtiges Set aus Providern. Der Vorteil besteht wieder darin, dass wir uns nicht darum kümmern müssen, was tatsächlich darin enthalten ist.

Wir können es benutzen, um mehrere Konfigurationswerte zu gruppieren oder um alles auf einmal zu mocken.

Es gibt allerdings noch eine letzte Schwierigkeit: Wir haben nur eine Registry pro Container und die Art und Weise, wie sie umgesetzt wird, hängt von der Reihenfolge ab, in der wir die Dinge registrieren. Abhängig von unseren Modulen könnte es auch zu bösen Überraschungen kommen, da Module ja undurchsichtig sein sollen. In einigen komplexeren Beispielen müssten wir die Reihenfolge der Dinge immer noch manuell optimieren.

Wie dem auch sein mag, sind wir hier fertig. Eigentlich hat mir das Schreiben dieses Blogposts noch mehr gezeigt, wie einfach die grundlegenden Konzepte hinter DI tatsächlich sind!

Es wird komplizierter, wenn wir ein reichhaltigeres API haben wollen, das async ist, zirkuläre Abhängigkeiten unterstützt (bitte nicht), und wenn wir Abhängigkeiten und Memoisierung im Container selbst haben wollen.

Dieser Artikel ist zuerst auf Englisch im Blog „Swatinem“ von Arpad Borsos erschienen.
Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -