读者单依赖注入: 多重依赖,嵌套调用

当被问及 Scala 中的依赖注入时,很多答案都指向使用 Reader Monad,要么是来自 Scalaz 的,要么就是自己开发的。有很多非常清晰的文章描述了这种方法的基础(例如 鲁纳的话杰森的博客) ,但是我没有找到一个更完整的例子,我也没有看到这种方法相对于传统的“手动”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 = {}
}

在这里,我使用类和构造函数参数来建模,这与“传统的”DI 方法非常匹配,但是这种设计有两个好的方面:

  • 每个功能都有清楚枚举的依赖项。我们假设功能正常工作确实需要依赖关系
  • 依赖关系隐藏在各种功能之间,例如,UserReminder不知道 FindUsers需要数据存储。这些功能甚至可以在单独的编译单元中
  • 我们只使用纯 Scala; 实现可以利用不可变的类、高阶函数,如果我们想要捕获效果,“业务逻辑”方法可以返回包装在 IO单子中的值等等。

这怎么能与阅读器单体模型?最好保留上面的特征,这样就可以清楚每个功能需要什么样的依赖关系,并将一个功能的依赖关系隐藏起来。请注意,使用 classes 更多的是一个实现细节; 也许使用 Reader monad 的“正确”解决方案将使用其他内容。

我确实发现了一个 有点相关的问题表明:

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

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

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

7917 次浏览

I think the main difference is that in your example you are injecting all dependencies when objects are instantiated. The Reader monad basically builds a more and more complex functions to call given the dependencies, wich are then returned to the highest layers. In this case, the injection happens when the function is finally called.

One immediate advantage is flexibility, especially if you can construct your monad once and then want to use it with different injected dependencies. One disadvantage is, as you say, potentially less clarity. In both cases, the intermediate layer only need to know about their immediate dependencies, so they both work as advertised for DI.

How to model this example

How could this be modelled with the Reader monad?

I'm not sure if this should be modelled with the Reader, yet it can be by:

  1. encoding the classes as functions which makes the code play nicer with Reader
  2. composing the functions with Reader in a for comprehension and using it

Just right before the start I need to tell you about small sample code adjustments that I felt beneficial for this answer. First change is about FindUsers.inactive method. I let it return List[String] so the list of addresses can be used in UserReminder.emailInactive method. I've also added simple implementations to methods. Finally, the sample will use a following hand-rolled version of 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)
}

Modelling step 1. Encoding classes as functions

Maybe that's optional, I'm not sure, but later it makes the for comprehension look better. Note, that resulting function is curried. It also takes former constructor argument(s) as their first parameter (parameter list). That way

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

becomes

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

Keep in mind that each of Dep, Arg, Res types can be completely arbitrary: a tuple, a function or a simple type.

Here's the sample code after the initial adjustments, transformed into functions:

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

One thing to notice here is that particular functions don't depend on the whole objects, but only on the directly used parts. Where in OOP version UserReminder.emailInactive() instance would call userFinder.inactive() here it just calls inactive() - a function passed to it in the first parameter.

Please note, that the code exhibits the three desirable properties from the question:

  1. it is clear what kind of dependencies each functionality needs
  2. hides dependencies of one functionality from another
  3. retainUsers method should not need to know about the Datastore dependency

Modelling step 2. Using the Reader to compose functions and run them

Reader monad lets you only compose functions that all depend on the same type. This is often not a case. In our example FindUsers.inactive depends on Datastore and UserReminder.emailInactive on EmailServer. To solve that problem one could introduce a new type (often referred to as Config) that contains all of the dependencies, then change the functions so they all depend on it and only take from it the relevant data. That obviously is wrong from dependency management perspective because that way you make these functions also dependent on types that they shouldn't know about in the first place.

Fortunately it turns out, that there exist a way to make the function work with Config even if it accepts only some part of it as a parameter. It's a method called local, defined in Reader. It needs to be provided with a way to extract the relevant part from the Config.

This knowledge applied to the example at hand would look like that:

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


}

Advantages over using constructor parameters

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

I hope that by preparing this answer I made it easier to judge for yourself in what aspects would it beat plain constructors. Yet if I were to enumerate these, here's my list. Disclaimer: I have OOP background and I may not appreciate Reader and Kleisli fully as I don't use them.

  1. Uniformity - no mater how short/long the for comprehension is, it's just a Reader and you can easily compose it with another instance, perhaps only introducing one more Config type and sprinkling some local calls on top of it. This point is IMO rather a matter of taste, because when you use constructors nobody prevents you to compose whatever things you like, unless someone does something stupid, like doing work in constructor which is considered a bad practice in OOP.
  2. Reader is a monad, so it gets all benefits related to that - sequence, traverse methods implemented for free.
  3. In some cases you may find it preferable to build the Reader only once and use it for wide range of Configs. With constructors nobody prevents you to do that, you just need to build the whole object graph anew for every Config incoming. While I have no problem with that (I even prefer doing that on every request to application), it isn't an obvious idea to many people for reasons I may only speculate about.
  4. Reader pushes you towards using functions more, which will play better with application written in predominantly FP style.
  5. Reader separates concerns; you can create, interact with everything, define logic without providing dependencies. Actually supply later, separately. (Thanks Ken Scrambler for this point). This is often heard advantage of Reader, yet that's also possible with plain constructors.

I would also like to tell what I don't like in Reader.

  1. Marketing. Sometimes I get impression, that Reader is marketed for all kind of dependencies, without distinction if that's a session cookie or a database. To me there's little sense in using Reader for practically constant objects, like email server or repository from this example. For such dependencies I find plain constructors and/or partially applied functions way better. Essentially Reader gives you flexibility so you can specify your dependencies at every call, but if you don't really need that, you only pay its tax.
  2. Implicit heaviness - using Reader without implicits would make the example hard to read. On the other hand, when you hide the noisy parts using implicits and make some error, compiler will sometimes give you hard to decipher messages.
  3. Ceremony with pure, local and creating own Config classes / using tuples for that. Reader forces you to add some code that isn't about problem domain, therefore introducing some noise in the code. On the other hand, an application that uses constructors often uses factory pattern, which is also from outside of problem domain, so this weakness isn't that serious.

What if I don't want to convert my classes to objects with functions?

You want. You technically can avoid that, but just look what would happen if I didn't convert FindUsers class to object. The respective line of for comprehension would look like:

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

which is not that readable, is that? The point is that Reader operates on functions, so if you don't have them already, you need to construct them inline, which often isn't that pretty.

The accepted answer provides a great explanation of how the Reader Monad works.

I would like to add a recipe to compose any two functions having varying dependencies using the Cats Library Reader. This snippet is also available on Scastie

Lets define the two functions that we would like to compose: The functions are similar to those defined in the accepted answer.

  1. Define the resources on which the functions depend
  case class DataStore()
case class EmailServer()
  1. Define the first function with a DataStore dependency. It takes DataStore and returns a List of inactive Users
  def f1(db:DataStore):List[String] = List("john@test.com", "james@test.com", "maria@test.com")
  1. Define another function with EmailServer as one of the dependency
  def f2_raw(emailServer: EmailServer, usersToEmail:List[String]):Unit =


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

Now the recipe to compose the two functions

  1. First, import the Reader from the Cats Library
  import cats.data.Reader
  1. Change the second function so that it has just one dependency.
  val f2 = (server:EmailServer) => (usersToEmail:List[String]) => f2_raw(server, usersToEmail)

Now f2 takes EmailServer, and returns another function that takes in a List of users to email

  1. Create a CombinedConfig class that contains dependencies for the two functions
  case class CombinedConfig(dataStore:DataStore, emailServer: EmailServer)
  1. Create Readers using the 2 functions
  val r1 = Reader(f1)
val r2 = Reader(f2)
  1. Change the Readers so that they can work with the combined config
  val r1g = r1.local((c:CombinedConfig) => c.dataStore)
val r2g = r2.local((c:CombinedConfig) => c.emailServer)
  1. Compose the Readers
  val composition = for {
u <- r1g
e <- r2g
} yield e(u)
  1. Pass the CombinedConfig and invoke the composition
  val myConfig = CombinedConfig(DataStore(), EmailServer())


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