问题描述
当被问及Scala中的依赖注入时,相当多的答案指出使用Reader Monad,或者是Scalaz的,或者只是滚动自己的。有许多非常明确的文章描述了方法的基础知识(例如,),但是我没有找到更完整的例子,我没有看到这种方法的优势比例如更传统的手册DI(请参阅)。很可能我错过了一些重要的一点,因此是一个问题。
作为一个例子,我们假设我们有这些类:
trait Datastore {def runQuery(query:String):List [String]}
trait EmailServer {def sendEmail(to:String,content:String)单元}
class FindUsers(datastore:Datastore){
def inactive():Unit =()
}
class UserReminder(findUser:FindUsers ,emailServer:EmailServer){
def emailInactive():Unit =()
}
class CustomerRelations(userReminder:UserReminder){
def retainUsers() = {}
}
这里我使用类和构造函数建模事物,使用传统DI方法非常出色,但是这种设计有两个方面:
- 每个功能都有明确的枚举依赖。我们认为依赖关系真正需要功能才能正常工作
- 依赖关系在功能之间被隐藏,例如 UserReminder 不知道 FindUsers 需要一个数据存储。这些功能甚至可以在单独的编译单元中使用。
- 我们只使用纯Scala;这些实现可以利用不可变类,高阶函数,如果我们要捕获效果等,业务逻辑方法可以返回包含在 IO monad中的值。 li>
如何用Reader monad建模?保留以上特征是很好的,因此清楚每个功能需要什么样的依赖关系,并隐藏另一个功能的依赖关系。请注意,使用类 es更多的是实现细节;也许使用Reader monad的正确解决方案会使用别的东西。
我找到了一个,这表明:
- 使用具有所有依赖关系的单个环境对象
- 使用本地环境
- parfait / li>
- type-indexed maps
然而,除了(但是这是主观的)对于这样一个简单的事情,在所有这些解决方案中,复杂的,例如 retainUsers 方法(调用 emailInactive ,调用 inactive 找到不活动的用户)需要了解 Datastore 依赖关系,以便能够正确地调用嵌套的函数 - 或者我错了?
在这种业务应用程序中哪些方面将使用Reader Monad比仅使用构造函数更好?
如何建模这个例子
我不知道这个是否应该使用Reader进行建模,但可以通过以下方式进行设计:
- 将类编码为使用代码更好阅读Reader的功能
- 使用Reader编写功能,以便理解并使用它
刚开始之前,我需要告诉你关于小的示例代码调整,我觉得这个答案是有益的。
第一次更改是关于 FindUsers.inactive 方法。我让它返回列表[String] 所以地址列表可以使用
在 UserReminder.emailInactive 方法。我还向方法添加了简单的实现。最后,样本将使用一个
以下的手写版本的读者monad:
案例类Reader [Conf, T](读:Conf => T){self =>
def map [U](convert:T => U):Reader [Conf,U] =
读者(self.read andThen convert)
def flatMap [V](toReader:T => Reader [Conf,V]):Reader [Conf,V] =
读者[Conf,V](conf => toReader(self.read )
def local [BiggerConf](extractFrom:BiggerConf => Conf):Reader [BiggerConf,T] =
读者[BiggerConf,T](extractFrom和Then self.read)
}
对象读者{
def pure [C,A](a:A):Reader [C,A] =
Reader _ => a)
implicit def funToReader [Conf,A](读取:Conf => A):Reader [Conf,A] =
读者(读)
}
建模步骤1.将类编码为函数
也许这是可选的,我不确定,但后来它使理解看起来更好。
注意,结果函数是咖喱。它还将以前的构造函数参数作为其第一个参数(参数列表)。
这样
class Foo(dep:Dep){
def bar(arg:Arg) Res = ???
}
//用法:val result = new Foo(依赖).bar(arg)
成为
对象Foo {
def bar:Dep => Arg => Res = ???
}
//用法:val result = Foo.bar(依赖)(arg)
请记住,每个 Dep , Arg , Res 类型可以是完全任意的:元组,函数或简单类型。
这是初始调整后的示例代码,转换为函数: p>
trait Datastore {def runQuery(query:String):List [String]}
trait EmailServer {def sendEmail(to: String,content:String):Unit}
object FindUsers {
def inactive:Datastore => ()=>列表[String] =
dataStore => ()=> dataStore.runQuery(select inactive)
}
对象UserReminder {
def emailInactive(inactive:()=> List [String]):EmailServer => ()=> Unit =
emailServer => ()=> inactive()。foreach(emailServer.sendEmail(_,我们想念你))
}
对象CustomerRelations {
def retainUsers(emailInactive:()=>单位):()=> Unit =
()=> {
println(发送非活动用户)
emailInactive()
}
}
这里要注意的一点是,特定的功能不依赖于整个对象,而只在于直接使用的部分。
OOP版本中的哪个 UserReminder.emailInactive()实例将调用 userFinder.inactive()调用 inactive()
- 在第一个参数中传递给它的函数。
请注意,
- 很明显每种功能需要什么样的依赖关系?/ li>
- 隐藏另一个功能的依赖关系
- retainUsers 方法不需要了解数据存储区依赖性
建模步骤2.使用阅读器撰写函数并运行它们
读者单元允许您仅组合所有依赖于相同类型的函数。这通常不是这样。在我们的例子中
FindUsers.inactive 取决于 Datastore 和 UserReminder.emailInactive 在 EmailServer 。为了解决这个问题,
可以引入一个包含所有依赖项的新类型(通常称为Config),然后更改
这些函数,以便它们都依赖于它,并且只从它获取相关数据。
这显然是依赖关系管理的角度错误的,因为你做这些函数的方式也依赖于
对于他们首先不了解的类型。
$ b $幸运的是,事实证明,即使它只接受了它的一部分作为参数,存在一种使 Config 使该函数工作的方法。
这是一个名为 local 的方法,在Reader中定义。需要提供从 Config 中提取相关部分的方法。
此知识适用于示例在手将看起来像:
对象主要扩展App {
案例类配置(dataStore :datastore,emailServer:EmailServer)
val config = Config(
new Datastore {def runQuery(query:String)= List([email protected])},
new EmailServer {def sendEmail(to:String,content:String)= println(ssend [$ content] to $ to)}
)
import Reader._
val reader = for {
getAddresses< - FindUsers.inactive.local [Config](_。dataStore)
emailInactive< - UserReminder.emailInactive(getAddresses).local [配置](_。emailServer)
retainUsers< - pure(CustomerRelations.retainUsers(emailInactive))
} yield retainUsers
reader.read(config)()
}
使用c的优点基本参数参数
我希望通过准备这个答案,我可以更容易地判断自己在哪些方面会击败简单的构造函数。
但是如果我要列举这些,这里是我的列表。免责声明:我有OOP背景,我可能不会非常欣赏Reader和Kleisli
,因为我不使用它们。
- 均匀度 - 没有一个理解的短短多久,它只是一个读者,你可以轻松地用另一个
的实例组合,也许只引入一个配置类型,并洒上一些本地调用它的顶部。这一点是IMO
,而是一个味道的问题,因为当你使用构造函数时,没有人阻止你组合任何你喜欢的东西,
除非有人做一些愚蠢的事情,比如在构造函数中做工作,这被认为是一个不好的做法在OOP中。 - 读者是一个monad,所以它得到所有的好处 - 序列,遍历$>
- 在某些情况下,您可能会发现只能构建一次Reader并将其用于各种配置。
使用构造函数没有人阻止你这样做,你只需要为每个Config
传入创建整个对象图。虽然我没有任何问题(我甚至更喜欢在每个应用程序的请求中这样做),但是对于许多人来说,这不是一个明显的想法,因为我可能只会猜测。 - 读者分离了问题;您可以创建,与所有内容交互,定义逻辑而不提供依赖关系。实际供应稍后分开。 (感谢Ken Scrambler)。经常听到Reader的优势,但也可以用简单的构造函数。
阅读器可以让您更多地使用功能,这将使用主要为FP风格编写的应用程序更好地运行。
我也想告诉我什么不像读者一样。
- 营销。有时候我会得到印象,那Reader就是为各种依赖而销售的,如果是
一个session cookie或者一个数据库,那么就不用说了。对于我来说,使用Reader几乎不变的对象,就像这个例子中的电子邮件
服务器或存储库一样。对于这样的依赖关系,我发现简单的构造函数和/或部分应用函数
更好。基本阅读器为您提供灵活性,因此您可以在每次通话时指定您的依赖关系,但如果您不需要
,您只需缴纳税款。 - 隐性重度 - 使用读者没有暗示会使这个例子很难阅读。另一方面,当您使用隐含隐藏
的嘈杂部分并发生错误时,编译器有时会使您难以破译消息。 - 颁发纯, local 并创建自己的Config类/使用元组。读者强迫你添加一些不是关于问题域的代码
,因此在代码中引入一些噪点。另一方面,使用构造函数的应用程序
通常使用工厂模式,这也是来自问题域外的,所以这个缺点不是那个
严重。
如果我不想将我的类转换为具有函数的对象怎么办?
您技术上可以避免这种情况,但只是看看如果我没有将 FindUsers 类转换为对象将会发生什么。理解的各行将如下所示:
getAddresses< - ((ds:Datastore)=> new FindUsers ds).inactive _)。local [Config](_。dataStore)
哪个不可读, 就是它?关键是读者操作功能,所以如果你还没有,那么你需要内联构造它们,这通常不是很漂亮。
When asked about Dependency Injection in Scala, quite a lot of answers point to the using the Reader Monad, either the one from Scalaz or just rolling your own. There are a number of very clear articles describing the basics of the approach (e.g. 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). Most probably I'm missing some important point, hence the question.
Just as an example, let's imagine we have these classes:
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 = {} }
Here I'm modelling things using classes and constructor parameters, which plays very nicely with "traditional" DI approaches, however this design has a couple of good sides:
- each functionality has clearly enumerated dependencies. We kind of assume that the dependencies are really needed for the functionality to work properly
- the dependencies are hidden across functionalities, e.g. the UserReminder has no idea that FindUsers needs a datastore. The functionalities can be even in separate compile units
- we are using only pure Scala; the implementations can leverage immutable classes, higher-order functions, the "business logic" methods can return values wrapped in the IO monad if we want to capture the effects etc.
How could this be modelled with the Reader monad? It would be good to retain the characteristics above, so that it is clear what kind of dependencies each functionality needs, and hide dependencies of one functionality from another. Note that using classes is more of an implementation detail; maybe the "correct" solution using the Reader monad would use something else.
I did find a somewhat related question which suggests either:
- using a single environment object with all the dependencies
- using local environments
- "parfait" pattern
- type-indexed maps
However, apart from being (but that's subjective) a bit too complex as for such a simple thing, in all of these solutions e.g. the retainUsers method (which calls emailInactive, which calls inactive to find the inactive users) would need to know about the Datastore dependency, to be able to properly call the nested functions - or am I wrong?
In what aspects would using the Reader Monad for such a "business application" be better than just using constructor parameters?
How to model this example
I'm not sure if this should be modelled with the Reader, yet it can be by:
- encoding the classes as functions which makes the code play nicer with Reader
- 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 usedin 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:
- it is clear what kind of dependencies each functionality needs
- hides dependencies of one functionality from another
- 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 exampleFindUsers.inactive depends on Datastore and UserReminder.emailInactive on EmailServer. To solve that problemone 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("[email protected]") }, 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
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.
- Uniformity - no mater how short/long the for comprehension is, it's just a Reader and you can easily compose it with anotherinstance, perhaps only introducing one more Config type and sprinkling some local calls on top of it. This point is IMOrather 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.
- Reader is a monad, so it gets all benefits related to that - sequence, traverse methods implemented for free.
- 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 Configincoming. While I have no problem with that (I even prefer doing that on every request to application), it isn'tan obvious idea to many people for reasons I may only speculate about.
- Reader pushes you towards using functions more, which will play better with application written in predominantly FP style.
- 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.
- Marketing. Sometimes I get impression, that Reader is marketed for all kind of dependencies, without distinction if that'sa session cookie or a database. To me there's little sense in using Reader for practically constant objects, like emailserver or repository from this example. For such dependencies I find plain constructors and/or partially applied functionsway better. Essentially Reader gives you flexibility so you can specify your dependencies at every call, but if youdon't really need that, you only pay its tax.
- Implicit heaviness - using Reader without implicits would make the example hard to read. On the other hand, when you hidethe noisy parts using implicits and make some error, compiler will sometimes give you hard to decipher messages.
- Ceremony with pure, local and creating own Config classes / using tuples for that. Reader forces you to add some codethat isn't about problem domain, therefore introducing some noise in the code. On the other hand, an applicationthat uses constructors often uses factory pattern, which is also from outside of problem domain, so this weakness isn't thatserious.
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.
这篇关于读取器Monad用于依赖注入:多个依赖关系,嵌套调用的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持!