- 模式:工程化实现及扩展(设计模式Java 版)
- 王翔 孙逊
- 6212字
- 2020-08-28 05:18:36
1.1 说明
在介绍模式内容之前,我们先谈一些有关面向对象的话题。
《模式——工程化实现及扩展》系列的各个分册,无论是设计模式还是架构模式,前面其实都应该加上一个“面向对象的”,即我们谈论的是面向对象的设计模式和面向对象的架构模式。
同其他软件领域一样,面向对象技术同样有一些传承下来的设计原则,它们是众多面向对象先驱们不断提炼的,这些原则甚至可以被称为是 “规律性” 的内容,因为随着项目规模的扩大,我们会不断体会到这些原则的重要性。对于开发人员这些原则的重要性往往是自己在一遍遍“撞南墙”的过程中体会到的,是不断 “费力”、“重写”之后慢慢体会到的。
这些原则同样体现在各类设计模式、架构模式之中,在学习过程中,我们会通过类图、时序图、示例代码等形式,不断体会这些原则在解决“依赖”和“变化”中的效果。当然,这些“原则”的队伍也在变化,不断有新的“原则”加入,也有被“大浪淘沙”淘汰掉的,真正沉淀下来的通用“原则”其实并不多。而且总体来说,面向对象的典型原则可以划分为两类——“面向类”的和“面向包”的。
●“面向类”的原则包括:
SRP——单一职责原则。
OCP——开放封闭原则。
LSP——里氏替换原则。
DIP——依赖倒置原则。
ISP——接口隔离原则。
●“面向包”的原则包括:
REP——重用发布等价原则。
CCP——共同封闭原则。
CRP——共同重用原则。
ADP——无环依赖原则。
SDP——稳定依赖原则。
SAP——稳定抽象原则。
前者主要是面向类型设计的,设计模式部分基本都是围绕这些原则展开讨论。后者主要是面向编译组件的,如Java的.jar包、.war包、.class文件,而对于.NET一般指的是.dll,当然还包括各类配置文件,尽管配置文件一般并不是直接编译的组件,但随着应用规模增大、可配置性的提高,它们往往也作为应用部署的必要组成部分。“面向包”的这些原则将会在架构模式的设计中体现。需要提醒的是“面向包”中的“包”并不是Java中的包(Package)或.NET中的命名空间(Namespace)。另外,“面向包”的这6个原则可以再划分为两类,REP、CCP、CRP强调的是包的内聚性设计要求,而ADP、SDP、SAP针对的是包间耦合性要求。
下面,我们介绍本册“面向类”的这些原则外加一个法则。
没有特殊说明,本章提到的“接口”指接口、抽象类或者在相应语境下的公共基类。
1.2 单一职责原则(SRP)
The single responsibility principle states that every object should have a single responsibility,and that responsibility should be entirely encapsulated by the class.All its services should be narrowly aligned with that responsibility.
单一职责原则简言之就是“每个类只担任一个职责”。
再深入考虑一下,单一职责原则其实要求的是每个类应只有一个引起它变化的原因。直接理解这个要求或许有些抽象,我们不妨先看看现实中单一职责原则的一个典型反例——瑞士军刀,如图1-1所示。
图1-1 单一职责原则的典型反例——端士军刀
如果把图1-1中的瑞士军刀抽象为SophisticatedSAK(Swiss Army Knife,SAK)类,则直观会感觉这个类,它几乎趋于万能,它可以表示为一个如图1-2所示的“身材修长”的类图。
图1-2 功能上已经简化后的瑞士军刀类图
现实中瑞士军刀提供的功能趋于稳定,而项目中如果某个类也能这样全面会出现什么情况呢?
首先,如果它能够像瑞士军刀那样稳定,它的客户程序就“有福了”。因为各类职责都可以找它完成。连接数据库找它、访问注册表找它、修改网页某层的模板、查找Web Service Endpoint……全都找它就可以了。
其次,它能稳定吗?很遗憾,概率太小了。因为影响它的因素太多,所以它很难稳定,结果是它可能经常会被修改,甚至成了最不稳定的一个类。
再次,客户程序就“有难了”。
最后的结果就是其他开发人员放弃这个“超级偶像” 类,选择相对“靠谱”不总是变来变去的类完成特定功能。
实际项目中,有些职责可能只涵盖很少的内容,开发人员一不小心就让自己写的类“身兼数职”了。但考虑日后维护的需要,建议要尽量坚持单一职责原则,目标是让永恒的“变化”尽可能少地影响我们的类型。
下面来分析一个示例。
有一个接口,名称为DataHandler,它完成数据处理,这个处理通常分为3个操作——读取、格式化、写入目标环境。我们可用两个方式设计,如图1-3所示。
图1-3 已分割职责(左侧)与未分割职责(右侧)的接口设计
未分割角色的设计只有3个方法——read()、format()、write(),它们正好覆盖了DataHandler的3个要求,这个未分割职责的设计相对更上层的逻辑而言基本上是“单一职责”的,但从下层影响它的因素看,3个方法都很可能受到不同外部因素的影响,这样看它又不够“单一职责”。例如:
●是从文件读取数据还是从数据库读取数据,它们对于read()方法会产生影响。
●格式化为普通的二进制数据还是XML数据,也会影响format()。
●写入到数据库还是消息队列,这也会对write()方法的实现产生差异。
可以说,3个方法都可能受到不同外部因素的影响,进而导致这个未分割角色的DataHandler发生变化。
再看图1-3中已分割职责的这个设计,尽管直观上它更“单一职责”,但在实际项目中不能一概而论,因为采用后者的潜台词是read()、format()和write()3个方法都可能受到“变化”影响,如果3个方法面临“变化”影响的概率都非常低,那么这样实现的成本相对就比较高了。
实际项目中,我们要把握好“单一职责”中“单一”的度。例如,在极端的情况下,项目中每个类的每个方法都独立定义为一个接口,这样彻底“单一”的结果就“不完整”了,因为这样的极端设计违背了面向对象的封装要求,过犹不及了。
1.3 里氏替换原则(LSP)和依赖倒置原则(DIP)
LSP——Inheritance should ensure that any property proved about supertype objects also holds for subtype objects.
DIP——The principle states:
A.High-level modules should not depend on low-level modules.Both should depend on abstractions.
B.Abstractions should not depend upon details.Details should depend upon abstractions.
里氏替换原则和依赖倒置原则普遍用于各类设计模式,接口代表抽象的内容,子类相对具体,而且子类更倾向于发生“变化”。设计模式是两个原则的忠实践行者,如果说所有设计模式有什么共性的话,那就是都在贯彻这两个原则,务求使客户程序“依赖于抽象而非具体”。
乍一看,两个原则“神似”,它们有什么区别呢?
关键在于两个原则站的角度不同。其中,里氏替换原则更多的是站在模式对象一方,而依赖倒置原则则是站在客户程序一方。模式对象一方将“相对多变的”子类视同它的接口(或父类)看待,而客户程序一方依赖的内容不是“相对多变的”子类,而是“相对稳定的”接口,如图1-4所示的例子。
图1-4 包括接口隔离原则和依赖倒置原则的示例
上面的例子不长,但可以看到两个原则协作的效果。对于客户程序而言,为了让车辆“跑两步”,它依赖的不是Bicycle或Train,而是能“跑”的Vehicle;同时,无论是Bicycle还是Train,因为它们满足了Vehicle接口能“跑”的要求,所以可以被替换为接口Vehicle来操作。
1.4 接口隔离原则(ISP)
ISP——No client should be forced to depend on methods it does not use.
接口隔离原则要求客户程序无须被迫依赖于它用不到的方法。
虽然软件设计是个耗时的工作,但实际项目中我们经常会因“深思熟虑”过多而在某些接口上“添足”,这样的接口被称为Fat Interface——臃肿、肥胖超标的接口。
据此,在很多情况下接口隔离原则与前面的单一职责原则是相辅相成的,如果每个接口只定义“单一职责”,一般不容易出现Fat Interface,而且冗余较少。但单一职责原则并不保证客户程序只知道必要的信息,甚至在有些情况下接口隔离原则与单一职责原则会出现一定的冲突,设计时我们需要根据用户界面、性能等因素决策。
例如,对于一个Service Desk(技术支持服务台)应用而言,人员信息只需知道姓名、职务、E-mail地址、电话等几项基本信息就够了,相应的操作方法仅围绕这些信息即可;但对于一个人力资源系统,人员信息往往就会很“壮观”了,包括家庭背景、学历、工作经历、获奖情况等,相应的操作方法就更“Fat”了。尽管依照单一职责原则人力资源系统中的Person接口可分解为多个接口,但即便这样一般也会比Service Desk多。对于一个ERP系统而言,它的人员信息类需要保存的内容也许更多、也许更少,多到可能包括一名职工生老病死全程内容、少到可能只有一个编号。
所以,贯彻接口隔离原则不是简单地说“多余”还是“不多余”,衡量的标准是有前提的。从设计角度看,它要依据业务领域的需要来衡量到底是否“多余”,确保在每个领域背景下贯彻接口隔离原则。实际项目中,很多类型也存在类似的问题,因此,我们可以将这些类型根据业务领域整理到不同的子系统、组件中,实现时也可以将它们划分到不同的Java包(Package)或C#命名空间中。
如图1-5所示是一个示例,由于人员信息(Interface Person)在不同的系统中需求不同,为了解决“众口难调”的问题,只好将它们分在两个Java 包中,每个包下的其他类型根据自己的“口味”选择包(自己业务领域)内的Person接口。
图1-5 依据领域分割后的接口隔离原则
1.5 迪米特法则(Law of Demeter,LoD)
A.Each unit should have only limited knowledge about other units: only units“closely”related to the current unit.
B.Each unit should only talk to its friends; don't talk to strangers.
C.Only talk to your immediate friends.
迪米特法则又被称为最少知识原则(LKP),该法则随着The Pragmatic Programmer被国内的开发社区广为了解,不过一般都只提到上述C点的要求,即“Only talk to your immediate friends.”,但迪米特法则还有两层意思,就是要让调用者对于目标对象的知识了解最少,而且不和陌生人交谈。
迪米特法则不希望与非“友”的类型建立直接联系,即便联系的都是“友”也要和“最近的密友”联系,并且联系的时候最好不要谈“隐私”(即专门类型),要用一些相对广为人知的知识与之交互。
设计模式在设置和引入类型时,也一直潜移默化地实践着各项原则,如前面里氏替换原则和依赖倒置原则的例子,客户程序不希望知道Bicycle怎么跑、Train怎么开,它只希望知道能run()就可以了,虽然做到了依赖倒置原则,但其中似乎缺少了一个中间环节——客户程序怎么 “找到”或者“被找到”这样一个Vehicle呢?一般我们会定义一个工厂类型或者通过某个注入机制让客户程序“找到”或“被找到”一个Vehicle实例,这时工厂类型和Vehicle接口就成了客户程序的“密友”了,至于Bicycle、Train还是视为“陌生人”为好,如图1-6所示。
图1-6 补充了工厂类型的静态结构及类型间的亲密关系
这也可以看出,“陌生”与“亲密”是相对的,客户程序之所以能够只认识Vehicle,而不认识Bicycle和Train,是因为有了工厂类型,而工厂类型自己则是Bicycle和Train的“熟人”。迪米特法则在生活中最直观的表现就是“人脉”,不过不同点在于迪米特法则希望的是人脉窄,而现实中我们希望的是人脉宽、路子广。
不过,这些还不是迪米特法则的全部,因为迪米特法则希望“知识”最少,因此细究下去除了关联关系外,还涉及关联手段和关联过程。例如:同样是访问信息源,尽管逻辑上双方都属于“最近”的关联关系——你有数据我来拿,但协议不同也会导致有的信息源使用时不是那么方便,这时只能称它们为“最近的陌生人”。为此,还要在中间补充一个对象,帮助它们建立联系。
下面以一个单证审核系统为例,单证实体类型的数据结构如图1-7所示。
图1-7 单证实体类型的数据结构
设计单证审核功能时,我们可能提供两种方案交由项目组选择。
方案1:客户程序知道单证实体类型,包括这个类型中具有Master-Detail关系的内容:
checkForm(Form form);
方案2:客户程序只知道单证的编号,除此之外其他不知道:
checkForm(String formID);
两个方案中,前者客户程序需要知道的更多,后者要求相对较少。类似问题频繁出现于很多业务系统中:对于企业内部的系统也许迪米特法则的这个要求关系不太大,毕竟都在可控的范围之内,无论是CheckForm(Form form)还是CheckForm(string formID),关系都不大,但对于集成项目、外包项目而言,迪米特法则的这层要求就显得非常有意义了。
1.6 开闭原则(OCP)
OCP——Closed for Modification; Open for Extension.
如果说前面的原则都可以视为针对面向对象设计具体方面要求的话,那么开闭原则则是前面一系列原则的集大成者,内容更加“高屋建瓴”。
开闭原则要求软件有一个良好的基本结构,确保面对“变化”的时候,仅仅扩展而不是修改现有对象的组织框架就可以随需而动。但我们知道越是这样趋于“道可道,非常道”的内容越是难以找到一个固定方式达成,我们可以举出很多例子指出这样或那样是违反开闭原则的,但不容易展示怎样才是开闭原则,原因在于影响软件“变化”的原因很多,即便现有的对象组织框架针对现状基本上满足了扩展要求,未来也常常发现框架本身成了实践开闭原则的阻力。
现实中,一个项目在设计之初往往开闭原则贯彻得比较好,但在工期的压力下开发团队越来越趋于浮躁和激进,开闭原则逐渐被一句“下个版本再考虑”带过。这不是一个团队的事情,很多大型商业软件也存在类似的问题,只不过项目经理控制力道有些差异,好的项目经理能让这些问题在不影响进度、成本的前提下,更晚或更局部的爆发。
下面,我们把前面所讲的DataHandler的例子进一步具体化,目的是了解如何在设计中把变化控制在一个尽可能小的范围内,讨论如何设计更符合开闭原则的要求:
●我们要做的是一个简易的ETL工具(ETL:Extract,Transform,Load,即数据的抽取、转换与装载),它只负责提取整个表(Table)或视图(View)的数据。
●这个工具深受好评,而且公司决定以后将这个工具作为主打品牌,让它成为一个专用的工具。但问题是数据库产品总是增加、数据库产品的版本总是升级,最近NO-SQL运行兴起后又出现了一大批非关系型的新型数据库。第一版的时候,只支持Oracle和SQL Server两个数据库,设计如图1-8所示。
图1-8 第一版设计
下面来分析一下这个设计:
●ETL类负责提取,ConnectionManager类管理数据库连接,两个类各司其职,而且根据上面示例情景的假设,影响ETL变化的因素只有一个——新类型的数据库,所以这个设计基本上满足单一职责原则的要求。
●没有满足里氏替换原则和依赖倒置原则的要求。
●两个类接口简单,仅从方法数量看,各自简单到对外只有一个公共方法,基本符合接口隔离原则。
●迪米特法则的各层意思基本上都能满足。虽然满足了一些原则,但读者一定可以很快指出这个设计还是不满足开闭原则,因为一旦有新数据库,ETL类就要修改,而不是扩展。于是我们又重构了第二版,如图1-9所示。
图1-9 第二版设计
上面的设计在保留了之前的一些特点外,还通过里氏替换原则和依赖倒置原则实现支持更多数据库的目标,而且ConnectionManager的getDbType()方法被置为private方法,所以传承了接口隔离原则。
但我们也要注意到,尽管ETL类在当前语境设计下满足开闭原则要求了,但却把麻烦抛给ConnectionManager类了,因为以后增加MySQL、DB2这些数据库的时候,ConnectionManager类还要修改。由于“修改”集中在构造过程,既然应用自己来解决总难免造成与具体类型的依赖关系(如图1-9所示的ConnectionManager类对于SqlDbAdapter、OracleDbAdapter的依赖性),不妨考虑将变化转移到语言运行环境层面。构造对象时,Java平台可以用Class.newInstance()方法,.NET平台可以用Activator.CreateInstance()方法,至于每类数据库应该用什么对象来处理,这些信息也找一个相对稳定的机制——配置文件保存即可。这样,第三版设计如图1-10所示。
图1-10 第三版设计
再次分析一下,似乎在当前语境下也大致满足了开闭原则的要求,但代价是增加了一系列对象,是在用现在的工作量减轻未来可能出现的工作量,投入是明显的,收益却是不确定的。实际项目中,面对开闭原则我们除了技术上这种敝帚自珍外,更需要考虑这么做是否值得,我们是不是真的把80%的精力放到20%的关键对象部分,而不是抛开软件需要不论,投入过多时间“打磨”自己的设计,一味钻开闭原则的“牛角尖”。
1.7 小结
面向对象设计已经出现并将继续出现各种原则,除了熟悉它们外,更重要的是谨记它们只是思路,而且只是技术层面的思路,我们开发软件的主要目的是满足用户的需要,同时获得收益。因此,面向对象原则还要和团队的承受能力、投入、时间进度等进行权衡。
大型的软件不是一天建成的,设计原则要活用。
本系列关于设计模式、架构模式的介绍将围绕这些基本原则或法则展开,具体章节中将有很多设计上的折中,届时请读者更多地关注采取这些折中手段的环境与上下文,毕竟工程化软件的特点不仅仅是看上去很“面向对象”,关键是能够用简洁、必要的面向对象手段解决问题,少投入、多产出。
1.8 自我检验
很多Java开发人员都接触过早期的DbHelper.java,它负责管理数据库连接、执行查询、执行DML语句(Insert、Delete和Update)或存储过程。
请用上面各原则分析这个DbHelper.java实现的优势和不足,对于各个分析结果可举证。