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
UserReminder
n'a aucune idée qui aFindUsers
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 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
IO
monade 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 class
es 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 retainUsers
méthode (qui appelle emailInactive
, qui appelle inactive
à trouver les utilisateurs inactifs) aurait besoin de connaître la Datastore
dé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?
Réponses:
Comment modéliser cet exemple
Je ne sais pas si cela doit être modélisé avec le Reader, mais cela peut être par:
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.inactive
méthode. Je le laisse revenirList[String]
pour que la liste d'adresses puisse être utilisée enUserReminder.emailInactive
mé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
,Res
types 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 appelleraituserFinder.inactive()
ici, elle appelle simplementinactive()
- 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:
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.inactive
dépend deDatastore
etUserReminder.emailInactive
deEmailServer
. 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
Config
même si elle n'en accepte qu'une partie comme paramètre. C'est une méthode appeléelocal
, définie dans Reader. Il doit être fourni avec un moyen d'extraire la partie pertinente du fichierConfig
.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
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.
local
appels 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.sequence
, lestraverse
méthodes mises en œuvre gratuitement.Je voudrais également dire ce que je n'aime pas dans Reader.
pure
,local
et 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
FindUsers
classe 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.
la source
Datastore
etEmailServer
sont laissés comme traits, et d'autres sont devenusobject
s? 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?EmailSender
en un objet, non? Je ne pourrais pas alors exprimer la dépendance sans avoir le type ...EmailSender
vous , 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
.(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;)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.
la source
Config
laquelle 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'unConfig
avec toutes les dépendances ne signifie- t-il pas que chaque méthode dépend de toutes ?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