Lecteur Monad pour dependency injections: plusieurs dépendances, appels nesteds

Lorsqu’on lui pose des questions sur l’dependency injections dans Scala, beaucoup de réponses indiquent l’utilisation de Reader Monad, que ce soit celle de Scalaz ou celle de la vôtre. Il y a un certain nombre d’articles très clairs décrivant les bases de l’approche (par exemple, le discours de Runar , le blog de Jason ), mais je n’ai pas réussi à trouver un exemple plus complet. traditionnel “manuel” DI (voir le guide que j’ai écrit ). Très probablement, il me manque un point important, d’où la question.

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

trait Datastore { def runQuery(query: Ssortingng): List[Ssortingng] } trait EmailServer { def sendEmail(to: Ssortingng, content: Ssortingng): 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 à l’aide de classes et de parameters 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 UserReminder n’a aucune idée que FindUsers besoin 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 de la Scala pure; 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 monade IO si nous voulons capturer les effets, etc.

Comment cela pourrait-il être modélisé avec le monad du lecteur? Il serait bon de conserver les caractéristiques ci-dessus, de sorte qu’il soit clair quel type de dépendances chaque fonctionnalité nécessite et de masquer les dépendances d’une fonctionnalité. Notez que l’utilisation de la class es est plus un détail d’implémentation; peut-être que la solution “correcte” utilisant la monade du lecteur utiliserait autre chose.

J’ai trouvé une question un peu reliée qui suggère soit:

  • en utilisant un seul object d’environnement avec toutes les dépendances
  • en utilisant les environnements locaux
  • motif “parfait”
  • cartes indexées par type

Cependant, en plus d’être (mais c’est subjectif) un peu trop complexe pour une chose aussi simple, dans toutes ces solutions, par exemple la méthode retainUsers (qui appelle emailInactive , qui appelle inactive pour trouver les utilisateurs inactifs) aurait besoin de connaître le Dépendance du Datastore , pour pouvoir appeler correctement les fonctions nestedes – ou est-ce que je me trompe?

Dans quels aspects l’utilisation du Reader Monad pour une telle “application métier” serait-elle préférable à l’utilisation de parameters de constructeur?

Comment modéliser cet exemple

Comment cela pourrait-il être modélisé avec le monad du lecteur?

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

  1. encoder les classes comme des fonctions qui rendent le code plus agréable avec Reader
  2. composer les fonctions avec Reader pour une compréhension et en l’utilisant

Juste avant le début, je dois vous parler de petits ajustements de code d’échantillon que j’ai jugés bénéfiques pour cette réponse. Le premier changement concerne la méthode FindUsers.inactive . Je le laisse retourner List[Ssortingng] pour que la liste d’adresses puisse être utilisée dans la méthode UserReminder.emailInactive . J’ai également ajouté des implémentations simples aux méthodes. Enfin, l’exemple utilisera une version roulée 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) } 

Etape de modélisation 1. Encodage des classes en tant que fonctions

Peut-être que c’est facultatif, je ne suis pas sûr, mais plus tard, cela rend la compréhension meilleure. Notez que cette fonction résultante est curry. Il prend également les anciens arguments de constructeur comme premier paramètre (liste de parameters). 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 des types Dep , Arg , Res peut être complètement arbitraire: un tuple, une fonction ou un type simple.

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

 trait Datastore { def runQuery(query: Ssortingng): List[Ssortingng] } trait EmailServer { def sendEmail(to: Ssortingng, content: Ssortingng): Unit } object FindUsers { def inactive: Datastore => () => List[Ssortingng] = dataStore => () => dataStore.runQuery("select inactive") } object UserReminder { def emailInactive(inactive: () => List[Ssortingng]): 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 certaines fonctions ne dépendent pas de l’ensemble des objects, mais uniquement des parties directement utilisées. Où dans la version de POO, l’instance UserReminder.emailInactive() appellerait userFinder.inactive() ici, elle appelle simplement inactive() – une fonction qui lui est transmise dans le premier paramètre.

S’il vous plaît noter que le code présente les trois propriétés souhaitables de la question:

  1. il est clair quel type de dépendances chaque fonctionnalité a besoin
  2. cache les dépendances d’une fonctionnalité d’une autre
  3. retainUsers méthode retainUsers ne devrait pas avoir besoin de connaître la dépendance de la banque de données

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

Reader monad vous permet de composer uniquement des fonctions qui dépendent toutes du même type. Ce n’est souvent pas un cas. Dans notre exemple, FindUsers.inactive dépend de Datastore et UserReminder.emailInactive sur EmailServer . Pour résoudre ce problème, il est possible d’introduire un nouveau type (souvent appelé Config) qui contient toutes les dépendances, puis de modifier les fonctions pour qu’elles en dépendent toutes et n’en prennent que les données pertinentes. Cela est évidemment faux du sharepoint vue de la gestion des dépendances, car cela rend également ces fonctions dépendantes de types qu’elles ne devraient pas connaître.

Heureusement, il existe un moyen de faire fonctionner la fonction avec Config même si elle n’en accepte qu’une partie en tant que 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 de la Config .

Cette connaissance appliquée à l’exemple présent ressemblerait à ceci:

 object Main extends App { case class Config(dataStore: Datastore, emailServer: EmailServer) val config = Config( new Datastore { def runQuery(query: Ssortingng) = List("[email protected]") }, new EmailServer { def sendEmail(to: Ssortingng, content: Ssortingng) = 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 sur l'utilisation des parameters constructeur

Dans quels aspects l'utilisation du Reader Monad pour une telle "application métier" serait-elle préférable à l'utilisation de parameters de constructeur?

J'espère qu'en préparant cette réponse, j'ai rendu plus facile de juger par vous-même dans quels aspects cela battrait les constructeurs ordinaires. Pourtant, si je devais les énumérer, voici ma liste. Disclaimer: J'ai des antécédents d'OOP et je n'apprécierai peut-être pas complètement Reader et Kleisli car je ne les utilise pas.

  1. Uniformité - peu importe la longueur / la longueur de la compréhension, c'est juste un lecteur et vous pouvez facilement le composer avec une autre instance, en introduisant peut-être seulement un type de configuration supplémentaire et en saupoudrant quelques appels local . Ce point est plutôt une question de goût, car lorsque vous utilisez des constructeurs, personne ne vous empêche de composer quoi que ce soit, à moins que quelqu'un fasse quelque chose de stupide, comme un travail considéré comme une mauvaise pratique dans la POO.
  2. Reader est une monade, il reçoit donc tous les avantages liés à cela - la sequence , les méthodes traverse implémentées gratuitement.
  3. Dans certains cas, il peut être préférable de ne construire le Reader qu'une seule fois et de l'utiliser pour de nombreuses configurations. Avec les constructeurs, personne ne vous empêche de le faire, il vous suffit de créer à nouveau le graphe d'object entier pour chaque configuration entrante. Même si cela ne me pose aucun problème (je préfère même le faire à chaque demande d’application), ce n’est pas une idée évidente pour beaucoup de gens pour des raisons que je ne peux que spéculer.
  4. Reader vous pousse à utiliser davantage les fonctions, qui joueront mieux avec les applications écrites principalement en style FP.
  5. Reader sépare les préoccupations; vous pouvez créer, interagir avec tout, définir la logique sans fournir de dépendances. Effectivement fournir plus tard, séparément. (Merci Ken Scrambler pour ce point). Ceci est souvent entendu avantage de Reader, mais cela est également possible avec des constructeurs simples.

Je voudrais aussi 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 firebase database. Pour moi, il n'y a pas de sens à utiliser Reader pour des objects pratiquement constants, comme un serveur de messagerie ou un 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 la flexibilité nécessaire pour que vous puissiez spécifier vos dépendances à chaque appel, mais si vous n'en avez pas vraiment besoin, vous ne payez que les taxes.
  2. La lourdeur implicite - l'utilisation de Reader sans implicites rendrait l'exemple difficile à lire. D'un autre côté, lorsque vous masquez les parties bruitées à l'aide d'implicits et que vous faites des erreurs, le compilateur vous donnera parfois du mal à déchiffrer les messages.
  3. Cérémonie avec pure , local et créer ses propres classes Config / en utilisant des tuples pour cela. Reader vous oblige à append du code qui ne concerne pas le domaine de problème, introduisant ainsi du bruit dans le code. D'un autre côté, une application qui utilise des constructeurs utilise souvent le modèle de fabrique, qui provient également de l'extérieur du domaine de problèmes. Cette faiblesse n'est donc pas si grave.

Et si je ne veux pas convertir mes classes en objects avec des fonctions?

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

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

ce qui n'est pas si lisible, c'est ça? 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 très joli.

Je pense que la principale différence est que, dans votre exemple, vous injectez toutes les dépendances lorsque les objects sont instanciés. Le monad du Reader construit essentiellement des fonctions de plus en plus complexes à appeler en fonction des dépendances, qui sont ensuite renvoyées aux couches supérieures. 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 ensuite l’utiliser avec différentes dépendances injectées. Un inconvénient est, comme vous le dites, potentiellement moins clair. Dans les deux cas, la couche intermédiaire n’a besoin que de connaître ses dépendances immédiates, de sorte qu’elles fonctionnent toutes les deux pour la DI.