理解 IoC:框架背后的思想

缘起

在学习软件设计的过程中,不知道你是否有这种感觉,有一些很常见的术语,自己似懂非懂,比如:Inversion of Control(缩写 IoC),Dependency Injection(缩写 DI),Service Locator 等等。

你很难精确把握这个概念的原因是,概念从诞生开始,自己也在演化,在这个过程中会出现定义的变化(内涵),使用场景的变化(外延),再加上不同使用者的解读也可能存在错漏和偏差,最终带来了学习的困难。

好在,就算理解个大概,也不影响你对它的应用,不用太过纠结。

什么是 IoC ?

IoC 是 Inversion of Control 的缩写,业界常见的翻译就是“控制反转”。这个术语的出现,至少可以追溯到 1988 年[1]。与其说,IoC 是一种设计思想,不如说是一种软件开发框架中常见的现象,是“框架”有别于“类库”的特征之一[1:1]

Inversion of Control is a common phenomenon that you come across when extending frameworks. Indeed it’s often seen as a defining characteristic of a framework.

—— Martin Fowler

在《设计模式》(GoF)这本著作里,有一个形象而且流传甚广的概念,从一定程度上,解释了 IoC,那就是“好莱坞”原则。

“Don’t call us, we’ll call you (Hollywood’s Law)

控制(什么不是 IoC ?)

Martin Fowler 举了一个简单的例子:比如,我们需要用户从命令行输入一些名字等信息,会这样写代码(Ruby 示例):

1
2
3
4
5
6
puts 'What is your name?'
name = gets
process_name(name)
puts 'What is your quest?'
quest = gets
process_quest(quest)

在这个代码示例里,第一步使用 gets 跟用户交互,然后,调用 process_name 程序进行业务处理,第二步…… 程序员在编写代码时,通过控制语句的顺序,控制了整个程序执行的流程。

不难发现,这是一个“指令式”编程范式(Imperative Paradigm)的例子,在命令行交互的时代,这样的做法非常普遍。

控制反转的出现

到了图形界面(GUI)交互的时代,比如,桌面应用程序,或者网页应用,用户与应用软件的交互方式出现了巨大的变化。

无论是桌面应用,还是网页应用,交互界面都给用户提供了丰富的功能,我们不能假设用户会按照什么样的顺序来使用软件提供的功能。

从这个原理上,软件执行流程的控制权,交到了用户的手上。编程方式也出现了巨大的变化。

1
2
3
4
5
6
7
8
9
10
11
require 'tk'
root = TkRoot.new()
name_label = TkLabel.new() {text "What is Your Name?"}
name_label.pack
name = TkEntry.new(root).pack
name.bind("FocusOut") {process_name(name)}
quest_label = TkLabel.new() {text "What is Your Quest?"}
quest_label.pack
quest = TkEntry.new(root).pack
quest.bind("FocusOut") {process_quest(quest)}
Tk.mainloop()

上面的代码,还是和前一个例子相同的业务逻辑,但是换到了图形界面上,可以看到,我们将 process_nameprocess_quest 作为事件处理函数,绑定到了 FocustOut 事件上面,整个应用程序是一个 mainloop 驱动,用户输入 namequest 的行为(输入顺序控制在用户手里),决定了程序执行的流程。

这就是控制的反转。

为什么要使用 IoC ?

在上面的例子里,使用了 TK 这个图形界面软件开发的框架,框架帮程序员处理了很多基础的事情,比如主窗体的绘制,事件触发,消息传递等等机制,而程序员在编写软件应用的时候,只需要专注于编写用户事件的响应函数即可。

框架的出现,就是为了解决这个问题,将整个应用运行的基础环境,机制搭建好,让程序员可以专心实现自己的业务逻辑。

而在桌面图形界面应用和网页应用这两个方面,都是程序员准备好自己的事件处理函数,然后由框架决定(其实最终使用的用户决定),何时调用这些处理函数。

所以我们说,IoC 就是在开发框架中一种普遍的现象。利用框架进行软件开发的这种生产模式,也往往就是如此。

图形界面应用程序中,一般采用 Event Driven 的模式,程序员去实现一个一个的 Event Handler,而整个应用程序由一个主 Loop 去驱动。

而在 Web 应用程序中,类似的行为,我们成为 Routing(路由),程序员去实现每个 Route 的响应程序,由 Web Server 负责调用。

框架和类库

类库相比之下,和开发框架不同,类库的组织方式,仍然是一个一个定义好的类,程序员决定去使用哪个类,使用哪些方法,以及还要决定类的构造,以及方法的调用顺序等。

所以,IoC 也是框架有别于类库的一个重要特征。

如何实现 IoC ?

其实,不难理解,IoC 就是在软件工业化发展道路上,自然而然的选择。

我们都知道,开发框架就是把软件应用中,比较共性的问题解决方案,抽取出来,组合在一起,供其他项目去复用,从而实现提高研发效率的目的。

那么,研发框架是怎么将“特定业务”的实现,嵌入到框架原本的代码中,实现软件应用的定制呢?

接口与协议

在面向对象软件设计中,接口(Interface)的作用,就是约定一个对象必须具有的属性和方法的一种抽象。

如果一个对象实现了某个接口,就一定会具有接口规定的属性和对应的方法。

现在,如果基于某种开发框架,去实现我们的应用程序,我们这只要声明一个类,去实现对应的接口,就可以实现将我们特定的业务逻辑,整合在一起,完成整个应用。

在这里,接口,其实就是我们对框架进行扩展,或者说与框架协作的“协议”。

组装

通过接口,我们实现了框架代码与业务代码的隔离。

我们将业务逻辑代码,写在了实现特定接口的类里。比如在 Yii 框架里,我们会创建一个新的 Controller,实现其中的 Action 方法,这本质上就是实现了接口,将业务逻辑写在了 Action 里面。

那么即便是这样,写好的业务逻辑,也是一段段零碎的程序,他们是怎么被框架组装起来的呢?

要知道,框架的编写,早于应用的实现。在编写框架的时候,我们并不知道程序员会怎么去实现业务逻辑,也不知道程序员会把实现业务逻辑的类叫什么名字,有多少个这样的类,那么框架在启动运行的时候,怎么能准确无误的找到实现业务逻辑的类,并将这些代码嵌入到正确的为止呢?

这种组织方式,正式 IoC 的具体实现了,常见的有两种方式,一种是 Dependency Injection(DI),依赖注入,另一种,是 Service Locator。

依赖注入 DI

讲到这里,我们就不难理解依赖注入的思想了。

因为,我们编写框架的时候,还不知道“框架”需要依赖的“业务逻辑”叫什么名字,所以,我们并不能直接把对应的业务逻辑的实现给创建出来。必须是在应用实现后,这些业务逻辑的名字和实现才被确定下来。

所以,当编写框架的时候,只能确定,未来这个逻辑会被传入进来。我“依赖”的那段逻辑,会被“注入”进来的。

注入的方式有几种,但是核心思想都是一样的:

  1. 使用构造函数注入;
  2. 使用属性赋值注入;

使用构造函数注入

使用构造函数注入,就是在当前对象被创建的时候,其依赖的对象被作为参数传入进来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Action extends Component
{
public $id;
public $controller;

public function __construct($id, $controller, $config = [])
{
$this->id = $id;
$this->controller = $controller;
parent::__construct($config);
}

//....
}

上面的代码演示了,Action 类在实例化的时候,构造函数需要传入一个 Controller 的对象,那么负责创建 Action 对象的人,会决定传入哪个 Controller 的实例对象,这种就是构造函数注入的方法。

使用属性赋值注入

还是以这个例子为基础,我们把它改成使用属性赋值的方式:

1
2
3
4
5
6
7
8
9
10
11
12
class Action extends Component
{
public $id;
public $controller;

public function __construct()
{
parent::__construct($config);
}

//....
}

很容易看到区别,就是在创建 Action 对象实例的时候,我们无法传入其依赖的对象,而是在对象创建完毕后:

1
2
$action = new Action();
$action->controller = $aController;

这样的方式完成赋值,也实现了将依赖传入到 Action 内部的目的。

这两种注入方式,并没有本质的区别,视使用的场景而决定实现的方式。其实不难想象,都以来外部的一个工厂类,去创建这些对象的时候,完成“框架”和“业务逻辑”的组装。

其实,除了“构造注入”,“属性赋值注入”,还有“接口注入”的方式,区别就是“接口注入”只假设了协议,没有假设父类型。不过这种方式比较抽象,感兴趣的读者可以自行搜索对应的实现方式,我就不赘述了。

服务定位器 Service Locator

上面不难看出来,使用 DI 这种方式进行组装,是依赖工厂对象对自己进行构建和组装,是完全被动的一种方式。

而 Service Locator 则是一种比较主动的方式,就是假设全局有一个可供注册 Service 的服务,也就是 Service Locator,各种需要被组装的业务逻辑,都有一个注册的过程,然后依赖的一方,在访问的时候调用 Service Locator 的实例,主动去访问被依赖的对象即可。

比如,在 Yii 框架里我们写代码经常会写:

1
Yii::app()->db->query();

这个代码里,我们可以看到,Yii 框架的 Application 对象,其实实现了一个 Service Locator,而 db 就是注册在其上的一个服务。所以,使用的时候,我就可以简单的访问到数据库。

Spring 框架的 IoC Container

如果你在网上搜索,反倒会找到大量介绍 IoC Container 的文章,因为 Spring 框架真的使用非常广泛,不过关于 IoC Container 的介绍,恰恰部分混淆了 IoC 的真正含义。

因为对象与对象的依赖关系,出现了传递或者说嵌套,而且还有共享的现象,在实际业务实现中,出现了复杂的树状依赖关系,为了解决这一点,框架层面设计了一种方式,统筹了所有对象的构建。

因为这个目的,就有了 IoC Container 这样的设计,在 Spring 框架里,你可以通过一个 XML 配置文件来描述不同对象之间的依赖关系,这样框架就会负责好对象的实例化工作,确保没有重复和遗漏。

这个 IoC 容器的概念,其实缩小了 IoC 这个术语的含义范围。里面已经没有了程序控制流的反转的含义,仅仅就是对象创建组装过程的统一控制。

IoC 容器真正实现组装的方法,无非上面提到的那些,仍然是依赖注入的范畴。

后记

写到这里,基本已经写完了。不敢说讲得很正确或者讲得很深入,但是,我是下了一番心思的,看了很多的资料,国内的,国外的,然后汇聚成文,应该算讲得很认真。IoC 因为太过普遍,可能大家都略知一二,不过,就算完全不知道,也不影响大家使用框架,Spring 也好,Yii 框架也好,都不例外。

但是,理解了这个概念,我认为再看框架代码的时候,就有一种了然的感觉。这种感觉让人欣喜,好像脑袋上一个灯泡“噔”的一声亮了那种。其他的各类模式和那种“可理解/可不理解”的概念,也有类似的特点。


  1. Inversion of Control ↩︎ ↩︎