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:
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 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:
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 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:
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) }
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:
retainUsers
méthode retainUsers
ne devrait pas avoir besoin de connaître la dépendance de la banque de données 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)() }
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.
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. sequence
, les méthodes traverse
implémentées gratuitement. Je voudrais aussi dire ce que je n'aime pas dans Reader.
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. 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.