Frage eines der Argumente für Abhängigkeitsinjektions-Frameworks: Warum ist das Erstellen eines Objektgraphen schwierig?

13

Abhängigkeitsinjektionsframeworks wie Google Guice geben die folgende Motivation für ihre Verwendung ( Quelle ):

Um ein Objekt zu erstellen, erstellen Sie zuerst seine Abhängigkeiten. Aber um jede Abhängigkeit aufzubauen, benötigen Sie ihre Abhängigkeiten und so weiter. Wenn Sie also ein Objekt erstellen, müssen Sie wirklich ein Objektdiagramm erstellen.

Das Erstellen von Objektdiagrammen von Hand ist arbeitsintensiv (...) und erschwert das Testen.

Aber dieses Argument kaufe ich mir nicht: Selbst ohne ein Framework für die Abhängigkeitsinjektion kann ich Klassen schreiben, die einfach zu instanziieren und bequem zu testen sind. Das Beispiel auf der Guice-Motivationsseite könnte beispielsweise folgendermaßen umgeschrieben werden:

class BillingService
{
    private final CreditCardProcessor processor;
    private final TransactionLog transactionLog;

    // constructor for tests, taking all collaborators as parameters
    BillingService(CreditCardProcessor processor, TransactionLog transactionLog)
    {
        this.processor = processor;
        this.transactionLog = transactionLog;
    }

    // constructor for production, calling the (productive) constructors of the collaborators
    public BillingService()
    {
        this(new PaypalCreditCardProcessor(), new DatabaseTransactionLog());
    }

    public Receipt chargeOrder(PizzaOrder order, CreditCard creditCard)
    {
        ...
    }
}

Es kann also andere Argumente für Abhängigkeitsinjektions-Frameworks geben ( die für diese Frage nicht in Frage kommen !), Aber die einfache Erstellung testbarer Objektgraphen ist keines davon, oder?

oberlies
quelle
1
Ich denke, dass ein weiterer Vorteil der Abhängigkeitsinjektion, der oft ignoriert wird, darin besteht, dass Sie wissen müssen, von welchen Objekten sie abhängen . Nichts erscheint magisch in Objekten, entweder mit explizit markierten Attributen oder direkt über den Konstruktor. Ganz zu schweigen davon, wie es Ihren Code testbarer macht.
Benjamin Gruenbaum
Beachten Sie, dass ich nicht nach anderen Vorteilen der Abhängigkeitsinjektion suche - ich bin nur daran interessiert, das Argument zu verstehen, dass "Objektinstanziierung ohne Abhängigkeitsinjektion
schwierig ist
Diese Antwort scheint ein sehr gutes Beispiel dafür zu sein, warum Sie eine Art Container für Ihre Objektdiagramme benötigen (kurz gesagt, Sie denken nicht groß genug, wenn Sie nur drei Objekte verwenden).
Izkata
@Izkata Nein, das ist keine Frage der Größe. Bei dem beschriebenen Ansatz wäre der Code von diesem Beitrag einfach new ShippingService().
oberlies
@oberlies Still ist; Sie müssten dann 9 Klassendefinitionen ausgraben, um herauszufinden, welche Klassen für diesen
Versandservice

Antworten:

12

Es gibt eine alte, alte Debatte darüber, wie man Abhängigkeitsinjektionen am besten durchführen kann.

  • Der ursprüngliche Schnitt der Feder instanziierte ein einfaches Objekt und injizierte dann Abhängigkeiten durch Setter-Methoden.

  • Aber dann bestand eine große Anzahl von Leuten darauf, dass das Einfügen von Abhängigkeiten durch Konstruktorparameter der richtige Weg war, dies zu tun.

  • Dann, in letzter Zeit, als die Verwendung von Reflektion immer häufiger wurde, wurde es zur Wut, die Werte privater Mitglieder direkt ohne Setter oder Konstrukteure festzulegen.

Ihr erster Konstruktor entspricht also dem zweiten Ansatz für die Abhängigkeitsinjektion. Es ermöglicht Ihnen, nette Dinge wie Injektions-Mocks zum Testen auszuführen.

Der Konstruktor ohne Argumente hat dieses Problem. Da die Implementierungsklassen für PaypalCreditCardProcessorund instanziiert werden DatabaseTransactionLog, entsteht eine schwierige Abhängigkeit zum Zeitpunkt der Kompilierung von PayPal und der Datenbank. Es übernimmt die Verantwortung für die korrekte Erstellung und Konfiguration des gesamten Abhängigkeitsbaums.

  • Stellen Sie sich vor, dass der PayPay-Prozessor ein wirklich kompliziertes Subsystem ist und zusätzlich viele Unterstützungsbibliotheken umfasst. Indem Sie eine Abhängigkeit zur Kompilierungszeit für diese Implementierungsklasse erstellen, stellen Sie eine unzerbrechliche Verknüpfung zu dieser gesamten Abhängigkeitsbaumstruktur her. Die Komplexität Ihres Objektgraphen ist gerade um eine Größenordnung gestiegen, vielleicht um zwei.

  • Viele dieser Elemente im Abhängigkeitsbaum sind transparent, aber viele müssen auch instanziiert werden. Wahrscheinlich können Sie a nicht einfach instanziieren PaypalCreditCardProcessor.

  • Zusätzlich zur Instanziierung müssen für jedes Objekt Eigenschaften aus der Konfiguration übernommen werden.

Wenn Sie nur eine Abhängigkeit von der Schnittstelle haben und einer externen Factory erlauben, die Abhängigkeit zu erstellen und einzufügen, schneiden Sie den gesamten PayPal-Abhängigkeitsbaum ab, und die Komplexität Ihres Codes hört an der Schnittstelle auf.

Es gibt noch weitere Vorteile, z. B. die Angabe von Implementierungsklassen bei der Konfiguration (dh zur Laufzeit anstatt zur Kompilierungszeit) oder eine dynamischere Abhängigkeitsspezifikation, die sich beispielsweise je nach Umgebung (Test, Integration, Produktion) ändert.

Angenommen, der PayPal-Prozessor hatte drei abhängige Objekte und jede dieser Abhängigkeiten hatte zwei weitere. Und all diese Objekte müssen Eigenschaften aus der Konfiguration abrufen. Der aktuelle Code übernimmt die Verantwortung für das Erstellen all dessen, das Festlegen von Eigenschaften aus der Konfiguration usw. usw. - alles Anliegen, die das DI-Framework erledigen wird.

Es mag auf den ersten Blick nicht offensichtlich erscheinen, wovor Sie sich mit einem DI-Framework abschirmen, aber es summiert sich und wird mit der Zeit schmerzhaft offensichtlich. (lol ich spreche aus der Erfahrung, es auf die harte Tour versucht zu haben)

...

In der Praxis sehe ich, dass ich selbst für ein sehr kleines Programm am Ende in einem DI-Stil schreibe und die Klassen in Implementierungs- / Factory-Paare aufteile. Das heißt, wenn ich kein DI-Framework wie Spring verwende, füge ich einfach einige einfache Factory-Klassen zusammen.

Das sorgt für die Trennung der Bedenken, sodass meine Klasse genau das Richtige tun kann, und die Factory-Klasse übernimmt die Verantwortung für das Bauen und Konfigurieren von Dingen.

Kein notwendiger Ansatz, aber FWIW

...

Im Allgemeinen reduziert das DI / Interface-Muster die Komplexität Ihres Codes, indem es zwei Dinge ausführt:

  • Abstraktion nachgelagerter Abhängigkeiten in Schnittstellen

  • Upstream-Abhängigkeiten aus dem Code in eine Art Container "heben"

Da die Instanziierung und Konfiguration von Objekten eine bekannte Aufgabe ist, kann das DI-Framework durch standardisierte Notation und Verwendung von Tricks wie Reflektion eine Menge Skaleneffekte erzielen. Das Verstreuen der gleichen Bedenken im Unterricht führt zu mehr Unordnung, als man denkt.

rauben
quelle
Der Code würde die Verantwortung dafür übernehmen, all das aufzubauen. Eine Klasse übernimmt nur die Verantwortung, zu wissen, was ihre Collaborator-Implementierungen sind. Sie muss nicht wissen, was diese Mitarbeiter wiederum benötigen. Das ist also nicht so viel.
oberlies
1
Aber wenn der PayPalProcessor-Konstruktor zwei Argumente hätte, woher würden diese kommen?
Rob
Das tut es nicht. Genau wie BillingService verfügt PayPalProcessor über einen Konstruktor mit Nullargumenten, mit dem die benötigten Mitbearbeiter selbst erstellt werden.
oberlies
Downstream-Abhängigkeiten in Interfaces abstrahieren - Das macht auch der Beispielcode. Das Prozessorfeld ist vom Typ CreditCardProcessor und nicht vom Implementierungstyp.
oberlies
2
@oberlies: Ihr Codebeispiel spreizt zwischen zwei Stilen, dem Konstruktorargumentstil und dem konstruierbaren Stil mit Nullargumenten. Der Irrtum ist, dass Nullargumente nicht immer konstruierbar sind. Das Schöne an DI oder Factory ist, dass sie es irgendwie ermöglichen, Objekte mit einem Konstruktor zu konstruieren, der aussieht wie ein Konstruktor ohne Argumente.
Rwong
4
  1. Wenn Sie am flachen Ende des Pools schwimmen, ist alles "einfach und bequem". Sobald Sie an etwa einem Dutzend Objekten vorbeigekommen sind, ist dies nicht mehr praktisch.
  2. In Ihrem Beispiel haben Sie Ihren Abrechnungsprozess für immer und einen Tag an PayPal gebunden. Angenommen, Sie möchten einen anderen Kreditkartenprozessor verwenden? Angenommen, Sie möchten einen speziellen Kreditkartenprozessor erstellen, der im Netzwerk eingeschränkt ist? Oder müssen Sie den Umgang mit Kreditkartennummern testen? Sie haben nicht portierbaren Code erstellt: "Einmal schreiben, nur einmal verwenden, da dies vom jeweiligen Objektdiagramm abhängt, für das er entworfen wurde."

Indem Sie Ihren Objektgraphen früh im Prozess binden, dh fest in den Code einbinden, müssen sowohl der Vertrag als auch die Implementierung vorhanden sein. Wenn jemand anderes (vielleicht sogar Sie) diesen Code für einen etwas anderen Zweck verwenden möchte, muss er das gesamte Objektdiagramm neu berechnen und neu implementieren.

Mit DI-Frameworks können Sie eine Reihe von Komponenten zur Laufzeit zusammenführen. Dies macht das System "modular", das aus einer Anzahl von Modulen besteht, die an den Schnittstellen des jeweils anderen arbeiten, anstatt an den Implementierungen des jeweils anderen.

BobDalgleish
quelle
Wenn ich Ihrem Argument folge, sollte die Begründung für DI lauten: "Ein Objektdiagramm von Hand zu erstellen ist einfach, führt aber zu Code, der schwer zu pflegen ist." Die Behauptung war jedoch, dass "das Erstellen eines Objektgraphen von Hand schwierig ist" und ich suche nur nach Argumenten, die diese Behauptung stützen. Dein Beitrag beantwortet meine Frage also nicht.
oberlies
1
Ich vermute, wenn Sie die Frage auf "Erstellen eines Objektgraphen ..." reduzieren , ist es einfach. Mein Punkt ist, dass DI das Problem anspricht, dass Sie sich nie nur mit einem Objektdiagramm beschäftigen. Sie beschäftigen sich mit einer Familie von ihnen.
BobDalgleish
Tatsächlich kann es schon schwierig sein , nur ein Objektdiagramm zu erstellen, wenn Mitarbeiter gemeinsam genutzt werden .
oberlies
4

Der Ansatz "Eigenen Mitbearbeiter instanziieren" kann für Abhängigkeitsbäume verwendet werden, eignet sich jedoch mit Sicherheit nicht für Abhängigkeitsdiagramme, bei denen es sich um allgemein gerichtete azyklische Diagramme (DAG) handelt. In einer Abhängigkeits-DAG können mehrere Knoten auf denselben Knoten verweisen. Dies bedeutet, dass zwei Objekte dasselbe Objekt als Collaborator verwenden. Dieser Fall kann in der Tat nicht mit dem in der Frage beschriebenen Ansatz konstruiert werden.

Wenn einige meiner Mitbearbeiter (oder Mitbearbeiter des Mitbearbeiters) ein bestimmtes Objekt gemeinsam nutzen sollen, muss dieses Objekt instanziiert und an meine Mitbearbeiter übergeben werden. Ich müsste also tatsächlich mehr wissen als meine direkten Mitarbeiter, und das ist offensichtlich nicht skalierbar.

oberlies
quelle
1
Aber der Abhängigkeitsbaum muss ein Baum im Sinne der Graphentheorie sein. Ansonsten hast du einen Zyklus, der einfach unbefriedigend ist.
Xion
1
@Xion Der Abhängigkeitsgraph muss ein gerichteter azyklischer Graph sein. Es ist nicht erforderlich, dass genau ein Pfad zwischen zwei Knoten vorhanden ist, wie in Bäumen.
oberlies
@Xion: Nicht unbedingt. Konsultieren Sie ein, UnitOfWorkdas ein einzelnes DbContextund mehrere Repositorys hat. Alle diese Repositorys sollten dasselbe DbContextObjekt verwenden. Mit der von OP vorgeschlagenen "Selbstinstanziierung" wird dies unmöglich.
Flater
1

Ich habe Google Guice nicht verwendet, aber ich habe viel Zeit gebraucht, um alte N-Tier-Anwendungen in .NET auf IoC-Architekturen wie Onion Layer zu migrieren, die von Dependency Injection abhängen, um Dinge zu entkoppeln.

Warum Abhängigkeitsinjektion?

Der Zweck von Dependency Injection besteht eigentlich nicht in der Testbarkeit, sondern darin, eng gekoppelte Anwendungen zu verwenden und die Kopplung so weit wie möglich zu lösen. (Was das wünschenswerte Nebenprodukt hat, Ihren Code viel einfacher anpassbar zu machen, um korrekte Komponententests durchführen zu können)

Warum sollte ich mich überhaupt um das Koppeln kümmern?

Kopplung oder enge Abhängigkeiten können eine sehr gefährliche Sache sein. (Besonders in kompilierten Sprachen) In diesen Fällen kann es vorkommen, dass Sie eine Bibliothek, eine DLL usw. haben, die sehr selten verwendet wird und ein Problem aufweist, das die gesamte Anwendung effektiv offline schaltet. (Ihre gesamte Anwendung stirbt, weil ein unwichtiges Teil ein Problem hat ... das ist schlimm ... WIRKLICH schlimm) Wenn Sie nun Dinge entkoppeln, können Sie Ihre Anwendung so einrichten, dass sie auch dann ausgeführt werden kann, wenn diese DLL oder Bibliothek vollständig fehlt! Sicher, dass ein Teil, das diese Bibliothek oder DLL benötigt, nicht funktionieren wird, aber der Rest der Anwendung tuckert glücklich darüber.

Warum brauche ich Dependency Injection zum Testen?

Wirklich, Sie möchten nur lose gekoppelten Code, die Abhängigkeitsinjektion ermöglicht dies. Sie können Dinge ohne IoC locker koppeln, aber normalerweise ist es mehr Arbeit und weniger anpassungsfähig (ich bin sicher, dass jemand da draußen eine Ausnahme hat).

In dem Fall, den Sie angegeben haben, ist es meiner Meinung nach viel einfacher, nur die Abhängigkeitsinjektion einzurichten, damit ich Code nachahmen kann, den ich nicht als Teil dieses Tests zählen möchte. Nur sagen Ihnen Methode „Hey ich weiß , ich habe dir gesagt , das Repository zu nennen, sondern hier sind die Daten , die sie‚sollte‘Rückkehr zwinkert jetzt“ , weil diese Daten nie ändern Sie wissen , dass Sie nur den Teil zu testen , dass verwendet diese Daten, nicht den tatsächlicher Abruf der Daten.

Grundsätzlich sollten Sie beim Testen Integrationstests (Funktionalitätstests) durchführen, bei denen ein Teil der Funktionalität von Anfang bis Ende getestet wird, und vollständige Komponententests, bei denen jeder einzelne Teil des Codes (normalerweise auf Methoden- oder Funktionsebene) unabhängig getestet wird.

Die Idee ist, dass Sie sicherstellen möchten, dass die gesamte Funktionalität funktioniert, wenn Sie nicht den genauen Teil des Codes kennen möchten, der nicht funktioniert.

Dies KANN ohne Dependency Injection geschehen. In der Regel wird es jedoch mit zunehmendem Projektwachstum immer schwieriger, dies ohne Dependency Injection zu tun. (Gehen Sie IMMER davon aus, dass Ihr Projekt wachsen wird! Es ist besser, unnötige Übung in nützlichen Fertigkeiten zu haben, als ein Projekt zu finden, das schnell anläuft und ernsthaft überarbeitet und überarbeitet werden muss, nachdem die Dinge bereits angelaufen sind.)

RualStorge
quelle
1
Das Schreiben von Tests mit einem großen Teil des Abhängigkeitsgraphen, aber nicht mit dem gesamten Abhängigkeitsgraphen, ist in der Tat ein gutes Argument für DI.
oberlies
1
Es ist auch erwähnenswert, dass Sie mit DI ganz einfach programmgesteuert ganze Teile Ihres Codes austauschen können. Ich habe Fälle erlebt, in denen ein System eine Schnittstelle mit einer völlig anderen Klasse löschte und erneut einfügte, um auf Ausfälle und Leistungsprobleme von Remoteservices zu reagieren. Es gab vielleicht eine bessere Möglichkeit, damit umzugehen, aber es funktionierte überraschend gut.
RualStorge
0

Wie ich in einer anderen Antwort erwähne , besteht das Problem darin, dass Sie möchten, dass die Klasse Avon einer KlasseB ohne Hardcodierung abhängt, welche Klasse Bim Quellcode von A verwendet wird. Dies ist in Java und C # nicht möglich, da nur so eine Klasse importiert werden kann bezieht sich auf einen global eindeutigen Namen.

Mithilfe eines Interfaces können Sie die hartcodierte Klassenabhängigkeit umgehen, aber Sie müssen noch eine Instanz des Interfaces in den Griff bekommen, und Sie können keine Konstruktoren aufrufen, oder Sie befinden sich wieder in Feld 1. Nun also zum Code Das könnte sonst zu Abhängigkeiten führen, die diese Verantwortung auf andere übertragen. Und seine Abhängigkeiten machen das Gleiche. So , jetzt jedes Mal müssen Sie eine Instanz einer Klasse , die Sie den gesamten Abhängigkeitsbaum manuell am Ende bauen, während in dem Fall , wo die Klasse A auf B hängt direkt konnte man nur rufen , new A()dass Konstruktoraufruf und hastnew B() , und so weiter.

Ein Abhängigkeitsinjektionsframework versucht, dies zu umgehen, indem Sie die Zuordnungen zwischen Klassen angeben und den Abhängigkeitsbaum für Sie erstellen. Der Haken dabei ist, dass Sie dies zur Laufzeit herausfinden, wenn Sie die Zuordnungen vermasseln, und nicht zur Kompilierungszeit, wie Sie es in Sprachen tun würden, die Zuordnungsmodule als erstklassiges Konzept unterstützen.

Doval
quelle
0

Ich denke, das ist hier ein großes Missverständnis.

Guice ist eine Abhängigkeitsinjektion Framework für die . Es macht DI automatisch . Der Punkt, den sie in dem von Ihnen zitierten Auszug angesprochen haben, ist, dass Guice nicht mehr den "testbaren Konstruktor" manuell erstellen muss, den Sie in Ihrem Beispiel vorgestellt haben. Es hat absolut nichts mit der Abhängigkeitsinjektion selbst zu tun.

Dieser Konstruktor:

BillingService(CreditCardProcessor processor, TransactionLog transactionLog)
{
    this.processor = processor;
    this.transactionLog = transactionLog;
}

verwendet bereits Abhängigkeitsinjektion. Sie haben gerade gesagt, dass die Verwendung von DI einfach ist.

Das Problem, das Guice löst, ist, dass Sie, um diesen Konstruktor zu verwenden, irgendwo einen Objektgraph-Konstruktorcode haben müssen , der die bereits instanziierten Objekte manuell als Argumente dieses Konstruktors übergibt. Mit Guice können Sie an einem einzigen Ort konfigurieren, welche realen Implementierungsklassen diesen CreditCardProcessorund entsprechen TransactionLog Schnittstellen entsprechen. Nach dieser Konfiguration werden BillingServicediese Klassen bei jeder Erstellung mit Guice automatisch an den Konstruktor übergeben.

Dies ist , was Dependency Injection Framework tut. Aber der Konstruktor selbst , die Sie präsentiert ist bereits eine Implementierung von depencency Injektionsprinzip . IoC-Container und DI-Frameworks sind Mittel, um die entsprechenden Prinzipien zu automatisieren, aber nichts hindert Sie daran, alles von Hand zu erledigen, das war der springende Punkt.

Hijarian
quelle