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

ada*_*amw 85 dependency-injection scala scalaz

当被问及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 = {}
}
Run Code Online (Sandbox Code Playgroud)

在这里,我使用类和构造函数参数进行建模,这与"传统"DI方法非常相似,但是这个设计有几个好的方面:

  • 每个功能都清楚地枚举了依赖关系.我们假设确实需要依赖关系才能使功能正常工作
  • 依赖关系跨功能隐藏,例如,UserReminder不知道FindUsers需要数据存储.功能甚至可以在单独的编译单元中
  • 我们只使用纯Scala; 实现可以利用不可变类,高阶函数,IO如果我们想要捕获效果等,"业务逻辑"方法可以返回包含在monad中的值.

怎么能用Reader monad建模呢?保留上面的特性会很好,因此很清楚每个功能需要什么样的依赖关系,并隐藏一个功能与另一个功能的依赖关系.请注意,使用classes更多的是实现细节; 也许使用Reader monad的"正确"解决方案会使用其他东西.

我找到了一个有点相关的问题,暗示:

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

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

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

Prz*_*wka 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)
}
Run Code Online (Sandbox Code Playgroud)

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

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

class Foo(dep: Dep) {
  def bar(arg: Arg): Res = ???
}
// usage: val result = new Foo(dependency).bar(arg)
Run Code Online (Sandbox Code Playgroud)

object Foo {
  def bar: Dep => Arg => Res = ???
}
// usage: val result = Foo.bar(dependency)(arg)
Run Code Online (Sandbox Code Playgroud)

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

这是初始调整后的示例代码,转换为函数:

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()
    }
}
Run Code Online (Sandbox Code Playgroud)

这里需要注意的一点是,特定功能不依赖于整个对象,而只依赖于直接使用的部分.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)()

}
Run Code Online (Sandbox Code Playgroud)

使用构造函数参数的优点

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

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

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

我还想在Reader中说出我不喜欢的内容.

  1. 营销.有时候我会得到一个印象,就是所有类型的依赖项都会销售,而不管是会话cookie还是数据库.对我来说,使用Reader实际上不变的对象,例如电子邮件服务器或本例中的存储库,没有多大意义.对于这种依赖关系,我发现普通构造函数和/或部分应用函数更好.基本上,Reader为您提供了灵活性,因此您可以在每次通话时指定您的依赖关系,但如果您真的不需要,则只需支付其税款.
  2. 隐含的沉重 - 使用没有暗示的读者会使这个例子难以阅读.另一方面,当您使用implicits隐藏嘈杂的部分并产生一些错误时,编译器有时会让您难以解密消息.
  3. 举行仪式pure,local并创建自己的配置类/使用元组.Reader会强制您添加一些与问题域无关的代码,因此会在代码中引入一些噪音.另一方面,使用构造函数的应用程序通常使用工厂模式,这也来自问题域之外,因此这种弱点并不严重.

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

你要.你在技术上能够避免这种情况,但只是看看,如果我不转换会发生什么FindUsers类的对象.理解的各个方面如下:

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

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