依赖倒置、控制反转和依赖注入辨析
在《道法自然——面向对象实践指南》一书中,我们采用了一个对立统一的辩证关系来说明“模板方法”模式—— “正向依赖 vs. 依赖倒置”(参见:《道法自然》第15章[王咏武, 王咏刚 2004])。这种把“好莱坞”原则和 “依赖倒置”原则等量齐观的看法其实来自于轻量级容器PicoContainer主页上的一段话:
“控制反转(Inversion of Control)的一个著名的同义原则是由Robert C. Martin提出的依赖倒置原则(Dependency Inversion Principle),它的另一个昵称是好莱坞原则(Hollywood Principle:不要调用我,让我来调用你)”[PicoContainer 2004]。
和网友们在CSDN Blog上进行了深入的讨论后,我又把这些概念重新梳理了一下。我发现,这几个概念虽然在思路和动机等宏观层面上是统一的,但在具体的应用层面还是存在着许多很微妙的差别。本文通过几个简单的例子对依赖倒置(Dependency Inversion Principle)、控制反转(Inversion of Control)、依赖注入(Dependency Injection)等概念进行了更为深入的辨析,也算是对于《道法自然》正文内容的一个补充吧。
依赖和耦合(Dependency and Coupling)
在《道法自然——面向对象实践指南》一书中,我们采用了一个对立统一的辩证关系来说明“模板方法”模式—— “正向依赖 vs. 依赖倒置”(参见:《道法自然》第15章[王咏武, 王咏刚 2004])。这种把“好莱坞”原则和 “依赖倒置”原则等量齐观的看法其实来自于轻量级容器PicoContainer主页上的一段话:
首先来看一下依赖和耦合的概念。
Rational Rose的帮助文档上是这样定义“依赖”关系的:“依赖描述了两个模型元素之间的关系,如果被依赖的模型元素发生变化就会影响到另一个模型元素。典型的,在类图上,依赖关系表明客户类的操作会调用服务器类的操作。”
Martin Fowler在《Reducing Coupling》一文中这样描述耦合:“如果改变程序的一个模块要求另一个模块同时发生变化,就认为这两个模块发生了耦合。” [Fowler 2001]
从上面的定义可以看出:如果模块A调用模块B提供的方法,或访问模块B中的某些数据成员(当然,在面向对象开发中一般不提倡这样做),我们就认为模块A依赖于模块B,模块A和模块B之间发生了耦合。
那么,依赖对于我们来说究竟是好事还是坏事呢?
由于人类的理解力有限,大多数人难以理解和把握过于复杂的系统。把软件系统划分成多个模块,可以有效控制模块的复杂度,使每个模块都易于理解和维护。但在这种情况下,模块之间就必须以某种方式交换信息,也就是必然要发生某种耦合关系。如果某个模块和其它模块没有任何关联(哪怕只是潜在的或隐含的依赖关系),我们就几乎可以断定,该模块不属于此软件系统,应该从系统中剔除。如果所有模块之间都没有任何耦合关系,其结果必然是:整个软件不过是多个互不相干的系统的简单堆积,对每个系统而言,所有功能还是要在一个模块中实现,这等于没有做任何模块的分解。
因此,模块之间必定会有这样或那样的依赖关系,永远不要幻想消除所有依赖。但是,过强的耦合关系(如一个模块的变化会造成一个或多个其他模块也同时发生变化的依赖关系)会对软件系统的质量造成很大的危害。特别是当需求发生变化时,代码的维护成本将非常高。所以,我们必须想尽办法来控制和消解不必要的耦合,特别是那种会导致其它模块发生不可控变化的依赖关系。依赖倒置、控制反转、依赖注入等原则就是人们在和依赖关系进行艰苦卓绝的斗争过程中不断产生和发展起来的。
接口和实现分离
把接口和实现分开是人们试图控制依赖关系的第一个尝试,图 1是Robert C. Martin在《依赖倒置》[Martin 1996]一文中所举的第一个例子。其中,ReadKeyboard()和WritePrinter()为函数库中的两个函数,应用程序循环调用这两个函数,以便把用户键入的字符拷贝到打印机输出。
为了使应用程序不依赖于函数库的具体实现,C语言把函数的定义写在了一个分离的头文件(函数库.h)中。这种做法的好处是:虽然应用程序要调用函数库、依赖于函数库,但是,当我们要改变函数库的实现时,只要重写函数的实现代码,应用程序无需发生变化。例如,改变函数库.c文件,把WritePrinter()函数重新实现成向磁盘中输出,这时只要将应用程序和函数库重新链接,程序的功能就会发生相应的变化。
上面的函数库也可以采用C++语言来实现。我们通常把这种用面向对象技术实现的,为应用程序提供多个支持类的模块称为 “类库”,如图 2所示。这种通过分离接口和实现来消解应用程序和类库之间依赖关系的做法具有以下特点:
1. 应用程序调用类库,依赖于类库。
2. 接口和实现的分离从一定的程度上消解了这个依赖关系,具体实现可以在编译期间发生变化。但是,这种消解方法的作用非常有限。比如说,一个系统中无法容纳多个实现,不同的实现不能动态发生变化,用WritePrinter函数名来实现向磁盘中输出的功能也显得非常古怪,等等。
3. 类库可以单独重用。但是应用程序不能脱离类库而重用,除非提供一个实现了相同接口的类库。
依赖倒置(Dependency Inversion Principle)
可以看出,上面讨论的简单分离接口的方法对于依赖关系的消解作用非常有限。Java语言提供了纯粹的接口类,这种接口类不包括任何实现代码,可以更好地隔离两个模块。C++语言中虽然没有定义这种纯粹的接口类,但所有成员函数都是纯虚函数的抽象类也不包含任何实现代码,可以起到类似于Java接口类的作用。为了和上一节中提到的简单接口相区别,本文后面将把基于Java 接口类或C++抽象类定义的接口称为抽象接口。依赖倒置原则就是建立在抽象接口的基础上的。Robert Martin这样描述依赖倒置原则[Martin 1996]:
A. 上层模块不应该依赖于下层模块,它们共同依赖于一个抽象。
B. 抽象不能依赖于具象,具象依赖于抽象。
其含义是:为了消解两个模块间的依赖关系,应该在两个模块之间定义一个抽象接口,上层模块调用抽象接口定义的函数,下层模块实现该接口。如图 3所示,对于上一节的例子,我们可以定义两个抽象类Reader和Writer作为抽象接口,其中的Read()和Write()函数都是纯虚函数,而具体的KeyboardReader和PrinterWriter类实现了这些接口。当应用程序调用Read()和Write()函数时,由于多态性机制的作用,实际调用的是具体的KeyboardReader和PrinterWriter类中的实现。因此,抽象接口隔离了应用程序和类库中的具体类,使它们之间没有直接的耦合关系,可以独立地扩展或重用。例如,我们可以用类似的方法实现FileReader或DiskWriter类,应用程序既可以根据需要选择从键盘或文件输入,也可以选择向打印机或磁盘输出,甚至同时完成多种不同的输入、输出任务。由此可以总结出,这种通过抽象接口消解应用程序和类库之间依赖关系的做法具有以下特点:
1. 应用程序调用类库的抽象接口,依赖于类库的抽象接口;具体的实现类派生自类库的抽象接口,也依赖于类库的抽象接口。
2. 应用程序和具体的类库实现完全独立,相互之间没有直接的依赖关系,只要保持接口类的稳定,应用程序和类库的具体实现都可以独立地发生变化。
3. 类库完全可以独立重用,应用程序可以和任何一个实现了相同抽象接口的类库协同工作。
一般情况下,由于类库的设计者并不知道应用程序会如何使用类库,抽象接口大多由类库设计者根据自己设想的典型使用模式总结出来,并保留一定的灵活度,以提供给应用程序的开发者使用。
但还有另外一种情况。图 4是Martin Fowler在《Reducing Coupling》一文中使用的一个例子[Fowler 2001]。其中,Domain包要使用数据库包,即Domain包依赖于数据库包。为了隔离Domain包和数据库包,可以引入一个Mapper包。如果在特定的情况下,我们希望Domain包能够被多次重用,而Mapper包可以随时变化,那么,我们就必须防止Domain包过分地依赖于Mapper包。这时,可以由 Domain包的设计者总结出自己需要的抽象接口(如Store),而由Mapper包的设计者来实现该抽象接口。这样一来,无论是在接口层面,还是在实现层面,依赖关系都完全颠倒过来了。
控制反转(Inversion of Control)
前面描述的是应用程序和类库之间的依赖关系。如果我们开发的不是类库,而是框架系统,依赖关系就会更强烈一点。那么,该如何消解框架和应用程序之间的依赖关系呢?
《道法自然》第5章描述了框架和类库之间的区别:
“框架和类库最重要的区别是:框架是一个‘半成品’的应用程序,而类库只包含一系列可被应用程序调用的类。
“类库给用户提供了一系列可复用的类,这些类的设计都符合面向对象原则和模式。用户使用时,可以创建这些类的实例,或从这些类中继承出新的派生类,然后调用类中相应的功能。在这一过程中,类库总是被动地响应用户的调用请求。
“框架则会为某一特定目的实现一个基本的、可执行的架构。框架中已经包含了应用程序从启动到运行的主要流程,流程中那些无法预先确定的步骤留给用户来实现。程序运行时,框架系统自动调用用户实现的功能组件。这时,框架系统的行为是主动的。
“我们可以说,类库是死的,而框架是活的。应用程序通过调用类库来完成特定的功能,而框架则通过调用应用程序来实现整个操作流程。框架是控制倒置原则的完美体现。”
框架系统的一个最好的例子就是图形用户界面(GUI)系统。一个简单的,使用面向过程的设计方法开发的GUI系统如图 5所示。
从图 5中可以看出,应用程序调用GUI框架中的CreateWindow()函数来创建窗口,在这里,我们可以说应用程序依赖于GUI框架。但GUI框架并不了解该窗口接收到窗口消息后应该如何处理,这一点只有应用程序最为清楚。因此,当GUI框架需要发送窗口消息时,又必须调用应用程序定义的某个特定的窗口函数(如上图中的MyWindowProc)。这时,GUI框架又必须依赖于应用程序。这是一个典型的双向依赖关系。这种双向依赖关系有一个非常严重的缺陷:由于GUI框架调用了应用程序中的某个特定函数(MyWindowProc), GUI框架根本无法独立存在;换一个新的应用程序,GUI框架多半就要做相应的修改。因此,如何消解框架系统对应用程序的依赖关系是实现框架系统的关键。
并非只有面向对象的方法才能解决这一问题。WIN32 API早就为我们提供了在面向过程的设计思路下解决类似问题的范例。类WIN32 的架构模型如图 6所示。
在图 6中,应用程序调用CreateWindow()函数时,要传递一个消息处理函数的指针给GUI框架(对WIN32而言,我们在注册窗口类时传递这一指针),GUI框架把该指针记录在窗口信息结构中。需要发送窗口消息时,GUI框架就通过该指针调用窗口函数。和图 5 相比,GUI框架仍然需要调用应用程序,但这一调用从一个硬编码的函数调用变成了一个由应用程序事先注册被调用对象的动态调用。图 6用一条虚线表示这种动态调用。可以看出,这种动态的调用关系有一个非常大的好处:当应用程序发生变化时,它可以自行改变框架系统的调用目标,GUI框架无需随之发生变化。现在,我们可以说,虽然还存在着从GUI框架到应用程序的调用关系,但GUI框架已经完全不再依赖于应用程序了。这种动态调用机制通常也被称为“回调函数”。
在面向对象领域,“回调函数”的替代物就是“模板方法模式”,也就是“好莱坞原则(不要调用我们,让我们调用你)”。GUI框架的一个面向对象的实现如图 7所示。
图 7中,“GUI框架抽象接口”是GUI框架系统提供给应用程序使用的接口。抽象出该接口的动机是根据“依赖倒置”的原则,消解从应用程序到GUI框架之间的直接依赖关系,以使得GUI框架实现的变化对应用程序的影响最小化。Window接口类则是“模板方法模式”的核心。应用程序调用CreateWindow()函数时,GUI框架会把该窗口的引用保存在窗口链表中。需要发送窗口消息时,GUI框架就调用窗口对象的SendMessage()函数,该函数是实现在Window类中的非虚成员函数。SendMessage()函数又调用WindowProc()虚函数,这里实际执行的是应用程序MyWindow类中实现的WindowProc()函数。在图 7中,我们已经看不到从GUI框架到应用程序之间的直接依赖关系了。因此,模板方法模式完全实现了回调函数的动态调用机制,消解了从框架到应用程序之间的依赖关系。
从上面的分析可以看出,模板方法模式是框架系统的基础,任何框架系统都离不开模板方法模式。Martin Fowler也说 [Folwer 2004],“几位轻量级容器的作者曾骄傲地对我说:这些容器非常有用,因为它们实现了‘控制反转’。这样的说辞让我深感迷惑:控制反转是框架所共有的特征,如果仅仅因为使用了控制反转就认为这些轻量级容器与众不同,就好像在说‘我的轿车是与众不同的,因为它有四个轮子’。问题的关键在于:它们反转了哪方面的控制?我第一次接触到的控制反转针对的是用户界面的主控权。早期的用户界面是完全由应用程序来控制的,你预先设计一系列命令,例如‘输入姓名’、‘输入地址’等,应用程序逐条输出提示信息,并取回用户的响应。而在图形用户界面环境下,UI 框架将负责执行一个主循环,你的应用程序只需为屏幕的各个区域提供事件处理函数即可。在这里,程序的主控权发生了反转:从应用程序移到了框架。”
确实:对比图 3和图 7可以看出,使用普通类库时,程序的主循环位于应用程序中,而使用框架系统的应用程序不再包括一个主循环,只是实现某些框架定义的接口,框架系统负责实现系统运行的主循环,并在必要的时候通过模板方法模式调用应用程序。
也就是说,虽然“依赖倒置”和“控制反转”在设计层面上都是消解模块耦合的有效方法,也都是试图令具体的、易变的模块依赖于抽象的、稳定的模块的基本原则,但二者在使用语境和关注点上存在差异:“依赖倒置”强调的是对于传统的、源于面向过程设计思想的层次概念的“倒置”,而“控制反转”强调的是对程序流程控制权的反转;“依赖倒置”的使用范围更为宽泛,既可用于对程序流程的描述(如流程的主从和层次关系),也可用于描述其他拥有概念层次的设计模型(如服务组件与客户组件、核心模块与外围应用等),而“控制反转”则仅适用于描述流程控制权的场合(如算法流程或业务流程的控制权)。
从某种意义上说,我们也可以把“控制反转”看作是“依赖倒置”的一个特例。例如,用模板方法模式实现的“控制反转”机制其实就是在框架系统和应用程序之间抽象出了一个描述所有算法步骤原型的接口类,框架系统依赖于该接口类定义并实现程序流程,应用程序依赖于该接口类提供具体算法步骤的实现,应用程序对框架系统的依赖被“倒置”为二者对抽象接口的依赖。
总地说来,应用程序和框架系统之间的依赖关系有以下特点:
1. 应用程序和框架系统之间实际上是双向调用,双向依赖的关系。
2. 依赖倒置原则可以减弱应用程序到框架之间的依赖关系。
3. “控制反转”及具体的模板方法模式可以消解框架到应用程序之间的依赖关系,这也是所有框架系统的基础。
4. 框架系统可以独立重用。
依赖注入(Dependency Injection)
在前面的例子里,我们通过“依赖倒置”原则,最大限度地减弱了应用程序Copy类和类库提供的服务Read,Write之间的依赖关系。但是,如果需要把Copy()函数也实现在类库中,又会发生什么情况呢?假设在类库中实现一个“服务类”,“服务类”提供Copy()方法供应用程序使用。应用程序使用时,首先创建“服务类”的实例,调用其中的Copy()函数。“服务类”的实例初始化时会创建KeyboardReader 和PrinterWriter类的实例对象。如图 8所示。
从图 8中可以看出,虽然Reader和Writer接口隔离了“服务类”和具体的Reader和Writer类,使它们之间的耦合降到了最小。但当 “服务类”创建具体的Reader和Writer对象时,“服务类”还是和具体的Reader和Writer对象发生了依赖关系——图 8中用蓝色的虚线描述了这种依赖关系。
在这种情况下,如何实例化具体的Reader和Writer类,同时又尽量减少服务类对它们的依赖,就是一个非常关键的问题了。如果服务类位于应用程序中,这一依赖关系对我们造成的影响还不算大。但当“服务类”位于需要独立发布的类库中,它的代码就不能随着应用程序的变化而改变了。这也意味着,如果“服务类”过度依赖于具体的Reader和Writer类,用户就无法自行添加新的Reader和Writer 的实现了。
解决这一问题的方法是“依赖注入”,即切断“服务类”到具体的Reader和Writer类之间的依赖关系,而由应用程序来注入这一依赖关系。如图 9所示。
在图 9中,“服务类”并不负责创建具体的Reader和Writer类的实例对象,而是由应用程序来创建。应用程序创建“服务类”的实例对象时,把具体的Reader和Write对象的引用注入“服务类”内部。这样,“服务类”中的代码就只和抽象接口相关的了。具体实现代码发生变化时,“服务类”不会发生任何变化。添加新的实现时,也只需要改变应用程序的代码,就可以定义并使用新的Reader和Writer类,这种依赖注入方式通常也被称为“构造器注入”。
如果专门为Copy类抽象出一个注入接口,应用程序通过接口注入依赖关系,这种注入方式通常被称为“接口注入”。如果为Copy类提供一个设值函数,应用程序通过调用设值函数来注入依赖关系,这种依赖注入的方法被称为“设值注入”。具体的“接口注入”和“设值注入”请参考[Martin 2004]。
PicoContainer和Spring轻量级容器框架都提供了相应的机制来帮助用户实现各种不同的“依赖注入”。并且,通过不同的方式,他们也都支持在XML文件中定义依赖关系,然后由应用程序调用框架来注入依赖关系,当依赖关系需要发生变化时,只要修改相应的 XML文件即可。
因此,依赖注入的核心思想是:
1. 抽象接口隔离了使用者和实现之间的依赖关系,但创建具体实现类的实例对象仍会造成对于具体实现的依赖。
2. 采用依赖注入可以消除这种创建依赖性。使用依赖注入后,某些类完全是基于抽象接口编写而成的,这可以最大限度地适应需求的变化。
结论
分离接口和实现是人们有效地控制依赖关系的最初尝试,而纯粹的抽象接口更好地隔离了相互依赖的两个模块,“依赖倒置”和 “控制反转”原则从不同的角度描述了利用抽象接口消解耦合的动机,GoF的设计模式正是这一动机的完美体现。具体类的创建过程是另一种常见的依赖关系,“依赖注入”模式可以把具体类的创建过程集中到合适的位置,这一动机和GoF的创建型模式有相似之处。
这些原则对我们的实践有很好的指导作用,但它们不是圣经,在不同的场合可能会有不同的变化,我们应该在开发过程中根据需求变化的可能性灵活运用。