文字显示结果
组合搜索  计算机图书分类目录
 
所在位置: 图书 -> 在线试读 -> 你必须知道的.NET(第2版) 
                 

第3章 OO之美

3.2 依赖的哲学

本节将介绍以下内容:

  关于依赖和耦合

  面向抽象编程

  依赖倒置原则

  控制反转

  依赖注入

  工厂模式

  Unity框架应用

3.2.1  引言

“不要调用我们,我们会调用你”是对DIP最形象的诠释。作为5大设计原则之一的DIP原则,有了2.4节“依赖倒置原则”由概念而实例的单纯讨论,还不能全面阐释清楚:

·  什么是依赖倒置?

·  为什么依赖倒置?

·  如何依赖倒置?

这几个关键的问题,不单纯地通过DIPDIP,而是从依赖这个最原始的概念讲起,来了解在面向对象软件设计体系中,关于“关系的处理”,也就是“依赖的哲学”。对,依赖就是关系,处理依赖也就意味着处理关系。人类是最善于搞关系的动物,所以原本简单的理论,在人类的意识哲学中变得复杂而多变,以至于本应简单的道理变得如此复杂,这就是依赖。那么,从依赖讲起来了解依赖倒置原则,首先应该回答以下的问题:

·  控制反转、依赖倒置、依赖注入这些概念,你认识但是否熟悉?

·  UnityObjectBuilderCastle这些容器,你相识但是否相知?

·  面向接口、面向抽象、开放封闭这些思想,你了解但是否了然?

带着对这些问题的思考和思索,本文带领大家就依赖这个话题开始一次循序渐进的面向对象之旅,以解答这些从一开始就有足够吸引力的问题。从原理到实例,从关系到异同,期待接下来的内容能给你带来一些认知的变革。

3.2.2  什么是依赖,什么是抽象

1.关于依赖和耦合:从小国寡民到和谐社会

在老子的“小国寡民”论中,提出了一种理想的社会状态:邻国相望,鸡犬之声相闻,民至老死,不相往来。这是他老人家的一种社会理想,老死不相往来的人群呈现了一片和谐景象。因为不发生瓜葛,也就无所谓关联,进而无法导致冲突。这是先祖哲学中的至纯哲理,但理想的大同总是和现实的生态有着或多或少的差距,人类社会无法避免联系的发生,所以小国寡民的理想成为一种美丽的梦想,不可实现。同样的道理,映射到软件“社会”中,也就是软件系统结构中,也预示着不同的层次、模块、类型之间也必然存在着或多或少的联系,这种联系不可避免但可管理。正如人类社会虽然无法实现小国寡民,但是理想的状态下我们推崇和谐社会,把人群的联系由复杂变为简单,由曲折变为统一,同样可以使得这种关联很和谐。所以,软件系统的使命也应该朝着和谐社会的目标前进,对于不同的关系处理,使用一套行之有效的哲学,把复杂问题简单化,把僵化问题柔性化,这种哲学或者说方法,在我看来就是:依赖的哲学,也就是本文所要阐释的中心思想。

因为“耦合是不可避免的”,所以首先就从认识依赖和耦合的概念开始,来一步步阐释依赖的哲学思想。

1)什么是依赖和耦合

赖,就是关系,代表了软件实体之间的联系。软件的实体可能是模块,可能是层次,也可能是具体的类型,不同的实体直接发生依赖,也就意味着发生了耦合。所以,依赖和耦合在我看来是对一个问题的两种表达,依赖阐释了耦合本质,而耦合量化了依赖程度。因此,对于关系的描述方式,就可以从两个方面的观点来分析。

从依赖的角度而言,可以分类为:

·  无依赖,代表没有发生任何联系,所以二者相互独立,互不影响,没有耦合关系。

·  单向依赖,关系双方的依赖是单向的,代表了影响的方向也是单向的,其中一个实体发生改变,会对另外的实体产生影响,反之则不然,耦合度不高。

·  双向依赖,关系双方的依赖是相互的,影响也是相互的,耦合度较高。

从耦合的角度而言,可以分类为(此处回归到具体的代码级耦合概念,以方便概念的阐释):

·  零耦合,表示两个类没有依赖。

·  具体耦合,如果一个类持有另一个具体类的引用,那么这两个类就发生了具体耦合关系。所以,具体耦合发生在具体类之间的依赖,因此具体类的变更将引起对其关联类的影响。

·  抽象耦合,发生在具体类和抽象类的依赖,其最大的作用就是通过对抽象的依赖,应用面向对象的多态机制,实现了灵活的扩展性和稳定性。

不同的耦合,代表了依赖程度的差别,以“粒度”为概念来分析其耦合的程度。引用中间层来分离耦合,可以使设计更加优雅,架构更加富有柔性,但直接的依赖也存在其市场,过度的设计也并非可取之道。因为,效率与性能同样是设计需要考量的因素,过多的不必要分离会增加调用的次数,造成效率浪费。

后文分析依赖倒置原则的弊端之一正是对此问题的进一步阐述。

2)耦合是如何产生的

那么,软件实体之间的耦合是如何产生呢?回归每天挥洒的代码片段,其实就是在重复的创造着耦合,并且得益于对这种耦合带来的数据通信。如果将历史的目光回归到软件设计之初,人类以简单的机器语言来实现最简单的逻辑,给一个输入,实现一个输出,可以表达为如图3-1所示的形式。

随着软件世界的革命,业务逻辑的复杂,以上的简单化处理已经不足以实现更复杂的软件产品,当系统内部的复杂度超越人脑可识别的程度时,就需要通过更科学的方法或者方式来梳理,如图3-2所示。

 

3-1  软件的输入和输出                                                 3-2  复杂系统的输入和输出

因此,人类开始发挥重组和简单化处理的优势,开发者不得不在软件设计上做出平衡。平衡的结果就是通过对复杂的系统模块化,把复杂问题简单处理,从而达到能够被人脑识别的目的。基于这种指导原则,随着复杂度的增加模块的划分更加朝着精细化发展,尤其是面向对象程序设计理论的出现,使得对复杂的处理实现了更科学的理论基础。然而,复杂的问题可以通过划分实现简单的功能模块或者技术单元,但由此应运而生的子单元会越来越多,而且越来越多的子单元必须发生数据的通信才能完成统一的业务处理,所以产生的数据通信管理也越来越多。对于子单元的管理,也就是本文关注的核心概念依赖,成为新的软件设计问题,那么总结前人的经验,提炼今人的智慧,对耦合的产生做如下归纳:

·  继承

·  聚合

·  接口

·  方法调用和引用

·  服务调用

了解了耦合发生的一般方式,就可以进入核心思想的讨论,那就是在认识和了解依赖的基础上,最终追求的目标。

话说

设计的目标:高内聚(High cohesion)、低耦合(Low coupling)。

讨论了半天,终于是时候对依赖和耦合进行一点儿总结了,也是该进行一点目标诉求了。在软件设计领域,有那么几个至高原则值得每个开发者铭记于心,它们是:

·  面向抽象编程

·  低耦合,高内聚

·  封装变化

·  实现重用:代码重用、算法重用

对了,就是这些平凡的字眼,汇集了面向对象思想的核心内容,也是本文力求阐释的禅意心经。关于面向抽象编程和封装变化,会在后面详细阐释,在此我们需要将注意力关注于“低耦合,高内聚”这一目标。

低耦合,代表了实现最简单的依赖关系,尽可能地减少类与类、模块与模块、层次与层次、系统与系统之间的联系。低耦合,体现了人类追求简单操作的理想状态,按照软件开发的基本实现技巧来追求软件实体之间的关系简单化,正是大部分设计模式力图追求的目标;低耦合,降低了一个类或一个模块发生修改对其他类或模块造成的影响,将影响范围简单化。在本文阐释的依赖关系方式中,实现单向的依赖,实现抽象的耦合,都是实现低耦合的基础条件。

高内聚,一方面代表了职责的统一管理,一方面体现了关系的有效隔离。例如单一职责原则其实归根结底是对功能性的一种指导性体现,将功能紧密联系的职责封装为一个类(或模块),而判断的准则正是基于引起类变化的原因。所以,封装离不开依赖,而抽象离不开变化,二者的概念和本质都是相对而言的。因此,高内聚的目标体现了以隔离为目标进行统一管理的思想。

那么,为了达到低耦合、高内聚的目标,通常意义上的设计原则和设计模式其实都是朝着这个方向实现的,因此仅仅总结并非普遍意义的规则:

·  尽可能实现单项依赖。

·  不需要进行数据交换的双方,不要实现多此一举的关联,人们将此形象称为“不要向陌生人说话(Don't talk to strangers)”。

·  保持内部的封装性,关联的双方不要深入实现细节进行通信,这是保证高内聚的必需条件。

2.关于抽象和具体

什么是抽象呢?首先不必澄清什么是抽象,而从什么算抽象说起,稳定的、高层的就代表了抽象。就像一个公司,最好保证了高层的稳定,才能保证全局的发展。在进行系统设计时,稳定的抽象接口和高层逻辑,也代表了整个系统的稳定与柔性。兵熊熊一窝,将良良一窝,软件的构建也正如打仗,良好的设计都是自上而下的。而对具体的编程实践而言,接口和抽象类则代表了语言层次的抽象。

追溯概念的分析,一一过招,首先来看依赖于具体,如图3-3所示。

因此,为了分离这种紧耦合,最好的办法就是隔离,引入中间层来分离变化,同时确保中间层本身的稳定性,因此抽象的中间层是最佳的选择(如图3-4所示)。

 

3-3  依赖的关系                                                          3-4  依赖的关系(引入抽象层)

以例而理,从最常见的服务端逻辑举例,如下所示:

public interface IUserService

{

}

 

public class UserService : IUserService

{

}

如果依赖于具体:

public class UserManager

{

   private UserService service = null;

}

或者依赖于抽象:

public class UserManager

{

   private IUserService service = null;

}

二者的区别仅在于引入了接口IUserService,从而使得UserManager对于UserService的依赖由强减弱。然而对于依赖的方式并非仅此一种,设计模式中的智慧正是通过各种编程技巧进行依赖关系的解耦,值得关注和学习,后文将对设计模式进行概要性的讨论。

WCF熟悉的读者一定不难看出这种实现方式如此类似于WCF的推荐模式,也是契约编程的基本思想。关于WCFSOA的相关内容,我们在后文进行了相关的讨论。

总结一番,什么是抽象,什么是具体?在作者看来,抽象就是系统中对变化封装的战略逻辑,体现了系统的必然性和稳定性,能够被具体层次复用和覆写;而具体则包含了与具体实现相关的逻辑,体现了系统的动态性和变动性。因此,抽象是稳定的,而具体是变动的。

Bob大叔在《Agile Principles, Patterns, and Practices》一书中直言,程序中所有的依赖关系都应终止于抽象类或者接口,就是对面向抽象编程一针见血的回应,其原因归根到底源自于对抽象和具体的认知和分解:关联应该终止于抽象,而不是具体,保证了系统依赖关系的稳定。具体类发生的修改,不会影响其他模块或者关系。那么如何做到这种理想的依赖于抽象的设计呢?

·  层次清晰化

将复杂的问题简单化,是人类思维的普世智慧,也自然而然是实现软件设计的基本思路。将复杂的业务需求通过建模过程的抽象化提炼,去粗取精,去伪存真,凡此种种。而抽象的过程,其目标之一就是形成对于复杂问题简单化的处理过程,只有形成层次简单的逻辑才能将复杂需求中的关系梳理清晰,而依赖的本质正如上文所言,不就是处理关系吗?

所以,清晰的层次划分,进而形成的模块化,是实现系统抽象的必经之路。

·  分散集中化

需求而设计的过程,就是一个分散集中化的过程,把需求相关的业务通过开发流程的需求分析过程进行整理,逐步形成需求规格说明、概要设计和详细设计等基本流程。分散集中化,是一个梳理需求到形成设计的过程,因此对于把握系统中的抽象和具体而言,是一个重要的分析过程和手段。现代软件工程已经对此形成了科学的标准化流程处理逻辑,例如可以借助UML更加清晰地设计流程、分析设计要素,进行标准化沟通和交流。

·  具体抽象化

将具体问题抽象化,是本节关注的要点,而处理的方法是什么呢?答案就在设计模式。设计模式是前辈智慧的总结和实践,所以熟悉和学习设计模式,是学习和实践设计问题的必经之路。然而,没有哪个问题是由设计模式全权解决,也没有哪个模式能够适应所有的问题,因此要努力的是尽量积累更多的模式来应对多变的需求。作为软件设计话题中最重量级的话题,关注模式和实践模式是成长的记录。

·  封装变化点

总的来说,抽象和变化就像一对孪生兄弟,将具体的变化点隔离出来以抽象的方式进行封装,在变化的地方寻找抽象是面对抽象最理想的方式。所以,如何去寻找变化是设计要解决的首要问题,例如工厂模式的目标是封装对象创建的变化,桥接模式关注对象间的依赖关系变化等。23个经典的设计模式,从某种角度来看,正是对不同变化点的封装角度提出的不同解决方案。

这一设计原则还被称为SoCSeparation of Concerns)原则,定义了对于实现理想的高耦合、低内聚目标的统一规则。

3.设计的哲学

之所以花如此篇幅来讲述一个看似简单的问题,其实最终理想是回归到软件设计目标这个命题上。如果悉心钻研就可发现,设计的最后就是对关系的处理,正如同生活的意义在于对社会的适应一样。因此,回归到设计的目标上就自然可知,完美的设计过程就是对关系的处理过程,也就是对依赖的梳理过程,并最终形成一种合理的耦合结果。

所以,面向对象并不神秘,以生活的现实眼光来看更是如此。把面向对象深度浓缩起来,可以概括为:

·  目标:重用、扩展、兼容。

·  核心:低耦合、高内聚。

·  手段:封装变化。

·  思想:面向接口编程、面向抽象编程、面向服务编程。

其实,就是这么简单。在这种意义上来说,面向对象思想是现代软件架构设计的基础。下面以三层架构的设计为例,来进一步感受这种依赖哲学在具体软件系统中的应用。关于依赖的抽象和对变化隔离的基本思路,其实也是实现典型三层架构或者多层架构的重要基础。只有使各个层次之间依赖于较稳定的接口,才能使得各个层次之间的变化被隔离在本层之内,不会造成对其他层次的影响,这完全符合开放封闭原则追求的优良设计理念。将这种思路表达为设计,可以表示为如图3-5所示的形式。

3-5  多层架构的依赖

由图3-5可知,IDataProvider作为隔离业务层和数据层的抽象,IService作为隔离业务层和表现层的抽象,保证了各个层次的相对稳定和封装。而体现在此的设计逻辑,就正是对于抽象和耦合基本目标概念的体现,例如作为重用的单元,抽象隔离保证了对外发布接口的单一和稳定,所以达到了最高限度的重用;通过引入中间的稳定接口,达到了不同层次的有效隔离,层与层之间体现为轻度耦合,业务层只持有IDataProvider就可以获取数据层的所有服务,而表现层也同样如此;最后,这种方式显然也直接实践了面向接口编程,面向抽象编程的经典理念。

同样的道理,对于架构设计的很多概念,放大可以扩展为面向服务设计所借鉴,放小这正是反复降调的依赖倒置原则在类设计中的基本思想。因此,牢记一位软件大牛的说法:软件设计的任何问题,都可以通过引入中间逻辑得到解决。而这个中间逻辑,很多时候被封装为抽象,是最为合理和智慧的解决方案。

让我们再次高颂《老子》的小国寡民论,来回味关于依赖哲学中,如何实现更好的和谐统一以及如何遵守科学的软件管理思想:邻国相望,鸡犬之声相闻,民至老死,不相往来。

3.2.3  重新回到依赖倒置

1.什么是依赖倒置

Bob大叔在《Agile Principles, Patterns, and Practices》一书中对依赖倒置原则进行了精辟的总结:

·  高层模块不应该依赖于低层模块,二者都应该依赖于抽象。

·  抽象不应该依赖于具体,细节应该依赖于抽象。

其实著名的好莱坞原则更形象地阐述了这一思想:你不要调我,我来调你。不管是通俗的还是高尚的,却都不约而同地揭示了依赖倒置原则的最核心思想就是:

依赖于抽象,对接口编程,对抽象编程!

关于依赖倒置原则的基本概念,可参考2.4节“依赖倒置原则”。回到对思想与设计的层面,相较而言,从实际的生活中来看依赖倒置,就像接下来的实例一样。

2.从实例开始

综合对依赖倒置的认识,结合到具体的程序实现而言,依赖倒置预示着程序中的依赖关系不应是具体的类型,而应归于抽象类和接口。下面通过一个简单的实例来分析符合依赖倒置和违反依赖倒置及其对于系统设计的影响和区别。示例的客户被假定为某个遥控器生产商,实现一个万能遥控器,该遥控器可以对当前市场上的很多电子设备进行“打开”、“关闭”和“换台”的操作,例如可以使用万能牌遥控器打开海尔电视、创维电视或者长虹电视,当然更理想的状态是可以打开电冰箱、电灯还有门窗等,总之凡是可以互联的设备都是未来万能遥控器的潜在需求。

那么该遥控器厂商在设计之初,该如何去考虑实现一个可以打开任何设备的遥控器呢?这一重责首先落在了一位年轻气盛的设计师小王身上,因为遥控器厂家当前的直接客户只有海尔电视一家,所以他轻松地实现了下面的设计,并且兴高采烈地进行了大批量生产(如图3-6所示)。

随后,厂商多了一个重量级客户长虹,所以小王不得不对初试设计进行了改造,勉强适应了新的需求,如图3-7所示。

 

3-6  遥控器初次设计                                                              3-7  遥控器二次设计

虽然小王应付了这次需求变动,但是原本的设计显然已经捉襟见肘。正当小王绞尽脑汁进行改造的同时,新的需求接踵而来:新飞冰箱、飞利浦照明、盼盼防盗门,一个接一个。小王的最终设计变成了如图3-8所示的模样。

哎,真是太累了。每一次的需求变更都伴随着小王对遥控器Remote的再次摧残,Remote内部不断增加新的引用和操作处理,显然一个if/else式的判断布满了整个OpenClose的操作中,这种设计显然无法满足OCP对扩展开放及修改封闭的要求。显然,如果想让卖出去的遥控器也适应新的需求,在小王当前的设计实现方案中是根本无法实现的,遥控器厂商总不能召回已经售出的所有控器,再拆开进行重新改造吧。

一筹莫展的小王,终于在崩溃之际想起了退休在家的前设计师老张,并立即请教如何解决当前的问题。老张经验丰富、为人谦和,毫不含糊地给出一个初步的实现(如图3-9所示):

 

3-8  遥控器三次设计                                                              3-9  重构的遥控器设计

在当前的设计中,老张的思路是让遥控器厂切断和各个厂家的直接联系,而是寻找所有电视厂商的领导(如电视机协会),请电视机协会制定所有电视机厂商必须遵守的打开和关闭等操作的契约,遥控器厂和电视机协会建立直接的联系而不是各个具体的电视厂商,于是便有了上述设计思路。而新的需求来临时,因为各个厂商必须遵守TurnOnTurnOff的契约,所以万能遥控器可以应付所有的电视机品牌,实现的具体操作已经由遥控器转移到具体的厂商手上(这也是所有权的倒置体现),小王轻松地大呼一口气,并且青出于蓝地修改了更完善的版本,如图3-10所示。

3-10  重构的遥控器设计

现在,遥控器基本实现了万能的要求,任何新的需求或者修改都可以轻松胜任。小王终于解决了原本设计的所有问题,带着感激盛情邀请老张赴宴致谢。席间就座,小王请教老张重构设计的秘诀,老张神秘一笑,嚼着茴香豆,沾酒在桌子上写了几个大字:依赖倒置。于是,小王会意地笑了。

万能遥控器的故事,是系统实现中经常的事儿。而这些设计在实际项目中有广泛的应用,例如对于DataProviderService的处理方式,正是一种典型的遵循DIP原则的设计思路。

3.为什么依赖倒置

依赖倒置原则揭示了面向对象思想中一个最基本而最核心的话题,那就是:面向抽象编程。任何对依赖倒置原则的违反都不同程度地偏离了面向对象设计思想的轨道,所以如果你想让自己的程序足够的OO,透彻地了解依赖倒置是必不可少的。

所以,要问答为什么依赖倒置这个话题,可以从以下几个方面来阐释:

·  依赖倒置是保证开放封闭的前提和基础。

·  依赖倒置是对抽象和依赖的基本原则和基本思想的哲学阐释。

·  依赖倒置是框架设计的核心思想。

·  依赖倒置是控制反转和依赖注入的思想基础。

上而言,依赖倒置是对软件实体关系处理的基本思想原则,也是其他设计原则与设计模式的基础之一,因此遵守依赖倒置是实现OO的基本原则,是必须了解的基础性原则。下面,我们对此进行详细的说明和举例。

4.为什么是倒置

鲁迅先生有云:其实地上本没有路,走的人多了也便成了路。对依赖倒置原则中的“倒置”二字而言,其实也类似于一条被很多人走过的路,因为习惯性地称呼走过的为“路”,所以只好把违反习惯的东西称为“倒置的路”。这倒置的含义,正在于此。

对于从结构化编程走过的人来说,基于软件复用的考虑,侧重于对具体模块的复用,因而也就习惯了从高层模块出发构建系统流程的思维模式,所以那时的高手一出手就实现了高层依赖于底层的典型套路,如图3-11所示。

3-11  高层依赖于底层

高层模块通过自上而下的实现,来完成系统功能的调用,将这种方式表达为代码就是:

public static void Main()

{

    try

    {

        //Do something here.

    }

    catch

    {

        Log(true, "XMLLog");

    }

}

 

public static void Log(bool isRead, string logType)

{

    if (isRead)

        ReadLog(logType);

    else

        WriteLog(logType);

}

然而,当软件设计的模式发展到面向对象阶段时,人们发现原来习惯的世界已经变了。基于高层依赖于底层的弊政,也越来越被可扩展性的系统需求折磨得面目全非,例如日志记录的载体发生变化,当前设计中需要同时自上而下地修改实现的逻辑,同时避免出现越来越多的if/else结构。所以当新的依赖关系从传统的方式被完全扭转时,“倒置”二字就此诞生了。于是修改Log实现的设计思路,将可能变化的逻辑封装为抽象接口,使得高层依赖发生转换,如图3-12所示。

3-12  高层依赖于抽象

程序实现的逻辑早已被面向对象的设计思想所取代,新的实现变成了如下形式:

public class Client

{

    public static void Main()

    {

        ILog myLogger = new XMLLog();

        try

        {

 

        }

        catch

        {

            myLogger.Write();

        }

    }

}

 

public interface ILog

{

    void Read();

    void Write();

}

 

public class XMLLog : ILog

{

    public void Read()

    {

    }

 

    public void Write()

    {

    }

}

所以,了解了历史才能正视现实,对于软件设计同样如此,只有认清楚依赖倒置产生的历史背景,才能更加熟练地驾驭倒置含义本身带来的误解,而将中心思想牢牢地把握在依赖倒置最核心的设计思想上,那还是万变不离其宗的:依赖于抽象,这简单5字箴言。

对于所属权关系的依赖问题上,可以看到,只有倒置的才是面向对象的,没有倒置的还是面向结构的。如果你的系统中存在着不合理的依赖关系,那么依赖倒置将是检查系统设计最好的标尺,这也是需要深入这一原则的实际意义之一。

5.如何依赖倒置

如何依赖倒置的关键,还是体现在如何对抽象和具体的封装和分离,实践的基本思路就是封装变化。这正如在单一职责原则中反复强调,对一个类只有一个引起它变化的原因。实践依赖倒置,仍然可以从关注变化开始,详细分析和预测系统中的变化点,然后针对每个可能的变化抽象出相对稳定的约束,这是我们实践依赖倒置原则最基本的方法步骤。

就原理而言,依赖倒置要求设计:

·  少继承,多聚合。

·  单向依赖(低耦合,高内聚)。

·  封装抽象。

·  对依赖关系都应该终止于抽象类和接口。

就实践而言,经典的软件设计实践提出了很多值得借鉴的思路,例如每个设计模式就是对一种特定情况的实践总结,在此继续列出一些经典的大师忠言,Bob大叔在《Agile Principles, Patterns, and Practices》一书对此进行了3点总结:

·  任何变量都不应该持有一个指向具体类的指针或者引用。

·  任何类都不应该从具体类派生。

·  任何方法都不应该覆写它的任何基类中已经实现的方法。

实际上,在实际的设计过程中要完全遵守这几点要求是有难度的,所以如何既能很好地遵守设计原则,又能很好地适应代码情况,是值得权衡的问题,需要不断地积累和实践。另外,还有几个经验之谈:

·  系统架构应该有清晰的层次定义,层次之间通过接口向外提供内聚服务,正如在三层架构中的示例一样。

·  典型的以new进行的对象创建操作,是对依赖倒置原则的典型违反,而通过依赖注入进行对象的创建解耦是常用的解决之道。

如何依赖倒置,我们阐释了一点原则还有一点方法,算是对实现依赖倒置的一点小结。然而,在实际的开发过程中,并没有一成不变的规则,当前的面向对象语言本身就提供了对抽象和封装的支持,为实现面向对象设计提供了基础机制。回顾软件开发的历史,不难看出依赖和封装哲学的发展轨迹,在结构化编程中函数是封装的基本单元;随着面向对象的发展,C++/C#高级语言以类为基本单元,第一次将数据和行为有机地组合为一个逻辑单元,于是有了对于不同类之间的关系处理哲学;而SOA中封装的单元上升为一个服务(service),是一种更高意义的逻辑封装,实现了更优良的逻辑封装和松散耦合关系。同样的道理,也体现在三层架构的分割和通信中,体现在ORM对表现层和领域层的分离中。

因此,依赖倒置是一种高度的智慧和经验总结,如何实现依赖倒置也是一种积累和不断学习的过程。

6.也有弊端

然而,一味地遵守原则,就等于没有原则。重要的是,需要把握其平衡,在进行开发中适当地把握其程度。Bob在《敏捷》中也提到这个问题,总结了依赖倒置的两个弊端,同样需要特别的关注:

·  对抽象编程,需要增加必要的类和辅助代码进行支持,某种程度上增加了系统复杂度和维护成本。

·  当具体类不存在变化时,遵守依赖倒置是多此一举。所以,如果具体或细节没有变化可能时,没有必要通过抽象转嫁依赖。

所以,学习模式或者原则必须灵活处理,不能一味强行。

3.2.4  解构控制反转(IoC)和依赖注入(DI

1.控制反转

控制反转(Inversion of ControlIoC),简言之就是代码的控制器交由系统控制,而不是在代码内部,通过IoC,消除组件或者模块间的直接依赖,使得软件系统的开发更具柔性和扩展性。控制反转的典型应用体现在框架系统的设计上,是框架系统的基本特征,不管是.NET Framework抑或是Java Framework都是建立在控制反转的思想基础之上。

控制反转很多时候被看做是依赖倒置原则的一个同义词,其概念产生的背景大概来源于框架系统的设计,例如.NET Framework就是一个庞大的框架(Framework)系统。在.NET Framework大平台上可以很容易地构建ASP.NET Web应用、Silverlight应用、Windows Phone应用或者Window Azure Cloud应用。很多时候,基于.NET Framework构建自定义系统的方式就是对.NET Framework本身的扩展,调用框架提供的基础API,扩展自定义的系统功能和行为。然而,不管如何新建或者扩展自定义功能,代码执行的最终控制权还是回到框架中执行,再交回应用程序。黄忠诚先生曾经在Object Builder Application Block一文中给出一个较为贴切的举例,就是在Window From应用程序中,当Application.Run调用之后,程序的控制权交由Windows Froms Framework上。所以,控制反转更强调控制权的反转,体现了控制流程的依赖倒置,所以从这个意义上来说,控制反转是依赖倒置的特例。

2.依赖注入

依赖注入(Dependency InjectionDI),早见于Martin FlowerInversion of Control Containers and the Dependency Injection pattern一文,其定义可概括为:

客户类依赖于服务类的抽象接口,并在运行时根据上下文环境,由其他组件(例如DI容器)实例化具体的服务类实例,将其注入到客户类的运行时环境,实现客户类与服务类实例之间松散 的耦合关系。

 

1)常见的三种注入方式

简单而言,依赖注入的方式被总结为以下三种。

·  接口注入(Interface Injection),将对象间的关系转移到一个接口,以接口注入控制。

首先定义注入的接口:

public interface IRunnerProvider

{

    void Run(Action action);

}

为注入的接口实现不同环境下的注入提供器,本例的系统是一个后台处理程序提供了运行环境的多种可能,默认情况下将运行于单独的线程,或者通过独立的Windows Service进程运行,那么需要为不同的情况实现不同的提供器,例如:

public class DefaultRunnerProvider : IRunnerProvider

{

    #region IRunnerProvider Members

 

    public void Run(Action action)

    {

        var thread = new Thread(() => action());

        thread.Start();

    }

 

    #endregion

}

对于后台服务的Host类,通过配置获取注入的接口实例,而Run方法的执行过程则被注入了接口所定义的逻辑,该逻辑由上下文配置所定义:

public class RunnerHost : IDisposable

{

    IRunnerProvider provider = null;

 

    public RunnerHost()

    {

        // Get Provider by configuration

        provider = GetProvider(config.Host.Provider.Name);

    }

 

    public void Run()

    {

        if (provider != null)

        {

            provider.Run(() => 

            {

                // exceute logic in this provider, if provider is DefualtRunnerProvider,

                // then this logic will run in a new thread context.

            });

        }

    }

}

口注入,为无须重新编译即可修改注入逻辑提供了可能,GetProvider方法完全可以通过读取配置文件的config.Host.Provider.Name内容,来动态地创建对应的Provider,从而动态地改变BackgroundHostRun()行为。

·  构造器注入(Constructor Injection),客户类在类型构造时,将服务类实例以构造函数参数的形式传递给客户端,因此服务类实例一旦注入将不可修改。

public class PicWorker

{

}

 

public class PicClient

{

    private PicWorker worker;

 

    public PicClient(PicWorker worker)

    {

        // 通过构造器注入

        this.worker = worker;

    }

}

·  属性注入(Setter Injection),通过客户类属性设置的方式,将服务器类实例在运行时设定为客户类属性,相较构造器注入方式,属性注入提供了改写服务器类实例的可能。

public class PicClient

{

    private PicWorker worker;

 

    // 通过属性注入

    public PicWorker Woker

    {

        get { return this.worker; }

        set { this.worker = value; }

    }

}

另外,在.NET平台下,除了Martin Flower大师提出的三种注入方式之外,还有一种更优雅的选择,那就是依靠.NET特有的Attribute实现,以ASP .NET MVC中的Action Filter为例:

[HttpPost]

public ActionResult Register(RegisterModel model)

{

    // 省略注册过程

    return View(model);

}

其中,HttpPostAttribute就是通过Attribute方式为Register Action注入了自动检查Post请求的逻辑,同样的注入方式广泛存在于ASP .NET MVC的很多Filter逻辑中。

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]

public sealed class HttpPostAttribute : ActionMethodSelectorAttribute

{

    // Fields

    private static readonly AcceptVerbsAttribute _innerAttribute = new AcceptVerbsAttribute(HttpVerbs.Post);

 

    // Methods

    public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)

    {

        return _innerAttribute.IsValidForRequest(controllerContext, methodInfo);

    }

}

关于Attribute的详细内容,请参考8.3节“历史纠葛:特性和属性”,其中的TrimAttribute特性正是应用Attribute注入进行属性Trim过滤处理的典型应用。

2)依赖注入框架

.NET世界里,已经有很多久经考验的依赖注入容器可供选择,在实际的应用系统中选择合适的依赖注入容器,会大大减少对于业务对象的管理,建立更加松散的联系。

·  Unity

·  ObjectBuilder

·  Castle

·  Sprint.NET

限于篇幅,本书不对具体的容器特性进行探讨,读者可自行研究,其中微软的Unity容器正逐渐成为.NET平台应用的首要选择,在本书后续内容中就有应用Unity进行对象创建过程的示例,值得特别关注和研究。

3.关系:DIPIoC还有DI

总体而言,DIPIoC还有DI之间有着剪不断理还乱的关系(如图3-13所示),其中DIP是对于依赖关系的理论总结,而IoCDI则体现为具体的实践模式。IoCDI为消除模块或者类之间的耦合关系提供了有效的解决方案,从而保证了依赖于抽象和稳定模块或者类型,也就意味着坚持了DIP原则的大方向。

3-13  DIPIoCDI

IoCDI之间的区别主要体现在关注场合的不同:IoC强调控制权的反转作用,着眼于流程控制的场合;而DI则关注层次与层次、组件与组件、模块与模块或者类型与类型之间的“倒置”,体现为设计模型上的依赖模式解构。

3.2.5  典型的设计模式

细数而来,几乎每个设计模式关注的核心都体现在依赖之上。创建型模式关注实例创建关系的依赖,结构型模式关注构建复杂对象过程的依赖,而行为型模式关注应用运行过程中的通信依赖。纵观GoF在《设计模式:可复用面向对象软件的基础》一书中梳理的23个设计模式,从依赖观点来看,有如表3-1所示的总结。

3-1  模式的依赖

   

   

   

创建型模式

创建型模式的核心关注点就在于对象创建的依赖关系上,将对象的依赖从new操作中解脱出来,隔离应用系统和类型实例化间的依赖。例如通过引入工厂,将对象创建职责委托于工厂类,解除了应用系统与类型对象在实例化过程中的直接引用。

详细的讨论,参考3.2.7节“对象创建的依赖”的论述

工厂方法、抽象工厂、单例、创建者、原型模式

续表 

   

   

   

结构型模式

结构型模式,是将简单类型组合为复杂类型的过程,通过灵活的设计要素,最终保证不同类型间保持尽量间接的引用和尽量松散的耦合,在复杂类型有更多变化与诉求时,以最小的代价兼容变化,扩展诉求。

例如,适配器模式的两种不同适配方式,分别代表了通过继承和组合方式实现对象适配的处理;而代理模式,则通过引入代理,来隔离不同层次间的依赖,同时保证真实对象的安全性与封装性

桥接、适配器、组合、外观、装饰、享元、代理

行为型模式

行为型模式,关注对象行为的扩展和对象间数据关系的通信,以面向对象方式描述控制流。

例如,职责链避免请求的发送者和接收者直接的直接耦合,而是将多个对象连成一条处理链条;而命令模式的核心则在于将行为的请求者和行为实现者之间通过封装的命令对象解耦

模板方法、迭代器、中介者、职责链、解释器、命令、观察者、备忘录、状态、策略、访问者

3.2.6  基于契约编程:SOA架构下的依赖

把握软件开发的历史脉搏,依赖关系的落脚点也在其演义过程中逐渐发生着改变,从面向过程以函数为核心,到面向对象以对象为核心,面向组件以组件为核心,再到面向服务中以服务为核心。基于契约编程的思想实现了更松散的耦合模型,当开发者调用Facebook服务获取好友列表这样的服务时,并不需要关心具体的服务内部逻辑,也不需要关注服务的物理存储,更不需要关心服务之间的关联关系,而只需要关注服务本身即可,如图3-14所示。

3-14  高层依赖于抽象

基于契约编程的依赖,就是对契约本身的依赖,也就是对具体服务的依赖。因此,SOA架构下的依赖是天生松散耦合、高度抽象、实现技术无关且物理无关的。

·  松散耦合,服务之间的关系、应用与服务的关系都是松散的、单一的,基于消息的低耦合依赖。

·  高度抽象,服务本身不仅抽象了逻辑,还同时抽象了通信的契约与寻址。高度抽象的服务实体,是实现SOA架构的基础单元。

·  实现技术无关,服务本身的实现可以是各种各样的技术平台、版本。

·  物理无关,应用访问的服务部署,在物理上和应用本身是可以完全分离的,可以是局域网或者是互联网上任何位置,可以是不同的服务提供商,也可以运行在不同的时区。

·  基于消息,客户端与服务端基于标准的消息协议进行数据通信。

3.2.7  对象创建的依赖

关于依赖的哲学,最典型的违反莫过于对象创建的依赖。自面向对象的大旗树立以来,对于对象创建话题的讨论就从未停止。不管是工厂模式还是依赖注入,其核心的思想就只有一个:如何更好地解耦对象创建的依赖关系。所以,在这一部分,我们就以对象创建为主线,来认识对于依赖关系的设计轨迹,分别论述一般的对象创建、工厂方式创建和依赖注入创建三种方式的实现、特点和区别。

1.典型的违反

一般而言,以new关键字进行对象创建,在.NET世界里是天经地义的事情。在本书7.1节“把new说透”中,就比较透彻地分析了new在对象创建时的作用和底层机制。对.NET程序员而言,以new进行对象创建已经是习以为常的事情,大部分情况下这种方式并没有任何问题。例如:

public abstract class Animal

    public abstract void Show();

}

 

public class Dog : Animal

{

    public override void Show()

    {

        Console.WriteLine("This is dog.");

    }

}

 

public class Cat : Animal

{

    public override void Show()

    {

        Console.WriteLine("This is cat.");

    }

}

 

public class NormalCreation

{

    public static void Main2()

    {

        Animal animal = new Dog();

    }

}

animal对象而言,大部分情况下具体的Dog类是相对稳定的,所以这种依赖很多时候是无害的。这也是我们习以为常的原因之一。

然而,诚如在本文开始对抽象和具体的概念进行分析的结论一样,依赖于具体很多时候并不能有效地保证其稳定性的状态。以本例而言,如果有新的BirdHorse加入到动物园中来,管理员基于现有体系的管理势必不能适应形式,因为所有创建而来的实例都是依赖于Dog的。所以,普遍的对象创建方式,实际上是对DIP原则的典型违反,高层Animal的创建依赖于低层的Dog,和普世的DIP基本原则是违背的。

因此,DIP并不是时时被OO所遵守,开发者要做的只是适度的把握。为了解决new方式创建对象的依赖违反问题,典型的解决思路是将创建的依赖由具体转移为抽象,通常情况下有两种方式来应对:工厂模式和依赖注入。

2.工厂模式

以工厂模式进行对象创建的方法,主要包括两种模式:抽象工厂模式和工厂方法模式,本文不想就二者的区别和意义展开细节讨论,如果有兴趣可以参阅GoF的《设计模式:可复用面向对象软件的基础》一书。

本文将视角拉回到WCFIChannelFactory和各种Channel的创建上,以此借用WCF架构中Channel Layer的设计思路,应用工厂模式进行对象创建的设计和扩展,来了解应用工厂模式进行对象创建依赖关系解除的实质和实现。

注意                                                                    

对于WCFChannel的概念可以参考相关的资料,在此你只需将其看成一个简单类型即可。

首先来了解一下Channel的创建过程:

public class FactoryCreation

{

    public static void Main()

    {

        EndpointAddress ea = new EndpointAddress("http://api.anytao.com /UserService");

        BasicHttpBinding binding = new BasicHttpBinding();

        IChannelFactory<IRequestChannel> facotry = binding.BuildChannelFactory<IRequestChannel>();

        facotry.Open();

        IRequestChannel channel = facotry.CreateChannel(ea);

        channel.Open();

        //Do something continue...

    }

}

在示例中,IRequestChannel实例通过IChannelFactory工厂来创建,因此关注工厂方式的创建焦点就着眼于IChannelFactoryIRequestChannel上。实质上,在WCF channel Layer中,Channel Factory是创建和管理Channel的工厂封装,通过一个个的Channel Factory来创建一个个对应的Channel实例,所有的Channel Factory必须继承自IChannelFactory,其定义为:

public interface IChannelFactory<TChannel> : IChannelFactory, ICommunicationObject

{

    TChannel CreateChannel(EndpointAddress to);

    TChannel CreateChannel(EndpointAddress to, Uri via);

}

通过类型参数TChannel来注册创建实例的类型信息,进而根据EndpointAddress信息来创建相应的对象实例。当然,WCF中的工厂模式应用,还有很多内容值得斟酌和学习。现有篇幅不可能实现完全类似的设计结构,借鉴于WCF的设计思路,对Animal实例的创建进行一点改造,实现基于泛型的工厂模式创建设计,首先定义一个对象创建的模板:

public interface IAnimalFacotry<TAnimal>

{

    TAnimal Create();

}

然后实现该模板的泛型工厂方法:

public class AnimalFacotry<TAnimalBase, TAnimal> : IAnimalFacotry<TAnimalBase> where TAnimal : TAnimalBase, new()

{

    public TAnimalBase Create()

    {

        return new TAnimal();

    }

}

其中类型参数TAnimalBase代表了高层类型,而TAnimal则代表了底层类型,其约定关系在where约束中有明确的定义,然后是一个基于对工厂方法的封装:

public class FacotryBuilder

{

    public static IAnimalFacotry<Animal> Build(string type)

    {

        if (type == "Dog")

        {

            return new AnimalFacotry<Animal, Dog>();

        }

        else if (type == "Cat")

        {

            return new AnimalFacotry<Animal, Cat>();

        }

 

        return null;

    }

}

最后,可以欣赏一下基于工厂方式的对象创建实现:

class Program

{

    static void Main(string[] args)

    {

        IAnimalFacotry<Animal> factory = FacotryBuilder.Build("Cat");

        Animal dog = factory.Create();

        dog.Show();

    }

}

你看,对象创建的依赖关系已经由new式的具体依赖转换为对于抽象和高层的依赖。在本例中,完全可以通过反射方式来消除if/else的运行时类型判定,从而彻底将这种依赖解除为可配置的灵活定制。这正是抽象工厂方式的伟大意义,其实在本例中完全可以将IAnimalFacotry扩展为IAnyFactory形式的灵活工厂,可以在类型参数中注册任何类型的TXXXBaseTXXX,从而实现功能更加强大的对象生成器,只不过需要更多的代码和扩展,读者可以就此进行自己的思考。

3.依赖注入

领略了工厂模式的强大威力,下面继续介绍更加灵活解耦的依赖注入方式,继续回到对于Animal实例化的依赖倒置环节,来看看注入方式下如何通过容器来实现实例创建过程。在此选择Unity基础容器来实现,引入Microsoft.Practices.Unity.dll程序集和Microsoft.Practices.Unity命名空间,然后就可以很容易地通过Unity容器来完成对象创建依赖关系的隔离:

class UnityCreation

{

    public static void Main()

    {

        IUnityContainer container = new UnityContainer();

        container.RegisterType<Animal, Dog>();

 

        Animal dog = container.Resolve<Animal>();

        dog.Show();

    }

}

Unity提供了强大而灵活的依赖注入支持:方法调用注入、属性注入和构造器注入等多种方式,可以在运行时或通过配置方式来注册和获取类型,是实现处理对象间依赖的有效方式。关于依赖注入,前文已有较多笔墨铺陈,在此就不做过多讨论。

综上而言,以对象创建这样一个常见而又简单的话题为焦点来讨论对这种依赖关系的场景复现。实际上,对象间的关系就像人类社会一样复杂多变,随时准备应对变化的依赖,着眼于对抽象的把握,是把复杂简单化的最佳实践,就类似于以工厂模式或者依赖注入方式将实体对象创建简单化处理的过程一样,是有意义的。

3.2.8  不规则总结

关于依赖的哲学,值得总结的有很多:

·  new创建对象,是对依赖倒置原则的典型违反,可以通过工厂模式或者依赖注入来解决。

·  一个对象持有另外一个具体对象的引用可能破坏了依赖倒置。

·  所有结构良好的面向对象架构都具有清晰的层次定义,每个层次通过一个定义良好的受控接口向外提供一组内聚的服务。

·  依赖倒置预示着程序中的依赖关系不应是具体的类型,而应是抽象类和接口。

·  依赖倒置适用于当一个类向另一个类发送消息的任何情况。

3.2.9  结论

从小国寡民到和谐社会,不是一段寻常的路,走得越远想得越多,才能挥洒自如。正如对依赖的体会,一点一滴积累下来,剥丝抽茧才能层层深入。本文的循序之旅不可能尽述本质,如果能做到一点点亮思想的火柴就已是功得圆满。对于设计的精妙,体会抽象的层次,升华依赖的哲学,就是本文所得。

 
 
  上一页 返回 下一页  
 
Copyright © 2010 TianMei Technology All rights reserved. To comment on this site
  辽B-2-4-20100065