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

92
当被问及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 需要一个数据存储。这些功能甚至可以在不同的编译单元中。 - 我们只使用纯 Scala;实现可以利用不可变类、高阶函数,业务逻辑方法可以返回包装在 IO monad 中的值,如果我们想捕获效果等。
如何使用 Reader monad 建模?保留上述特征是很好的,以便清楚地了解每个功能需要哪些依赖关系,并隐藏一个功能对另一个功能的依赖关系。请注意,使用类更多是实现细节;也许使用 Reader monad 的“正确”解决方案将使用其他东西。

我找到了一个有点相关的问题,它建议使用以下方法之一:

  • 使用包含所有依赖项的单个环境对象
  • 使用本地环境
  • "parfait" 模式
  • 类型索引映射

然而,除了可能有些复杂(但这是主观的),在所有这些解决方案中,例如retainUsers方法(调用emailInactive,后者调用inactive查找不活跃的用户)需要知道Datastore依赖关系,才能正确调用嵌套函数,或者我错了吗?

对于这样的“业务应用程序”,使用 Reader Monad 会比仅使用构造函数参数更好的方面是什么?


1
读者模式并不是万能的。我认为,如果你需要很多层次的依赖关系,那么你的设计就相当不错。 - ZhekaKozlov
然而,它经常被描述为依赖注入的替代品;也许应该被描述为一种补充?我有时会感到“真正的函数式程序员”对DI不屑一顾,因此我想知道“替代方案是什么”:)无论如何,我认为具有多个依赖级别,或者说需要与多个外部服务进行通信,这就是每个中大型“业务应用程序”的样子(对于库来说肯定不是这种情况)。 - adamw
2
我一直认为Reader Monad是一种局部的东西。例如,如果你有一个只与数据库交互的模块,你可以用Reader Monad风格来实现这个模块。然而,如果你的应用程序需要许多不同的数据源进行组合,我认为Reader Monad并不适合。 - ZhekaKozlov
啊,这可能是一个很好的指导方针,教我们如何结合这两个概念。然后确实看起来依赖注入(DI)和资源管理(RM)相互补充。我猜事实上,在只操作一个依赖项的函数中使用资源管理会有助于明确依赖关系和数据边界。 - adamw
3个回答

42

如何对这个例子建模

如何使用Reader Monad对此进行建模?

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

  1. 将类编码为函数,使得代码更适合与Reader一起使用
  2. 在for循环中将函数与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循环更好看。 请注意,结果函数是柯里化的。它也将以前的构造函数参数作为它们的第一个参数(参数列表)。 这样做的好处是

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()
    }
}

注意的一件事是,特定的函数不依赖于整个对象,而只依赖于直接使用的部分。 在面向对象编程版本中,UserReminder.emailInactive()实例会调用userFinder.inactive(),但在这里它只调用第一个参数传递给它的inactive()函数。
请注意,代码展示了问题中的三个理想属性: 1.清楚地知道每个功能需要哪些依赖项 2.隐藏一个功能对另一个功能的依赖关系 3.retainUsers方法不应该知道Datastore的依赖关系
建模步骤2。使用Reader组合函数并运行它们
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来处理“业务应用程序”比仅使用构造函数参数更好?

我希望通过准备这个答案,能让您更容易地判断它在哪些方面能够击败普通的构造函数。 如果我要列举这些方面,以下是我的列表。免责声明:我有面向对象编程背景,可能无法完全欣赏Reader和Kleisli。

  1. 统一性 - 无论for comprehension多长,它只是一个Reader,您可以轻松地将其与另一个实例组合起来,也许只需引入一个Config类型并在其上添加一些“local”调用即可。这一点在我看来更多是口味问题,因为当您使用构造函数时,除非有人做了一些愚蠢的事情(例如在构造函数中执行工作,这在OOP中被认为是一种不良实践),否则您可以组合任何您喜欢的东西。
  2. Reader是一个monad,因此它获得了所有相关的好处 - “sequence”,“traverse”方法都是免费实现的。
  3. 在某些情况下,您可能会发现仅构建一次Reader并将其用于广泛的Configs更可取。使用构造函数时,没有人会阻止您这样做,只需为每个传入的Config重新构建整个对象图即可。虽然我对此没有问题(我甚至更喜欢在每个请求到达应用程序时执行此操作),但出于我只能猜测的原因,这不是许多人的明显想法。
  4. Reader推动您更多地使用函数,这将与主要以FP风格编写的应用程序更好地配合。
  5. Reader分离关注点;您可以创建、交互和定义逻辑而不提供依赖项。实际上稍后单独提供。 (感谢Ken Scrambler提供此观点)。这通常是Reader的优点,但是使用普通构造函数也可以实现。

我也想说一下我不喜欢Reader的地方。

  1. 市场营销。有时我会有这样的印象,即Reader被用于各种依赖项的营销,而不区分是会话cookie还是数据库。对我来说,对于诸如电子邮件服务器或存储库之类的几乎恒定的对象,使用Reader没有什么意义。对于这样的依赖项,我发现普通的构造函数和/或部分应用的函数更好。基本上,Reader为您提供了灵活性,以便您可以在每次调用时指定依赖项,但如果您真的不需要它,那么您只需支付其税费。
  2. 隐式繁重 - 如果没有隐式地使用Reader将使示例难以阅读。另一方面,当您使用隐式隐藏嘈杂的部分并出现错误时,编译器有时会给您难以解释的消息。
  3. 使用“pure”,“local”和创建自己的Config类/使用元组的仪式。Reader强制您添加一些与问题域无关的代码,从而在代码中引入了一些噪音。另一方面,使用构造函数的应用程序通常使用工厂模式,这也是来自问题域之外的,因此这种弱点并

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

    你可以,从技术上讲,避免这种做法,但是看看如果我不将 FindUsers 类转换为对象会发生什么。相应的 for 推导式行将如下:

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

    读起来不是很易懂,对吧?重点是Reader操作函数,因此如果您没有它们,您需要在内联中构建它们,这通常不是很美观。


谢谢您详细的回答 :) 有一点我不太清楚,为什么DatastoreEmailServer被留作traits,而其他的则成为了object?这些服务/依赖项(无论您如何称呼它们)之间是否存在根本性的差异,导致它们被不同地处理? - adamw
嗯...我也不能将例如EmailSender转换为对象,对吧?如果这样做,我就无法在没有类型的情况下表达依赖关系了... - adamw
啊,那么依赖关系将采用具有适当类型的函数形式 - 因此,除了使用类型名称外,所有内容都必须进入函数签名中(名称只是附带的)。也许吧,但我并不完全相信 ;) - adamw
正确。与其依赖于EmailSender,不如依赖于(String, String) => Unit。这是否令人信服是另一个问题 :) 确定的是,它至少更加通用,因为每个人都已经依赖于Function2 - Przemek Pokrywka
我总是想给它命名,但并不总是通过封装成一种类型来实现。在这里需要取得一个平衡。几乎从不希望在我的API中使用如此原始的类型来定义函数。然而,如果我用(EmailAddress, EmailContent) => scalaz.concurrent.Task来替换它们,也许编译器会充分检查,并且同时我的用户将获得一个易于与其他事物组合的函数。 - Przemek Pokrywka
显示剩余7条评论

3
我认为主要区别在于,在您的示例中,所有依赖项在对象实例化时都被注入。Reader monad基本上构建了越来越复杂的函数来调用给定的依赖项,然后将它们返回到最高层。在这种情况下,注入发生在最终调用函数时。
一个直接的优点是灵活性,特别是如果您可以构建您的monad一次,然后希望使用不同的注入依赖项。一个缺点是,正如您所说,可能缺乏清晰度。在两种情况下,中间层只需知道其直接的依赖关系,因此它们都适用于DI。

中间层如何只知道它们的中间依赖项,而不是所有依赖项?您能否给出一个代码示例,展示如何使用reader monad实现该示例? - adamw
我可能无法比Json的博客(你发布的那篇)更好地解释它。引用他的话:“与隐式示例不同,我们在userEmail和userInfo的签名中没有任何UserRepository”。仔细检查一下那个示例。 - Daniel Langdon
1
嗯,但这假设你使用的Reader Monad是参数化的Config,其中包含对UserRepository的引用。所以说,没错,它在签名上并不直接可见,但我认为这甚至更糟,一眼看去你根本不知道你的代码使用了哪些依赖项。依赖于带有所有依赖项的“配置”是否意味着每个方法都有点依赖于所有依赖项? - adamw
它确实依赖于它们,但它不必知道它们。就像你在类的例子中一样。我认为它们是相当等价的 :-) - Daniel Langdon
在使用类的示例中,您只依赖于实际需要的内容,而不是一个包含所有依赖项的全局对象。您还需要解决如何决定将什么放入全局config的“依赖项”中,以及什么是“仅仅是一个函数”的问题。可能最终会出现很多自我依赖项。无论如何,这更多是一种偏好讨论,而不是问答 :) - adamw

1

接受的答案提供了一个很好的解释,说明了Reader Monad的工作原理。

我想通过使用Cats Library Reader添加一种组合任意两个具有不同依赖关系的函数的方法。 此代码片段也可在Scastie上找到。

让我们定义我们想要组合的两个函数: 这些函数与接受的答案中定义的函数类似。

  1. 定义函数所依赖的资源
  case class DataStore()
  case class EmailServer()

使用 DataStore 依赖定义第一个函数。它接受 DataStore 并返回一个不活跃用户的列表。
  def f1(db:DataStore):List[String] = List("john@test.com", "james@test.com", "maria@test.com")
  1. 定义另一个函数,并将EmailServer作为其中一个依赖项。
  def f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit =

    usersToEmail.foreach(user => println(s"emailing ${user} using server ${emailServer}"))

现在,我们来学习如何组合这两个函数。
1. 首先,从Cats库中导入Reader。
  import cats.data.Reader
  1. 更改第二个函数,使其只有一个依赖项。
  val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)

现在,f2接受EmailServer,并返回另一个函数,该函数接受要发送电子邮件的List用户。
创建一个CombinedConfig类,其中包含两个函数的依赖项。
  case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)

创建读取器使用2个函数。
  val r1 = Reader(f1)
  val r2 = Reader(f2)
  1. 更改读取器,使其能够与合并配置文件一起使用
  val r1g = r1.local((c:CombinedConfig) => c.dataStore)
  val r2g = r2.local((c:CombinedConfig) => c.emailServer)
  1. 组合读取器
  val composition = for {
    u <- r1g
    e <- r2g
  } yield e(u)
  1. CombinedConfig传递并调用组合
  val myConfig = CombinedConfig(DataStore(), EmailServer())

  println("Invoking Composition")
  composition.run(myConfig)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接