介绍
DI是一个流行的模式,用于降低客户端与服务端实现间的耦合.本文将介绍如何使用Scala语言来构建依赖注入,然后剩下的一些实践将会借助MacWire
库进行.
DI是一个简单的概念,可以通过几种简单的方式实现.我们应该避免不对代价进行分析就使用过于复杂或难于使用的容器或框架.
什么是依赖注入
DI主要是为了解耦客户端和服务端代码(这里的客户端可以是另一个服务端).服务端需要显示他们需要的依赖信息.不在服务内部创建一个依赖服务,而是通过”引用”依赖服务的方式,称为”注入”.这使代码更易于理解,测试和重用.
注入依赖的方式多种多样,这里我们将通过构造器参数传入依赖.其他的方式有setter/field
方式的注入,或者使用一个服务定位.因此,DI的本质可以总结为使用构造器参数.
DI一个重要的方面是控制反转,服务实现需要在服务外部创建,比如一个容器或者一些外部连接代码.而在服务中直接使用new
来创建依赖是不允许的.
如果对DI不熟悉,可以首先了解一下guice.基于Java语言,想法都是一致的,并且应用广泛.
其他方式
有很多框架和方法通过不同的语言和平台实现DI,下面是一些使用Scala+Macwire
实现的方法.
框架:
- Subcut: 混合服务定位和依赖注入的模式
- Scaldi: 与Subcut类似
- Spring: 是一个流行的Java DI框架,同样可以用于Scala
- Guice: 另一个流行的Java DI框架
使用纯Scala:
- Cake pattern
- Reader monad
运行实例
下面提供一个贯穿整个文档的实例,我们会运行这个实例.
比如我们正在为一个火车站创建一个系统.我们的目标是调用方法来准备(装载或组装车辆)并调遣下一辆货车.为了实现这个目标,我们需要创建如下服务类:
class PointSwitcher()
class TrainCarCoupler()
class TrainShunter(
pointSwitcher: PointSwitcher,
trainCarCoupler: TrainCarCoupler)
class CraneController()
class TrainLoader(
craneController: CraneController,
pointSwitcher: PointSwitcher)
class TrainDisptch()
class TrainStation(
trainShunter: TrainShunter,
trainLoader: TrainLoader,
trainDisptch: TrainDisptch){
def prepareAndDisptchNextTrain() {...}
}
每个类的依赖表示为一个构造器参数.这些依赖构成一个需要进行接线的对象图标.
手动依赖注入
一种太容易而经常被忽视的方法是手动进行依赖注入.当然这需要更多的编码,但是手动的方式不用顾虑框架必须的一些规定系统参数并且能够更加灵活.
使用DI时,我们需要以某种方法来创建对象图表,也就是为给出的类创建带有合适依赖的实例.当使用框架时这个任务会委托给容器.但是我们也可以通过编写一些简单的代码来完成.
这个对象图表需要尽可能晚的创建,比如在我们应用的主类中创建.如果你之前使用过DI容器,当使用手动DI或和与Macwire
一起使用,你会重新发现在应用中有一个主方法的好处.
下面使我们手动DI版本的具体样例:
object TrainStation extends App{
val pointSwitcher = new PointSwitcher()
val trainCarCoupler = new TrainCarCoupler()
val trainShunter = new TrainShunter(pointSwitcher, trainCarCoupler)
val canceController = new CanceController()
val trainLoader = new TrainLoader(craneController, pointSwitcher)
val trainDisptch = new TrainDisptch()
val trainStation = new TrainStation(trainShunter, trainLoader, trainDisptch)
trainStation.prepareAndDisptchNextTrain()
}
手动DI的建议
上面方法中的第一个建议是类型安全: 依赖在编译期决定完成,因此可以确定所有的依赖都被集合了.
并不需要运行时反射,虽然它有一些启动时的好处(不需要扫描classpath),但是能够移除很多魔法代码.没有任何注解需要扫描.我们仅仅使用了简单的Scala代码和构造器参数,这能够很好的操纵实例创建的位置.创建对象图表的过程也是清晰的.应用也很易于使用或者打包,比如一个fat-jar.不需要启动容器或者和框架进行斗争.
如果创建一些复杂对象的实例,或者选择一个基于很多配置的实现,感谢手动DI的灵活性,我们可以简单的运行指定代码来计算需要使用的依赖.
val vs. lazy val
使用val定义依赖有一个缺点: 当一个依赖在被初始化之前使用了,引用时会是一个null
.因为val
是从上到下进行计算的.
使用lazy val
可以解决这个问题,他可以根据需求进行计算,并且会自动计算正确的初始化顺序.
因此我们的手动DI样例可以修改为:
object TrainStation extends App {
lazy val pointSwitcher = new PointSwitcher()
lazy val trainCarCoupler = new TrainCarCoupler()
lazy val trainShunter = new TrainShunter(
pointSwitcher, trainCarCoupler)
lazy val craneController = new CraneController()
lazy val trainLoader = new TrainLoader(
craneController, pointSwitcher)
lazy val trainDispatch = new TrainDispatch()
lazy val trainStation = new TrainStation(
trainShunter, trainLoader, trainDispatch)
trainStation.prepareAndDispatchNextTrain()
}
使用MacWire
进行架线
手动DI并不是一个银弹,手动为每个类编写实例穿件代码,并且使用正确的参数,这会很乏味.
这就是MacWire
和wire
方法能够提供帮助的地方.wire
是一个Scala宏命令(Scala Macros),用于生产实例创建代码.
使用wire
修改代码后会变得更简洁:
object TrainStation extends App {
lazy val pointSwitcher = wire[PointSwitcher]
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = wire[TrainShunter]
lazy val craneController = wire[CraneController]
lazy val trainLoader = wire[TrainLoader]
lazy val trainDispatch = wire[TrainDispatch]
lazy val trainStation = wire[TrainStation]
trainStation.prepareAndDispatchNextTrain()
}
如果服务添加了一个新的依赖或者参数顺序发生了变化,这些对象图表架线代码并不需要进行改变,宏命令会进行处理.只有在引介一个新的服务时,必须将它加到列表中.
新示例的创建代码由wire
在编译时生成,因此当你比较两个例子的字节码时会发现是一致的.生成的代码使用通常的方式进行类型检查,因此我们可以保持手动方式的类型安全.
wire
宏命令的用法可以手动混合到新实例的创建中.向之前所说的,创建一个复杂实例时会比较有用.
使用wire
需要引入com.softwaremill.macwire._
,更多整合MacWire
的信息可以参阅Github项目Wiki.
wire
的工作方式
提供一个类后,wire
宏命令首先尝试查找一个@Inject
构造器注解,然后是主(非私有)构造器,最终是半生对象中的apply
方法,来确定需要的依赖.对于每个依赖他首先会查找符合参数类型的值,在method/class/object
的内部:
- 首先会在当前块中查找一个声明为值的唯一值,被方法包装的参数或者匿名函数
- 然后会在包含的类型中查找一个被声明或引入的唯一值
- 然后在父类型(traits/classes)中查找唯一值
- 如果参数被标记为
implicit
,它会被Macwire
忽略,然后被标准的隐式处理机制处理
这里的值可以是val
或lazy val
,或者返回类型匹配的无参def
.
如下情况会引起编译时错误:
- 在
block/method/function
中声明的类型有多个值,包含父类类型 - 参数被声明为隐式,但是隐式查找值时失败
- 没有值符合给出的类型
使用隐式参数
更上面描述类似的一个作用可以通过隐式参数和隐式值获得.如果所有的构造器参数被标记为implicit
,并且所有的实例被标记为implicit
,同时对象图表已经被连接,Scala编译器会创建一个特有的构造器调用.
首先是类定义:
class PointSwitcher()
class TrainCarCoupler()
class TrainShunter(
implicit
pointSwitcher: PointSwitcher,
trainCarCoupler: TrainCarCoupler)
class CraneController()
class TrainLoader(
implicit
craneController: CraneController,
pointSwitcher: PointSwitcher)
class TrainDispatch()
class TrainStation(
implicit
trainShunter: TrainShunter,
trainLoader: TrainLoader,
trainDispatch: TrainDispatch) {
def prepareAndDispatchNextTrain() { ... }
}
然后进行连线:
object TrainStation extends App {
implicit lazy val pointSwitcher = new PointSwitcher
implicit lazy val trainCarCoupler = new TrainCarCoupler
implicit lazy val trainShunter = new TrainShunter
implicit lazy val craneController = new CraneController
implicit lazy val trainLoader = new TrainLoader
implicit lazy val trainDispatch = new TrainDispatch
implicit lazy val trainStation = new TrainStation
trainStation.prepareAndDispatchNextTrain()
}
然后,使用隐式方式有两个缺点,首先需要标记所有类的构造器参数列表以进行隐式连接.这很不理想,阅读代码的人会疑惑为何要将参数标记为隐式.然后,隐式参数在Scala中的其他地方会用于不同的目的,大量的隐式方式会引起混乱.当然在有些场合是比较合适的.
Simple scoping
目前为止所有的依赖都被声明为lazy val
,他们实质上被标记为单例,在一个单独应用用途的当前作用域中.注意在全局范围内并不是单例,因为我们可以创建多个对象图表的副本.
然而,我们想为每个用途(或称为dependent scope
)创建一个依赖的新实例.这时我们可以将该实例声明为一个def
而不是lazy val
.比如我们每次都需要一个火车分派器的新实例,代码会被修改为:
object TrainStation extends App {
lazy val pointSwitcher = wire[PointSwitcher]
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = wire[TrainShunter]
lazy val craneController = wire[CraneController]
lazy val trainLoader = wire[TrainLoader]
// note the def instead of lazy val
def trainDispatch = wire[TrainDispatch]
// the stations share all services except the train dispatch,
// for which a new instance is create on each usage
lazy val trainStationEast = wire[TrainStation]
lazy val trainStationWest = wire[TrainStation]
trainStationEast.prepareAndDispatchNextTrain()
trainStationWest.prepareAndDispatchNextTrain()
}
因此使用Scala构造器我们可以实现两个作用域: 单例或依赖.
模块化对象图表创建
蛋糕模式
有时在最后时刻创建整个对象图表会不切实际,代码会变得很大且难于阅读.我们需要在一些时候把他们分成小的部分.幸运的是,Scala的trait
能够完美的完成整个任务,可以用于拆分对象图表的创建代码.
在每个trait
中用于完成各种目的的任务被称为一个module
,来创建对象图表的一部分.最后把需要的特质放在一起进行重组.
有多种不同的规则用于将代码拆分到不同的module.一种好的方式是每个package
创建一个预连接
的module.没个package需要包含一组类,共享或实现一些指定的功能.最可能的就是这些类通过一些方式进行拆分,以便能够进行连线.
传递package的一个额外好处是不仅能够使用代码,同时可以使用一个对象图表片段,这会使代码的使用更加清晰.使用以连线的module并没有一些必须的要求,因此可以通过不同的方式完成.
通常module不能作为单独的形式存在,经常会会依赖于其他module中的类,有两种方式表示依赖.
通过抽象成员表示依赖
由于一些module是一个trait,这允许留下一些未明确的依赖,作为抽象成员.这些抽象成员可以在连线(手动或wire)时使用,但是并不需要提供指定的实现.
当所有的module在应用中组合时,编译器会验证所有这些被定义为抽象成员的实际定义.
注意我们可以声明所有的抽象成员为def
,然后在后来可以被实现为val
或者lazy val
,或者仍然为def
,使用def
可以保留所有可能的选项.
我们样例代码的连线可以按如下方式拆分,这些类被分组为pakage:
package shunting {
class PointSwitcher()
class TrainCarCoupler()
class TrainShunter(
pointSwitcher: PointSwitcher,
trainCarCoupler: TrainCarCoupler)
}
package loading {
class CraneController()
class TrainLoader(
craneController: CraneController,
pointSwitcher: PointSwitcher)
}
package station {
class TrainDispatch()
class TrainStation(
trainShunter: TrainShunter,
trainLoader: TrainLoader,
trainDispatch: TrainDispatch) {
def prepareAndDispatchNextTrain() { ... }
}
}
每个package相当于一个trait-module
,注意shunting
和loading
包之间的依赖表示为一个抽象成员:
package shunting {
trait ShuntingModule {
lazy val pointSwitcher = wire[PointSwitcher]
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = wire[TrainShunter]
}
}
package loading {
trait LoadingModule {
lazy val craneController = wire[CraneController]
lazy val trainLoader = wire[TrainLoader]
// dependency of the module
def pointSwitcher: PointSwitcher
}
}
package station {
trait StationModule {
lazy val trainDispatch = wire[TrainDispatch]
lazy val trainStation = wire[TrainStation]
// dependencies of the module
def trainShunter: TrainShunter
def trainLoader: TrainLoader
}
}
object TrainStation extends App {
val modules = new ShuntingModule
with LoadingModule
with StationModule
modules.trainStation.prepareAndDispatchNextTrain()
}
以这样的方式实现依赖需要一个一致的命名约定,比如抽象成员和实现的名字一样.类实例的名字和类的一致然后首字母小写,也是一个很好的命名约定.
这个个方法在一些部分类似于蛋糕模式,因此命名为Thin Cake Pattern
.
通过self-types
表示依赖
另一种表示依赖的方式是通过self-types
或者扩展其他的trait-modules
.这种方式在两个模块之间建立了强烈的连接关系,取代了解耦的抽象成员方式,但是在有些场景也是有应用价值的.
比如,我们可以通过集成一个trait-module
来表示shunting,loading,station
之间的依赖,而不是使用抽象成员:
package shunting {
trait ShuntingModule {
lazy val pointSwitcher = wire[PointSwitcher]
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = wire[TrainShunter]
}
}
package loading {
trait LoadingModule {
lazy val craneController = wire[CraneController]
lazy val trainLoader = wire[TrainLoader]
// dependency expressed using an abstract member
def pointSwitcher: PointSwitcher
}
}
package station {
// dependencies expressed using extends
trait StationModule extends ShuntingModule with LoadingModule {
lazy val trainDispatch = wire[TrainDispatch]
lazy val trainStation = wire[TrainStation]
}
}
object TrainStation extends App {
val modules = new ShuntingModule
with LoadingModule
with StationModule
modules.trainStation.prepareAndDispatchNextTrain()
}
使用self-type
可以实现一个类似的效果.
这种方式有利于在多个小的模块之外创建一个大型的模块,而不需要重新表示各个小模块之间的依赖.只需要定义一个bigger-module-trait
来继承多个smaller-module-traits
.
组合模块
模块可以通过组合的方式结合在一起,可以嵌套多个模块,然后在嵌套的模块内使用依赖来连接(wire)对象.
比如,我们可以给列车管理应用添加一个插件以提供数据收集统计:
package stats {
class LoadingStats(trainLoader: TrainLoader)
class ShuntingStats(trainShunter: TrainShunter)
class StatsModule(
shuntingModule: ShuntingModule,
loadingModule: LoadingModule) {
import shuntingModule._
import loadingModule._
lazy val loadingStats = wire[LoadingStats]
lazy val shuntingStats = wire[ShuntingStats]
}
}
注意import
语句,用于将定义在嵌套模块内的依赖引入到当前作用域.
如果给trait/class
模块使用实验性质的@Module
注解会更加简洁.嵌套模块中带有这个注解的成员会在连线时自动引入.
package loading {
@Module
trait LoadingModule { ... }
}
package shunting {
@Module
trait ShuntingModule { ... }
}
package stats {
class LoadingStats(trainLoader: TrainLoader)
class ShuntingStats(trainShunter: TrainShunter)
class StatsModule(
shuntingModule: ShuntingModule,
loadingModule: LoadingModule) {
lazy val loadingStats = wire[LoadingStats]
lazy val shuntingStats = wire[ShuntingStats]
}
}
这种方案就不需要再进行import
引入了.
多种实现
一个功能经常会有多种实现,然后我们根据配置进行选择.这种情况可以至少使用两种模式进行模拟.
首先,我们可以有一个单独的模块,包含一个条件逻辑来选择合适的实现.加入火车分流(shunting)有两个选项,传统或者隐形传输,然后是一个配置:
package shunting {
trait TrainShunter
class PointSwitcher()
class TrainCarCoupler()
class TraditionalTrainShunter(
pointSwitcher: PointSwitcher,
trainCarCoupler: TrainCarCoupler)
extends TrainShunter
class TeleportingTrainShunter() extends TrainShunter
trait ShuntingModule {
lazy val pointSwitcher = wire[PointSwitcher]
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = if (config.modern) {
wire[TeleportingTrainShunter]
} else {
wire[TraditionalTrainShunter]
}
def config: Config
}
}
然后,一个模块可以有多个实现.这种场景下,我们可以创建一个仅包含抽象成员的interface-module
,这些成员在合适的模块中被实现.这样一个interface-module
同时可以很好的用来表示依赖(不用命名约定),并能建立强力的联系:
package shunting {
trait TrainShunter
class PointSwitcher()
class TrainCarCoupler()
class TraditionalTrainShunter(
pointSwitcher: PointSwitcher,
trainCarCoupler: TrainCarCoupler)
extends TrainShunter
class TeleportingTrainShunter() extends TrainShunter
trait ShuntingModule {
lazy val pointSwitcher = wire[PointSwitcher]
def trainShunter: TrainShunter
}
trait TraditionalShuntingModule extends ShuntingModule {
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = wire[TraditionalTrainShunter]
}
trait ModernShuntingModule extends ShuntingModule {
lazy val trainShunter = wire[TeleportingTrainShunter]
}
}
// ...
object TrainStation extends App {
val traditionalModules = new TraditionalShuntingModule
with LoadingModule
with StationModule
val modernModules = new ModernShuntingModule
with LoadingModule
with StationModule
traditionalModules.trainStation.prepareAndDispatchNextTrain()
modernModules.trainStation.prepareAndDispatchNextTrain()
}
这个方式缺陷在于模块栈必须在编译时知道,不能动态的选择.
测试
每个单独的组件可以通过提供所依赖的mock/stub
实现进行测试.而且,使用thin cake pattern
时,模块可以进行整合测试(integration-tested),使用定义在模块中的wiring
.
当然,我们也需要为那些表示为抽象成员的依赖提供一些实现,可以是mock/stub
.同时,能够对一些依赖进行重写来提供一个替代实现以用于测试.这些依赖用于连接模块中定义的图片段.
比如测试分流模块,可以模拟一个与外部系统相互作用的point switcher
,然后写一个整合测试:
// main code
package shunting {
trait ShuntingModule {
lazy val pointSwitcher = wire[PointSwitcher]
lazy val trainCarCoupler = wire[TrainCarCoupler]
lazy val trainShunter = wire[TrainShunter]
}
}
// test
class ShuntingModuleItTest extends FlatSpec {
it should "work" in {
// given
val mockPointSwitcher = mock[PointSwitcher]
// when
val moduleToTest = new ShuntingModule {
// the mock implementation will be used to wire the graph
override lazy val pointSwitcher = mockPointSwitcher
}
moduleToTest.trainShunter.shunt()
// then
verify(mockPointSwitcher).switch(...)
}
}
拦截器(Interceptors)
拦截器在实现横切关注点(cross-cutting concerns)
时非常有用,几乎是所有依赖框架或容器的一部分.然而在Scala中没有拦截器的直接支持,使用一个薄的库层(由MacWire提供),也可以很简单的编写或使用拦截器.
使用拦截器是一个两步的过程.首先需要声明什么需要被拦截.概念上,这不能牵涉任何拦截器的具体实现.然后,需要定义拦截器需要做什么,即行为.
在实现第一部分时我们定义一个抽象拦截器,然后把他应用于已选择的值.比如我们要核查所有的point switches
和car couplings
事件到外部系统.因此我们要拦截所有作用到PointSwitcher
和TrainCarCoupler
服务的方法调用:
package shunting {
trait ShuntingModule {
lazy val pointSwitcher: PointSwitcher =
logEvents(wire[PointSwitcher])
lazy val trainCarCoupler: TrainCarCoupler =
logEvents(wire[TrainCarCoupler])
lazy val trainShunter = wire[TrainShunter]
def logEvents: Interceptor
}
}
上面我们已经声明了我们想要把logEvents
拦截器作用到pointSwitcher
和trainCarCoupler
服务.注意到目前还没有提及实现方式.我们仅使用了一个抽象的Interceptor
特质,它用于一个apply
方法,然后返回通过参数传递给他的相同的类型.
当然我们需要指定一个实现.我们可以尽可能晚的来做这件事,比如在应用的mian实体的最后位置:
object TrainStation extends App {
val modules = new ShuntingModule
with LoadingModule
with StationModule {
lazy val logEvents = ProxyingInterceptor { ctx =>
println("Calling method: " + ctx.method.getName())
ctx.proceed()
}
}
modules.trainStation.prepareAndDispatchNextTrain()
}
这里指定了我们想要创建一个代理拦截器(会创建一个java代理),并提供了响应的行为.注意在处理代理调用时,我们可以使用定义在模块中的所有服务.
在测试时跳过拦截器会比较有利.可以通过提供一个no-op
拦截器实现:
class ShuntingModuleItTest extends FlatSpec {
it should "work" in {
// given
val moduleToTest = new ShuntingModule {
lazy val logEvents = NoOpInterceptor
}
// ...
}
}
Advanced scoping
……