Reader Monad用于依赖性注入:多个依赖性,嵌套调用


87

当被问及Scala中的依赖注入时,有很多答案指向使用Reader Monad,要么是Scalaz的,要么是自己滚动的。有很多非常清晰的文章描述了该方法的基础(例如Runar的演讲Jason的博客),但是我没有找到一个更完整的示例,而且我看不到该方法相对于其他方法的优势。传统的“手动” 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 = {}
}

在这里,我使用类和构造函数参数对事物进行建模,这在“传统” DI方法中非常有效,但是此设计有两个方面:

  • 每个功能都清楚地列举了依赖性。我们以为功能正常工作确实需要依赖项
  • 依赖项隐藏在所有功能中,例如,UserReminder不知道FindUsers需要数据存储区。功能甚至可以在单独的编译单元中
  • 我们只使用纯Scala;实现可以利用不可变的类,高阶函数,IO如果我们想捕获效果等,“业务逻辑”方法可以返回包装在monad中的值。

如何用Reader monad建模?保留上面的特征将是很好的,这样可以很清楚地知道每个功能需要什么样的依赖关系,并将一个功能的依赖关系隐藏起来。注意,使用classes是更多的实现细节;使用Reader monad的“正确”解决方案可能还会使用其他方法。

我确实发现了一个与之相关的问题,该问题提示:

  • 使用具有所有依赖项的单个环境对象
  • 使用本地环境
  • “冻糕”模式
  • 类型索引地图

但是,除了要像这样简单的事情那样过于主观之外,在所有这些解决方案中,例如retainUsers方法(调用emailInactiveinactive以查找非活动用户)都需要了解Datastore相关性,以便能够正确调用嵌套函数-还是我错了?

在哪方面将Reader Monad用于此类“业务应用程序”会比仅使用构造函数参数更好?


1
Reader monad不是灵丹妙药。我认为,如果您需要很多级别的依赖关系,那么您的设计就很好。
ZhekaKozlov 2015年

但是,它通常被描述为依赖注入的替代方法。也许应该将其描述为补充?有时我会感觉到“真正的函数式程序员”会丢弃DI,因此我想知道“什么呢” :)无论哪种方式,我都认为具有多个级别的依赖关系,或者说您需要与之交谈的多个外部服务是如何每个中型的“业务应用程序”都看起来像(肯定不是图书馆的情况)
adamw 2015年

2
一直以来,我一直以为Reader monad是本地的。例如,如果您有一些仅与数据库对话的模块,则可以采用Reader monad样式实现此模块。但是,如果您的应用程序需要将许多不同的数据源组合在一起,那么我认为Reader monad不适合这样做。
ZhekaKozlov 2015年

嗯,这可能是将两个概念结合在一起的好指南。然后确实,DI和RM似乎是相辅相成的。我想实际上只具有一种依赖关系的功能是很普遍的,在这里使用RM将有助于弄清依赖关系/数据边界。
adamw

Answers:


36

如何为这个例子建模

如何用Reader monad建模?

我不确定是否应该使用Reader进行建模,但是可以通过以下方式实现:

  1. 将类编码为函数,从而使代码与Reader更好地配合
  2. 用Reader组合功能以进行理解和使用

就在开始之前,我需要向您介绍一些我认为对此答案有益的小样本代码调整。第一个变化是关于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)
}

建模步骤1.将类编码为函数

也许这是可选的,我不确定,但是稍后它会使for forrehension看起来更好。注意,结果函数是咖喱的。它还将以前的构造函数参数作为第一个参数(参数列表)。那样

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)

请记住,每一个DepArgRes类型可以是完全随意:一个元组,一个功能或一个简单的类型。

这是经过初步调整后转换为函数的示例代码:

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. 显然每个功能需要什么样的依赖关系
  2. 隐藏一种功能对另一种功能的依赖性
  3. retainUsers 方法不需要了解数据存储依赖项

建模步骤2。使用Reader编写功能并运行它们

Reader monad使您仅可以编写全部依赖同一类型的函数。通常不是这种情况。在我们的例子 FindUsers.inactive依赖于DatastoreUserReminder.emailInactiveEmailServer。为了解决该问题,可以引入一种包含所有依赖项的新类型(通常称为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用于此类“业务应用程序”会比仅使用构造函数参数更好?

我希望通过准备此答案,我可以更轻松地自己判断在哪些方面胜过普通的构造方法。但是,如果我要列举这些,这是我的清单。免责声明:我具有OOP背景知识,由于不使用Reader和Kleisli,因此可能不会完全欣赏它们。

  1. 统一性-理解的长度有多短,它都不是什么大问题,它只是一个Reader,您可以轻松地将其与另一个实例进行组合,也许只引入了一个Config类型,并local在其上进行了一些调用。这一点是IMO的问题,因为您使用构造函数时,没有人会阻止您编写自己喜欢的东西,除非有人做一些愚蠢的事情,例如在构造函数中进行工作,这在OOP中被认为是不好的做法。
  2. Reader是一种monad,因此可以获得与该方法有关的所有好处- sequence,这些traverse方法是免费实施的。
  3. 在某些情况下,您可能会发现最好只构建一次Reader并将其用于各种Config。使用构造函数时,没有人会阻止您这样做,您只需要为每个传入的Config重新构建整个对象图即可。尽管我对此没有任何问题(我甚至更喜欢在对应用程序的每个请求中都这样做),但是对于我来说,由于我可能只是出于推测的原因,这对许多人来说并不是一个显而易见的主意。
  4. Reader会促使您更多地使用函数,这些函数将以FP风格编写的应用程序更好地发挥作用。
  5. 读者将关注点分开;您可以创建,与所有事物进行交互,定义逻辑而无需提供依赖关系。实际稍后再提供。(感谢Ken Scrambler)。这通常是Reader的优势,但普通构造函数也可以实现。

我还想告诉我我在Reader中不喜欢的内容。

  1. 行销 有时我会觉得,Reader是针对各种依赖而销售的,如果是会话cookie或数据库,则没有区别。对我而言,将Reader用于几乎恒定的对象(如本例中的电子邮件服务器或存储库)几乎没有意义。对于这种依赖性,我发现普通的构造函数和/或部分应用的函数会更好。从本质上讲,Reader为您提供了灵活性,因此您可以在每次调用时指定依赖项,但是如果您确实不需要,则只需支付其税款。
  2. 隐式繁重-使用没有隐式含义的Reader会使示例难以阅读。另一方面,当您使用隐式方法隐藏嘈杂的部分并产生一些错误时,编译器有时会使您难以理解消息。
  3. 使用进行仪式purelocal并创建自己的Config类/为此使用元组。读者会强迫您添加一些与问题域无关的代码,从而在代码中引入一些干扰。另一方面,使用构造函数的应用程序通常使用工厂模式,该模式也来自问题域之外,因此这种弱点并不严重。

如果我不想将类转换为带有函数的对象怎么办?

你要。从技术上讲,可以避免这种情况,但是只要看看我不将FindUsers类转换为对象会发生什么。用于理解的相应行如下所示:

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

这不是那么可读吗?关键是Reader可以对函数进行操作,因此,如果您还不具备这些功能,则需要内联构造它们,这通常不是那么漂亮。


感谢您的详细回答:)我不清楚一个要点,就是为什么DatastoreEmailServer被保留为特质,而另一些变为objects?这些服务/依赖关系/(无论如何称呼)是否存在根本区别,从而导致对它们的区别对待?
adamw 2015年

好吧...我也不能转换成EmailSender对象,对吧?那我将无法在没有类型的情况下表达依赖关系……
adamw 2015年

嗯,依赖项将采用具有适当类型的函数的形式-因此,除了使用类型名称之外,所有内容都必须放入函数签名中(名称只是偶然的)。也许吧,但我不相信;)
adamw 2015年

正确。不必EmailSender依赖于(String, String) => Unit。这是否令人信​​服是另一个问题:)可以肯定,至少它更通用,因为每个人都已经依赖Function2
Przemek Pokrywka

好吧,您当然想要命名, (String, String) => Unit以便传达一些含义,尽管不是使用类型别名,而是使用在编译时检查过的内容;)
adamw 2015年

3

我认为主要区别在于,在您的示例中,您在实例化对象时注入了所有依赖项。Reader monad基本上会构建一个越来越复杂的函数来调用给定的依赖项,然后将其返回到最高层。在这种情况下,注入会在最终调用该函数时发生。

一个直接的优势是灵活性,尤其是如果您可以一次构造您的monad,然后又想将其与不同的注入依赖项一起使用时,尤其如此。正如您所说,缺点之一是清晰度可能较低。在这两种情况下,中间层都只需要知道它们的直接依赖关系,因此它们都按DI的方式工作。


中间层将如何只知道它们的中间依赖性,而不是全部?您能否给出一个代码示例,说明如何使用阅读器monad实现该示例?
adamw 2015年

我大概可以解释一下它,而不是Json的博客(您发布的博客)更好。在这里引用表格“与隐式示例不同,在userEmail和userInfo的签名中我们没有UserRepository”。仔细检查该示例。
丹尼尔·兰登

1
是的,但是前提是假设您使用的阅读器monad已参数化,Config其中包含对的引用UserRepository。没错,它不是直接在签名中可见的,但是我要说的是,更糟糕的是,您乍一看并不知道您的代码正在使用哪个依赖项。并非Config所有依赖项都依赖于a意味着每种方法都依赖于所有这些方法吗?
adamw

它确实取决于他们,但不必知道。与示例中的类相同。我认为它们相当等效:-)
Daniel Langdon

在带有类的示例中,您仅取决于实际需要的内容,而不取决于内部具有所有依赖关系的全局对象。您会遇到一个问题,即如何决定在全局“依赖关系”中包含config哪些内容,以及“仅是一个功能”。也许您最终也会有很多自立。无论如何,这比Q&A更像是一个优先事项:)
adamw 2015年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.