向依赖关系宣战——依赖倒置、控制反转和依赖注入辨析
在《道法自然——面向对象实践指南》一书中,我们采用了一个对立统一的辩证关系来说明“模板方法”模式—— “正向依赖 vs. 依赖倒置”(参见:《道法自然》第15章[王咏武, 王咏刚 2004])。这种把“好莱坞”原则和 “依赖倒置”原则等量齐观的看法其实来自于轻量级容器PicoContainer主页上的一段话: 依赖和耦合(Dependency and Coupling) Rational Rose的帮助文档上是这样定义“依赖”关系的:“依赖描述了两个模型元素之间的关系,如果被依赖的模型元素发生变化就会影响到另一个模型元素。典型的,在类图上,依赖关系表明客户类的操作会调用服务器类的操作。” 接口和实现分离 把接口和实现分开是人们试图控制依赖关系的第一个尝试,图 1是Robert C. Martin在《依赖倒置》[Martin 1996]一文中所举的第一个例子。其中,ReadKeyboard()和WritePrinter()为函数库中的两个函数,应用程序循环调用这两个函数,以便把用户键入的字符拷贝到打印机输出。 上面的函数库也可以采用C++语言来实现。我们通常把这种用面向对象技术实现的,为应用程序提供多个支持类的模块称为 “类库”,如图 2所示。这种通过分离接口和实现来消解应用程序和类库之间依赖关系的做法具有以下特点: 1. 应用程序调用类库,依赖于类库。 2. 接口和实现的分离从一定的程度上消解了这个依赖关系,具体实现可以在编译期间发生变化。但是,这种消解方法的作用非常有限。比如说,一个系统中无法容纳多个实现,不同的实现不能动态发生变化,用WritePrinter函数名来实现向磁盘中输出的功能也显得非常古怪,等等。 3. 类库可以单独重用。但是应用程序不能脱离类库而重用,除非提供一个实现了相同接口的类库。 依赖倒置(Dependency Inversion Principle) 可以看出,上面讨论的简单分离接口的方法对于依赖关系的消解作用非常有限。Java语言提供了纯粹的接口类,这种接口类不包括任何实现代码,可以更好地隔离两个模块。C++语言中虽然没有定义这种纯粹的接口类,但所有成员函数都是纯虚函数的抽象类也不包含任何实现代码,可以起到类似于Java接口类的作用。为了和上一节中提到的简单接口相区别,本文后面将把基于Java 接口类或C++抽象类定义的接口称为抽象接口。依赖倒置原则就是建立在抽象接口的基础上的。Robert Martin这样描述依赖倒置原则[Martin 1996]: 但还有另外一种情况。图 4是Martin Fowler在《Reducing Coupling》一文中使用的一个例子[Fowler 2001]。其中,Domain包要使用数据库包,即Domain包依赖于数据库包。为了隔离Domain包和数据库包,可以引入一个Mapper包。如果在特定的情况下,我们希望Domain包能够被多次重用,而Mapper包可以随时变化,那么,我们就必须防止Domain包过分地依赖于Mapper包。这时,可以由 Domain包的设计者总结出自己需要的抽象接口(如Store),而由Mapper包的设计者来实现该抽象接口。这样一来,无论是在接口层面,还是在实现层面,依赖关系都完全颠倒过来了。 控制反转(Inversion of Control) 前面描述的是应用程序和类库之间的依赖关系。如果我们开发的不是类库,而是框架系统,依赖关系就会更强烈一点。那么,该如何消解框架和应用程序之间的依赖关系呢? 并非只有面向对象的方法才能解决这一问题。WIN32 API早就为我们提供了在面向过程的设计思路下解决类似问题的范例。类WIN32 的架构模型如图 6所示。 在面向对象领域,“回调函数”的替代物就是“模板方法模式”,也就是“好莱坞原则(不要调用我们,让我们调用你)”。GUI框架的一个面向对象的实现如图 7所示。 从上面的分析可以看出,模板方法模式是框架系统的基础,任何框架系统都离不开模板方法模式。Martin Fowler也说 [Folwer 2004],“几位轻量级容器的作者曾骄傲地对我说:这些容器非常有用,因为它们实现了‘控制反转’。这样的说辞让我深感迷惑:控制反转是框架所共有的特征,如果仅仅因为使用了控制反转就认为这些轻量级容器与众不同,就好像在说‘我的轿车是与众不同的,因为它有四个轮子’。问题的关键在于:它们反转了哪方面的控制?我第一次接触到的控制反转针对的是用户界面的主控权。早期的用户界面是完全由应用程序来控制的,你预先设计一系列命令,例如‘输入姓名’、‘输入地址’等,应用程序逐条输出提示信息,并取回用户的响应。而在图形用户界面环境下,UI 框架将负责执行一个主循环,你的应用程序只需为屏幕的各个区域提供事件处理函数即可。在这里,程序的主控权发生了反转:从应用程序移到了框架。” 确实:对比图 3和图 7可以看出,使用普通类库时,程序的主循环位于应用程序中,而使用框架系统的应用程序不再包括一个主循环,只是实现某些框架定义的接口,框架系统负责实现系统运行的主循环,并在必要的时候通过模板方法模式调用应用程序。 也就是说,虽然“依赖倒置”和“控制反转”在设计层面上都是消解模块耦合的有效方法,也都是试图令具体的、易变的模块依赖于抽象的、稳定的模块的基本原则,但二者在使用语境和关注点上存在差异:“依赖倒置”强调的是对于传统的、源于面向过程设计思想的层次概念的“倒置”,而“控制反转”强调的是对程序流程控制权的反转;“依赖倒置”的使用范围更为宽泛,既可用于对程序流程的描述(如流程的主从和层次关系),也可用于描述其他拥有概念层次的设计模型(如服务组件与客户组件、核心模块与外围应用等),而“控制反转”则仅适用于描述流程控制权的场合(如算法流程或业务流程的控制权)。 从某种意义上说,我们也可以把“控制反转”看作是“依赖倒置”的一个特例。例如,用模板方法模式实现的“控制反转”机制其实就是在框架系统和应用程序之间抽象出了一个描述所有算法步骤原型的接口类,框架系统依赖于该接口类定义并实现程序流程,应用程序依赖于该接口类提供具体算法步骤的实现,应用程序对框架系统的依赖被“倒置”为二者对抽象接口的依赖。 总地说来,应用程序和框架系统之间的依赖关系有以下特点: 1. 应用程序和框架系统之间实际上是双向调用,双向依赖的关系。 2. 依赖倒置原则可以减弱应用程序到框架之间的依赖关系。 3. “控制反转”及具体的模板方法模式可以消解框架到应用程序之间的依赖关系,这也是所有框架系统的基础。 4. 框架系统可以独立重用。 依赖注入(Dependency Injection) 在前面的例子里,我们通过“依赖倒置”原则,最大限度地减弱了应用程序Copy类和类库提供的服务Read,Write之间的依赖关系。但是,如果需要把Copy()函数也实现在类库中,又会发生什么情况呢?假设在类库中实现一个“服务类”,“服务类”提供Copy()方法供应用程序使用。应用程序使用时,首先创建“服务类”的实例,调用其中的Copy()函数。“服务类”的实例初始化时会创建KeyboardReader 和PrinterWriter类的实例对象。如图 8所示。 在这种情况下,如何实例化具体的Reader和Writer类,同时又尽量减少服务类对它们的依赖,就是一个非常关键的问题了。如果服务类位于应用程序中,这一依赖关系对我们造成的影响还不算大。但当“服务类”位于需要独立发布的类库中,它的代码就不能随着应用程序的变化而改变了。这也意味着,如果“服务类”过度依赖于具体的Reader和Writer类,用户就无法自行添加新的Reader和Writer 的实现了。 解决这一问题的方法是“依赖注入”,即切断“服务类”到具体的Reader和Writer类之间的依赖关系,而由应用程序来注入这一依赖关系。如图 9所示。 如果专门为Copy类抽象出一个注入接口,应用程序通过接口注入依赖关系,这种注入方式通常被称为“接口注入”。如果为Copy类提供一个设值函数,应用程序通过调用设值函数来注入依赖关系,这种依赖注入的方法被称为“设值注入”。具体的“接口注入”和“设值注入”请参考[Martin 2004]。 PicoContainer和Spring轻量级容器框架都提供了相应的机制来帮助用户实现各种不同的“依赖注入”。并且,通过不同的方式,他们也都支持在XML文件中定义依赖关系,然后由应用程序调用框架来注入依赖关系,当依赖关系需要发生变化时,只要修改相应的 XML文件即可。 因此,依赖注入的核心思想是: 1. 抽象接口隔离了使用者和实现之间的依赖关系,但创建具体实现类的实例对象仍会造成对于具体实现的依赖。 2. 采用依赖注入可以消除这种创建依赖性。使用依赖注入后,某些类完全是基于抽象接口编写而成的,这可以最大限度地适应需求的变化。 结论 分离接口和实现是人们有效地控制依赖关系的最初尝试,而纯粹的抽象接口更好地隔离了相互依赖的两个模块,“依赖倒置”和 “控制反转”原则从不同的角度描述了利用抽象接口消解耦合的动机,GoF的设计模式正是这一动机的完美体现。具体类的创建过程是另一种常见的依赖关系,“依赖注入”模式可以把具体类的创建过程集中到合适的位置,这一动机和GoF的创建型模式有相似之处。 这些原则对我们的实践有很好的指导作用,但它们不是圣经,在不同的场合可能会有不同的变化,我们应该在开发过程中根据需求变化的可能性灵活运用。 (编辑:李大同) 【声明】本站内容均来自网络,其相关言论仅代表作者个人观点,不代表本站立场。若无意侵犯到您的权利,请及时与联系站长删除相关内容! |