web-dev-qa-db-fra.com

Reader Monad for Dependency Injection: plusieurs dépendances, appels imbriqués

Lorsqu'on leur a posé des questions sur l'injection de dépendance dans Scala, un grand nombre de réponses indiquent l'utilisation de Reader Monad, soit celle de Scalaz, soit simplement la vôtre. Il y a un certain nombre d'articles très clairs décrivant les bases de l'approche (par exemple Runar's talk , Jason's blog ), mais je n'ai pas réussi à trouver un exemple plus complet, et je ne vois pas les avantages de cette approche par exemple une DI "manuelle" plus traditionnelle (voir le guide que j'ai écrit ). Je 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 fonctionne 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 masquées à travers les fonctionnalités, par ex. UserReminder n'a aucune idée que FindUsers a besoin d'une banque de données. Les fonctionnalités peuvent être même dans des unités de compilation distinctes
  • nous utilisons uniquement 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 monade IO 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, de sorte qu'il soit clair de quel type de dépendances chaque fonctionnalité a besoin et de cacher les dépendances d'une fonctionnalité à une autre. Notez que l'utilisation de classes est davantage un détail d'implémentation; peut-être que la solution "correcte" utilisant la monade Reader utiliserait autre chose.

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

  • en utilisant un seul objet d'environnement avec toutes les dépendances
  • en utilisant des environnements locaux
  • motif "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 méthode retainUsers (qui appelle emailInactive, qui appelle inactive pour trouver les utilisateurs inactifs) devrait connaître la dépendance Datastore, pour pouvoir appeler correctement les fonctions imbriquées - ou je me trompe?

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

84
adamw

Comment modéliser cet exemple

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

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 à lire avec Reader
  2. composer les fonctions avec Reader dans un pour la compréhension et l'utiliser

Juste avant le début, j'ai besoin de vous parler de petits exemples d'ajustements de code qui m'ont semblé bénéfiques pour cette réponse. Le premier changement concerne la méthode FindUsers.inactive. Je le laisse retourner List[String] Pour que la liste des 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 à 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. 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 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 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: 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 uniquement des parties directement utilisées. Où dans OOP version 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. il est clair de quel type de dépendances chaque fonctionnalité a besoin
  2. masque les dépendances d'une fonctionnalité à une autre
  3. retainUsers la méthode ne devrait pas avoir besoin de connaître la dépendance Datastore

É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 De 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 pour qu'elles dépendent toutes de lui et ne prendre que les données pertinentes. C'est évidemment faux du point de vue de la gestion des dépendances, car de cette façon, vous faites également dépendre ces fonctions de types qu'ils ne devraient pas connaître en premier lieu.

Heureusement, il s'avère qu'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 du 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 de Reader Monad pour une telle "application métier" serait-elle meilleure que l'utilisation de paramètres constructeurs?

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. Avis de non-responsabilité: j'ai une expérience de OOP et je ne peux pas apprécier pleinement Reader et Kleisli car je ne les utilise pas.

  1. Uniformité - peu importe à quel point la compréhension est courte/longue, c'est juste un Reader et vous pouvez facilement le composer avec une autre instance, peut-être en introduisant seulement un type de configuration supplémentaire et en saupoudrant quelques appels local par-dessus. Ce point est IMO plutôt une question de goût, 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 faire du travail dans un constructeur qui est considéré comme une mauvaise pratique dans la POO.
  2. Reader est une monade, il bénéficie donc de tous les avantages liés aux méthodes - sequence, traverse mises en œuvre 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 une large gamme de configurations. Avec les constructeurs, personne ne vous empêche de le faire, il vous suffit de recréer l'intégralité du graphique d'objet pour chaque entrée de configuration. Bien que 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 pour lesquelles je ne peux que spéculer.
  4. Reader vous pousse à utiliser davantage les fonctions, qui joueront mieux avec une application écrite 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. Fournissez en fait plus tard, séparément. (Merci Ken Scrambler pour ce point). C'est souvent entendu l'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, il est peu judicieux d'utiliser Reader pour des objets pratiquement constants, 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é vous permettant de 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 faites des erreurs, le compilateur vous donnera parfois des messages difficiles à déchiffrer.
  3. Cérémonie avec pure, local et création de ses propres classes de configuration/en utilisant des 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 problématique, donc cette faiblesse n'est pas si grave.

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

Tu veux. Techniquement, vous pouvez éviter cela, mais regardez ce qui se passerait si je ne convertissais pas la classe FindUsers en objet. La ligne respective de compréhension devrait ressembler à:

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

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.

36
Przemek Pokrywka

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 hautes. 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 ses dépendances immédiates, elles fonctionnent donc toutes les deux comme annoncé pour DI.

3
Daniel Langdon