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

时间:2015-03-20 19:34:08

标签: scala dependency-injection scalaz

当被问及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建模?保留上面的特性会很好,因此很清楚每个功能需要什么样的依赖关系,并隐藏一个功能与另一个功能的依赖关系。请注意,使用class es更多的是实现细节;也许使用Reader monad的“正确”解决方案会使用别的东西。

我找到了一个somewhat related question,暗示:

  • 使用具有所有依赖关系的单个环境对象
  • 使用本地环境
  • “parfait”pattern
  • 类型索引地图

然而,在所有这些解决方案中,除了(但这是主观的)有点过于复杂之外,在所有这些解决方案中,例如retainUsers方法(调用emailInactive,调用inactive以查找非活动用户)需要知道Datastore依赖关系,才能正确调用嵌套功能 - 或者我错了?

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

3 个答案:

答案 0 :(得分:35)

如何建模此示例

  

如何使用Reader monad建模?

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

  1. 将类编码为函数,使得代码在Reader
  2. 中发挥更好的作用
  3. 使用Reader编写功能以便理解和使用
  4. 就在开始之前,我需要告诉你一些我觉得对这个答案有益的小样本代码调整。 第一个变化是关于FindUsers.inactive方法。我让它返回List[String],以便可以使用地址列表 在UserReminder.emailInactive方法中。我还在方法中添加了简单的实现。最后,样本将使用a 以下是阅读器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.将类编码为函数

    也许这是可选的,我不确定,但后来它让理解看起来更好。 注意,结果函数是curry。它还将前构造函数参数作为其第一个参数(参数列表)。 那样

    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方法不需要了解数据存储依赖性
    4. 建模步骤2.使用Reader编写功能并运行它们

      Reader monad允许您只编写所有依赖于相同类型的函数。通常情况并非如此。在我们的例子中 FindUsers.inactive取决于Datastore上的UserReminder.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进行这样一个&#34;业务应用程序&#34;是否比仅使用构造函数参数更好?

      我希望通过准备这个答案,我可以更容易地判断自己在哪些方面会胜过普通的构造者。 然而,如果我要列举这些,请点击我的列表。免责声明:我有OOP背景,我可能不喜欢Reader和Kleisli 因为我不能使用它们。

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

        1. 营销。有时候我会得到一个印象,那就是读者会被推销用于所有类型的依赖关系,如果不是这样的话 会话cookie或数据库。对我而言,使用Reader实际上不变的对象(如电子邮件)没什么意义 此示例中的服务器或存储库。对于这种依赖关系,我找到了普通的构造函数和/或部分应用的函数 方式更好。本质上,Reader为您提供了灵活性,因此您可以在每次调用时指定依赖项,但如果您这样做 不是真的需要,你只需支付税款。
        2. 隐含沉重 - 使用不带隐含的Reader会使示例难以阅读。另一方面,当你隐藏 使用implicits的嘈杂部分会产生一些错误,编译器有时会让你难以破译消息。
        3. purelocal举行仪式并创建自己的配置类/使用元组。 Reader会强制您添加一些代码 这不是问题域,因此在代码中引入了一些噪音。另一方面,一个应用程序 使用构造函数的人经常使用工厂模式,这也是来自问题域之外的,所以这个弱点不是那个 严重。
        4. 如果我不想将我的类转换为具有函数的对象,该怎么办?

          你想要的。您在技术上可以避免这种情况,但只是看看如果我没有将FindUsers类转换为对象会发生什么。理解的各个方面如下:

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

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

答案 1 :(得分:3)

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

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

答案 2 :(得分:1)

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

我想添加一个配方来使用 Cats 库阅读器组合具有不同依赖关系的任意两个函数。 此代码段也可在 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 库中导入阅读器
  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)