Reader Monad for Dependency Injection: Mehrere Abhängigkeiten, verschachtelte Aufrufe

87

Bei der Frage nach der Abhängigkeitsinjektion in Scala deuten viele Antworten darauf hin, dass Sie die Reader-Monade verwenden, entweder die von Scalaz oder nur Ihre eigene. Es gibt eine Reihe sehr klarer Artikel, die die Grundlagen des Ansatzes beschreiben (z. B. Runars Vortrag , Jasons Blog ), aber ich habe es nicht geschafft, ein vollständigeres Beispiel zu finden, und ich sehe die Vorteile dieses Ansatzes gegenüber z traditionelles "manuelles" DI (siehe die Anleitung, die ich geschrieben habe ). Höchstwahrscheinlich fehlt mir ein wichtiger Punkt, daher die Frage.

Stellen wir uns als Beispiel vor, wir haben diese Klassen:

trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }

class FindUsers(datastore: Datastore) {
  def inactive(): Unit = ()
}

class UserReminder(findUser: FindUsers, emailServer: EmailServer) {
  def emailInactive(): Unit = ()
}

class CustomerRelations(userReminder: UserReminder) {
  def retainUsers(): Unit = {}
}

Hier modelliere ich Dinge mit Klassen und Konstruktorparametern, was sehr gut mit "traditionellen" DI-Ansätzen funktioniert, aber dieses Design hat ein paar gute Seiten:

  • Jede Funktionalität hat klar aufgezählte Abhängigkeiten. Wir gehen davon aus, dass die Abhängigkeiten wirklich benötigt werden, damit die Funktionalität ordnungsgemäß funktioniert
  • Die Abhängigkeiten sind über Funktionen hinweg verborgen, z. B. UserReminderhat der keine Ahnung, dass FindUsersein Datenspeicher benötigt wird. Die Funktionen können sogar in separaten Kompiliereinheiten vorliegen
  • Wir verwenden nur reine Scala. Die Implementierungen können unveränderliche Klassen, Funktionen höherer Ordnung nutzen, die "Geschäftslogik" -Methoden können Werte zurückgeben, die in die IOMonade eingeschlossen sind, wenn wir die Effekte erfassen möchten usw.

Wie könnte dies mit der Reader-Monade modelliert werden? Es wäre gut, die oben genannten Merkmale beizubehalten, damit klar ist, welche Art von Abhängigkeiten jede Funktionalität benötigt, und Abhängigkeiten einer Funktionalität von einer anderen zu verbergen. Beachten Sie, dass die Verwendung von classes eher ein Implementierungsdetail ist. Vielleicht würde die "richtige" Lösung mit der Reader-Monade etwas anderes verwenden.

Ich habe eine etwas verwandte Frage gefunden, die entweder Folgendes nahe legt:

  • Verwenden eines einzelnen Umgebungsobjekts mit allen Abhängigkeiten
  • mit lokalen Umgebungen
  • "Parfait" -Muster
  • typindizierte Karten

Abgesehen davon, dass es für all diese Lösungen etwas zu komplex ist (aber das ist subjektiv), muss die retainUsersMethode (die aufruft emailInactive, die aufruft inactive, um die inaktiven Benutzer zu finden) über die DatastoreAbhängigkeit Bescheid wissen die verschachtelten Funktionen richtig aufrufen können - oder irre ich mich?

In welchen Aspekten wäre die Verwendung der Reader-Monade für eine solche "Geschäftsanwendung" besser als nur die Verwendung von Konstruktorparametern?

Adamw
quelle
1
Die Reader-Monade ist keine Silberkugel. Ich denke, wenn Sie viele Abhängigkeitsstufen benötigen, ist Ihr Design ziemlich gut.
ZhekaKozlov
Es wird jedoch häufig als Alternative zur Abhängigkeitsinjektion beschrieben. Vielleicht sollte es dann als Ergänzung beschrieben werden? Ich habe manchmal das Gefühl, dass DI von "echten funktionalen Programmierern" abgelehnt wird, daher habe ich mich gefragt, "was stattdessen" :) In beiden Fällen denke ich, dass es mehrere Ebenen von Abhängigkeiten oder vielmehr mehrere externe Dienste gibt, mit denen Sie sprechen müssen Jede mittelgroße "Geschäftsanwendung" sieht aus wie (was bei Bibliotheken sicher nicht der Fall ist)
Adamw
2
Ich habe immer an die Reader-Monade als etwas Lokales gedacht. Wenn Sie beispielsweise ein Modul haben, das nur mit einer Datenbank kommuniziert, können Sie dieses Modul im Reader-Monadenstil implementieren. Wenn Ihre Anwendung jedoch viele verschiedene Datenquellen benötigt, die miteinander kombiniert werden sollten, ist die Reader-Monade meiner Meinung nach nicht dafür geeignet.
ZhekaKozlov
Ah, das könnte eine gute Richtlinie sein, wie man die beiden Konzepte kombiniert. Und dann scheinen sich DI und RM tatsächlich zu ergänzen. Ich denke, es ist in der Tat durchaus üblich, Funktionen zu haben, die nur mit einer Abhängigkeit arbeiten, und die Verwendung von RM hier würde helfen, die Abhängigkeits- / Datengrenzen zu klären.
Adamw

Antworten:

36

So modellieren Sie dieses Beispiel

Wie könnte dies mit der Reader-Monade modelliert werden?

Ich bin mir nicht sicher, ob dies mit dem Reader modelliert werden soll, aber es kann sein durch:

  1. Codierung der Klassen als Funktionen, wodurch der Code mit Reader besser abgespielt werden kann
  2. Zusammenstellen der Funktionen mit Reader zum Verständnis und Verwenden

Kurz vor dem Start muss ich Ihnen über kleine Anpassungen des Beispielcodes berichten, die ich für diese Antwort als vorteilhaft empfunden habe. Bei der ersten Änderung geht es um die FindUsers.inactiveMethode. Ich lasse es zurückgeben, List[String]damit die Liste der Adressen in der UserReminder.emailInactiveMethode verwendet werden kann. Ich habe auch einfache Implementierungen zu Methoden hinzugefügt. Schließlich wird im Beispiel eine folgende handgerollte Version der Reader-Monade verwendet:

case class Reader[Conf, T](read: Conf => T) { self =>

  def map[U](convert: T => U): Reader[Conf, U] =
    Reader(self.read andThen convert)

  def flatMap[V](toReader: T => Reader[Conf, V]): Reader[Conf, V] =
    Reader[Conf, V](conf => toReader(self.read(conf)).read(conf))

  def local[BiggerConf](extractFrom: BiggerConf => Conf): Reader[BiggerConf, T] =
    Reader[BiggerConf, T](extractFrom andThen self.read)
}

object Reader {
  def pure[C, A](a: A): Reader[C, A] =
    Reader(_ => a)

  implicit def funToReader[Conf, A](read: Conf => A): Reader[Conf, A] =
    Reader(read)
}

Modellierungsschritt 1. Codieren von Klassen als Funktionen

Vielleicht ist das optional, ich bin mir nicht sicher, aber später sieht das Verständnis dadurch besser aus. Beachten Sie, dass die resultierende Funktion Curry ist. Es werden auch frühere Konstruktorargumente als erster Parameter (Parameterliste) verwendet. Dieser Weg

class Foo(dep: Dep) {
  def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)

wird

object Foo {
  def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)

Beachten Sie, dass jeder Dep, Arg, ResTypen völlig willkürlich sein kann: ein Tupel, eine Funktion oder einen einfachen Typen.

Hier ist der Beispielcode nach den ersten Anpassungen, der in Funktionen umgewandelt wurde:

trait Datastore { def runQuery(query: String): List[String] }
trait EmailServer { def sendEmail(to: String, content: String): Unit }

object FindUsers {
  def inactive: Datastore => () => List[String] =
    dataStore => () => dataStore.runQuery("select inactive")
}

object UserReminder {
  def emailInactive(inactive: () => List[String]): EmailServer => () => Unit =
    emailServer => () => inactive().foreach(emailServer.sendEmail(_, "We miss you"))
}

object CustomerRelations {
  def retainUsers(emailInactive: () => Unit): () => Unit =
    () => {
      println("emailing inactive users")
      emailInactive()
    }
}

Hierbei ist zu beachten, dass bestimmte Funktionen nicht von den gesamten Objekten abhängen, sondern nur von den direkt verwendeten Teilen. Wo in der OOP-Versionsinstanz hier nur UserReminder.emailInactive()aufgerufen wird userFinder.inactive(), wird nur aufgerufen inactive() - eine Funktion, die im ersten Parameter an sie übergeben wird.

Bitte beachten Sie, dass der Code die drei wünschenswerten Eigenschaften aus der Frage aufweist:

  1. Es ist klar, welche Art von Abhängigkeiten jede Funktionalität benötigt
  2. verbirgt Abhängigkeiten einer Funktionalität von einer anderen
  3. retainUsers Methode sollte nicht über die Datenspeicherabhängigkeit wissen müssen

Modellierungsschritt 2. Verwenden des Readers zum Erstellen und Ausführen von Funktionen

Mit Reader Monad können Sie nur Funktionen erstellen, die alle vom selben Typ abhängen. Dies ist oft nicht der Fall. In unserem Beispiel ist FindUsers.inactiveabhängig von Datastoreund UserReminder.emailInactiveauf EmailServer. Um dieses Problem zu lösen, könnte man einen neuen Typ (oft als Config bezeichnet) einführen, der alle Abhängigkeiten enthält, und dann die Funktionen so ändern, dass sie alle davon abhängen und nur die relevanten Daten daraus entnehmen. Dies ist aus Sicht des Abhängigkeitsmanagements offensichtlich falsch, da Sie auf diese Weise diese Funktionen auch von Typen abhängig machen, über die sie überhaupt nichts wissen sollten.

Glücklicherweise stellt sich heraus, dass es eine Möglichkeit gibt, die Funktion zum Arbeiten zu bringen, Configauch wenn nur ein Teil davon als Parameter akzeptiert wird. Es ist eine Methode namens local, definiert in Reader. Es muss eine Möglichkeit bereitgestellt werden, das relevante Teil aus dem zu extrahieren Config.

Dieses Wissen, das auf das vorliegende Beispiel angewendet wird, würde folgendermaßen aussehen:

object Main extends App {

  case class Config(dataStore: Datastore, emailServer: EmailServer)

  val config = Config(
    new Datastore { def runQuery(query: String) = List("[email protected]") },
    new EmailServer { def sendEmail(to: String, content: String) = println(s"sending [$content] to $to") }
  )

  import Reader._

  val reader = for {
    getAddresses <- FindUsers.inactive.local[Config](_.dataStore)
    emailInactive <- UserReminder.emailInactive(getAddresses).local[Config](_.emailServer)
    retainUsers <- pure(CustomerRelations.retainUsers(emailInactive))
  } yield retainUsers

  reader.read(config)()

}

Vorteile gegenüber der Verwendung von Konstruktorparametern

In welchen Aspekten wäre die Verwendung der Reader-Monade für eine solche "Geschäftsanwendung" besser als nur die Verwendung von Konstruktorparametern?

Ich hoffe, dass ich es durch die Vorbereitung dieser Antwort leichter gemacht habe, selbst zu beurteilen, in welchen Aspekten es einfache Konstrukteure schlagen würde. Wenn ich diese jedoch aufzählen würde, wäre hier meine Liste. Haftungsausschluss: Ich habe OOP-Hintergrund und kann Reader und Kleisli möglicherweise nicht vollständig schätzen, da ich sie nicht verwende.

  1. Einheitlichkeit - egal wie kurz / lang das Verständnis ist, es ist nur ein Reader und Sie können es leicht mit einer anderen Instanz zusammenstellen, indem Sie möglicherweise nur einen weiteren Konfigurationstyp einführen und einige localAufrufe darüber streuen . Dieser Punkt ist IMO eher Geschmackssache, denn wenn Sie Konstruktoren verwenden, hindert Sie niemand daran, alles zu komponieren, was Sie möchten, es sei denn, jemand tut etwas Dummes, wie die Arbeit im Konstruktor, was in OOP als schlechte Praxis angesehen wird.
  2. Reader ist eine Monade, so dass es alle Vorteile wird auf die in Verbindung stehend - sequence, traverseMethoden kostenlos umgesetzt.
  3. In einigen Fällen ist es möglicherweise vorzuziehen, den Reader nur einmal zu erstellen und für eine Vielzahl von Konfigurationen zu verwenden. Bei Konstruktoren hindert Sie niemand daran. Sie müssen lediglich das gesamte Objektdiagramm für jede eingehende Konfiguration neu erstellen. Ich habe zwar kein Problem damit (ich bevorzuge es sogar, dies bei jeder Bewerbung zu tun), aber es ist für viele Menschen aus Gründen, über die ich möglicherweise nur spekuliere, keine offensichtliche Idee.
  4. Der Reader bringt Sie dazu, Funktionen stärker zu nutzen, was bei Anwendungen, die überwiegend im FP-Stil geschrieben sind, besser funktioniert.
  5. Leser trennt Bedenken; Sie können erstellen, mit allem interagieren, Logik definieren, ohne Abhängigkeiten bereitzustellen. Eigentlich später separat liefern. (Danke Ken Scrambler für diesen Punkt). Dies wird oft als Vorteil von Reader gehört, aber das ist auch mit einfachen Konstruktoren möglich.

Ich möchte auch sagen, was ich in Reader nicht mag.

  1. Marketing. Manchmal habe ich den Eindruck, dass Reader für alle Arten von Abhängigkeiten vermarktet wird, ohne Unterschied, ob es sich um ein Sitzungscookie oder eine Datenbank handelt. Für mich macht es wenig Sinn, Reader für praktisch konstante Objekte wie E-Mail-Server oder Repository aus diesem Beispiel zu verwenden. Für solche Abhängigkeiten finde ich einfache Konstruktoren und / oder teilweise angewendete Funktionen viel besser. Im Wesentlichen bietet Ihnen Reader Flexibilität, sodass Sie Ihre Abhängigkeiten bei jedem Anruf angeben können. Wenn Sie dies jedoch nicht wirklich benötigen, zahlen Sie nur die Steuer.
  2. Implizite Schwere - Die Verwendung von Reader ohne Implizite würde das Beispiel schwer lesbar machen. Wenn Sie andererseits die verrauschten Teile mit impliziten Elementen ausblenden und Fehler machen, gibt Ihnen der Compiler manchmal schwer zu entschlüsselnde Nachrichten.
  3. Zeremonie mit pure, localund die Schaffung von eigenen Config - Klassen / Tupel für die Verwendung. Der Reader zwingt Sie, Code hinzuzufügen, bei dem es nicht um Problemdomänen geht, und führt daher zu Rauschen im Code. Andererseits verwendet eine Anwendung, die Konstruktoren verwendet, häufig ein Factory-Muster, das auch außerhalb des Problembereichs liegt, sodass diese Schwachstelle nicht so schwerwiegend ist.

Was ist, wenn ich meine Klassen nicht in Objekte mit Funktionen konvertieren möchte?

Sie wollen. Sie können das technisch vermeiden, aber schauen Sie, was passieren würde, wenn ich die FindUsersKlasse nicht in ein Objekt konvertieren würde . Die jeweilige Zeile zum Verständnis würde folgendermaßen aussehen:

getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)

was ist nicht so lesbar, oder? Der Punkt ist, dass Reader Funktionen ausführt. Wenn Sie sie also noch nicht haben, müssen Sie sie inline erstellen, was oft nicht so schön ist.

Przemek Pokrywka
quelle
Vielen Dank für die ausführliche Antwort :) Ein Punkt, der mir nicht klar ist, ist warum Datastoreund EmailServerbleiben als Merkmale übrig, und andere wurden objects? Gibt es einen grundlegenden Unterschied zwischen diesen Diensten / Abhängigkeiten / (wie auch immer Sie sie nennen), der dazu führt, dass sie unterschiedlich behandelt werden?
Adamw
Nun ... ich kann zB auch nicht EmailSenderin ein Objekt konvertieren , oder? Ich wäre dann nicht in der Lage, die Abhängigkeit auszudrücken, ohne den Typ zu haben ...
Adamw
Ah, die Abhängigkeit würde dann die Form einer Funktion mit einem geeigneten Typ annehmen. Anstatt also Typnamen zu verwenden, müsste alles in die Funktionssignatur eingehen (der Name ist nur zufällig). Vielleicht, aber ich bin nicht überzeugt;)
Adamw
Richtig. Anstatt davon abhängig zu sein EmailSender, würden Sie sich darauf verlassen (String, String) => Unit. Ob das überzeugt oder nicht, ist ein anderes Problem :) Natürlich ist es zumindest allgemeiner, da jeder bereits davon abhängt Function2.
Przemek Pokrywka
Nun, Sie möchten sicherlich einen Namen geben, (String, String) => Unit damit er eine Bedeutung vermittelt, allerdings nicht mit einem
Typalias,
3

Ich denke, der Hauptunterschied besteht darin, dass Sie in Ihrem Beispiel alle Abhängigkeiten einfügen, wenn Objekte instanziiert werden. Die Reader-Monade erstellt im Grunde genommen immer komplexere Funktionen, die angesichts der Abhängigkeiten aufgerufen werden können und die dann an die höchsten Ebenen zurückgegeben werden. In diesem Fall erfolgt die Injektion, wenn die Funktion endgültig aufgerufen wird.

Ein unmittelbarer Vorteil ist die Flexibilität, insbesondere wenn Sie Ihre Monade einmal erstellen können und sie dann mit verschiedenen injizierten Abhängigkeiten verwenden möchten. Ein Nachteil ist, wie Sie sagen, möglicherweise weniger Klarheit. In beiden Fällen muss die Zwischenschicht nur über ihre unmittelbaren Abhängigkeiten Bescheid wissen, sodass beide wie für DI angekündigt funktionieren.

Daniel Langdon
quelle
Wie würde die Zwischenschicht nur über ihre Zwischenabhängigkeiten Bescheid wissen und nicht über alle? Können Sie ein Codebeispiel geben, das zeigt, wie das Beispiel mit der Reader-Monade implementiert werden kann?
Adamw
Ich könnte es wahrscheinlich nicht besser erklären als Jsons Blog (den Sie gepostet haben). Überprüfen Sie dieses Beispiel sorgfältig.
Daniel Langdon
1
Nun ja, aber dies setzt voraus, dass die von Ihnen verwendete Lesermonade parametrisiert ist, mit Configder ein Verweis auf enthält UserRepository. So wahr, es ist nicht direkt in der Signatur sichtbar, aber ich würde sagen, das ist noch schlimmer, Sie haben auf den ersten Blick keine Ahnung, welche Abhängigkeiten Ihr Code wirklich verwendet. Bedeutet die Abhängigkeit von a Configmit all den Abhängigkeiten nicht, dass jede Methode von allen abhängt ?
Adamw
Es hängt von ihnen ab, aber es muss es nicht wissen. Gleich wie in Ihrem Beispiel mit Klassen. Ich sehe sie als ziemlich gleichwertig :-)
Daniel Langdon
Im Beispiel mit Klassen hängen Sie nur von dem ab, was Sie tatsächlich benötigen, nicht von einem globalen Objekt mit allen darin enthaltenen Abhängigkeiten. Und Sie bekommen ein Problem bei der Entscheidung, was innerhalb der "Abhängigkeiten" des Globalen configund was "nur eine Funktion" ist. Wahrscheinlich würden Sie auch viele Selbstabhängigkeiten haben. Wie auch immer, das ist eher eine Frage der Präferenz als eine Frage und
Antwort