Vor langer Zeit im Land der Scala entstanden neue Art-sichere Art der Dependency Injection. Auf lange Sicht bringt es mehr Ärger als es wert ist.
Kuchenmuster
Lassen Sie uns daran erinnern, was ein Kuchenmuster ist. Um es zu verstehen, müssen wir Selbsttypen kennen. Sie sollten als Mixins verwendet werden und sehen mehr oder weniger so aus:
trait UserRepository { def fetchUsers(userIds: Seq): Seq}class UserRepositoryDbImpl(database: Database) extends UserRepository { def fetchUsers(userIds: Seq): Seq = ...}trait UserRepositoryLogging { self: UserRepository => private lazy val logger: Logger = ... override def fetchUsers(userIds: Seq): Seq = { val users = super.fetchUsers(usersIds) logger.debug(s"""Fetched users by ids: ${users.mkString(", ")}""") users }}val userRepository: UserRepository = new UserRepositoryDbImpl(database) with UserRepositoryLogging
Hier können wir trait UserRepositoryLogging
zu allem hinzufügen, was UserRepository
Implementierung ist – es würde sonst nicht kompiliert. Zusätzlich gehen wir innerhalb von UserRepositoryLogging
davon aus, dass es sich um die Implementierung von UserRepository
. Alles, was in UserRepository
ist, ist also auch dort zugänglich.
Da wir nun auf alle deklarierten Typen zugreifen können, die für den Selbsttyp verwendet wurden, dürfen wir dies tun:
trait UserRepository { def fetchUsers(userIds: Seq): Seq}trait UserRepositoryComponent { def userRepository: UserRepository}trait UserRepositoryComponentImpl extends UserRepositoryComponent { lazy val userRepository: UserRepository = new UserRepository { // ... }}trait SomeController { self: UserRepositoryComponent => def handleRequest(userIds: Seq): Response = { // ... userRepository.fetchUsers(userIds) // ... }}object SomeController extends SomeController with UserRepositoryComponentImpl
Innerhalb von SomeController
deklarieren wir self-type, der sicherstellt, dass seine instanziierbare Implementierung userRepository
Methode hat. Durch Hinzufügen des Mixins stellen wir die Implementierung bereit und stellen so sicher, dass die Abhängigkeit zur Kompilierungszeit injiziert wird. Ohne Laufzeitreflexion, mit Typsicherheit, ohne zusätzliche Konfigurationen oder Bibliotheken.
Jede dieser Komponenten könnte eine Schicht unserer Anwendung sein (Geschäftslogikschicht, Infrastrukturebene usw.), die jemand mit den Schichten des Kuchens vergleicht. Also, indem Sie Ihre Anwendung so erstellen, backen Sie effektiv einen Kuchen. So Kuchenmuster.
Warum ist das ein Problem?
Abhängigkeiten hinzufügen
In größeren Projekten ist die Anzahl der Dienste, Repositorys und Dienstprogramme sicherlich größer als 1-2. Ihr Kuchen sieht also folgendermaßen aus:
object PaymentTransactionApiController extends TransactionApiController with ConfigComponentImpl with DatabaseComponentImpl with UserRepositoryComponentImpl with SessionRepositoryComponentImpl with SecurityServicesComponentImpl with ExternalPaymentApiServicesComponentImpl with PaymentServicesComponentImpl with TransactionServicesComponentImpl
Tatsächlich habe ich einmal einen Kuchen gesehen, bei dem ComponentImpl
2-3 Bildschirme durchlaufen hat. Sie injizierten dort nicht nur Komponenten, die gleichzeitig benötigt wurden, Sie kombinierten alles auf einmal: infrastruktur, Domänendienste, Persistenz, Geschäftslogik…
Stellen Sie sich nun vor, was passiert, wenn Sie eine Abhängigkeit hinzufügen müssen. Sie beginnen mit dem Hinzufügen eines weiteren with Dependency
zu Ihrem Selbsttyp. Dann überprüfen Sie, wo das aktualisierte Merkmal verwendet wird. Möglicherweise müssen Sie auch hier self-type hinzufügen. Du kletterst den Baum der Mixins hinauf, bis du eine Klasse oder ein Objekt erreichst. Uff.
Außer, dieses Mal hast du das selbst hinzugefügt, also weißt du irgendwie, was hinzugefügt werden muss. Wenn Sie jedoch eine Rebase durchführen oder einen Konflikt lösen, können Sie in eine Situation geraten, in der eine neue Abhängigkeit auftritt, die Sie nicht haben. Compiler-Fehler sagt nur, dass self-type X does not conform to Y
. Bei über 50 Komponenten können Sie genauso gut erraten, welche fehlschlägt. (Oder starten Sie eine binäre Suche mit dem Entfernen von Komponenten, bis der Fehler verschwindet).
Wenn die Kuchengröße und die Menge weiter wächst, möchte man vielleicht eine Trockenregel implementieren und sie in kleinere Kuchen aufteilen, die später zusammengestellt werden. Dies deckt neue Tiefen der Hölle auf.
Abhängigkeiten entfernen
Mit normalem DI Wenn Sie ein Objekt nicht mehr verwenden, kann Ihnen Ihre IDE / Ihr Compiler mitteilen, dass es nicht mehr benötigt wird. Mit Kuchenmuster, werden Sie nicht über solche Dinge informiert werden.
Infolgedessen ist die Bereinigung viel schwieriger und es ist durchaus möglich, dass Sie irgendwann viel mehr Abhängigkeiten haben, als Sie wirklich benötigen.
Kompilierzeit
All das summiert sich zur Kompilierzeit. Es mag auf den ersten Blick nicht offensichtlich sein, aber Sie können am Ende mit:
trait ModuleAComponentImpl { self: ModuleBComponent => }trait ModuleBComponentImpl { self: ModuleAComponent => }
was eine zyklische Abhängigkeit ist. (Wir alle wissen, dass sie schlecht sind, aber Sie werden vielleicht nicht einmal bemerken, dass Sie gerade eine erstellt haben. Vor allem, wenn Sie alles träge initialisieren).
Diese 2 Merkmale werden also sehr oft zusammen kompiliert, und der inkrementelle Compiler hilft dabei nicht. Darüber hinaus lösen Änderungen an einer der Komponenten in einem Kuchen eine Neukompilierung der darüber liegenden Elemente im Abhängigkeitsdiagramm aus.
Testen
Zyklische Abhängigkeiten verursachen ein weiteres Problem. Sie könnten sehr gut mit etwas enden wie:
trait AComponentImpl { self: BComponent => lazy val a: A = new A { b.someMethod() // use b }}trait BComponentImpl { self: AComponent => lazy val b: B = new B { a.someMethod() // use a }}object Cake extends AComponentImpl with BComponentImplCake.a // NPE!Cake.b // NPE!
Jede Komponente befindet sich in einer eigenen Datei, sodass Sie diese Abhängigkeit nicht bemerken. Tests mit Mock helfen Ihnen dabei nicht:
trait BComponentMock { val b: B = mock }val aComponent = new AComponentImpl with BComponentMock {}aComponent.a // works!
Es wird also alles in Ordnung sein, bis Sie den Code tatsächlich ausführen. Sie wissen – der Code ist typsicher, was bedeutet, dass der Wert, den Sie konstruieren, Typbeschränkungen folgt. Diese Einschränkungen bedeuten jedoch nichts, da Nothing
(ausgelöste Ausnahme) ein vollkommen gültiger Subtyp Ihres erforderlichen Typs ist.
Initialisierungsreihenfolge
Das vorherige Problem bezieht sich auf dieses: Wie lautet die Reihenfolge der Komponenteninitialisierung?
Bei normalem DI ist es offensichtlich – bevor Sie eine Sache in die andere einfügen, müssen Sie sie erstellen. Sie sehen sich also nur die Reihenfolge an, in der Sie Objekte erstellen.
Mit einem Kuchen können Sie es vergessen. Werte innerhalb von Komponenten sind häufig lazy val
s oder object
s. Das Attribut, auf das zuerst zugegriffen wird, versucht, seine Abhängigkeiten zu instanziieren, wodurch versucht wird, seine Abhängigkeiten zu instanziieren usw.
Selbst wenn wir mit val
s gehen, ist es eine Frage der Reihenfolge, in der wir Kuchen komponiert haben – Sie wissen schon, Merkmalslinearisierung. Solange jede Komponente genau einmal verwendet wird – kein Problem. Aber wenn Sie Ihren Kuchen getrocknet haben und sich einige Komponenten wiederholen, wenn Sie sie zusammenführen …
In einer perfekten Welt sollten Dinge wie die Initialisierungsreihenfolge keine Rolle spielen, aber ziemlich oft, und wir möchten z. B. wissen, wann eine Verbindung zur Datenbank oder zum Abonnement von Kafka hergestellt wurde, und Dinge protokollieren, um mögliche Fehler zu beheben.
Boilerplate und Lock-In
Wie Sie wahrscheinlich bemerkt haben, erzeugt dieses Muster eine Menge Boilerplate – im Grunde müsste jedes Repository oder jeder Dienst Wrapper haben, nur um die Abhängigkeiten zu kommentieren.
Sobald Sie diesen Weg gehen, werden Sie feststellen, dass es ziemlich schwierig ist, Kuchenmuster loszuwerden. Jede Implementierung wird in ein Merkmal eingefügt, und ihre Abhängigkeiten kommen aus dem Bereich, nicht über Parameter. Daher müssten Sie für jede Klasse zuerst einen Refactor durchführen, um sie ohne den Wrapper instanziieren zu können.
Aber dann müssten auch die Abhängigkeiten umgestaltet werden und so weiter. Kuchenmuster ist eine hässliche Verpflichtung, das schmerzt und widersetzt sich dir, wenn du versuchst, dich allmählich davon zurückzuziehen.
Buchstäblich jede andere Form von DI in Scala, die ich kenne (Laufzeitreflexion a’la Guice, Makroanmerkungen a’la MacWire, implizite, Kombinationen dieser 3) ermöglicht es Ihnen, stop zu sagen! in jedem Moment, und nach und nach zu einer anderen Lösung wechseln.
Zusammenfassung
Kuchenmuster ist eine Lösung für ein DI-Problem, das auf lange Sicht mehr schadet als nützt. Nach dem, was ich gehört habe, wurde es im Scala-Compiler verwendet, und sie bereuen es. Es war der Standard-DI-Mechanismus für Play Framework, aber aufgrund all dieser Probleme beschlossen die Autoren, zu Guice zu wechseln – ja, funktionale Programmierer bevorzugten Laufzeitreflexion gegenüber typsicherem Cake, das sagt also viel aus.
Also, was sind die Alternativen?
- einfache alte manuelle Argumentübergabe – keine Notwendigkeit zu erklären, dass
- implizite – haben ihre Vorteile, bis Ihr Projekt von mehr als sich selbst gepflegt wird, und Mitarbeiter beginnen sich darüber zu beschweren, nicht zu wissen, was passiert, langsame Kompilierung, implizite nicht verstehen …
- runtime reflection – Guice ist ein ausgereiftes Framework, das DI genau so macht, wie ich es hasse. Es wurde in vielen Projekten verwendet, Java-Programmierer lieben es – genau wie sie Spring lieben. Einige von ihnen lassen Abstraktion auslaufen, indem sie injector an die Klasse übergeben. Einige von ihnen möchten, dass ihre Konfiguration flexibel ist – so flexibel, dass die Klassenverdrahtung zur Laufzeit fehlschlagen kann, bleh!
- MacWire – nach der Hälfte der manuellen Übergabe von Argumenten und der Verwendung von Makros für alles – scannt es den aktuellen Bereich in der Kompilierungszeit und fügt Abhängigkeiten für Sie ein. Wenn einige fehlen, schlägt die Kompilierung fehl. Sie können nur
wire
- Airframe – makrobasiertes Framework schreiben, in dem Sie ein Rezept für DI mit einem DSL erstellen. Es hat Unterstützung für Object Lifecycle Management
- pulp – my shameless Auto-Promotion. Es wurden implizite und Makroanmerkungen verwendet, um Anbieter für Klassen zu generieren
IMHO sollte jeder von ihnen besser funktionieren, als Kuchenmuster.