【问题标题】:Reader Monad for Dependency Injection: multiple dependencies, nested calls依赖注入的Reader Monad:多依赖,嵌套调用
【发布时间】:2015-05-24 07:59:18
【问题描述】:

当被问及 Scala 中的依赖注入时,很多答案都指向使用 Reader Monad,要么来自 Scalaz,要么只是滚动你自己的。有许多非常清晰的文章描述了该方法的基础知识(例如Runar's talkJason'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 对其进行建模?最好保留上述特征,以便清楚每个功能需要什么样的依赖关系,并隐藏一个功能与另一个功能的依赖关系。请注意,使用classes 更多的是实现细节;也许使用 Reader monad 的“正确”解决方案会使用其他东西。

我确实找到了somewhat related question,这表明:

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

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

在这样的“业务应用程序”中使用 Reader Monad 在哪些方面比仅使用构造函数参数更好?

【问题讨论】:

  • Reader monad 不是灵丹妙药。我认为,如果您需要很多级别的依赖关系,那么您的设计非常好。
  • 然而,它经常被描述为依赖注入的替代方案;也许它应该被描述为补充?我有时会觉得 DI 被“真正的函数式程序员”所忽视,因此我想知道“改什么”:) 无论哪种方式,我认为拥有多个级别的依赖关系,或者更确切地说,您需要与之交谈的多个外部服务是如何每个中型“业务应用程序”看起来都像(库肯定不是这样)
  • 我一直认为 Reader monad 是本地的。例如,如果您有一些只与数据库对话的模块,您可以在 Reader monad 样式中实现该模块。但是,如果您的应用程序需要将许多不同的数据源组合在一起,我认为 Reader monad 不适合。
  • 啊,这可能是一个很好的指导如何结合这两个概念。然后确实看起来 DI 和 RM 是相辅相成的。我想实际上只有一个依赖项运行的函数很常见,在这里使用 RM 将有助于澄清依赖项/数据边界。

标签: scala dependency-injection scalaz


【解决方案1】:

如何为这个例子建模

如何使用 Reader monad 对其进行建模?

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

  1. 将类编码为函数,使代码在 Reader 中更好玩
  2. 用 Reader 在 for comprehension 中组合函数并使用它

就在开始之前,我需要告诉你一些我认为对这个答案有益的小示例代码调整。 第一个变化是关于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()
    }
}

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

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

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

建模步骤 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)()

}

使用构造函数参数的优势

在这样的“业务应用程序”中使用 Reader Monad 在哪些方面比仅使用构造函数参数更好?

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

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

我还想说明我在 Reader 中不喜欢的地方。

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

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

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

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

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

【讨论】:

  • 感谢您的详细回答 :) 有一点我不清楚,为什么 DatastoreEmailServer 被保留为特征,而其他成为 objects?这些服务/依赖项/(无论您如何称呼它们)是否存在根本差异,导致它们被区别对待?
  • 嗯......我不能转换例如EmailSender 也指向一个对象,对吧?如果没有类型,我将无法表达依赖关系...
  • 啊,依赖将采用具有适当类型的函数的形式 - 因此,不使用类型名称,所有内容都必须进入函数签名(名称只是偶然的)。也许吧,但我不相信 ;)
  • 正确。与其依赖EmailSender,不如依赖(String, String) =&gt; Unit。这是否令人信​​服是另一个问题 :) 可以肯定的是,至少它更通用,因为每个人都已经依赖于 Function2
  • 嗯,你当然想 name (String, String) =&gt; Unit 以便它传达一些含义,尽管不是使用类型别名而是在编译时检查的东西;)
【解决方案2】:

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

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

【讨论】:

  • 中间层如何只知道它们的中间依赖项,而不是全部?您能否给出一个代码示例,说明如何使用 reader monad 实现该示例?
  • 我可能无法比 Json 的博客(您发布的)更好地解释它。引用表格“与隐式示例不同,我们在 userEmail 和 userInfo 的签名中没有任何地方的 UserRepository”。仔细检查该示例。
  • 嗯,是的,但这假设您正在使用的 reader monad 是用 Config 参数化的,其中包含对 UserRepository 的引用。确实如此,它在签名中并不直接可见,但我想说更糟糕的是,乍一看,您根本不知道您的代码使用了哪些依赖项。不依赖于所有依赖项的 Config 是否意味着每种方法都依赖于它们中的 all
  • 它确实取决于它们,但它不必知道它。与您的课程示例相同。我认为它们相当相似:-)
  • 在带有类的示例中,您只依赖于您实际需要的内容,而不是包含所有依赖项的全局对象。你会遇到一个问题,即如何确定全局 config 的“依赖项”内部的内容,以及“只是一个函数”的内容。很可能你最终也会有很多的自力更生。无论如何,这更像是一个偏好问题的讨论而不是问答:)
【解决方案3】:

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

我想添加一个配方来使用 Cats Library Reader 组合具有不同依赖关系的任意两个函数。 这个 sn-p 也可以在 Scastie

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

  1. 定义函数所依赖的资源
  case class DataStore()
  case class EmailServer()
  1. 使用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 的用户发送电子邮件

  1. 创建一个 CombinedConfig 类,其中包含两个函数的依赖项
  case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
  1. 使用 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)

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2021-11-23
    • 1970-01-01
    • 2020-06-07
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多