Eine praktische Einführung in die Abhängigkeitsinjektion
Veröffentlicht: 2022-03-10Das Konzept der Abhängigkeitsinjektion ist im Kern ein grundlegend einfacher Begriff. Es wird jedoch häufig neben den eher theoretischen Konzepten der Umkehrung der Kontrolle, der Umkehrung der Abhängigkeit, der SOLID-Prinzipien usw. präsentiert. Um Ihnen den Einstieg in die Dependency Injection so einfach wie möglich zu machen und die Vorteile zu nutzen, bleibt dieser Artikel sehr auf der praktischen Seite der Geschichte und zeigt Beispiele, die in erster Linie genau die Vorteile seiner Verwendung zeigen von der zugehörigen Theorie getrennt.
Wir werden hier nur sehr wenig Zeit darauf verwenden, die akademischen Konzepte rund um die Abhängigkeitsinjektion zu diskutieren, da der Großteil dieser Erklärung dem zweiten Artikel dieser Serie vorbehalten ist. In der Tat können und wurden ganze Bücher geschrieben, die eine eingehendere und strengere Behandlung der Konzepte bieten.
Hier beginnen wir mit einer einfachen Erklärung, gehen zu einigen weiteren Beispielen aus der Praxis über und besprechen dann einige Hintergrundinformationen. Ein weiterer Artikel (der diesem folgt) wird erörtern, wie Dependency Injection in das Gesamtökosystem der Anwendung von Best-Practice-Architekturmustern passt.
Eine einfache Erklärung
„Dependency Injection“ ist ein zu komplexer Begriff für ein extrem einfaches Konzept. An dieser Stelle wären einige kluge und vernünftige Fragen: „Wie definieren Sie ‚Abhängigkeit‘?“, „Was bedeutet es, wenn eine Abhängigkeit ‚eingeschleust‘ wird?“, „Können Sie Abhängigkeiten auf unterschiedliche Weise einschleusen?“. und "Warum ist das nützlich?" Sie glauben vielleicht nicht, dass ein Begriff wie „Dependency Injection“ in zwei Codeschnipseln und ein paar Worten erklärt werden kann, aber leider kann er das.
Der einfachste Weg, das Konzept zu erklären, ist, es Ihnen zu zeigen.
Dies ist beispielsweise keine Abhängigkeitsinjektion:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Aber das ist Abhängigkeitsinjektion:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Getan. Das ist es. Cool. Das Ende.
Was hat sich geändert? Anstatt der Car
-Klasse zu erlauben, Engine
zu instanziieren (wie es im ersten Beispiel der Fall war), wurde im zweiten Beispiel Car
eine Instanz von Engine
übergeben – oder injiziert – von einer höheren Steuerungsebene an seinen Konstruktor. Das ist es. Im Kern ist dies alles Dependency Injection – der Vorgang des Einfügens (Übergebens) einer Abhängigkeit in eine andere Klasse oder Funktion. Alles andere, was mit dem Begriff der Abhängigkeitsinjektion zu tun hat, ist einfach eine Variation dieses grundlegenden und einfachen Konzepts. Trivial ausgedrückt ist Dependency Injection eine Technik, bei der ein Objekt andere Objekte empfängt, von denen es abhängig ist, sogenannte Abhängigkeiten, anstatt sie selbst zu erstellen.
Im Allgemeinen, um zu definieren, was eine „Abhängigkeit“ ist, wenn eine Klasse A
die Funktionalität einer Klasse B
verwendet, dann ist B
eine Abhängigkeit für A
, oder mit anderen Worten, A
hat eine Abhängigkeit von B
. Dies ist natürlich nicht auf Klassen beschränkt und gilt auch für Funktionen. In diesem Fall hat die Klasse Car
eine Abhängigkeit von der Engine
-Klasse, oder Engine
ist eine Abhängigkeit von Car
. Abhängigkeiten sind einfach Variablen, genau wie die meisten Dinge in der Programmierung.
Abhängigkeitsinjektion wird häufig verwendet, um viele Anwendungsfälle zu unterstützen, aber die offensichtlichste Verwendung besteht darin, einfachere Tests zu ermöglichen. Im ersten Beispiel können wir den engine
nicht einfach verspotten, weil die Car
-Klasse ihn instanziiert. Es wird immer der echte Motor verwendet. Aber im letzteren Fall haben wir die Kontrolle über die verwendete Engine
, was bedeutet, dass wir in einem Test Engine
ableiten und ihre Methoden überschreiben können.
Wenn wir beispielsweise sehen wollten, was Car.startEngine()
macht, wenn engine.fireCylinders()
einen Fehler ausgibt, könnten wir einfach eine FakeEngine
-Klasse erstellen, sie die Engine
-Klasse erweitern lassen und dann fireCylinders
überschreiben, damit sie einen Fehler ausgibt . Im Test können wir dieses FakeEngine
Objekt in den Konstruktor für Car
einfügen. Da FakeEngine
aufgrund der Vererbung eine Engine
ist, ist das TypeScript-Typsystem erfüllt. Die Verwendung von Vererbung und Methodenüberschreibung wäre nicht unbedingt der beste Weg, dies zu tun, wie wir später sehen werden, aber es ist sicherlich eine Option.
Ich möchte sehr, sehr deutlich machen, dass das, was Sie oben sehen, der Kernbegriff der Abhängigkeitsinjektion ist. Ein Car
allein ist nicht schlau genug, um zu wissen, welchen Motor es braucht. Nur die Ingenieure, die das Auto konstruieren , verstehen die Anforderungen an seine Motoren und Räder. Daher ist es sinnvoll, dass die Leute, die das Auto konstruieren , den spezifischen erforderlichen Motor liefern, anstatt ein Car
selbst den Motor auswählen zu lassen, den es verwenden möchte.
Ich verwende das Wort „konstruieren“ speziell, weil Sie das Auto konstruieren, indem Sie den Konstruktor aufrufen, an dem Abhängigkeiten eingefügt werden. Wenn das Auto zusätzlich zum Motor auch seine eigenen Reifen herstellt, woher wissen wir dann, dass die verwendeten Reifen sicher mit der maximalen Drehzahl gedreht werden können, die der Motor abgeben kann? Aus all diesen Gründen und mehr sollte es sinnvoll sein, vielleicht intuitiv, dass das Car
nichts mit der Entscheidung zu tun haben sollte, welcher Engine
und welche Wheels
es verwendet. Sie sollten von einer höheren Kontrollebene bereitgestellt werden.
Wenn Sie sich im letzten Beispiel, das die Abhängigkeitsinjektion in Aktion zeigt, vorstellen, dass Engine
eher eine abstrakte als eine konkrete Klasse ist, sollte dies noch mehr Sinn machen – das Auto weiß, dass es einen Motor braucht, und es weiß, dass der Motor einige grundlegende Funktionen haben muss , aber wie dieser Motor verwaltet wird und was die spezifische Implementierung davon ist, ist der Entscheidung und Bereitstellung durch den Code vorbehalten, der das Auto erstellt (konstruiert).
Ein Beispiel aus der Praxis
Wir werden uns ein paar weitere praktische Beispiele ansehen, die hoffentlich dabei helfen, wieder intuitiv zu erklären, warum Dependency Injection nützlich ist. Hoffentlich können Sie die Vorteile, die die Abhängigkeitsinjektion bietet, und die Schwierigkeiten des Lebens ohne sie besser erkennen, indem Sie sich nicht auf das Theoretische einlassen und stattdessen direkt zu anwendbaren Konzepten übergehen. Wir werden später auf eine etwas „akademischere“ Behandlung des Themas zurückkommen.
Wir beginnen damit, unsere Anwendung normal zu konstruieren, in einer Weise, die stark gekoppelt ist, ohne Abhängigkeitsinjektion oder Abstraktionen zu verwenden, sodass wir die Nachteile dieses Ansatzes und die Schwierigkeiten sehen, die er zum Testen hinzufügt. Unterwegs werden wir schrittweise umgestalten, bis wir alle Probleme behoben haben.
Angenommen, Sie wurden mit dem Erstellen von zwei Klassen beauftragt – einem E-Mail-Anbieter und einer Klasse für eine Datenzugriffsschicht, die von einem UserService
verwendet werden muss. Wir beginnen mit dem Datenzugriff, aber beide sind einfach zu definieren:
// UserRepository.ts import { dbDriver } from 'pg-driver'; export class UserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Hinweis: Der Name „Repository“ kommt hier vom „Repository Pattern“, einer Methode zur Entkopplung Ihrer Datenbank von Ihrer Geschäftslogik. Sie können mehr über das Repository-Pattern erfahren, aber für die Zwecke dieses Artikels können Sie es einfach als eine Klasse betrachten, die Ihre Datenbank wegkapselt, sodass Ihr Datenspeichersystem für die Geschäftslogik lediglich als In-Memory behandelt wird Sammlung. Das vollständige Erklären des Repository-Musters liegt außerhalb des Geltungsbereichs dieses Artikels.
So erwarten wir normalerweise, dass die Dinge funktionieren, und dbDriver
ist in der Datei fest codiert.
In Ihrem UserService
würden Sie die Klasse importieren, instanziieren und mit der Verwendung beginnen:
import { UserRepository } from './UserRepository.ts'; class UserService { private readonly userRepository: UserRepository; public constructor () { // Not dependency injection. this.userRepository = new UserRepository(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send a welcome email // ... } public async findUserById(id: string): Promise<User> { // No need for await here, the promise will be unwrapped by the caller. return this.userRepository.findUserById(id); } }
Wieder einmal bleibt alles normal.
Eine kurze Randbemerkung: Ein DTO ist ein Datenübertragungsobjekt – es ist ein Objekt, das als Eigenschaftsbehälter fungiert, um eine standardisierte Datenform zu definieren, wenn es sich zwischen zwei externen Systemen oder zwei Schichten einer Anwendung bewegt. Weitere Informationen zu DTOs finden Sie hier in Martin Fowlers Artikel zu diesem Thema. In diesem Fall definiert IRegisterUserDto
einen Vertrag darüber, wie die Daten aussehen sollen, wenn sie vom Client kommen. Ich habe nur zwei Eigenschaften – id
und email
. Sie finden es vielleicht seltsam, dass das DTO, das wir vom Client erwarten, um einen neuen Benutzer zu erstellen, die ID des Benutzers enthält, obwohl wir noch keinen Benutzer erstellt haben. Die ID ist eine UUID, und ich erlaube dem Client, sie aus verschiedenen Gründen zu generieren, die den Rahmen dieses Artikels sprengen würden. Außerdem sollte die findUserById
Funktion das User
-Objekt einem Antwort-DTO zuordnen, aber ich habe das der Kürze halber vernachlässigt. Schließlich würde ich in der realen Welt kein User
haben, das eine fromDto
Methode enthält. Das ist nicht gut für die Domain-Reinheit. Noch einmal, sein Zweck ist hier die Kürze.
Als nächstes möchten Sie das Versenden von E-Mails handhaben. Auch hier können Sie wie gewohnt einfach eine E-Mail-Provider-Klasse erstellen und in Ihren UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Im UserService
:
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor () { // Still not doing dependency injection. this.userRepository = new UserRepository(); this.sendGridEmailProvider = new SendGridEmailProvider(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Wir haben jetzt eine vollwertige Arbeiterklasse und in einer Welt, in der uns die Testbarkeit oder das Schreiben von sauberem Code egal ist, und in einer Welt, in der technische Schulden nicht existieren und lästige Programmmanager nicht keine Fristen setzen, das ist völlig in Ordnung. Leider ist das keine Welt, in der wir leben können.
Was passiert, wenn wir entscheiden, dass wir für E-Mails von SendGrid weg migrieren und stattdessen MailChimp verwenden müssen? Was passiert in ähnlicher Weise, wenn wir unsere Methoden Unit-Tests durchführen wollen – werden wir die echte Datenbank in den Tests verwenden? Schlimmer noch, werden wir tatsächlich echte E-Mails an potenziell echte E-Mail-Adressen senden und auch dafür bezahlen?
Im traditionellen JavaScript-Ökosystem sind die Methoden der Unit-Testing-Klassen in dieser Konfiguration voller Komplexität und Overengineering. Die Leute bringen ganze ganze Bibliotheken ein, nur um Stubbing-Funktionalität bereitzustellen, die alle Arten von Indirektionsschichten hinzufügt und, noch schlimmer, die Tests direkt mit der Implementierung des zu testenden Systems koppeln kann, wenn Tests in Wirklichkeit nie wissen sollten, wie das reale System funktioniert (dies wird als Black-Box-Test bezeichnet). Wir werden daran arbeiten, diese Probleme zu mindern, während wir diskutieren, was die eigentliche Verantwortung von UserService
ist, und neue Techniken der Abhängigkeitsinjektion anwenden.
Überlegen Sie für einen Moment, was ein UserService
tut. Der springende Punkt bei der Existenz von UserService
ist die Ausführung spezifischer Anwendungsfälle, an denen Benutzer beteiligt sind – sie registrieren, lesen, aktualisieren usw. Es ist eine bewährte Methode, dass Klassen und Funktionen nur eine Verantwortung haben (SRP – das Single Responsibility Principle). und die Verantwortung von UserService
besteht darin, benutzerbezogene Operationen durchzuführen. Warum ist UserService
in diesem Beispiel dann für die Steuerung der Lebensdauer von UserRepository
und SendGridEmailProvider
verantwortlich?
Stellen Sie sich vor, wir hätten eine andere Klasse, die von UserService
verwendet wird und eine lange laufende Verbindung geöffnet hat. Sollte UserService
auch für die Entsorgung dieser Verbindung verantwortlich sein? Natürlich nicht. Allen diesen Abhängigkeiten ist eine Lebensdauer zugeordnet – sie könnten Singletons sein, sie könnten vorübergehend sein und auf eine bestimmte HTTP-Anforderung beschränkt sein usw. Die Kontrolle dieser Lebensdauern liegt weit außerhalb des Zuständigkeitsbereichs von UserService
. Um diese Probleme zu lösen, werden wir alle Abhängigkeiten einfügen, genau wie wir es zuvor gesehen haben.
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { // Yay! Dependencies are injected. this.userRepository = userRepository; this.sendGridEmailProvider = sendGridEmailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Toll! Jetzt erhält UserService
vorinstanziierte Objekte, und welcher Codeabschnitt einen neuen UserService
aufruft und erstellt, ist der Codeabschnitt, der für die Steuerung der Lebensdauer der Abhängigkeiten zuständig ist. Wir haben die Kontrolle weg von UserService
und auf eine höhere Ebene umgedreht. Wenn ich nur zeigen wollte, wie wir Abhängigkeiten durch den Konstruktor injizieren können, um den Grundmieter der Abhängigkeitsinjektion zu erklären, könnte ich hier aufhören. Aus gestalterischer Sicht gibt es jedoch noch einige Probleme, die, wenn sie behoben werden, dazu beitragen werden, dass wir die Abhängigkeitsinjektion noch leistungsfähiger machen.
Erstens, warum weiß UserService
, dass wir SendGrid für E-Mails verwenden? Zweitens beziehen sich beide Abhängigkeiten auf konkrete Klassen – das konkrete UserRepository
und das konkrete SendGridEmailProvider
. Diese Beziehung ist zu starr – wir müssen ein Objekt übergeben, das ein UserRepository
und ein SendGridEmailProvider
ist.
Das ist nicht großartig, weil wir möchten, dass UserService
völlig unabhängig von der Implementierung seiner Abhängigkeiten ist. Indem UserService
auf diese Weise blind ist, können wir die Implementierungen austauschen, ohne den Dienst überhaupt zu beeinträchtigen – das heißt, wenn wir uns entscheiden, von SendGrid weg zu migrieren und stattdessen MailChimp zu verwenden, können wir dies tun. Das bedeutet auch, wenn wir den E-Mail-Anbieter für Tests vortäuschen wollen, können wir das auch tun.
Was nützlich wäre, wäre, wenn wir eine öffentliche Schnittstelle definieren und erzwingen könnten, dass eingehende Abhängigkeiten sich an diese Schnittstelle halten, während der UserService
weiterhin unabhängig von Implementierungsdetails ist. Anders ausgedrückt, wir müssen UserService
zwingen, nur von einer Abstraktion seiner Abhängigkeiten und nicht von seinen tatsächlichen konkreten Abhängigkeiten abhängig zu sein. Wir können das durch Schnittstellen tun.
Beginnen Sie mit der Definition einer Schnittstelle für das UserRepository
und implementieren Sie sie:
// UserRepository.ts import { dbDriver } from 'pg-driver'; export interface IUserRepository { addUser(user: User): Promise<void>; findUserById(id: string): Promise<User>; existsByEmail(email: string): Promise<boolean>; } export class UserRepository implements IUserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Und definieren Sie eine für den E-Mail-Anbieter und implementieren Sie sie auch:
// IEmailProvider.ts export interface IEmailProvider { sendWelcomeEmail(to: string): Promise<void>; } // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; import { IEmailProvider } from './IEmailProvider'; export class SendGridEmailProvider implements IEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Hinweis: Dies ist das Adaptermuster aus den Gang-of-Four-Designmustern.
Jetzt kann unser UserService
von den Schnittstellen statt von den konkreten Implementierungen der Abhängigkeiten abhängen:
import { IUserRepository } from './UserRepository.ts'; import { IEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: IUserRepository; private readonly emailProvider: IEmailProvider; public constructor ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { // Double yay! Injecting dependencies and coding against interfaces. this.userRepository = userRepository; this.emailProvider = emailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.emailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Wenn Schnittstellen für Sie neu sind, kann dies sehr, sehr komplex aussehen. Tatsächlich könnte das Konzept der Erstellung von lose gekoppelter Software auch für Sie neu sein. Denken Sie an Wandsteckdosen. Sie können jedes Gerät an jede Steckdose anschließen, solange der Stecker in die Steckdose passt. Das ist lose Kopplung in Aktion. Ihr Toaster ist nicht fest mit der Wand verdrahtet, denn wenn dies der Fall wäre und Sie sich entscheiden, Ihren Toaster aufzurüsten, haben Sie Pech. Stattdessen werden Ausgänge verwendet, und der Ausgang definiert die Schnittstelle. Wenn Sie ein elektronisches Gerät an Ihre Steckdose anschließen, kümmern Sie sich nicht um das Spannungspotential, die maximale Stromaufnahme, die Wechselstromfrequenz usw. Sie kümmern sich nur darum, ob der Stecker in die Steckdose passt. Sie könnten einen Elektriker kommen lassen und alle Kabel hinter dieser Steckdose austauschen, und Sie werden keine Probleme haben, Ihren Toaster anzuschließen, solange sich diese Steckdose nicht ändert. Außerdem könnte Ihre Stromquelle auf die Stadt oder Ihre eigenen Sonnenkollektoren umgestellt werden, und wieder einmal ist es Ihnen egal, solange Sie noch an diese Steckdose anschließen können.
Die Schnittstelle ist die Steckdose und bietet „Plug-and-Play“-Funktionalität. In diesem Beispiel sind die Verkabelung in der Wand und die Stromquelle den Abhängigkeiten ähnlich und Ihr Toaster ist dem UserService
(er hat eine Abhängigkeit vom Strom) – die Stromquelle kann sich ändern und der Toaster funktioniert immer noch gut und muss es nicht berührt werden, weil die Steckdose als Schnittstelle die Standardmittel für die Kommunikation zwischen beiden definiert. Tatsächlich könnte man sagen, dass die Steckdose als „Abstraktion“ der Wandverkabelung, der Leistungsschalter, der Stromquelle usw. fungiert.
Aus den oben genannten Gründen ist es ein gängiges und anerkanntes Prinzip des Softwaredesigns, gegen Schnittstellen (Abstraktionen) und nicht gegen Implementierungen zu codieren, was wir hier getan haben. Dadurch haben wir die Freiheit, Implementierungen nach Belieben auszutauschen, da diese Implementierungen hinter der Schnittstelle verborgen sind (so wie die Wandverkabelung hinter der Steckdose verborgen ist), und die Geschäftslogik, die die Abhängigkeit verwendet, dies nie tun muss ändern, solange sich die Schnittstelle nie ändert. Denken Sie daran, UserService
nur wissen muss, welche Funktionalität von seinen Abhängigkeiten angeboten wird , nicht wie diese Funktionalität hinter den Kulissen unterstützt wird. Deshalb funktioniert die Verwendung von Schnittstellen.
Diese beiden einfachen Änderungen der Nutzung von Schnittstellen und des Einfügens von Abhängigkeiten machen den Unterschied in der Welt, wenn es darum geht, lose gekoppelte Software zu erstellen, und lösen alle Probleme, auf die wir oben gestoßen sind.
Wenn wir morgen entscheiden, dass wir uns für E-Mails auf Mailchimp verlassen wollen, erstellen wir einfach eine neue Mailchimp-Klasse, die die IEmailProvider
Schnittstelle berücksichtigt, und fügen sie anstelle von SendGrid ein. Die eigentliche UserService
-Klasse muss sich nie ändern, obwohl wir gerade eine gigantische Änderung an unserem System vorgenommen haben, indem wir zu einem neuen E-Mail-Anbieter gewechselt haben. Das Schöne an diesen Mustern ist, dass UserService
glücklicherweise nicht weiß, wie die von ihm verwendeten Abhängigkeiten hinter den Kulissen funktionieren. Die Schnittstelle dient als architektonische Grenze zwischen beiden Komponenten und hält sie entsprechend entkoppelt.
Darüber hinaus können wir beim Testen Fälschungen erstellen, die sich an die Schnittstellen halten, und sie stattdessen einschleusen. Hier sehen Sie ein gefälschtes Repository und einen gefälschten E-Mail-Anbieter.
// Both fakes: class FakeUserRepository implements IUserRepository { private readonly users: User[] = []; public async addUser(user: User): Promise<void> { this.users.push(user); } public async findUserById(id: string): Promise<User> { const userOrNone = this.users.find(u => u.id === id); return userOrNone ? Promise.resolve(userOrNone) : Promise.reject(new NotFoundError()); } public async existsByEmail(email: string): Promise<boolean> { return Boolean(this.users.find(u => u.email === email)); } public getPersistedUserCount = () => this.users.length; } class FakeEmailProvider implements IEmailProvider { private readonly emailRecipients: string[] = []; public async sendWelcomeEmail(to: string): Promise<void> { this.emailRecipients.push(to); } public wasEmailSentToRecipient = (recipient: string) => Boolean(this.emailRecipients.find(r => r === recipient)); }
Beachten Sie, dass beide Fälschungen dieselben Schnittstellen implementieren, die UserService
von seinen Abhängigkeiten erwartet. Jetzt können wir diese Fälschungen an UserService
statt an die echten Klassen übergeben, und UserService
wird nicht klüger sein; es wird sie verwenden, als ob sie das echte Geschäft wären. Der Grund dafür ist, dass es weiß, dass alle Methoden und Eigenschaften, die es für seine Abhängigkeiten verwenden möchte, tatsächlich existieren und tatsächlich zugänglich sind (weil sie die Schnittstellen implementieren), was alles ist, was UserService
wissen muss (dh nicht wie die Abhängigkeiten funktionieren).
Wir werden diese beiden während der Tests injizieren, und das wird den Testprozess so viel einfacher und unkomplizierter machen, als Sie es vielleicht gewohnt sind, wenn Sie mit übertriebenen Spott- und Stubbing-Bibliotheken arbeiten und mit Jests eigenen Internen arbeiten Werkzeuge oder versuchen, Monkey-Patch zu machen.
Hier sind tatsächliche Tests mit den Fälschungen:
// Fakes let fakeUserRepository: FakeUserRepository; let fakeEmailProvider: FakeEmailProvider; // SUT let userService: UserService; // We want to clean out the internal arrays of both fakes // before each test. beforeEach(() => { fakeUserRepository = new FakeUserRepository(); fakeEmailProvider = new FakeEmailProvider(); userService = new UserService(fakeUserRepository, fakeEmailProvider); }); // A factory to easily create DTOs. // Here, we have the optional choice of overriding the defaults // thanks to the built in `Partial` utility type of TypeScript. function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto { return { id: 'someId', email: '[email protected]', ...opts }; } test('should correctly persist a user and send an email', async () => { // Arrange const dto = createSeedRegisterUserDto(); // Act await userService.registerUser(dto); // Assert const expectedUser = User.fromDto(dto); const persistedUser = await fakeUserRepository.findUserById(dto.id); const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email); expect(persistedUser).toEqual(expectedUser); expect(wasEmailSent).toBe(true); }); test('should reject with a DuplicateEmailError if an email already exists', async () => { // Arrange const existingEmail = '[email protected]'; const dto = createSeedRegisterUserDto({ email: existingEmail }); const existingUser = User.fromDto(dto); await fakeUserRepository.addUser(existingUser); // Act, Assert await expect(userService.registerUser(dto)) .rejects.toBeInstanceOf(DuplicateEmailError); expect(fakeUserRepository.getPersistedUserCount()).toBe(1); }); test('should correctly return a user', async () => { // Arrange const user = User.fromDto(createSeedRegisterUserDto()); await fakeUserRepository.addUser(user); // Act const receivedUser = await userService.findUserById(user.id); // Assert expect(receivedUser).toEqual(user); });
Hier werden Ihnen einige Dinge auffallen: Die handgeschriebenen Fälschungen sind sehr einfach. Es gibt keine Komplexität durch spöttische Frameworks, die nur der Verschleierung dienen. Alles ist handgerollt und das bedeutet, dass die Codebasis keine Magie enthält. Asynchrones Verhalten wird vorgetäuscht, um den Schnittstellen zu entsprechen. Ich verwende async/await in den Tests, obwohl das gesamte Verhalten synchron ist, weil ich das Gefühl habe, dass es eher der Funktionsweise der Vorgänge in der realen Welt entspricht, und weil ich durch Hinzufügen von async/await dieselbe Testsuite ausführen kann Zusätzlich zu den Fälschungen auch gegen echte Implementierungen, daher ist eine angemessene Übergabe der Asynchronität erforderlich. Tatsächlich würde ich mir im wirklichen Leben höchstwahrscheinlich nicht einmal Gedanken darüber machen, die Datenbank zu verspotten, und stattdessen eine lokale DB in einem Docker-Container verwenden, bis es so viele Tests gibt, dass ich sie wegen der Leistung verspotten müsste. Ich könnte dann die In-Memory-DB-Tests nach jeder einzelnen Änderung ausführen und die echten lokalen DB-Tests direkt vor dem Festschreiben von Änderungen und auf dem Build-Server in der CI/CD-Pipeline reservieren.
Im ersten Test erstellen wir im Abschnitt „Anordnen“ einfach das DTO. Im Abschnitt „act“ rufen wir das zu testende System auf und führen sein Verhalten aus. Etwas komplexer wird es bei Behauptungen. Denken Sie daran, dass wir zu diesem Zeitpunkt des Tests nicht einmal wissen, ob der Benutzer korrekt gespeichert wurde. Also definieren wir, wie ein persistenter Benutzer aussehen soll, und dann rufen wir das gefälschte Repository auf und fragen es nach einem Benutzer mit der ID, die wir erwarten. Wenn der UserService
den Benutzer nicht korrekt beibehalten hat, wird dies einen NotFoundError
und der Test schlägt fehl, andernfalls gibt er uns den Benutzer zurück. Als nächstes rufen wir den gefälschten E-Mail-Anbieter an und fragen ihn, ob er den Versand einer E-Mail an diesen Benutzer aufgezeichnet hat. Schließlich machen wir die Behauptungen mit Jest und damit ist der Test abgeschlossen. Es ist ausdrucksstark und liest sich genau so, wie das System tatsächlich funktioniert. Es gibt keinen Umweg von Mocking-Bibliotheken und es gibt keine Kopplung zur Implementierung von UserService
.
Im zweiten Test erstellen wir einen vorhandenen Benutzer und fügen ihn dem Repository hinzu. Anschließend versuchen wir, den Dienst erneut aufzurufen, indem wir ein DTO verwenden, das bereits zum Erstellen und Beibehalten eines Benutzers verwendet wurde, und wir erwarten, dass dies fehlschlägt. Wir behaupten auch, dass dem Repository keine neuen Daten hinzugefügt wurden.
Für den dritten Test besteht der Abschnitt „Anordnen“ nun darin, einen Benutzer zu erstellen und ihn im gefälschten Repository zu speichern. Dann rufen wir das SUT auf und prüfen schließlich, ob der Benutzer, der zurückkommt, derjenige ist, den wir zuvor im Repo gespeichert haben.
Diese Beispiele sind relativ einfach, aber wenn die Dinge komplexer werden, hält die Möglichkeit, sich auf diese Weise auf Dependency Injection und Schnittstellen zu verlassen, Ihren Code sauber und macht das Schreiben von Tests zu einer Freude.
Eine kurze Bemerkung zum Testen: Im Allgemeinen müssen Sie nicht jede Abhängigkeit, die der Code verwendet, verspotten. Viele Leute behaupten fälschlicherweise, dass eine „Unit“ in einem „Unit-Test“ eine Funktion oder eine Klasse ist. Das könnte falscher nicht sein. Die „Einheit“ ist definiert als die „Einheit der Funktionalität“ oder die „Einheit des Verhaltens“, nicht eine Funktion oder Klasse. Wenn also eine Verhaltenseinheit 5 verschiedene Klassen verwendet, müssen Sie nicht alle diese Klassen verspotten, es sei denn , sie reichen über die Grenzen des Moduls hinaus. In diesem Fall habe ich die Datenbank und den E-Mail-Anbieter verspottet, weil ich keine Wahl habe. Wenn ich keine echte Datenbank verwenden und keine E-Mail senden möchte, muss ich sie verspotten. Aber wenn ich ein paar mehr Klassen hätte, die im gesamten Netzwerk nichts tun, würde ich sie nicht verspotten, weil sie Implementierungsdetails der Verhaltenseinheit sind. Ich könnte mich auch dagegen entscheiden, die Datenbank und E-Mails zu verspotten und eine echte lokale Datenbank und einen echten SMTP-Server hochfahren, beides in Docker-Containern. Zum ersten Punkt habe ich kein Problem damit, eine echte Datenbank zu verwenden und sie trotzdem als Komponententest zu bezeichnen, solange sie nicht zu langsam ist. Im Allgemeinen würde ich zuerst die echte DB verwenden, bis sie zu langsam wurde und ich spotten musste, wie oben besprochen. Aber egal, was Sie tun, Sie müssen pragmatisch sein – das Versenden von Willkommens-E-Mails ist kein geschäftskritischer Vorgang, daher müssen wir in Bezug auf SMTP-Server in Docker-Containern nicht so weit gehen. Wann immer ich mich verspotte, würde ich sehr unwahrscheinlich ein spöttisches Framework verwenden oder versuchen, die Anzahl der Aufrufe oder übergebenen Parameter zu bestätigen, außer in sehr seltenen Fällen, da dies Tests an die Implementierung des zu testenden Systems und sie koppeln würde sollte diesen Details gegenüber agnostisch sein.
Durchführen einer Abhängigkeitsinjektion ohne Klassen und Konstruktoren
Bisher haben wir im gesamten Artikel ausschließlich mit Klassen gearbeitet und die Abhängigkeiten über den Konstruktor eingefügt. Wenn Sie einen funktionalen Entwicklungsansatz verfolgen und keine Klassen verwenden möchten, können Sie dennoch die Vorteile der Abhängigkeitsinjektion mithilfe von Funktionsargumenten nutzen. Zum Beispiel könnte unsere UserService
-Klasse umgestaltet werden in:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
Es ist eine Factory, die die Abhängigkeiten empfängt und das Dienstobjekt erstellt. Wir können auch Abhängigkeiten in Funktionen höherer Ordnung einfügen. Ein typisches Beispiel wäre das Erstellen einer Express-Middleware-Funktion, die ein UserRepository
und einen ILogger
injiziert bekommt:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
Im ersten Beispiel habe ich den Typ von dto
und id
nicht definiert, denn wenn wir eine Schnittstelle namens IUserService
definieren, die die Methodensignaturen für den Dienst enthält, leitet der TS-Compiler die Typen automatisch ab. Hätte ich in ähnlicher Weise eine Funktionssignatur für die Express-Middleware als Rückgabetyp von authProvider
, hätte ich auch dort die Argumenttypen nicht deklarieren müssen.
Wenn wir den E-Mail-Anbieter und das Repository auch als funktionsfähig betrachten und ihre spezifischen Abhängigkeiten ebenfalls einfügen, anstatt sie fest zu codieren, könnte der Stamm der Anwendung so aussehen:
import { sendMail } from 'sendgrid'; async function main() { const app = express(); const dbConnection = await connectToDatabase(); // Change emailProvider to `makeMailChimpEmailProvider` whenever we want // with no changes made to dependent code. const userRepository = makeUserRepository(dbConnection); const emailProvider = makeSendGridEmailProvider(sendMail); const userService = makeUserService(userRepository, emailProvider); // Put this into another file. It's a controller action. app.post('/login', (req, res) => { await userService.registerUser(req.body as IRegisterUserDto); return res.send(); }); // Put this into another file. It's a controller action. app.delete( '/me', authProvider(userRepository, emailProvider), (req, res) => { ... } ); }
Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository
or emailProvider
to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService
will just use it with no changes needed, which, once again, is because UserService
cares about nothing but the public interface of the dependencies, not how the dependencies work.
As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn't optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById
method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it's not, and I actively discourage people from using NestJS for reasons outside the scope of this article.
A Brief Theoretical Overview
All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.
The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn't know how to create them or dispose of them. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.
Despite the simplicity of what we've seen thus far, there's a lot more complexity that surrounds dependency injection.
Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository)
. In the last case, we can define interfaces through which to perform the injection, but I'll omit the explanation of the last technique here for brevity since we'll spend more time discussing it and more in the second article of this series.
Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young's 8 Lines of Code talk.
Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you've inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix by Jeff Hansen solve this problem since they remain divorced from your application's business logic.
Fazit
This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.
In the second article of this series, we'll take a much, much more in-depth look, including at:
- The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
- Dependency Injection anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- The different types of dependency lifetimes;
- How IoC Containers are designed;
- Dependency Injection with React;
- Advanced testing scenarios;
- Und mehr.
Bleib dran!