Wie viel ist zu viel Dependency Injection?

38

Ich arbeite in einem Projekt, das (Spring) Dependency Injection für buchstäblich alles verwendet, was eine Abhängigkeit von einer Klasse ist. Wir sind an einem Punkt angelangt, an dem die Spring-Konfigurationsdatei auf etwa 4000 Zeilen angewachsen ist. Vor nicht allzu langer Zeit habe ich einen der Vorträge von Onkel Bob auf YouTube gesehen (leider konnte ich den Link nicht finden), in dem er empfiehlt, nur ein paar zentrale Abhängigkeiten (z. B. Fabriken, Datenbanken usw.) in die Hauptkomponente zu integrieren, von der aus sie dann ausgeführt werden verteilt werden.

Der Vorteil dieses Ansatzes ist die Entkopplung des DI-Frameworks vom Großteil der Anwendung und macht die Spring-Konfiguration übersichtlicher, da die Fabriken viel mehr von dem enthalten, was zuvor in der Konfiguration enthalten war. Im Gegenteil, dies führt dazu, dass die Erstellungslogik auf viele Factory-Klassen verteilt wird und das Testen möglicherweise schwieriger wird.

Meine Frage ist also wirklich, welche anderen Vor- oder Nachteile Sie in dem einen oder anderen Ansatz sehen. Gibt es Best Practices? Vielen Dank für Ihre Antworten!

Antimon
quelle
3
Eine 4000-Zeilen-Konfigurationsdatei liegt nicht an der Abhängigkeitsinjektion. Es gibt viele Möglichkeiten, dies zu beheben: Modularisieren Sie die Datei in mehrere kleinere Dateien, wechseln Sie zur annotationsbasierten Injektion, verwenden Sie JavaConfig-Dateien anstelle von XML. Grundsätzlich besteht das Problem darin, dass Sie die Komplexität und Größe der Abhängigkeitsinjektionsanforderungen Ihrer Anwendung nicht verwalten können.
Maybe_Factor
1
@Maybe_Factor TL; DR teilt das Projekt in kleinere Komponenten auf.
Walfrat

Antworten:

44

Wie immer ist It Depends ™. Die Antwort hängt von dem Problem ab, das gelöst werden soll. In dieser Antwort werde ich versuchen, einige gemeinsame motivierende Kräfte anzusprechen:

Bevorzugen Sie kleinere Codebasen

Wenn Sie über 4.000 Zeilen Spring-Konfigurationscode verfügen, besteht die Codebasis vermutlich aus Tausenden von Klassen.

Es ist kaum ein Thema, das Sie nachträglich ansprechen können, aber als Faustregel bevorzuge ich kleinere Anwendungen mit kleinerer Codebasis. Wenn Sie sich für domänenbasiertes Design interessieren , können Sie beispielsweise eine Codebasis für einen begrenzten Kontext erstellen.

Dieser Rat basiert auf meiner begrenzten Erfahrung, da ich den größten Teil meiner Karriere über webbasierten Code für Geschäftsbereiche geschrieben habe. Ich könnte mir vorstellen, dass es schwieriger ist, Dinge auseinander zu ziehen, wenn Sie eine Desktop-Anwendung, ein eingebettetes System oder anderes entwickeln.

Obwohl mir klar ist, dass dieser erste Rat einfach der unpraktischste ist, glaube ich auch, dass er der wichtigste ist, und deshalb schließe ich ihn ein. Die Komplexität des Codes variiert nicht linear (möglicherweise exponentiell) mit der Größe der Codebasis.

Bevorzugen Sie Pure DI

Obwohl mir immer noch klar ist, dass diese Frage eine bestehende Situation darstellt, empfehle ich Pure DI . Verwenden Sie keinen DI-Container, aber wenn Sie dies tun, verwenden Sie ihn zumindest zum Implementieren einer auf Konventionen basierenden Komposition .

Ich habe keine praktischen Erfahrungen mit Spring, gehe aber davon aus, dass per Konfigurationsdatei eine XML-Datei impliziert wird.

Die Konfiguration von Abhängigkeiten mithilfe von XML ist die schlimmste beider Welten. Erstens verlieren Sie die Sicherheit beim Kompilieren, aber Sie gewinnen nichts. Eine XML-Konfigurationsdatei kann problemlos so groß sein wie der Code, den sie zu ersetzen versucht.

Verglichen mit dem Problem, das behoben werden soll, belegen Konfigurationsdateien für die Abhängigkeitsinjektion den falschen Platz in der Uhr für die Komplexität der Konfiguration .

Der Fall für eine grobkörnige Abhängigkeitsinjektion

Ich kann mich für eine grobkörnige Abhängigkeitsinjektion aussprechen. Ich kann mich auch für eine feinkörnige Abhängigkeitsinjektion aussprechen (siehe nächster Abschnitt).

Wenn Sie nur ein paar "zentrale" Abhängigkeiten einfügen, sehen die meisten Klassen möglicherweise so aus:

public class Foo
{
    private readonly Bar bar;

    public Foo()
    {
        this.bar = new Bar();
    }

    // Members go here...
}

Dies ist immer noch paßt Design Patterns ‚s für Objekt - Komposition über Klassenvererbung , da Fookomponiert Bar. Aus Sicht der Wartbarkeit kann dies immer noch als wartbar angesehen werden, da Sie den Quellcode einfach bearbeiten müssen, wenn Sie die Komposition ändern müssen Foo.

Dies ist kaum weniger wartbar als die Abhängigkeitsinjektion. Tatsächlich würde ich sagen, dass es einfacher ist, die verwendete Klasse direkt zu bearbeiten, als der Barmit der Abhängigkeitsinjektion verbundenen Indirektion zu folgen.

In der ersten Ausgabe meines Buches über Abhängigkeitsinjektion unterscheide ich zwischen flüchtigen und stabilen Abhängigkeiten.

Flüchtige Abhängigkeiten sind die Abhängigkeiten, die Sie einschleusen sollten. Sie beinhalten

  • Abhängigkeiten, die nach der Kompilierung neu konfiguriert werden müssen
  • Abhängigkeiten wurden parallel von einem anderen Team entwickelt
  • Abhängigkeiten mit nicht deterministischem Verhalten oder Verhalten mit Nebenwirkungen

Stabile Abhängigkeiten hingegen sind Abhängigkeiten, die sich genau definiert verhalten. In gewissem Sinne könnte man argumentieren, dass diese Unterscheidung für eine grobkörnige Abhängigkeitsinjektion spricht, obwohl ich zugeben muss, dass ich das beim Schreiben des Buches nicht ganz realisiert habe.

Aus der Sicht der Tests wird das Testen von Einheiten jedoch schwieriger. Sie können nicht mehr Foounabhängig von Unit-Test Bar. Wie JB Rainsberger erklärt , leiden Integrationstests unter einer kombinatorischen Explosion der Komplexität. Sie müssen buchstäblich Zehntausende von Testfällen schreiben, wenn Sie alle Pfade durch die Integration von sogar 4-5 Klassen abdecken möchten.

Das Gegenargument dazu ist, dass Ihre Aufgabe häufig nicht darin besteht, eine Klasse zu programmieren. Ihre Aufgabe ist es, ein System zu entwickeln, das einige spezifische Probleme löst. Dies ist die Motivation hinter Behavior-Driven Development (BDD).

Eine andere Ansicht hierzu vertritt DHH, die behauptet, TDD führe zu testbedingten Designschäden . Er bevorzugt auch grobkörnige Integrationstests.

Wenn Sie diese Perspektive für die Softwareentwicklung einnehmen, ist eine grobkörnige Abhängigkeitsinjektion sinnvoll.

Der Fall für eine feinkörnige Abhängigkeitsinjektion

Eine feinkörnige Abhängigkeitsinjektion hingegen könnte als " alle Dinge injizieren" bezeichnet werden!

Mein Hauptanliegen bezüglich der grobkörnigen Abhängigkeitsinjektion ist die von JB Rainsberger geäußerte Kritik. Sie können nicht alle Codepfade mit Integrationstests abdecken, da Sie buchstäblich Tausende oder Zehntausende Testfälle schreiben müssen, um alle Codepfade abzudecken.

Die Befürworter von BDD kontern mit dem Argument, dass Sie nicht alle Codepfade mit Tests abdecken müssen. Sie müssen nur diejenigen abdecken, die geschäftlichen Nutzen bringen.

Nach meiner Erfahrung werden jedoch alle "exotischen" Codepfade auch in einer Bereitstellung mit hohem Volumen ausgeführt, und wenn sie nicht getestet werden, weisen viele davon Fehler auf und verursachen Laufzeitausnahmen (häufig Ausnahmen mit Nullreferenz).

Dies hat mich veranlasst, eine feinkörnige Abhängigkeitsinjektion zu bevorzugen, da ich damit die Invarianten aller Objekte isoliert testen kann.

Funktionale Programmierung bevorzugen

Während ich mich für eine feinkörnige Abhängigkeitsinjektion interessiere, habe ich meinen Schwerpunkt auf die funktionale Programmierung verlagert, unter anderem, weil sie an sich testbar ist .

Je mehr Sie sich in Richtung SOLID-Code bewegen, desto funktionaler wird dieser . Früher oder später können Sie auch den Sprung wagen. Die funktionale Architektur ist die Architektur von Ports und Adaptern , und die Abhängigkeitsinjektion ist auch ein Versuch, Ports und Adapter zu verwenden . Der Unterschied besteht jedoch darin, dass eine Sprache wie Haskell diese Architektur über ihr Typensystem erzwingt.

Bevorzugen Sie statisch typisierte funktionale Programmierung

An diesem Punkt habe ich im Wesentlichen die objektorientierte Programmierung (OOP) aufgegeben, obwohl viele der Probleme von OOP mehr mit den gängigen Sprachen wie Java und C # als mit dem eigentlichen Konzept verknüpft sind.

Das Problem mit den gängigen OOP-Sprachen ist, dass das Problem der kombinatorischen Explosion, das, ungetestet, zu Laufzeitausnahmen führt, so gut wie unmöglich zu vermeiden ist. Mit statisch getippten Sprachen wie Haskell und F # können Sie dagegen viele Entscheidungspunkte im Typensystem codieren. Dies bedeutet, dass der Compiler Ihnen nicht Tausende von Tests schreiben muss, sondern lediglich mitteilt, ob Sie sich mit allen möglichen Codepfaden befasst haben (bis zu einem gewissen Grad, es ist keine Silberkugel).

Außerdem ist die Abhängigkeitsinjektion nicht funktionsfähig . Wahre funktionale Programmierung muss den gesamten Begriff der Abhängigkeiten verwerfen . Das Ergebnis ist einfacherer Code.

Zusammenfassung

Wenn ich gezwungen bin, mit C # zu arbeiten, bevorzuge ich die feinkörnige Abhängigkeitsinjektion, da ich so die gesamte Codebasis mit einer überschaubaren Anzahl von Testfällen abdecken kann.

Am Ende ist meine Motivation schnelles Feedback. Dennoch ist Unit - Tests nicht die einzige Möglichkeit , Feedback zu bekommen .

Mark Seemann
quelle
1
Diese Antwort gefällt mir sehr gut, und insbesondere diese im Zusammenhang mit Spring verwendete Aussage: "Das Konfigurieren von Abhängigkeiten mithilfe von XML ist die schlechteste beider Welten. Erstens verlieren Sie die Sicherheit beim Kompilieren, aber Sie gewinnen nichts. Eine XML-Konfiguration Datei kann leicht so groß sein wie der Code, den sie zu ersetzen versucht. "
Thomas Carlisle
@ThomasCarlisle war nicht der Punkt der XML-Konfiguration, an dem Sie den Code nicht berühren (sogar kompilieren) müssen, um ihn zu ändern? Ich habe es wegen der beiden Dinge, die Mark erwähnt hat, nie (oder kaum) benutzt, aber du bekommst etwas dafür.
El Mac,
1

Riesige DI-Setup-Klassen sind ein Problem. Aber denken Sie an den Code, den es ersetzt.

Sie müssen alle diese Dienste und so weiter instanziieren. Der dazu erforderliche Code befindet sich entweder am app.main()Startpunkt und wird manuell eingefügt oder ist wie this.myService = new MyService();in den Klassen eng gekoppelt .

Reduzieren Sie die Größe Ihrer Setup-Klasse, indem Sie sie in mehrere Setup-Klassen aufteilen und vom Programmstartpunkt aus aufrufen. dh

main()
{
   var c = new diContainer();
   var service1 = diSetupClass.SetupService1(c);
   var service2 = diSetupClass.SetupService2(c, service1); //if service1 is required by service2
   //etc

   //main logic
}

Sie müssen nicht auf service1 oder 2 verweisen, außer um sie an andere ci-Setup-Methoden weiterzugeben.

Dies ist besser als der Versuch, sie aus dem Container abzurufen, da hierdurch die Reihenfolge des Aufrufs der Setups erzwungen wird.

Ewan
quelle
1
Für diejenigen, die sich näher mit diesem Konzept
befassen möchten
@Ewan was ist das für eine ciVariable?
Superjos
Ihre Ci-Setup-Klasse mit statischen Methoden
Ewan
dringend sollte di sein. Ich denke immer 'Container'
Ewan