Dependency Injection mit Closures

Dependency Injection (DI) wird oft als eine komplizierte Technik im Rahmen von "Real Enterprise™" angesehen, die viele Dekoratoren, Annotationen, Magie und Komplexität beinhaltet. Aber das muss nicht der Fall sein. Dieser Artikel handelt von einer einfacheren Methode zur Durchführung von DI in verschiedenen Situationen, insbesondere wenn Sie mit Frameworks interagieren müssen, die nicht kooperativ sein möchten.

Die Idee von Dependency Injection, kurz DI, ist nicht neu. Es handelt sich um ein Entwurfsmuster, mit dem wir verhindern können, dass verschiedene Komponenten zu eng miteinander verbunden werden. Mit Hilfe von Dependency Injection reduzieren wir das Ausmaß, in dem Module von der eigentlichen Implementierung der anderen abhängig sind, und erleichtern die gemeinsame Nutzung von Implementierungen. Dadurch ist der Code leichter zu testen, leichter zu warten und leichter zu aktualisieren, wenn neue Funktionen oder Änderungen erforderlich sind.

Warum DI?

Dependency Injection ist eine Erweiterung der Idee, dass eine Code-Einheit hauptsächlich über ihre eigenen Verantwortlichkeiten Bescheid wissen sollte und nicht darüber, wie andere Code-Einheiten arbeiten (d.h. Kapselung). Eine Klasse oder Funktion zur Verarbeitung von Geschäftslogik im Zusammenhang mit Benutzern wird wahrscheinlich auf eine Datenbank zugreifen müssen, um diese Benutzer zu laden, aber wir wollen nicht, dass sie alle Anweisungen zum Erstellen dieser Datenbankverbindung, zum Laden der richtigen Tabellen, zum Parsen der Daten usw. enthält. Im Idealfall muss die Benutzerlogik nur wissen, dass die Benutzer von irgendwoher geladen werden können, und das war's auch schon.

Genauer gesagt besagt DI, dass eine Klasse oder Funktion, die von einem anderen Service oder einer Einheit (z. B. einem UserRepository-Service) abhängt, diese Abhängigkeit nicht selbst aufbauen sollte. Stattdessen sollte die Abhängigkeit "injiziert" werden - typischerweise als Parameter in eine Funktion oder einen Klassenkonstruktor.

DI macht unseren Code etwas komplexer (mit neuen Parametern und komplexeren Funktionssignaturen), aber auch viel flexibler. Zum Beispiel:

  • Das Testen wird dadurch sehr viel einfacher. Anstatt Importe zu "monkeypatchen" oder Klassen zu mocken, können wir einfach eine fake Version der Abhängigkeit erstellen und sie an die Funktion oder Klasse übergeben, die wir testen wollen.
  • Es ist einfacher, Services oder Objekte auf der Grundlage verschiedener Umgebungen und Kontexte zu konfigurieren. Die Konfiguration ist nur eine weitere Sache, die injiziert werden kann, was bedeutet, dass es einfacher ist, z. B. Anfragen an unterschiedliche URLs in der Entwicklung und Produktion zu stellen. Erstelle einfach eine andere Version der Konfiguration für verschiedene Umgebungen und lasse sie injizieren. Es ist wichtig, sich daran zu erinnern, dass es bei DI um die Übergabe von Abhängigkeiten als Parameter geht. Viele Frameworks bieten verschiedene Mechanismen für die automatische Übergabe dieser Parameter mit Hilfe von Decorators, Reflection, Annotations und anderen Formen der dunklen Magie - und wenn viel los ist, kann das auch sehr nützlich sein - aber es ist nicht notwendig, und der gleiche Effekt kann mit viel weniger Code erreicht werden.

DI & Closures

Hier ist ein Beispiel für eine Routendefinition in Fastify:

import Fastify from 'fastify'
const fastify = Fastify();

fastify.get("/products/:productId", async (request, reply) => {
  // ... TODO: Produktkosten aus einer Datenbank irgendwoher abrufen
  return reply.send({ id: request.params.productId, value: "???" });
});

Wie bereits erwähnt, geht es bei der Dependency Injection um die Übergabe von Parametern an Funktionen. Aber in diesem Beispiel ist das schwierig, weil die Parameter der Handler-Funktionen festgelegt sind und wir nicht diejenigen sind, die die Funktionen aufrufen - hier wird Fastify die Handler-Funktionen mit den Argumenten "request" und "reply" aufrufen. Wie kommen wir also von hier aus zurück zu DI? Eine Antwort sind Closures.

Eine Closure ist eine Funktion, die den Scope, in dem sie definiert ist, festhält. Anders ausgedrückt: Wenn Du eine Funktion mit einer Reihe von darin definierten Variablen hast und dann eine Funktion innerhalb dieser Funktion schreibst, dann hat diese innere Funktion Zugriff auf ihre eigenen Argumente sowie auf die in der übergeordneten Funktion definierten Variablen.

Dies kann als Werkzeug für Dependency Injection verwendet werden. Wenn wir unsere Abhängigkeiten in den äußeren Bereich injizieren und dann die Handler-Funktionen innerhalb dieses Bereichs definieren, dann haben sie Zugriff auf die Dependencies, ohne dass wir ihre Signatur ändern müssen.

Hier ist das Beispiel von vorher, aber jetzt mit Closures:

import Fastify from 'fastify'

function buildServer(productStore: ProductStore): Fastify {
  const fastify = Fastify();

  fastify.get("/products/:productId", async (request, reply) => {
    const product = await productStore.load(request.params.productId);
    return reply.send({ id: request.params.productId, value: product.value });
  });

  return fastify;
}

const itemStore = new ItemStore(db);
const app = buildServer(itemStore);

Die Handler selbst sehen identisch aus (denn in Javascript sind alle Funktionen automatisch Closures, so dass wir hier nicht viel ändern müssen), aber anstatt sie auf der obersten Ebene zu definieren, definieren wir sie innerhalb einer buildServer-Funktion. Wir können alle Abhängigkeiten, die wir brauchen, an diese Funktion übergeben und sie dann innerhalb der Handler-Funktionen verwenden.

Hier habe ich das gesamte fastify-Objekt in einer einzigen Funktion aufgebaut, aber in der Praxis kann das natürlich aufgeteilt werden, so dass verschiedene Routen durch verschiedene Funktionen hinzugefügt werden, oder welche andere Abgrenzung für Ihre Anwendung sinnvoll ist.

Und wie könnte das beim Testen aussehen? Nun, wenn wir nur die Routen testen, wollen wir nicht das echte "ProductStore"-Objekt verwenden, also können wir einfach einen gefälschten Wert übergeben - ein Test-Double oder eine andere Form von Mock-Objekt, das das zurückgibt, was wir zurückgeben wollen. Das könnte etwa so aussehen:

test("returns product ID", async () => {
  // das Mock-Produktstore erstellen
  // (hier mit Objekten und Type Assertions, andere Optionen sind möglich).
  const productStore = {} as ProductStore;
  productStore.load = async () => ({ value: 123 } as Product);
  
  // unser Serverobjekt erstellen
  const server = buildServer(productStore);

  // den Test durchführen
  // (Anmerkung: `inject` ist eine fastify-spezifische Methode zur Übergabe einer Testanforderung an eine Funktion)
  const response = await server.inject({ method: "GET", url: "/products/testid" });
  
  // die Ergebnisse bestätigen
  expect(response.json()).toEqual({ id: "testid", value: 123 });
});

Fazit

Dependency Injection ist ein flexibler Weg, um den Code sauber und wartbar zu halten. Sie ermöglicht eine bessere Trennung von Belangen und macht den Code einfacher zu testen und zu erweitern.

Durch die Verwendung der Closure-Technik können wir DI auch in Situationen anwenden, in denen wir nicht die volle Kontrolle über die Parameter haben, die eine Funktion annehmen kann. In Anbetracht der Leistungsfähigkeit und des Nutzens von DI gibt uns dies mehr Möglichkeiten, diese Technik in verschiedenen Kontexten und mit verschiedenen Frameworks zu nutzen.