Reader Monaddependency injection:多重依赖,嵌套调用
当被问及Scala中的dependency injection问题时,很多答案都指向了使用Reader Monad的方法,无论是来自Scalaz还是自己的方法。 有很多非常清晰的文章描述了这种方法的基础(例如Runar的谈话 , Jason的博客 ),但是我没有设法find一个更完整的例子,我没有看到这个方法的优点,比如更多传统的“手动”DI(参见我写的指南 )。 很可能我错过了一些重要的观点,因此是一个问题。
举一个例子,让我们想象我们有这些类:
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 = {} }
在这里,我使用类和构造函数参数进行build模,这与“传统”DI方法非常吻合,但是这种devise有两个好方面:
- 每个function都清楚地列举了依赖关系。 我们假设依赖关系是function正常工作所必需的
- 依赖关系隐藏在function上,例如
UserReminder
不知道FindUsers
需要数据存储。 这些function甚至可以在单独的编译单元中使用 - 我们只使用纯粹的Scala; 实现可以利用不可变类,高阶函数,如果我们要捕获效果等,“业务逻辑”方法可以返回包装在
IO
monad中的值。
这怎么能用Reader monad来模拟? 保留上面的特征是很好的,所以清楚每个function需要什么样的依赖关系,并且隐藏一个function与另一个function的依赖关系。 请注意,使用class
es更多的是实现细节。 也许使用Reader monad的“正确”解决scheme会使用别的东西。
我确实发现了一个相关的问题 ,
- 使用具有所有依赖关系的单个环境对象
- 使用本地环境
- “冻糕”模式
- types索引的地图
然而,除了(但是这是主观的),对于这样一个简单的事情来说有些复杂,在所有这些解决scheme中,例如, retainUsers
方法(调用emailInactive
,调用inactive
来查找非活动用户)需要知道Datastore
依赖,能够正确调用嵌套function – 或者我错了吗?
在哪些方面使用Reader Monad这样的“业务应用程序”比使用构造函数参数更好?
如何build模这个例子
这怎么能用Reader monad来模拟?
我不确定这是否应该与读者build模,但它可以通过:
- 将类编码为使得代码更好地与Reader一起播放的函数
- 与Reader一起构成function用于理解和使用它
就在开始之前,我需要告诉你关于小样本的代码调整,我觉得这个答案是有益的。 首先改变是关于FindUsers.inactive
方法。 我让它返回List[String]
所以可以在UserReminder.emailInactive
方法中使用地址列表。 我也添加了简单的实现方法。 最后,该示例将使用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) }
build模步骤1.将类编码为function
也许这是可选的,我不确定,但后来它使理解看起来更好。 请注意,由此产生的function是curried。 它还将前构造参数作为它们的第一个参数(参数列表)。 那样
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
types可以是完全任意的:元组,函数或简单types。
以下是初始调整后的示例代码,转换为函数:
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()
– 在第一个参数中传递给它的函数。
请注意,代码显示了这个问题的三个理想属性:
- 很明显每个function需要什么样的依赖关系
- 隐藏另一个function的依赖关系
-
retainUsers
方法不需要了解Datastore依赖关系
build模步骤2.使用阅读器编写function并运行它们
读者monad让你只能编写依赖于相同types的函数。 这通常不是一个例子。 在我们的示例中, FindUsers.inactive
依赖于FindUsers.inactive
上的Datastore
和EmailServer
。 为了解决这个问题,可以引入一个新的types(通常被称为Config),它包含所有的依赖关系,然后改变这些函数,使它们都依赖于它,并且只从中获取相关的数据。 从依赖pipe理的angular度来看,这显然是错误的,因为这样你使这些函数也依赖于他们不应该知道的types。
幸运的是,即使只接受其中的一部分作为参数,也存在一种使Config
能够工作的方法。 这是一个在Reader中定义的称为local
的方法。 需要提供一种从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这样的“业务应用程序”比使用构造函数参数更好?
我希望通过准备这个答案,我更容易判断它在哪些方面会击败普通的build设者。 但是,如果我列举这些,这是我的列表。 免责声明:我有OOP的背景,我可能不完全赞赏读者和Kleisli,因为我不使用它们。
- 一致性 – 无论理解是多么短/多长,它只是一个读者,你可以很容易地与另一个实例组合,也许只能引入一个configurationtypes,并在上面添加一些
local
调用。 这一点是国际海事组织,而不是一个味道的问题,因为当你使用构造函数时,没有人会阻止你编写任何你喜欢的东西,除非有人做了一些愚蠢的事情,比如在构造函数中做这种在OOP中被认为是不好的做法。 - 读者是一个monad,所以它获得了所有相关的好处 –
sequence
,traverse
方法免费实现。 - 在某些情况下,您可能会发现最好只构build一次Reader,并将其用于各种Config。 使用构造函数,没有人会阻止你这样做,只需要为每个configuration传入重新构build整个对象图。 虽然我没有问题(我甚至更喜欢在每个应用程序的请求上这样做),但由于我只能推测的原因,对许多人来说这不是一个明显的想法。
- 读者推动你更多的使用function,这将更好地发挥主要FP风格的应用程序。
- 读者分离关注; 你可以创build,与所有事物交互,定义逻辑而不提供依赖关系。 其实以后再分开。 (感谢Ken加扰器这一点)。 读者经常听到这个优点,然而这对简单的构造函数也是可能的。
我也想告诉读者我不喜欢什么。
- 营销。 有时候我会得到一个印象,那就是Reader是针对所有types的依赖进行销售的,如果这是一个会话cookie或者一个数据库的话。 对我来说,使用Reader几乎是不变的对象,比如这个例子中的电子邮件服务器或者存储库。 对于这样的依赖关系,我发现普通的构造函数和/或部分应用的函数更好。 本质上来说,读者给你灵活性,所以你可以在每个通话中指定你的依赖关系,但是如果你不需要,你只需要缴税。
- 隐含的沉重感 – 使用Reader而没有暗示会使这个例子难以阅读。 另一方面,当你使用implicit来隐藏噪声部分并且出错时,编译器有时会给你很难解密的消息。
- 仪式与
pure
,local
和创build自己的configuration类/使用元组。 阅读器迫使你添加一些与问题域无关的代码,因此在代码中引入一些噪声。 另一方面,使用构造函数的应用程序经常使用工厂模式,这也是来自问题域之外的,所以这个弱点并不那么严重。
如果我不想将我的类转换为具有函数的对象呢?
你要。 你在技术上可以避免这一点,但只是看看如果我没有将FindUsers
类转换为对象会发生什么。 相应的理解行将如下所示:
getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore)
这是不可读的,是吗? 关键在于Reader对函数进行操作,所以如果你没有它们,你需要将它们内联构build,这通常不是那么漂亮。
我认为主要的区别是,在你的例子中,当实例化对象时,注入所有的依赖关系。 Reader monad基本上构build了一个越来越复杂的函数来调用给定的依赖关系,然后返回到最高层。 在这种情况下,最后调用函数时会发生注入。
一个直接的好处是灵活性,特别是如果你可以构造你的monad一次,然后想要用不同的注入依赖。 如你所说,一个缺点是可能不太清晰。 在这两种情况下,中间层只需要知道他们的直接依赖关系,所以他们都为广告的DI工作。