SpringBoot-IoC-DI-PartII
最近读了Martin Fowler
关于IoC
和DI
的文章,觉得之前SpringBoot-IoC-DI写的还是太过狭隘。因此本篇对这个话题进行补充。
IoC 和 DI 的关系
在上一篇文章中,首段的描述似乎把DI
定义为与IoC
同级的概念,这种暗示是错误的。
而
Spring Core
完成了两个关键概念:IoC
(Inversion of Control,控制反转)和DI
(Dependency Injection,依赖注入)。
实际上,IoC
是一种设计原则或者说设计理念,而DI
是实现这种设计原则的一种具体方式。除DI
以外,还有其他实现IoC
的方式。IoC
在不同语境下,也存在着不同的解释。
通俗地说,DI
是IoC
的一种特例,是在将不同组件/模块组合成一个大型程序的语境下,实现控制反转的一类方法。
IoC
IoC
(Inversion of
Control,控制反转),在上篇中默认了将不同组件/模块组合成一个大型程序的语境,给出了狭隘的定义。
IoC
是一种设计模式,它将对象的创建和管理的控制从应用程序代码转移到了容器中。
广义的IoC
不限于此。要搞清什么是控制反转,那么就得清楚,被控制的是什么,被反转的又是什么。
程序员学习编程语言,通常会从命令行程序开始。在命令行中输出提示语句,等待用户输入。程序将用户输入保存在变量中,然后输出下一个提示语句并等待。在这个过程中,用户无法改变输入顺序。比如程序要求先输入姓名,然后输入性别,最后输入年龄,用户没法先输入年龄,后输入姓名。这是因为程序的运行顺序已经在编码时确定,编码(内部)顺序控制着程序的执行顺序。这也是面向过程编程的特点。
1 |
|
更进一步,一些命令行程序允许用户输入操作指令,来控制程序的执行顺序。例如输入name
后,程序等待用户输入姓名,而输入q
后程序则终止。这样用户可以按照任意顺序进行输入,甚至修改已输入的内容。此时,程序的执行顺序在编码时是不确定的,而是在运行时,根据用户(外部)行为随机应变的。从代码上看,录入姓名功能的代码,并不知道自己何时将被调用。外部可能包含一个循环和if
语句,根据用户输入的name
和q
,来调用对应功能的代码。这实现了控制反转。
1 |
|
再进一步,不再是命令行,而是具有GUI
的程序。用户通过选中文本框输入,在任意时刻点击提交或取消按钮,来控制程序的执行顺序。用户控制程序的执行顺序变得更加自由。这更加实现了控制反转。
总结上述例子,当程序功能的执行顺序/时机由外部控制,而不是内部定义,就实现了控制反转。这回答了本节开头的问题,被控制的是程序功能的执行顺序/时机,被反转的是控制方由内部转为外部。
实现了控制反转时,体现在代码中的特点,通常是定义了回调函数(callback)。在上例中,设置名称和退出方法,都是回调函数。这两个方法的执行时机,在编译时无法知晓。条件满足时,会由外部控制器调用具体方法。在编写方法时,不用关心方法会何时被执行。这意味着what to do
和when to do
的代码是分开的。
Spring中的IoC
上一节提到控制反转,IoC
是将程序功能的执行控制由内部转移到外部,实现IoC
的特点是定义了回调函数。那么在提到Spring
实现了IoC
时,内部和外部指什么,又是定义了哪个回调函数。
在回答上面问题之前,先聊聊使用Spring
的动机。
程序员都是从某个单一功能的程序开始学习编程,后续也少有功能的扩展。而企业中运行的应用程序(通常称为系统)随着版本更迭,通常由多个不同的开发人员合作完成各个功能的扩展。为了避免多人开发时,开发人员相互影响,提出了模块化编程的思想。主旨就是将功能拆分成多个模块/文件,每个模块负责实现特定的功能或逻辑。模块化编程可以提高代码的可维护性、可读性、复用性。安排开发人员开发不同模块,也能减少相互影响的程度。企业应用常见的MVC
(Model-View-Controller,模型-视图-控制器)模型也正是模块化编程的体现。
模块化编程下,原本复杂的功能,被拆分成多个功能模块/组件。在实际使用应用程序时,就涉及将多个功能各异的模块,组装成一个应用程序。具体来说,模块之间存在着依赖(模块存在的意义就是被其他模块调用),例如A
模块通过成员变量持有B
模块的引用,并在A的方法定义中,调用了B
模块中的方法。这就引起了一个问题,如何处理模块间的依赖关系,将各个模块链接在一起。
1 |
|
最直观简单的方式,就是new
一个B
对象。
1 |
|
这样做的确能把A
和B
联系起来。但如果还有其他模块也需要依赖模块B
,并且也是通过这种形式创建再链接,那么在内存空间会存在B
的多个实例。每个模块中对B
的依赖都是一个独立的实例。如果模块B
包含一些状态,多个实例的状态不一致会引起问题。因此我们更希望多个模块依赖B
,引用的是同一个B
实例。
此外,为了写出通用代码,我们会将B
模块设置成一个接口,使得A模块的功能可以独立于B
模块的具体实现。
1 |
|
语法上代码可以通过编译。但这种写法使得A
模块不仅依赖了B
接口,同时也通过new
关键字依赖了B
接口的具体实现。
B
接口有多个实现,具体需要哪个实现,可能在运行时才能确定,例如根据配置文件或用户输入。直接在模块中创建任意一个B
接口实例,会使得定义B
接口变得没有意义,仍然不是一个通用的代码。
所以对于A
来说,要写出通用的代码,不应该依赖B
接口具体实现。换句话说,在A
的内部代码中,无法完成对成员变量B
接口赋值。那么就需要一个额外的组装器类,专负责把A
和B
拼接在一起。
classDiagram
direction TB
class A {
- BInterface
+ Amethod()
}
class BInterface {
<< interface >>
+ Bmethod()
}
A --o BInterface
class BImplementation {
+ Bmethod()
}
BImplementation ..|> BInterface
class Assembler {
+ assembleAAndB()
}
Assembler ..> A
Assembler ..> BImplementation
Assembler ..> BInterface
具体来说,组装类需要找到符合条件的对象,通过某种方式把对象赋值到需要的地方。DI
就是其中一种方式,指模块通过定义注入方法,等待外部组装器调用,进行依赖链接。从描述来看,对模块间链接的过程,由模块内部,转移到了外部专门的组装器类,实现了IoC
。定义的回调函数,通常是构造函数或setter函数,而外部的组装类,控制了回调函数的执行。
对于多个模块来说,每个模块都需要一个组装类。但实际上不同模块的组装类在功能上是高度相似的。抽象地说,就是找到等待注入的回调函数,使用合适的参数调用这些注入方法。因此通常可以将多个模块的组装器类整合到一个通用的组装器类中,我们称之为容器。通常容器不仅完成了模块间的链接,还负责了每个模块的创建工作。
DI
依赖注入有多种形式,按照Martin Fowler
的定义包括:构造器注入、setter注入、接口注入。实际上在现代Spring
开发中,使用更多的是基于注解与反射的字段注入,因此对于上述三种注入形式,只是简单了解学习。
构造器注入,setter
注入是较为常见的注入方式。指在模块中,对其依赖的模块,提供对应的构造方法或setter
方法。
1 |
|
编写的注入方法无需考虑具体的调用时机,因此某种程度这些方法也属于回调函数。定义的方法会由容器进行调用。容器会判断,需要注入的类是否含有构造方法或setter
方法,找到符合条件的对象,实际调用这些注入方法。
接口注入是指,定义一个注入某个模块的接口,通常只声明一个注入指定模块方法。然后由依赖这个模块的类实现这个接口。
1 |
|
这种方式和setter
注入很像,都是定义了一个回调函数。区别是容器可以通过instanceof
关键字判断某个类是否实现了注入接口,来确定是否存在需要注入的依赖。如果一个模块依赖着多个模块,会需要实现非常多的接口,显得繁琐,因此实际上接口注入使用不多。Spring
中最常见的接口注入形式,是各种xxxAware
,例如ApplicationContextAwareProcessor
。在此不再赘述。
上文仅描述了定义这些回调方法。我们仍需要某种方式告知容器,需要对哪些模块进行组装。可以通过多种形式,例如在代码中对需要组装的模块进行注册,或者在XML
文件中对bean
和其依赖进行描述性定义。容器创建对象和进行组装的时机,取决于具体容器实现。这部分不是本文重点,不再赘述。
Service Locator
DI的主要动机是,移除了模块对某些接口具体实现的依赖。DI不是达成这个目的的唯一途径,服务定位(service locator)是另一种模式。
在服务定位模式中,存在一个全局单例对象,掌握着所有接口对应的实现类对象。每个模块可以通过这个服务定位器对象,获取指定接口类型/名称的实例。
classDiagram
direction TB
class A {
- BInterface
+ Amethod()
}
class BInterface {
<< interface >>
+ Bmethod()
}
A --o BInterface
class BImplementation {
+ Bmethod()
}
BImplementation ..|> BInterface
class Assembler
Assembler ..> BImplementation
Assembler ..> BInterface
class ServiceLocator{
+ getB()
}
A ..> ServiceLocator
Assembler ..> ServiceLocator
ServiceLocator ..> BInterface
在类图中,Assembler
和ServiceLocator
,通常前者完成对象的创建,后者完成将某个实例与某个接口/名称绑定。两者也可以合并成一个类。
之后,只需要在需要依赖的模块中,例如构造函数、字段初始化或函数定义中,访问服务定位器的方法,就可完成依赖的链接。
1 |
|
从这里看,服务定位也消除了模块A
对B
接口具体实现的依赖。模块A
对于依赖链接的时机是了解的,是在模块A
内部显示定义的。模块A
也没有提供相应的回调函数,等待被外部调用。但对于B
接口具体实现的实例化过程,是不了解的。因此笔者认为服务定位模式不完全实现了IoC
。
对比DI
和服务定位,在DI
中,模块对于外部的认知是最少的,甚至不知道容器的存在;而在服务定位中,所有模块都需要依赖服务定位器。服务定位器是一个全局单例,有着全局变量的缺点,例如可能会掩盖模块间的依赖关系。
在Spring
中,BeanFactory
容器,同时完成了DI
和服务定位两种功能。服务定位对应的就是BeanFactory
中的getBean
方法。在源码的注释中,更推荐使用DI
来完成依赖链接的功能。
Note that it is generally better to rely on Dependency Injection ("push" configuration) to configure application objects through setters or constructors, rather than use any form of "pull" configuration like a BeanFactory lookup. Spring's Dependency Injection functionality is implemented using this BeanFactory interface and its subinterfaces.
总结
广义的IoC
,是指某段程序的执行由外部行为控制,而不是内部定义,则称实现了IoC
。
实现IoC
体现在代码中的特征通常是定义了回调函数,将what to do
和when to do
的代码分开。
现代大型企业应用程序为了代码可维护性、可读性、复用性,采取了模块化编程的形式。
在模块化编程中,模块之间存在着依赖,具体体现在一个模块通过成员变量持有另一个模块的引用。因此需要将多个模块组装成应用的过程。
在将不同组件/模块组合成一个大型程序的语境下,DI
实现IoC
的一种方式,通常用容器完成这个过程。
DI
通常包括构造器注入,setter
注入,接口注入。现代更常用的是通过注解和反射的字段注入的方式。
服务定位也可以消除模块对接口实现类的依赖。通过访问一个全局单例的服务定位器,来获取需要的依赖。
服务定位没有完全实现IoC
。
Spring
同时实现了DI
和服务定位,但更推荐使用DI
。