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:
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 distinctesIO
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 class
es 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:
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?
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:
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)
}
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:
retainUsers
la méthode ne devrait pas avoir besoin de connaître la dépendance DatastoreReader 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)()
}
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.
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.sequence
, traverse
mises en œuvre gratuitement.Je voudrais également dire ce que je n'aime pas dans Reader.
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.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.
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.