当被问及Scala中的依赖注入时,很多答案都指向使用Reader Monad,无论是来自Scalaz还是只是自己编写。有许多非常明确的文章描述了该方法的基础知识(例如Runar's talk,Jason's blog),但我没有找到更完整的例子,我没有看到它的优点这种方法超过了例如更传统的“手动”DI(见the guide I wrote)。最有可能的是我错过了一些重要的观点,因此提出了问题。
举个例子,我们假设我们有这些类:
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 = {}
}
这里我使用类和构造函数参数进行建模,这与“传统”DI方法非常吻合,但是这个设计有几个好的方面:
UserReminder
不知道FindUsers
需要数据存储区。功能甚至可以在单独的编译单元中IO
monad中的值。如何使用Reader monad建模?保留上面的特性会很好,因此很清楚每个功能需要什么样的依赖关系,并隐藏一个功能与另一个功能的依赖关系。请注意,使用class
es更多的是实现细节;也许使用Reader monad的“正确”解决方案会使用别的东西。
我找到了一个somewhat related question,暗示:
然而,在所有这些解决方案中,除了(但这是主观的)有点过于复杂之外,在所有这些解决方案中,例如retainUsers
方法(调用emailInactive
,调用inactive
以查找非活动用户)需要知道Datastore
依赖关系,才能正确调用嵌套功能 - 或者我错了?
在哪些方面使用Reader Monad来实现这样的“业务应用程序”要比使用构造函数参数更好?
答案 0 :(得分:35)
如何使用Reader monad建模?
我不确定这个是否应该使用Reader建模,但它可以通过:
就在开始之前,我需要告诉你一些我觉得对这个答案有益的小样本代码调整。
第一个变化是关于FindUsers.inactive
方法。我让它返回List[String]
,以便可以使用地址列表
在UserReminder.emailInactive
方法中。我还在方法中添加了简单的实现。最后,样本将使用a
以下是阅读器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)
}
也许这是可选的,我不确定,但后来它让理解看起来更好。 注意,结果函数是curry。它还将前构造函数参数作为其第一个参数(参数列表)。 那样
class Foo(dep: Dep) {
def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)
变为
object Foo {
def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)
请注意,Dep
,Arg
,Res
类型中的每一种都可以是完全任意的:元组,函数或简单类型。
这是初始调整后的示例代码,转换为函数:
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()
}
}
有一点需要注意的是,特定功能并不依赖于整个对象,而只取决于直接使用的部分。
OOP版本UserReminder.emailInactive()
实例在此处调用userFinder.inactive()
的位置只需调用inactive()
- 在第一个参数中传递给它的函数。
请注意,该代码展示了问题中的三个理想属性:
retainUsers
方法不需要了解数据存储依赖性 Reader monad允许您只编写所有依赖于相同类型的函数。通常情况并非如此。在我们的例子中
FindUsers.inactive
取决于Datastore
上的UserReminder.emailInactive
和EmailServer
。解决这个问题
一个人可以引入一个包含所有依赖关系的新类型(通常称为Config),然后进行更改
功能因此它们都依赖于它并且只从中获取相关数据。
从依赖管理的角度来看,这显然是错误的,因为这样你使这些功能也依赖
关于他们不应该首先了解的类型。
幸运的是,有一种方法可以使函数与Config
一起工作,即使它只接受它的某些部分作为参数。
它是一种名为local
的方法,在Reader中定义。需要提供一种从Config
中提取相关部分的方法。
应用于手头示例的知识看起来像这样:
object Main extends App {
case class Config(dataStore: Datastore, emailServer: EmailServer)
val config = Config(
new Datastore { def runQuery(query: String) = List("john.doe@fizzbuzz.com") },
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)()
}
在哪些方面会使用Reader Monad进行这样一个&#34;业务应用程序&#34;是否比仅使用构造函数参数更好?
我希望通过准备这个答案,我可以更容易地判断自己在哪些方面会胜过普通的构造者。 然而,如果我要列举这些,请点击我的列表。免责声明:我有OOP背景,我可能不喜欢Reader和Kleisli 因为我不能使用它们。
local
个调用。这一点是IMO
而是一种品味问题,因为当你使用构造函数时,没有人会阻止你构建你喜欢的任何东西,
除非有人做了一些愚蠢的事情,比如在构造函数中做工作,这在OOP中被认为是一种不好的做法。sequence
,traverse
方法。我还想告诉读者我不喜欢什么。
pure
,local
举行仪式并创建自己的配置类/使用元组。 Reader会强制您添加一些代码
这不是问题域,因此在代码中引入了一些噪音。另一方面,一个应用程序
使用构造函数的人经常使用工厂模式,这也是来自问题域之外的,所以这个弱点不是那个
严重。你想要的。您在技术上可以避免这种情况,但只是看看如果我没有将FindUsers
类转换为对象会发生什么。理解的各个方面如下:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
哪个不可读,是吗?关键在于Reader对函数进行操作,所以如果你还没有它们,你需要内联构建它们,这通常不是那么漂亮。
答案 1 :(得分:3)
我认为主要区别在于,在您的示例中,您在实例化对象时注入所有依赖项。 Reader monad基本上构建了一个越来越复杂的函数来调用依赖关系,然后返回到最高层。在这种情况下,最终调用函数时会发生注入。
一个直接的优势是灵活性,特别是如果你可以构造你的monad一次然后想要使用不同的注入依赖项。如你所说,一个缺点是可能不太清晰。在这两种情况下,中间层只需要知道它们的直接依赖性,因此它们都像DI所宣传的那样工作。
答案 2 :(得分:1)
接受的答案很好地解释了 Reader Monad 的工作原理。
我想添加一个配方来使用 Cats 库阅读器组合具有不同依赖关系的任意两个函数。 此代码段也可在 Scastie
上找到让我们定义两个我们想要组合的函数: 功能类似于已接受的答案中定义的功能。
case class DataStore()
case class EmailServer()
DataStore
依赖项定义第一个函数。它需要 DataStore
并返回非活动用户列表 def f1(db:DataStore):List[String] = List("john@test.com", "james@test.com", "maria@test.com")
EmailServer
作为依赖项之一定义另一个函数 def f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit =
usersToEmail.foreach(user => println(s"emailing ${user} using server ${emailServer}"))
现在组合这两个函数的秘诀
import cats.data.Reader
val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)
现在 f2
接受 EmailServer
,并返回另一个函数,该函数接受 List
的用户发送电子邮件
CombinedConfig
类,其中包含两个函数的依赖项 case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
val r1 = Reader(f1)
val r2 = Reader(f2)
val r1g = r1.local((c:CombinedConfig) => c.dataStore)
val r2g = r2.local((c:CombinedConfig) => c.emailServer)
val composition = for {
u <- r1g
e <- r2g
} yield e(u)
CombinedConfig
并调用组合 val myConfig = CombinedConfig(DataStore(), EmailServer())
println("Invoking Composition")
composition.run(myConfig)