SpringBoot-IoC-DI-PartII

最近读了Martin Fowler关于IoCDI文章,觉得之前SpringBoot-IoC-DI写的还是太过狭隘。因此本篇对这个话题进行补充。

IoC 和 DI 的关系

在上一篇文章中,首段的描述似乎把DI定义为与IoC同级的概念,这种暗示是错误的。

Spring Core完成了两个关键概念:IoC(Inversion of Control,控制反转)和DI(Dependency Injection,依赖注入)。

实际上,IoC是一种设计原则或者说设计理念,而DI是实现这种设计原则的一种具体方式。除DI以外,还有其他实现IoC的方式。IoC在不同语境下,也存在着不同的解释。

通俗地说,DIIoC的一种特例,是在将不同组件/模块组合成一个大型程序的语境下,实现控制反转的一类方法。

IoC

IoC(Inversion of Control,控制反转),在上篇中默认了将不同组件/模块组合成一个大型程序的语境,给出了狭隘的定义。

IoC是一种设计模式,它将对象的创建和管理的控制从应用程序代码转移到了容器中。

广义的IoC不限于此。要搞清什么是控制反转,那么就得清楚,被控制的是什么,被反转的又是什么

程序员学习编程语言,通常会从命令行程序开始。在命令行中输出提示语句,等待用户输入。程序将用户输入保存在变量中,然后输出下一个提示语句并等待。在这个过程中,用户无法改变输入顺序。比如程序要求先输入姓名,然后输入性别,最后输入年龄,用户没法先输入年龄,后输入姓名。这是因为程序的运行顺序已经在编码时确定,编码(内部)顺序控制着程序的执行顺序。这也是面向过程编程的特点。

1
2
3
4
5
6
print "enter your name"
read name
print "enter your age"
read age
etc...
store in database

更进一步,一些命令行程序允许用户输入操作指令,来控制程序的执行顺序。例如输入name后,程序等待用户输入姓名,而输入q后程序则终止。这样用户可以按照任意顺序进行输入,甚至修改已输入的内容。此时,程序的执行顺序在编码时是不确定的,而是在运行时,根据用户(外部)行为随机应变的。从代码上看,录入姓名功能的代码,并不知道自己何时将被调用。外部可能包含一个循环和if语句,根据用户输入的nameq,来调用对应功能的代码。这实现了控制反转

1
2
3
4
5
6
7
8
9
10
11
12
13
loop
print "enter your operator, 'name' for set name, 'q' for quit"
read operator
if operator == name
call setName
else if operator == q
call quit
etc...
end loop

function setName:
print "enter your name"
read name

再进一步,不再是命令行,而是具有GUI的程序。用户通过选中文本框输入,在任意时刻点击提交或取消按钮,来控制程序的执行顺序。用户控制程序的执行顺序变得更加自由。这更加实现了控制反转

总结上述例子,当程序功能的执行顺序/时机由外部控制,而不是内部定义,就实现了控制反转。这回答了本节开头的问题,被控制的是程序功能的执行顺序/时机,被反转的是控制方由内部转为外部

实现了控制反转时,体现在代码中的特点,通常是定义了回调函数(callback)。在上例中,设置名称和退出方法,都是回调函数。这两个方法的执行时机,在编译时无法知晓。条件满足时,会由外部控制器调用具体方法。在编写方法时,不用关心方法会何时被执行。这意味着what to dowhen to do的代码是分开的。

Spring中的IoC

上一节提到控制反转,IoC是将程序功能的执行控制由内部转移到外部,实现IoC的特点是定义了回调函数。那么在提到Spring实现了IoC,内部和外部指什么,又是定义了哪个回调函数。

在回答上面问题之前,先聊聊使用Spring的动机。

程序员都是从某个单一功能的程序开始学习编程,后续也少有功能的扩展。而企业中运行的应用程序(通常称为系统)随着版本更迭,通常由多个不同的开发人员合作完成各个功能的扩展。为了避免多人开发时,开发人员相互影响,提出了模块化编程的思想。主旨就是将功能拆分成多个模块/文件,每个模块负责实现特定的功能或逻辑。模块化编程可以提高代码的可维护性、可读性、复用性。安排开发人员开发不同模块,也能减少相互影响的程度。企业应用常见的MVC(Model-View-Controller,模型-视图-控制器)模型也正是模块化编程的体现。

模块化编程下,原本复杂的功能,被拆分成多个功能模块/组件。在实际使用应用程序时,就涉及将多个功能各异的模块,组装成一个应用程序。具体来说,模块之间存在着依赖(模块存在的意义就是被其他模块调用),例如A模块通过成员变量持有B模块的引用,并在A的方法定义中,调用了B模块中的方法。这就引起了一个问题,如何处理模块间的依赖关系,将各个模块链接在一起

1
2
3
4
public class A {
// 对B模块的依赖
private B b;
}

最直观简单的方式,就是new一个B对象。

1
2
3
4
public class A {
// 对B模块的依赖
private B b = new B();
}

这样做的确能把AB联系起来。但如果还有其他模块也需要依赖模块B,并且也是通过这种形式创建再链接,那么在内存空间会存在B的多个实例。每个模块中对B的依赖都是一个独立的实例。如果模块B包含一些状态,多个实例的状态不一致会引起问题。因此我们更希望多个模块依赖B,引用的是同一个B实例。

此外,为了写出通用代码,我们会将B模块设置成一个接口,使得A模块的功能可以独立于B模块的具体实现。

1
2
3
4
public class A {
// 对B模块的依赖
private BInterface b = new BImplement();
}

语法上代码可以通过编译。但这种写法使得A模块不仅依赖了B接口,同时也通过new关键字依赖了B接口的具体实现。

B接口有多个实现,具体需要哪个实现,可能在运行时才能确定,例如根据配置文件或用户输入。直接在模块中创建任意一个B接口实例,会使得定义B接口变得没有意义,仍然不是一个通用的代码。

所以对于A来说,要写出通用的代码,不应该依赖B接口具体实现。换句话说,在A的内部代码中,无法完成对成员变量B接口赋值。那么就需要一个额外的组装器类,专负责把AB拼接在一起。

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
2
3
4
5
6
7
8
9
10
11
12
public class A {
// 对B模块的依赖
private BInterface b;
// 构造器注入
public A(BInterface b) {
this.b = b;
}
// setter注入
public void setB(BInterface b) {
this.b = b;
}
}

编写的注入方法无需考虑具体的调用时机,因此某种程度这些方法也属于回调函数。定义的方法会由容器进行调用。容器会判断,需要注入的类是否含有构造方法或setter方法,找到符合条件的对象,实际调用这些注入方法。

接口注入是指,定义一个注入某个模块的接口,通常只声明一个注入指定模块方法。然后由依赖这个模块的类实现这个接口。

1
2
3
4
5
6
7
8
9
10
11
12
public interface InjectB {
void injectB(BInterface b)
}

public class A implements InjectB {
private BInterface b;

@Override
public void injectB(BInterface b) {
this.b = b;
}
}

这种方式和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

在类图中,AssemblerServiceLocator,通常前者完成对象的创建,后者完成将某个实例与某个接口/名称绑定。两者也可以合并成一个类。

之后,只需要在需要依赖的模块中,例如构造函数、字段初始化或函数定义中,访问服务定位器的方法,就可完成依赖的链接。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ServiceLocator {
// 指定方法获取某个接口对象
public static BInterface getB() {
...
}
// 通过接口类型获取对象
public static <T> T getBean(Class<T> requiredType) {
...
}

// 通过名称获取对象
public static Object getBean(String name) {
}
}

public class A {
// 字段初始化时访问服务定位器
private BInterface b = ServiceLocator.getB();
// 默认构造函数中访问服务定位器
public A() {
this.b = ServiceLocator.getBean(BInterface.class);
}
// 函数中访问服务定位器
public void Amethod() {
this.b = (BInterface)ServiceLocator.getBean("OneOfBImplementation");
}
}

从这里看,服务定位也消除了模块AB接口具体实现的依赖。模块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 dowhen to do的代码分开。

现代大型企业应用程序为了代码可维护性、可读性、复用性,采取了模块化编程的形式。

在模块化编程中,模块之间存在着依赖,具体体现在一个模块通过成员变量持有另一个模块的引用。因此需要将多个模块组装成应用的过程。

在将不同组件/模块组合成一个大型程序的语境下,DI实现IoC的一种方式,通常用容器完成这个过程。

DI通常包括构造器注入,setter注入,接口注入。现代更常用的是通过注解和反射的字段注入的方式。

服务定位也可以消除模块对接口实现类的依赖。通过访问一个全局单例的服务定位器,来获取需要的依赖。

服务定位没有完全实现IoC

Spring同时实现了DI和服务定位,但更推荐使用DI


SpringBoot-IoC-DI-PartII
http://dracoyus.github.io/2024/08/16/SpringBoot-IoC-DI-PartII/
作者
DracoYu
发布于
2024年8月16日
许可协议