Hier ist eine Klasse, die Sie ohne eine laufende Datenbank und einen funktionierenden SMTP-Server nicht unit-testen können:
| |
OrderService baut sich seine eigenen SqlOrderRepository- und SmtpEmailSender-Instanzen mit new, Connection-String und allem. Jeder Test, der PlaceOrder berührt, braucht eine echte Orders-Datenbank und einen echten Mailserver, sonst scheitert er, bevor Ihre Assertion überhaupt läuft. Die Logik, die Sie testen wollen, sind zwei Zeilen: die Bestellung speichern, die Bestätigung senden. Sie ist an zwei konkrete Klassen geschweißt, die Sie nicht austauschen können.
Was Dependency Injection in der Praxis bedeutet
Dependency Injection bedeutet, einer Klasse die Objekte zu geben, die sie braucht, statt sie selbst konstruieren zu lassen. Die Klasse erklärt, wovon sie abhängt. Code außerhalb der Klasse entscheidet, welche konkrete Implementierung sie bekommt. Container und Lifetimes sind Tooling rund um genau diesen einen Schritt.
Schreiben Sie OrderService so um, dass die Abhängigkeiten über den Konstruktor hereinkommen, als Interfaces statt als konkrete Klassen:
| |
OrderService weiß nun nicht mehr, ob das Repository in SQL Server schreibt oder in eine In-Memory-Liste, oder ob die E-Mail über SMTP rausgeht oder gar nirgendwohin. Es kennt zwei Interfaces und ruft deren Methoden auf. Wer den OrderService erzeugt, entscheidet, was verdrahtet wird: Ein Test übergibt Fakes, die Web-App übergibt die echte Implementierung.
Das ist Constructor Injection, und das ist die Form, die Sie für fast alles verwenden werden. Property Injection (eine öffentliche Property nach der Konstruktion setzen) und Method Injection (eine Abhängigkeit an die eine Methode übergeben, die sie braucht) gibt es für optionale Abhängigkeiten und einmalige Werte. Der eingebaute Container von ASP.NET Core unterstützt Property Injection gar nicht erst. Die Regel ist kurz: Wenn eine Klasse ihre Aufgabe ohne etwas nicht erfüllen kann, gehört dieses Etwas in den Konstruktor.
Wie Dependency Injection eine Klasse leicht testbar macht
Da die Abhängigkeiten als Interface-Parameter ankommen, kann ein Test OrderService geben, was er will:
| |
Der Test läuft in Millisekunden und berührt weder Datenbank noch Netzwerk. Er sitzt am Unit-Ende des Spektrums, das in Unit-Tests vs Integrationstests vs End-to-End-Tests beschrieben wird: eine einzelne Klasse, deren Collaborators ersetzt werden, um ihr Verhalten zu prüfen. Diese von Hand geschriebenen Klassen sind Fakes, kleine funktionierende Implementierungen mit eigenem Zustand. Eine Mocking-Library wie Moq oder NSubstitute generiert Ihnen dieselbe Art von Platzhalter, sodass Sie nicht pro Interface eine eigene Klasse schreiben müssen.
Was ein DI-Container macht, und warum Sie keinen brauchen
Nichts davon oben hat ein Framework gebraucht. Sie können die echten Abhängigkeiten von Hand im Entry-Point Ihrer Anwendung verdrahten:
| |
Das nennt man „Pure DI" (oder „Poor Man’s DI"), und für eine kleine App reicht das. Das Muster ist, Abhängigkeiten zu übergeben. Ein Container automatisiert das Verdrahten, und er zahlt sich aus, sobald Sie Dutzende Klassen mit überlappenden Abhängigkeiten haben und eine geänderte Konstruktorsignatur bedeutet, den Verdrahtungscode an fünf Stellen anzufassen.
ASP.NET Core bringt einen Container mit. Sie registrieren jedes Interface einmal gegen eine Implementierung, beim Start, und das Framework löst den Abhängigkeitsgraphen auf, sobald es einen Controller, einen Minimal-API-Handler oder einen anderen registrierten Service baut:
| |
SqlOrderRepository und SmtpEmailSender nehmen einen Connection-String beziehungsweise einen Hostnamen entgegen, keinen parameterlosen Konstruktor, also ist jede Registrierung eine Factory: ein Lambda, das den Container (sp) entgegennimmt und die Instanz zurückgibt. Wenn keine der beiden Implementierungen so einen Wert braucht, ist die Form mit zwei Typparametern kürzer: AddScoped<IOrderRepository, SqlOrderRepository>().
Registrieren Sie OrderService selbst genauso (builder.Services.AddScoped<OrderService>()), und Sie können ihn im Konstruktor eines Controllers anfordern. Der Container baut ihn, löst IOrderRepository und IEmailSender aus diesen Registrierungen auf, und Sie rufen nie new auf.
ASP.NET-Core-Service-Lifetimes: Singleton, Scoped, Transient
AddScoped, AddTransient und AddSingleton registrieren auf dieselbe Weise; der Methodenname ist die Lifetime, und die falsche zu wählen verursacht die meisten DI-Bugs in ASP.NET Core.
| Lifetime | Neue Instanz wird erzeugt | Typischer Einsatz |
|---|---|---|
Transient | Bei jeder Anfrage | Zustandslose, billige Services (ein Formatter, ein Validator) |
Scoped | Einmal pro HTTP-Request | Alles, was an den aktuellen Request gebunden ist: ein DbContext, eine Unit of Work |
Singleton | Einmal, für die Lebensdauer der App | Geteilter, thread-sicherer Zustand: Konfiguration, ein Connection-Pool, ein Cache |
Die obige AddScoped-Registrierung gibt jedem Request sein eigenes SqlOrderRepository. Wenn das Repository einen DbContext kapselt, können zwei gleichzeitige Requests nie eine halbfertige Transaktion teilen. Die AddTransient-Registrierung erzeugt bei jedem Injection-Punkt einen neuen SmtpEmailSender. Das ist unproblematisch, weil der Sender keinen request-spezifischen Zustand hält.
Captive Dependencies: ein Scoped-Service gefangen in einem Singleton
Das ist die Lifetime-Regel, die zubeißt. Ein Service darf sicher nur von Dingen abhängen, die mindestens so lange leben wie er selbst. Ein Singleton kann andere Singletons nehmen. Ein Scoped-Service kann Scoped oder Singleton nehmen. Ein Transient kann alles nehmen. Der Fehlerfall ist ein Singleton, der eine Scoped-Abhängigkeit in seinem Konstruktor nimmt:
| |
Der Container baut ReportCache einmal, und der AppDbContext, den er bekommen hat, bleibt für die gesamte Lebensdauer der Anwendung in diesem Feld. Das ist eine Captive Dependency. Jeder spätere Request teilt sich einen DbContext, eine Klasse, die weder thread-sicher ist noch dafür gedacht, lange zu leben. Sichtbar wird das als veraltete Lesezugriffe oder als InvalidOperationException, die sich beschwert, dass auf diesem Context eine zweite Operation gestartet wurde, bevor eine vorherige abgeschlossen war.
ASP.NET Core fängt das in der Entwicklung ab. Mit aktivierter Scope-Validierung, die in der Development-Umgebung standardmäßig an ist, wirft das Auflösen eines Singletons, der einen Scoped-Service braucht, beim Start eine InvalidOperationException, statt in Produktion Daten zu beschädigen. Wenn ein Singleton wirklich hin und wieder einen Scoped-Service braucht, injizieren Sie IServiceScopeFactory, erzeugen Sie dort, wo Sie ihn brauchen, einen Scope und lösen Sie den Service aus diesem Scope auf, statt ihn in einem Feld zu halten.
Dependency-Injection-Antipatterns: Service Locator und überladene Konstruktoren
Service Locator in Verkleidung. Den Container selbst zu injizieren und Abhängigkeiten innerhalb einer Methode aus ihm herauszuziehen, sieht aus wie DI, wirft aber genau das weg, was DI bringt:
| |
Der Konstruktor sagt nicht mehr, was OrderService braucht. Sie müssen jede Methode lesen, um es herauszufinden, und eine fehlende Registrierung zeigt sich als null mitten in PlaceOrder, statt als Fehler schon beim Bauen der Klasse. Injizieren Sie die konkreten Interfaces, die Sie nutzen, nicht das Objekt, das sie herausreicht.
Der Zwölf-Parameter-Konstruktor. Eine Klasse, die IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger und sieben weitere braucht, macht zu viele Dinge gleichzeitig. DI hat das nicht verursacht, es hat es nur sichtbar gemacht. Es ist dasselbe Symptom, das in Was ist Spaghetti-Code und wie man ihn refactort beschrieben wird. Teilen Sie die Klasse nach Verantwortlichkeit auf, und der Konstruktor schrumpft von selbst. Die zwölf Abhängigkeiten in ein einziges „Services"-Objekt zu bündeln versteckt das Problem, statt es zu lösen.
Beide rutschen leicht durchs Code-Review. Sie hören auf durchzurutschen, sobald jemand versucht, einen Unit-Test für die Klasse zu schreiben.
Wo Sie mit Dependency Injection anfangen
Suchen Sie sich eine Klasse in Ihrer Codebasis, die new auf einen Datenbank-Client, einen HTTP-Client oder einen Mail-Sender aufruft. Ändern Sie den Konstruktor so, dass er ein Interface entgegennimmt, verdrahten Sie die echte Implementierung von Hand in Program.cs, und schreiben Sie einen Test, der einen Fake übergibt. In einer ASP.NET-Core-App sparen Sie sich das Verdrahten von Hand und registrieren stattdessen in builder.Services, weil der Container ohnehin schon da ist. Wählen Sie die Lifetime bewusst für alles, was Zustand hält, und lassen Sie die Scope-Validierung aktiviert, damit das Framework eine Captive Dependency findet, bevor Ihre Nutzer es tun.