Wo beginnt die Gefahr? Angenommen, Sie sind fest entschlossen, ein Projekt zu entwickeln, das sich an ein bestimmtes Konzept oder einen bestimmten Ansatz hält. In unserer Situation ist dies DI, obwohl beispielsweise auch die reaktive Programmierung an ihrer Stelle sein kann. Es ist logisch, dass Sie sich zur Erreichung Ihres Ziels vorgefertigten Lösungen zuwenden (in unserem Beispiel dem DI Zenject-Container). Sie werden sich mit der Dokumentation vertraut machen und mit dem Erstellen des Anwendungsframeworks unter Verwendung der Hauptfunktionalität beginnen. Wenn Sie in den ersten Phasen der Verwendung der Lösung keine unangenehmen Empfindungen haben, bleibt sie höchstwahrscheinlich ein Leben lang in Ihrem Projekt. Während Sie mit den Grundfunktionen der Lösung (Container) arbeiten, haben Sie möglicherweise Fragen oder Wünsche, um einige Funktionen schöner oder effektiver zu gestalten. Sicherlich wenden Sie sich zunächst den erweiterten „Funktionen“ der Lösung (Container) zu. In diesem Stadium kann die folgende Situation auftreten: Sie kennen die gewählte Lösung bereits gut und vertrauen ihr, weshalb viele möglicherweise nicht darüber nachdenken, wie ideologisch korrekt die Verwendung der einen oder anderen Funktion in der Lösung sein kann, oder der Übergang zu einer anderen Lösung ist bereits recht teuer und unangemessen ( z. B. nähert sich die Frist). In diesem Stadium kann die gefährlichste Situation auftreten - die Lösungsfunktionalität wird mit wenig Sorgfalt oder in seltenen Fällen einfach auf der Maschine (gedankenlos) verwendet.
Wer könnte daran interessiert sein?
Dieser Artikel ist sowohl für diejenigen nützlich, die mit DI-Anhängern vertraut sind, als auch für Anfänger. Um genügend Grundkenntnisse darüber zu verstehen, welche Muster von DI verwendet werden, welchen Zweck DI hat und welche Funktionen ein IoC-Container ausführt. Es geht nicht um die Feinheiten der Zenject-Implementierung, sondern um die Anwendung eines Teils ihrer Funktionalität. Der Artikel stützt sich nur auf die offizielle Zenject-Dokumentation und Codebeispiele sowie auf Mark Simans Buch „Dependency Injection in .NET“, eine klassische, umfassende Arbeit zum Thema DI-Theorie. Alle Zitate in diesem Artikel sind Auszüge aus Mark Simans Buch. Trotz der Tatsache, dass wir über einen bestimmten Container sprechen werden, kann der Artikel für diejenigen nützlich sein, die andere Container verwenden.
Der Zweck dieses Artikels ist es zu zeigen, wie ein Tool, das Ihnen bei der Implementierung von DI in Ihrem Projekt helfen soll, Sie in eine völlig andere Richtung führen kann und Sie dazu bringt, Fehler zu machen, die Ihren Code binden, die Testbarkeit von Code verringern und Ihnen im Allgemeinen alle Vorteile vorenthalten, die sich bieten können du DI.
Haftungsausschluss : Der Zweck dieses Artikels besteht nicht darin, Zenject oder seine Autoren zu kritisieren. Zenject kann für den beabsichtigten Zweck verwendet werden und dient als hervorragendes Werkzeug für die Implementierung von DI, vorausgesetzt, Sie verwenden nicht alle Funktionen, da Sie einige Einschränkungen für sich selbst definiert haben.
Einführung
Zenject ist ein Open-Source-Container für Abhängigkeitsinjektionen, der auf die Verwendung der Unity3D-Spiele-Engine abzielt, die auf den meisten von Unity3D unterstützten Plattformen funktioniert. Es ist erwähnenswert, dass Zenject auch für C # -Anwendungen verwendet werden kann, die ohne Unity3D entwickelt wurden. Dieser Container ist bei Unity-Entwicklern sehr beliebt, wird aktiv unterstützt und entwickelt. Darüber hinaus verfügt Zenject über alle erforderlichen DI-Container-Funktionen.
Ich habe Zenject in 3 großen Unity-Projekten verwendet und auch mit einer großen Anzahl von Entwicklern kommuniziert, die es verwendet haben. Der Grund für das Schreiben dieses Artikels sind häufig gestellte Fragen:
- Ist die Verwendung von Zenject eine gute Lösung?
- Was ist los mit Zenject?
- Welche Schwierigkeiten treten bei der Verwendung von Zenject auf?
Und auch einige Projekte, bei denen die Verwendung von Zenject nicht zur Lösung von Problemen mit starker Code-Konnektivität und erfolgloser Architektur führte, sondern im Gegenteil die Situation verschärfte.
Mal sehen, warum Entwickler solche Fragen und Probleme haben. Sie können wie folgt antworten:
Ironischerweise neigen DI-Container selbst dazu, stabile Abhängigkeiten zu sein. ... Wenn Sie sich entscheiden, Ihre Anwendung basierend auf einem bestimmten DI-Container zu entwickeln, laufen Sie Gefahr, während des gesamten Anwendungslebenszyklus auf diese Auswahl beschränkt zu sein.
Es ist anzumerken, dass bei ordnungsgemäßer und eingeschränkter Verwendung des Containers die Umstellung auf die Verwendung eines anderen Containers in der Anwendung (oder die Verweigerung der Verwendung des Containers zugunsten der „
Implementierung für die Armen “) durchaus möglich ist und nicht viel Zeit in Anspruch nimmt. In einer solchen Situation ist es zwar unwahrscheinlich, dass Sie sie benötigen.
Bevor Sie beginnen, die potenziell gefährliche Funktionalität von Zenject zu zerlegen, ist es sinnvoll, einige grundlegende Aspekte von DI oberflächlich zu aktualisieren.
Der erste Aspekt ist der
Zweck von DI-Containern. Mark Siman schreibt in seinem Buch Folgendes zu diesem Thema:
Ein DI-Container ist eine Softwarebibliothek, die viele der Aufgaben automatisieren kann, die beim Zusammenstellen von Objekten und Verwalten ihres Lebenszyklus ausgeführt werden.
Erwarten Sie nicht, dass der DI-Container stark gekoppelten Code auf magische Weise in lose gekoppelten Code verwandelt. Ein Container kann die Effizienz der Verwendung von DI verbessern. Der Schwerpunkt der Anwendung sollte jedoch in erster Linie auf der Verwendung von Mustern und der Arbeit mit DI liegen.
Der zweite Aspekt sind
DI-Muster . Mark Siman identifiziert vier Hauptmuster, sortiert nach Häufigkeit und Notwendigkeit ihrer Verwendung:
- Implementierung des Konstruktors - Wie können wir sicherstellen, dass die erforderliche Abhängigkeit für die zu entwickelnde Klasse immer verfügbar ist?
- Eigenschaftsimplementierung - Wie kann ich DI als Option in der Klasse aktivieren, wenn ein geeigneter lokaler Standard vorhanden ist?
- Implementierung der Methode - Wie kann ich Abhängigkeiten in eine Klasse einfügen, wenn sie für jede Operation unterschiedlich sind?
- Umgebungskontext - Wie können wir eine Abhängigkeit in jedem Modul verfügbar machen, ohne übergreifende Aspekte der Anwendung in jede API-Komponente aufzunehmen?
Die neben dem Namen der Muster angegebenen Fragen beschreiben ihren Umfang vollständig. Gleichzeitig wird in dem Artikel nicht auf die Implementierung des Konstruktors (da praktisch keine Beschwerden über seine Implementierung in Zenject vorliegen) und den Umgebungskontext (seine Implementierung befindet sich nicht im Container, aber Sie können ihn basierend auf vorhandenen Funktionen problemlos implementieren) eingegangen.
Jetzt können Sie direkt zur potenziell gefährlichen Funktionalität von Zenject wechseln.
Gefährliche Funktionalität.
Implementieren Sie Eigenschaften
Dies ist nach der Implementierung des Konstruktors das zweithäufigste DI-Muster, wird jedoch viel seltener verwendet. In Zenject wie folgt implementiert:
public class Foo { [Inject] public IBar Bar { get; private set; } }
Darüber hinaus hat Zenject auch ein Konzept wie "Field Injection". Mal sehen, warum diese Funktionalität in allen Zenjects am gefährlichsten ist.
- Ein Attribut wird verwendet, um dem Container anzuzeigen, welches Feld eingebettet werden soll. Dies ist unter dem Gesichtspunkt der Einfachheit und Logik der Implementierung des Containers selbst eine vollständig verständliche Lösung. Wir sehen jedoch ein Attribut (sowie einen Namespace) im Klassencode. Das ist zumindest indirekt, aber die Klasse beginnt zu wissen, woher sie die Abhängigkeit bezieht. Außerdem fangen wir an, den Klassencode für den Container zu verschärfen. Mit anderen Worten, wir können die Verwendung von Zenject nicht länger ablehnen, ohne den Klassencode zu manipulieren.
- Das Muster selbst wird in Situationen verwendet, in denen die Abhängigkeit einen lokalen Standard hat. Das heißt, dies ist eine optionale Abhängigkeit. Wenn der Container sie nicht bereitstellen kann, gibt es keine Fehler im Projekt und alles funktioniert. Bei Verwendung von Zenject erhalten Sie jedoch immer diese Abhängigkeit - die Abhängigkeit wird nicht optional.
- Da die Abhängigkeit in diesem Fall nicht optional ist, wird die gesamte Logik der Konstruktorimplementierung beeinträchtigt, da dort nur die erforderlichen Abhängigkeiten eingeführt werden sollten. Durch die Implementierung nicht optionaler Abhängigkeiten über Eigenschaften erhalten Sie die Möglichkeit, zirkuläre Abhängigkeiten im Code zu erstellen. Sie sind nicht so offensichtlich, da in Zenject zuerst die Implementierung des Konstruktors und dann die Implementierung der Eigenschaft erfüllt wird und Sie keine Warnung vom Container erhalten.
- Die Verwendung des DI-Containers impliziert die Implementierung des Composition Root-Musters. Die Verwendung des Attributs zum Konfigurieren der Implementierung der Eigenschaft führt jedoch dazu, dass Sie den Code nicht nur im Composition Root, sondern auch nach Bedarf in jeder Klasse konfigurieren.
Fabriken (und MemoryPool)
Die Zenject-Dokumentation enthält einen ganzen
Abschnitt über Fabriken. Diese Funktionalität wird auf der Ebene des Containers selbst implementiert, und es ist auch möglich, eigene benutzerdefinierte Fabriken zu erstellen. Schauen wir uns das erste Beispiel aus der Dokumentation an:
public class Enemy { DiContainer Container; public Enemy(DiContainer container) { Container = container; } public void Update() { ... var player = Container.Resolve<Player>(); WalkTowards(player.Position); ... etc. } }
Bereits in diesem Beispiel liegt eine grobe Verletzung von DI vor. Dies ist jedoch eher ein Beispiel für die Herstellung einer vollständig benutzerdefinierten Fabrik. Was ist hier das Hauptproblem?
Ein DI-Container kann fälschlicherweise als Service Locator betrachtet werden, sollte jedoch nur als Mechanismus zum Verknüpfen von Objektgraphen verwendet werden. Wenn wir den Container unter diesem Gesichtspunkt betrachten, ist es sinnvoll, seine Verwendung nur auf den Stamm des Layouts zu beschränken. Dieser Ansatz hat den wichtigen Vorteil, dass keine Bindung zwischen dem Container und dem Rest des Anwendungscodes besteht.
Schauen wir uns an, wie die „eingebauten“ Fabriken von Zenject funktionieren. Hierfür gibt es eine IFactory-Schnittstelle, deren Implementierung uns zur PlaceholderFactory-Klasse führt:
public abstract class PlaceholderFactory<TValue> : IPlaceholderFactory { [Inject] void Construct(IProvider provider, InjectContext injectContext)
Darin sehen wir den InjectContext-Parameter, der viele Konstruktoren der Form hat:
public InjectContext(DiContainer container, Type memberType) : this() { Container = container; MemberType = memberType; }
Und wieder erhalten wir die Übertragung des Containers selbst als Abhängigkeit von der Klasse. Dieser Ansatz ist eine grobe Verletzung von DI und eine teilweise Umwandlung des Containers in einen Services Locator.
Darüber hinaus besteht der Nachteil dieser Lösung darin, dass der Container zum Erstellen kurzfristiger Abhängigkeiten verwendet wird und nur langfristige Abhängigkeiten erstellen sollte.
Um solche Verstöße zu vermeiden, könnten die Autoren des Containers die Möglichkeit, den Container als Abhängigkeit an alle registrierten Klassen zu übergeben, vollständig ausschließen. Es wäre nicht schwierig, dies zu implementieren, da der gesamte Container durch Reflexion und Analyse der Parameter von Methoden und Konstruktoren arbeitet, um das Diagramm von Anwendungsobjekten zu erstellen und zu gestalten.
Methodenimplementierung
Die Logik der Implementierung der Methode in Zenject lautet wie folgt: Zuerst wird in allen Klassen der Konstruktor implementiert, dann werden die Eigenschaften implementiert und schließlich wird die Methode implementiert. Betrachten Sie das Implementierungsbeispiel in der Dokumentation:
public class Foo { [Inject] public Init(IBar bar, Qux qux) { _bar = bar; _qux = qux; } }
Was sind die Nachteile hier:
- Sie können beliebig viele Methoden schreiben, die im Rahmen einer Klasse implementiert werden. So erhalten wir wie bei der Implementierung der Eigenschaft die Möglichkeit, möglichst viele zyklische Abhängigkeiten herzustellen.
- Wie die Implementierung einer Eigenschaft wird die Implementierung einer Methode mithilfe eines Attributs implementiert, das Ihren Code mit dem Code des Containers selbst verknüpft.
- Die Implementierung der Methode in Zenject wird nur als Alternative zu Konstruktoren verwendet, was im Fall von MonoBehavior-Klassen praktisch ist, widerspricht jedoch absolut der von Mark Siman beschriebenen Theorie. Das klassische Beispiel für die kanonische Implementierung der Methode kann die Verwendung von Fabriken (Fabrikmethoden) sein.
- Wenn es in der Klasse mehrere eingeführte Methoden gibt oder zusätzlich zu der Methode auch einen Konstruktor, stellt sich heraus, dass die für die Klasse erforderlichen Abhängigkeiten an verschiedenen Stellen verstreut sind, was das gesamte Bild beeinträchtigt. Das heißt, wenn Klasse 1 einen Konstruktor hat, kann die Anzahl ihrer Parameter deutlich zeigen, ob Entwurfsfehler in der Klasse vorliegen und ob das Prinzip der alleinigen Verantwortung verletzt wird und ob die Abhängigkeiten durch mehrere Methoden, durch den Konstruktor oder möglicherweise durch einige Eigenschaften verstreut sind. dann wird das Bild nicht so offensichtlich sein, wie es sein könnte.
Daraus folgt, dass das Vorhandensein einer solchen Implementierung der Methodenimplementierung im Container, die der DI-Theorie widerspricht, kein einziges Plus hat. Mit einer großen Einschränkung kann ein Plus nur als die Möglichkeit angesehen werden, die implementierte Methode als Konstruktor für MonoBehaviour zu verwenden. Dies ist jedoch ein ziemlich kontroverser Punkt, da aus Sicht der Containerlogik, der DI-Muster und des internen Unity3D-Speichergeräts alle MonoBehaviour-Objekte in Ihrer Anwendung als ressourcenverwaltet betrachtet werden können. In diesem Fall ist es viel effizienter, die Lebenszyklusverwaltung solcher Objekte zu delegieren kein DI-Container, sondern eine Hilfsklasse (sei es Wrapper, ViewModel, Fasade oder etwas anderes).
Globale Bindungen
Dies ist eine recht praktische Zusatzfunktion, mit der Sie globale Ordner festlegen können, die unabhängig vom Übergang zwischen Szenen leben können. Weitere Informationen finden Sie
in der Dokumentation . Diese Funktionalität ist äußerst praktisch und sehr nützlich. Es ist erwähnenswert, dass es nicht gegen die Muster und Prinzipien von DI verstößt, jedoch eine nicht offensichtliche und hässliche Implementierung hat. Die Quintessenz ist, dass Sie eine spezielle Art von Fertighaus erstellen, ein Skript mit der Containerkonfiguration (Installationsprogramm) anhängen und es in einem streng definierten Projektordner speichern, ohne die Möglichkeit, irgendwohin zu wechseln und ohne Links dazu. Der Nachteil dieses Tools liegt allein in seiner Implizität. Bei normalen Installationsprogrammen ist alles ganz einfach: Sie haben ein Objekt auf der Bühne, das Installationsskript hängt daran. Wenn ein neuer Entwickler zum Projekt kommt, wird der Installer zu einem hervorragenden Punkt für das Eintauchen in das Projekt. Basierend auf einem einzelnen Installationsprogramm kann ein Entwickler eine Vorstellung davon machen, aus welchen Modulen ein Projekt besteht und wie ein Diagramm von Objekten erstellt wird. Mit der Verwendung globaler Bindemittel ist der Installer auf der Bühne jedoch keine ausreichende Quelle für diese Informationen mehr. Es gibt keinen einzigen Link zur globalen Bindung im Code anderer Installationsprogramme (in den Szenen vorhanden), und daher wird nicht das vollständige Diagramm der Objekte angezeigt. Und nur während der Analyse der Klassen verstehen Sie, dass einige der Ordner im Installationsprogramm auf der Bühne nicht ausreichen. Ich werde noch einmal einen Vorbehalt machen, dass dieser Nachteil rein kosmetischer Natur ist.
Kennungen
Die Möglichkeit, eine bestimmte Bindung für einen Bezeichner festzulegen, um eine bestimmte Abhängigkeit von einer Reihe ähnlicher Abhängigkeiten in einer Klasse zu erhalten. Ein Beispiel:
Container.Bind<IFoo>().WithId("foo").To<Foo1>().AsSingle(); Container.Bind<IFoo>().To<Foo2>().AsSingle(); public class Bar1 { [Inject(Id = "foo")] IFoo _foo; } public class Bar2 { [Inject] IFoo _foo; }
Diese Funktionalität kann wirklich situativ nützlich sein und ist eine zusätzliche Option für die Implementierung von Eigenschaften. Neben der Benutzerfreundlichkeit werden jedoch alle im Abschnitt „Implementieren von Eigenschaften“ genannten Probleme übernommen, wodurch der Code noch kohärenter wird, indem eine bestimmte Konstante eingeführt wird, die Sie bei der Konfiguration Ihres Codes berücksichtigen müssen. Wenn Sie diese Kennung versehentlich löschen, können Sie leicht eine nicht funktionierende aus der funktionierenden Anwendung abrufen.
Signale und ITickable
Signale sind ein Analogon des in den Container integrierten Ereignisaggregatormechanismus. Die Idee, diese Funktionalität zu implementieren, ist zweifellos nobel, da sie darauf abzielt, die Anzahl der Verbindungen zwischen Objekten zu verringern, die über den Mechanismus von Ereignisabonnements kommunizieren. Ein ziemlich umfangreiches Beispiel finden Sie in der
Dokumentation , es wird jedoch nicht im Artikel enthalten sein, da die spezifische Implementierung keine Rolle spielt.
Unterstützung für die ITickable-Schnittstelle - Ersetzen der Standardmethoden Update, LateUpdate und FixedUpdate in Unity durch Delegieren von Aufrufen an Methoden zum Aktualisieren von Objekten mit der ITickable-Schnittstelle an den Container. Ein Beispiel finden Sie auch in der
Dokumentation , und die Implementierung im Kontext des Artikels spielt ebenfalls keine Rolle.
Das Problem von Signals und ITickable betrifft keine Aspekte ihrer Implementierung, seine Wurzel liegt in der Verwendung von Nebenwirkungen des Containers. Im Kern kennt der Container fast alle Klassen und ihre Instanzen innerhalb des Projekts. Seine Aufgabe besteht jedoch darin, ein Diagramm der Objekte zu erstellen und ihren Lebenszyklus zu verwalten. Durch Hinzufügen von Mechanismen wie Signale, ITickable usw. fügen wir dem Container mehr Verantwortlichkeiten hinzu und fügen ihm immer mehr den Anwendungscode hinzu, wodurch er zum exklusiven und unersetzlichen Teil des Codes wird, praktisch zu einem „göttlichen Objekt“.
Anstelle von Ausgabe
Das Wichtigste an Containern ist zu verstehen, dass die Verwendung von DI unabhängig von der Verwendung eines DI-Containers ist. Eine Anwendung kann aus vielen lose gekoppelten Klassen und Modulen erstellt werden, und keines dieser Module sollte etwas über den Container wissen.
Seien Sie vorsichtig, wenn Sie Out-of-the-Box-Lösungen (Boxed-Lösungen) oder kleine Plugins verwenden. Verwenden Sie sie nachdenklich. In der Tat können noch großartigere Dinge, auf die Sie sich verlassen (zum Beispiel Spiel-Engines der Größenordnung von Unity3D selbst), mit solchen theoretischen Fehlern und Blots sündigen. Dies wirkt sich letztendlich nicht auf die Arbeit der von Ihnen verwendeten Lösung aus, sondern auf die Nachhaltigkeit, Arbeit und Qualität Ihres Endprodukts. Ich hoffe, jeder, der bis zum Ende gelesen hat, der Artikel wird nützlich sein oder zumindest die Zeit, die er damit verbracht hat, nicht zu bereuen.