Reader 用于依赖注入的 Monad:多重依赖、嵌套调用

Reader Monad for Dependency Injection: multiple dependencies, nested calls

当被问及 Scala 中的依赖注入时,很多答案都指向使用 Reader Monad,无论是来自 Scalaz 的还是您自己的。有许多非常清晰的文章描述了该方法的基础知识(例如 Runar's talk, Jason's blog), but I didn't manage to find a more complete example, and I fail to see the advantages of that approach over e.g. a more traditional "manual" DI (see 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 = {}
}

在这里,我使用 类 和构造函数参数对事物进行建模,这非常适合 "traditional" DI 方法,但是这种设计有几个好的方面:

如何使用 Reader monad 对其进行建模?最好保留上面的特性,这样可以清楚每个功能需要什么样的依赖关系,并隐藏一个功能与另一个功能的依赖关系。请注意,使用 classes 更像是一个实现细节;也许使用 Reader monad 的 "correct" 解决方案会使用其他东西。

我确实找到了一个 somewhat related question,它表明:

然而,除了(但这是主观的)对于这样一个简单的事情来说有点过于复杂,在所有这些解决方案中,例如retainUsers 方法(调用 emailInactive,调用 inactive 来查找非活动用户)需要了解 Datastore 依赖项,以便能够正确调用嵌套函数 - 还是我错了?

在哪些方面使用 Reader Monad 对于这样的 "business application" 比仅使用构造函数参数更好?

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

一个直接的优势是灵活性,特别是如果你可以构建你的 monad 一次,然后想将它与不同的注入依赖项一起使用。正如您所说,一个缺点是可能不太清晰。在这两种情况下,中间层只需要知道它们的直接依赖关系,因此它们都像为 DI 宣传的那样工作。

如何为这个例子建模

How could this be modelled with the Reader monad?

我不确定这个是否应该使用Reader建模,但可以通过:

  1. 将 classes 编码为函数,这使得代码与 Reader
  2. 一起玩得更好
  3. 在 for comprehension 中使用 Reader 组合函数并使用它

就在开始之前,我需要告诉你一些我认为对这个答案有益的小示例代码调整。 第一个变化是关于 FindUsers.inactive 方法。我让它returnList[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。将 classes 编码为函数

也许这是可选的,我不确定,但稍后它会使 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()
    }
}

这里需要注意的一点是,特定的功能并不依赖于整个对象,而只依赖于直接使用的部分。 在 OOP 版本中,UserReminder.emailInactive() 实例会在此处调用 userFinder.inactive(),它只调用 inactive() - 在第一个参数中传递给它的函数。

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

  1. 很清楚每个功能需要什么样的依赖
  2. 隐藏一个功能与另一个功能的依赖关系
  3. retainUsers 方法不需要了解数据存储依赖性

建模步骤 2。使用 Reader 组合函数和 运行 它们

Reader monad 只允许你组合所有依赖于同一类型的函数。通常情况并非如此。在我们的例子中 FindUsers.inactive 依赖于 DatastoreUserReminder.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)()

}

相对于使用构造函数参数的优势

In what aspects would using the Reader Monad for such a "business application" be better than just using constructor parameters?

我希望通过准备这个答案,我可以更轻松地自己判断它在哪些方面优于普通构造函数。 然而,如果我要列举这些,这是我的清单。免责声明:我有 OOP 背景,我可能不喜欢 Reader 和 Kleisli 完全因为我不使用它们。

  1. 统一性 - 无论 short/long for 理解如何,它只是一个 Reader,您可以轻松地将它与另一个组合 例如,也许只引入了另一种 Config 类型并在其上添加了一些 local 调用。这一点是海事组织 而是品味问题,因为当你使用构造函数时,没有人会阻止你编写任何你喜欢的东西, 除非有人做了一些愚蠢的事情,比如在构造函数中工作,这在 OOP 中被认为是一种不好的做法。
  2. Reader 是一个 monad,因此它获得了与此相关的所有好处 - sequencetraverse 方法免费实施。
  3. 在某些情况下,您可能会发现只构建一次 Reader 并将其用于广泛的配置更可取。 使用构造函数没有人会阻止你这样做,你只需要为每个配置重新构建整个对象图 传入。虽然我对此没有问题(我什至更喜欢在每次请求应用程序时都这样做),但事实并非如此 对很多人来说这是一个显而易见的想法,原因我可能只是推测。
  4. Reader 促使您更多地使用函数,这将更好地与以 FP 风格为主的应用程序一起使用。
  5. Reader 分离关注点;您可以在不提供依赖项的情况下创建、与一切交互、定义逻辑。实际上稍后单独供应。 (感谢 Ken Scrambler 的这一点)。这是经常听到的 Reader 的优势,但对于普通构造函数也是可能的。

我也想在Reader中说出我不喜欢的地方。

  1. 营销。有时我的印象是 Reader 是针对所有类型的依赖项进行营销的,如果那是 会话 cookie 或数据库。对我来说,将 Reader 用于几乎不变的对象(例如电子邮件)几乎没有意义 此示例中的服务器或存储库。对于此类依赖项,我发现普通构造函数 and/or 部分应用函数 好多了本质上 Reader 为您提供了灵活性,因此您可以在每次调用时指定您的依赖项,但是如果您 真的不需要那个,你只需要付税。
  2. 隐式沉重 - 使用 Reader 而不使用隐式会使示例难以阅读。另一方面,当你隐藏 嘈杂的部分使用隐式并产生一些错误,编译器有时会让你难以破译信息。
  3. purelocal 的仪式并创建自己的配置 classes / 为此使用元组。 Reader 强制您添加一些代码 这与问题域无关,因此在代码中引入了一些噪音。另一方面,一个应用程序 使用构造函数的通常使用工厂模式,它也来自问题域之外,所以这个弱点不是 严重。

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

你想要的。从技术上讲,您 可以 避免这种情况,但看看如果我不将 FindUsers class 转换为对象会发生什么。 for comprehension 的相应行如下所示:

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

这不是那么可读,是吗?关键是 Reader 对函数进行操作,因此如果您还没有它们,则需要内联构建它们,这通常不是那么漂亮。

接受的答案很好地解释了 Reader Monad 的工作原理。

我想添加一个配方,使用 Cats 库 Reader 组合具有不同依赖关系的任意两个函数。 此代码段也可在 Scastie

上找到

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

  1. 定义函数所依赖的资源
  case class DataStore()
  case class EmailServer()
  1. 定义具有 DataStore 依赖关系的第一个函数。它需要 DataStore 和 returns 一个非活动用户列表
  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,而 returns 另一个接受 List 用户发送电子邮件的函数

  1. 创建一个 CombinedConfig class 包含两个函数的依赖项
  case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
  1. 使用 2 个函数创建 Readers
  val r1 = Reader(f1)
  val r2 = Reader(f2)
  1. 更改 Readers 以便它们可以使用组合配置
  val r1g = r1.local((c:CombinedConfig) => c.dataStore)
  val r2g = r2.local((c:CombinedConfig) => c.emailServer)
  1. 撰写 Readers
  val composition = for {
    u <- r1g
    e <- r2g
  } yield e(u)
  1. 传递 CombinedConfig 并调用合成
  val myConfig = CombinedConfig(DataStore(), EmailServer())

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