Reader Monad pour l'injection de dépendances: dépendances multiples, appels imbriqués

87

Interrogé sur l'injection de dépendances dans Scala, de nombreuses réponses indiquent l'utilisation du Reader Monad, soit celui de Scalaz, soit simplement le vôtre. Il existe un certain nombre d'articles très clairs décrivant les bases de l'approche (par exemple, l'exposé de Runar , le blog de Jason ), mais je n'ai pas réussi à trouver un exemple plus complet, et je ne vois pas les avantages de cette approche par rapport à une approche plus DI "manuelle" traditionnelle (voir le guide que j'ai rédigé ). Il me manque probablement un point important, d'où la question.

À titre d'exemple, imaginons que nous avons ces classes:

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 = {}
}

Ici, je modélise des choses en utilisant des classes et des paramètres de constructeur, ce qui joue très bien avec les approches DI "traditionnelles", mais cette conception a quelques bons côtés:

  • chaque fonctionnalité a des dépendances clairement énumérées. Nous supposons en quelque sorte que les dépendances sont vraiment nécessaires pour que la fonctionnalité fonctionne correctement
  • les dépendances sont cachées à travers les fonctionnalités, par exemple, le UserRemindern'a aucune idée qui a FindUsersbesoin d'un magasin de données. Les fonctionnalités peuvent être même dans des unités de compilation séparées
  • nous n'utilisons que du Scala pur; les implémentations peuvent exploiter des classes immuables, des fonctions d'ordre supérieur, les méthodes de «logique métier» peuvent renvoyer des valeurs enveloppées dans la IOmonade si nous voulons capturer les effets, etc.

Comment cela pourrait-il être modélisé avec la monade Reader? Il serait bon de conserver les caractéristiques ci-dessus, afin de savoir clairement le type de dépendances dont chaque fonctionnalité a besoin et de masquer les dépendances d'une fonctionnalité d'une autre. Notez que l'utilisation de classes est plus un détail d'implémentation; peut-être que la solution «correcte» utilisant la monade de Reader utiliserait autre chose.

J'ai trouvé une question quelque peu liée qui suggère soit:

  • en utilisant un seul objet d'environnement avec toutes les dépendances
  • en utilisant des environnements locaux
  • modèle "parfait"
  • cartes indexées par type

Cependant, en plus d'être (mais c'est subjectif) un peu trop complexe comme pour une chose aussi simple, dans toutes ces solutions, par exemple, la retainUsersméthode (qui appelle emailInactive, qui appelle inactiveà trouver les utilisateurs inactifs) aurait besoin de connaître la Datastoredépendance, pour être capable d'appeler correctement les fonctions imbriquées - ou est-ce que je me trompe?

Dans quels aspects l'utilisation du Reader Monad pour une telle "application métier" serait-elle meilleure que la simple utilisation de paramètres de constructeur?

Adamw
la source
1
La monade Reader n'est pas une solution miracle. Je pense que si vous avez besoin de beaucoup de niveaux de dépendances, votre conception est plutôt bonne.
ZhekaKozlov
Il est cependant souvent décrit comme une alternative à l'injection de dépendance; peut-être devrait-il alors être décrit comme un complément? J'ai parfois le sentiment que la DI est rejetée par les "vrais programmeurs fonctionnels", donc je me demandais "quoi à la place" :) Quoi qu'il en soit, je pense qu'avoir plusieurs niveaux de dépendances, ou plutôt plusieurs services externes auxquels vous devez parler, c'est comment chaque "application métier" de taille moyenne à grande ressemble (ce n'est certainement pas le cas pour les bibliothèques)
adamw
2
J'ai toujours pensé que la monade Reader était quelque chose de local. Par exemple, si vous avez un module qui ne communique qu'avec un DB, vous pouvez implémenter ce module dans le style Reader monad. Cependant, si votre application nécessite de nombreuses sources de données différentes qui doivent être combinées, je ne pense pas que la monade de Reader soit bonne pour cela.
ZhekaKozlov
Ah, cela pourrait être un bon guide pour combiner les deux concepts. Et puis en effet, il semblerait que DI et RM se complètent. Je suppose qu'il est en fait assez courant d'avoir des fonctions qui n'opèrent que sur une seule dépendance, et l'utilisation de RM ici aiderait à clarifier les limites de dépendance / données.
adamw

Réponses:

36

Comment modéliser cet exemple

Comment cela pourrait-il être modélisé avec la monade Reader?

Je ne sais pas si cela doit être modélisé avec le Reader, mais cela peut être par:

  1. encoder les classes comme des fonctions qui rend le code plus agréable avec Reader
  2. composition des fonctions avec Reader en vue de leur compréhension et utilisation

Juste avant le début, je dois vous parler de petits exemples d'ajustements de code qui m'ont semblé utiles pour cette réponse. Le premier changement concerne la FindUsers.inactiveméthode. Je le laisse revenir List[String]pour que la liste d'adresses puisse être utilisée en UserReminder.emailInactiveméthode. J'ai également ajouté des implémentations simples aux méthodes. Enfin, l'exemple utilisera une version roulée à la main suivante de Reader monad:

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)
}

Étape de modélisation 1. Codage des classes en tant que fonctions

Peut-être que c'est facultatif, je ne suis pas sûr, mais plus tard, cela rendra la compréhension meilleure. Notez que la fonction résultante est curry. Il prend également les anciens arguments du constructeur comme premier paramètre (liste de paramètres). De cette façon

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

devient

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

Gardez à l' esprit que chacun Dep, Arg, Restypes peuvent être complètement arbitraire: un tuple, une fonction ou d' un type simple.

Voici l'exemple de code après les ajustements initiaux, transformé en fonctions:

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()
    }
}

Une chose à noter ici est que des fonctions particulières ne dépendent pas des objets entiers, mais seulement des parties directement utilisées. Où, dans la version OOP, l' UserReminder.emailInactive()instance appellerait userFinder.inactive()ici, elle appelle simplement inactive() - une fonction qui lui est passée dans le premier paramètre.

Veuillez noter que le code présente les trois propriétés souhaitables de la question:

  1. le type de dépendances dont chaque fonctionnalité a besoin est clair
  2. masque les dépendances d'une fonctionnalité à une autre
  3. retainUsers ne devrait pas avoir besoin de connaître la dépendance du magasin de données

Étape de modélisation 2. Utilisation du Reader pour composer des fonctions et les exécuter

Reader monad vous permet de ne composer que des fonctions qui dépendent toutes du même type. Ce n'est souvent pas un cas. Dans notre exemple FindUsers.inactivedépend de Datastoreet UserReminder.emailInactivede EmailServer. Pour résoudre ce problème, on pourrait introduire un nouveau type (souvent appelé Config) qui contient toutes les dépendances, puis changer les fonctions afin qu'elles en dépendent toutes et n'en tirent que les données pertinentes. Cela est évidemment faux du point de vue de la gestion des dépendances, car de cette façon, vous rendez ces fonctions également dépendantes de types qu'elles ne devraient pas connaître en premier lieu.

Heureusement, il s'avère qu'il existe un moyen de faire fonctionner la fonction Configmême si elle n'en accepte qu'une partie comme paramètre. C'est une méthode appelée local, définie dans Reader. Il doit être fourni avec un moyen d'extraire la partie pertinente du fichier Config.

Cette connaissance appliquée à l'exemple en question ressemblerait à ceci:

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)()

}

Avantages par rapport à l'utilisation des paramètres du constructeur

Dans quels aspects l'utilisation du Reader Monad pour une telle "application métier" serait-elle meilleure que la simple utilisation de paramètres de constructeur?

J'espère qu'en préparant cette réponse, je vous ai rendu plus facile de juger par vous-même sous quels aspects cela battrait-il les constructeurs simples. Pourtant, si je devais les énumérer, voici ma liste. Clause de non-responsabilité: j'ai une expérience en POO et je n'apprécie peut-être pas pleinement Reader et Kleisli car je ne les utilise pas.

  1. Uniformité - peu importe la longueur / courte de la compréhension, c'est juste un lecteur et vous pouvez facilement le composer avec une autre instance, peut-être en introduisant seulement un autre type de configuration et en saupoudrant quelques localappels dessus. Ce point est plutôt une question de goût à l'OMI, car lorsque vous utilisez des constructeurs, personne ne vous empêche de composer ce que vous voulez, à moins que quelqu'un ne fasse quelque chose de stupide, comme travailler dans un constructeur, ce qui est considéré comme une mauvaise pratique en POO.
  2. Reader est une monade, il obtient tous les avantages liés à cette - sequence, les traverseméthodes mises en œuvre gratuitement.
  3. Dans certains cas, il peut être préférable de créer le Reader une seule fois et de l'utiliser pour une large gamme de configurations. Avec les constructeurs, personne ne vous empêche de faire cela, il vous suffit de créer à nouveau le graphe d'objets entier pour chaque configuration entrante. Bien que cela ne me pose aucun problème (je préfère même le faire à chaque demande de candidature), ce n'est pas une idée évidente pour beaucoup de gens pour des raisons sur lesquelles je ne peux que spéculer.
  4. Reader vous pousse à utiliser davantage les fonctions, qui fonctionneront mieux avec des applications écrites principalement dans le style FP.
  5. Le lecteur sépare les préoccupations; vous pouvez créer, interagir avec tout, définir la logique sans fournir de dépendances. En fait, fournir plus tard, séparément. (Merci Ken Scrambler pour ce point). On entend souvent cet avantage de Reader, mais c'est également possible avec des constructeurs simples.

Je voudrais également dire ce que je n'aime pas dans Reader.

  1. Commercialisation. Parfois, j'ai l'impression que Reader est commercialisé pour toutes sortes de dépendances, sans distinction s'il s'agit d'un cookie de session ou d'une base de données. Pour moi, l'utilisation de Reader pour des objets pratiquement constants n'a guère de sens, comme le serveur de messagerie ou le référentiel de cet exemple. Pour de telles dépendances, je trouve que les constructeurs simples et / ou les fonctions partiellement appliquées sont bien meilleurs. Essentiellement, Reader vous offre une flexibilité afin que vous puissiez spécifier vos dépendances à chaque appel, mais si vous n'en avez pas vraiment besoin, vous ne payez que sa taxe.
  2. Lourdeur implicite - l'utilisation de Reader sans implication rendrait l'exemple difficile à lire. D'un autre côté, lorsque vous masquez les parties bruyantes à l'aide d'implicits et que vous faites des erreurs, le compilateur vous donnera parfois des messages difficiles à déchiffrer.
  3. Cérémonie avec pure, localet la création de classes propres / Config à l' aide tuples pour cela. Reader vous oblige à ajouter du code qui ne concerne pas le domaine problématique, introduisant ainsi du bruit dans le code. D'un autre côté, une application qui utilise des constructeurs utilise souvent un modèle d'usine, qui provient également de l'extérieur du domaine du problème, donc cette faiblesse n'est pas si grave.

Que faire si je ne souhaite pas convertir mes classes en objets avec des fonctions?

Tu veux. Vous pouvez techniquement éviter cela, mais regardez ce qui se passerait si je ne convertissais pas la FindUsersclasse en objet. La ligne respective de pour la compréhension ressemblerait à:

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

ce qui n'est pas si lisible, n'est-ce pas? Le fait est que Reader fonctionne sur des fonctions, donc si vous ne les avez pas déjà, vous devez les construire en ligne, ce qui n'est souvent pas si joli.

Przemek Pokrywka
la source
Merci pour la réponse détaillée :) Un point qui n'est pas clair pour moi, c'est pourquoi Datastoreet EmailServersont laissés comme traits, et d'autres sont devenus objects? Y a-t-il une différence fondamentale dans ces services / dépendances / (comment vous les appelez) qui les amène à être traités différemment?
adamw
Eh bien ... je ne peux pas convertir par exemple EmailSenderen un objet, non? Je ne pourrais pas alors exprimer la dépendance sans avoir le type ...
adamw
Ah, la dépendance prendrait alors la forme d'une fonction avec un type approprié - donc au lieu d'utiliser des noms de type, tout devrait aller dans la signature de la fonction (le nom étant simplement accessoire). Peut-être, mais je ne suis pas convaincu;)
adamw
Correct. Au lieu de dépendre de EmailSendervous , vous dépendez (String, String) => Unit. Que ce soit convaincant ou non est un autre problème :) Pour être sûr, c'est au moins plus générique, puisque tout le monde en dépend déjà Function2.
Przemek Pokrywka
Eh bien, vous voudrez certainement nommer (String, String) => Unit pour qu'il transmette un sens, mais pas avec un alias de type mais avec quelque chose qui est vérifié au moment de la compilation;)
adamw
3

Je pense que la principale différence est que dans votre exemple, vous injectez toutes les dépendances lorsque les objets sont instanciés. La monade Reader construit essentiellement des fonctions de plus en plus complexes à appeler compte tenu des dépendances, qui sont ensuite renvoyées aux couches les plus élevées. Dans ce cas, l'injection se produit lorsque la fonction est finalement appelée.

Un avantage immédiat est la flexibilité, surtout si vous pouvez construire votre monade une fois et que vous souhaitez ensuite l'utiliser avec différentes dépendances injectées. Un inconvénient est, comme vous le dites, potentiellement moins de clarté. Dans les deux cas, la couche intermédiaire n'a besoin que de connaître leurs dépendances immédiates, de sorte qu'elles fonctionnent toutes les deux comme annoncé pour DI.

Daniel Langdon
la source
Comment la couche intermédiaire ne connaîtrait-elle que leurs dépendances intermédiaires, et pas toutes? Pourriez-vous donner un exemple de code montrant comment l'exemple pourrait être implémenté en utilisant la monade de lecture?
adamw
Je ne pourrais probablement pas l'expliquer mieux que le blog de Json (que vous avez posté). Pour citer le formulaire ici "Contrairement à l'exemple implicit, nous n'avons UserRepository nulle part dans les signatures de userEmail et userInfo". Vérifiez cet exemple attentivement.
Daniel Langdon
1
Eh bien oui, mais cela suppose que la monade de lecture que vous utilisez est paramétrée avec Configlaquelle contient une référence à UserRepository. Tellement vrai, ce n'est pas directement visible dans la signature, mais je dirais que c'est encore pire, vous n'avez aucune idée vraiment des dépendances que votre code utilise à première vue. Le fait d'être dépendant d'un Configavec toutes les dépendances ne signifie- t-il pas que chaque méthode dépend de toutes ?
adamw
Cela dépend d'eux, mais il n'a pas à le savoir. Identique à votre exemple avec les classes. Je les vois comme assez équivalents :-)
Daniel Langdon
Dans l'exemple avec les classes, vous ne dépendez que de ce dont vous avez réellement besoin, pas d'un objet global avec toutes les dépendances à l'intérieur. Et vous avez un problème sur la façon de décider de ce qui entre dans les «dépendances» du global config, et de ce qui est «juste une fonction». Vous vous retrouveriez probablement également avec beaucoup d'auto-dépendances. Quoi qu'il en soit, c'est plus une discussion question de préférence qu'une question
réponse