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模,但它可以通过:

  1. 将类编码为使得代码更好地与Reader一起播放的函数
  2. 与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) 

请记住,每个DepArgRestypes可以是完全任意的:元组,函数或简单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() – 在第一个参数中传递给它的函数。

请注意,代码显示了这个问题的三个理想属性:

  1. 很明显每个function需要什么样的依赖关系
  2. 隐藏另一个function的依赖关系
  3. retainUsers方法不需要了解Datastore依赖关系

build模步骤2.使用阅读器编写function并运行它们

读者monad让你只能编写依赖于相同types的函数。 这通常不是一个例子。 在我们的示例中, FindUsers.inactive依赖于FindUsers.inactive上的DatastoreEmailServer 。 为了解决这个问题,可以引入一个新的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,因为我不使用它们。

  1. 一致性 – 无论理解是多么短/多长,它只是一个读者,你可以很容易地与另一个实例组合,也许只能引入一个configurationtypes,并在上面添加一些local调用。 这一点是国际海事组织,而不是一个味道的问题,因为当你使用构造函数时,没有人会阻止你编写任何你喜欢的东西,除非有人做了一些愚蠢的事情,比如在构造函数中做这种在OOP中被认为是不好的做法。
  2. 读者是一个monad,所以它获得了所有相关的好处 – sequencetraverse方法免费实现。
  3. 在某些情况下,您可能会发现最好只构build一次Reader,并将其用于各种Config。 使用构造函数,没有人会阻止你这样做,只需要为每个configuration传入重新构build整个对象图。 虽然我没有问题(我甚至更喜欢在每个应用程序的请求上这样做),但由于我只能推测的原因,对许多人来说这不是一个明显的想法。
  4. 读者推动你更多的使用function,这将更好地发挥主要FP风格的应用程序。
  5. 读者分离关注; 你可以创build,与所有事物交互,定义逻辑而不提供依赖关系。 其实以后再分开。 (感谢Ken加扰器这一点)。 读者经常听到这个优点,然而这对简单的构造函数也是可能的。

我也想告诉读者我不喜欢什么。

  1. 营销。 有时候我会得到一个印象,那就是Reader是针对所有types的依赖进行销售的,如果这是一个会话cookie或者一个数据库的话。 对我来说,使用Reader几乎是不变的对象,比如这个例子中的电子邮件服务器或者存储库。 对于这样的依赖关系,我发现普通的构造函数和/或部分应用的函数更好。 本质上来说,读者给你灵活性,所以你可以在每个通话中指定你的依赖关系,但是如果你不需要,你只需要缴税。
  2. 隐含的沉重感 – 使用Reader而没有暗示会使这个例子难以阅读。 另一方面,当你使用implicit来隐藏噪声部分并且出错时,编译器有时会给你很难解密的消息。
  3. 仪式与purelocal和创build自己的configuration类/使用元组。 阅读器迫使你添加一些与问题域无关的代码,因此在代码中引入一些噪声。 另一方面,使用构造函数的应用程序经常使用工厂模式,这也是来自问题域之外的,所以这个弱点并不那么严重。

如果我不想将我的类转换为具有函数的对象呢?

你要。 你在技术上可以避免这一点,但只是看看如果我没有将FindUsers类转换为对象会发生什么。 相应的理解行将如下所示:

 getAddresses <- ((ds: Datastore) => new FindUsers(ds).inactive _).local[Config](_.dataStore) 

这是不可读的,是吗? 关键在于Reader对函数进行操作,所以如果你没有它们,你需要将它们内联构build,这通常不是那么漂亮。

我认为主要的区别是,在你的例子中,当实例化对象时,注入所有的依赖关系。 Reader monad基本上构build了一个越来越复杂的函数来调用给定的依赖关系,然后返回到最高层。 在这种情况下,最后调用函数时会发生注入。

一个直接的好处是灵活性,特别是如果你可以构造你的monad一次,然后想要用不同的注入依赖。 如你所说,一个缺点是可能不太清晰。 在这两种情况下,中间层只需要知道他们的直接依赖关系,所以他们都为广告的DI工作。