6.3 为什么要采用开闭原则

每个事物的诞生都有它存在的必要性,存在即合理,那开闭原则的存在也是合理的,为什么这么说呢?

首先,开闭原则非常著名,只要是做面向对象编程的,甭管是什么语言,Java也好,C++也好,或者是Smalltalk,在开发时都会提及开闭原则。

其次,开闭原则是最基础的一个原则,前五章节介绍的原则都是开闭原则的具体形态, 也就是说前五个原则就是指导设计的工具和方法,而开闭原则才是其精神领袖。换一个角度来理解,依照Java语言的称谓,开闭原则是抽象类,其他五大原则是具体的实现类,开闭原则在面向对象设计领域中的地位就类似于牛顿第一定律在力学、勾股定律在几何学、质能方程在狭义相对论中的地位,其地位无人能及。

最后,开闭原则是非常重要的,可通过以下几个方面来理解其重要性。

1. 开闭原则对测试的影响

所有已经投产的代码都是有意义的,并且都受系统规则的约束,这样的代码都要经过“千锤百炼”的测试过程,不仅保证逻辑是正确的,还要保证苛刻条件(高压力、异常、错误)下不产生“有毒代码”(Poisonous Code),因此有变化提出时,我们就需要考虑一下, 原有的健壮代码是否可以不修改,仅仅通过扩展实现变化呢?否则,就需要把原有的测试过程回笼一遍,需要进行单元测试、功能测试、集成测试甚至是验收测试,现在虽然在大力提倡自动化测试工具,但是仍然代替不了人工的测试工作。

以上面提到的书店售书为例,IBook接口写完了,实现类NovelBook也写好了,我们需要写一个测试类进行测试,测试类如代码清单6-6所示。

代码清单6-6 小说类的单元测试

1
2
3
4
5
6
7
8
9
10
11
public class NovelBookTest extends TestCase {
private String name = "平凡的世界";
private int price = 6000;
private String author = "路遥";
private IBook novelBook = new NovelBook(name,price,author);
//测试getPrice方法
public void testGetPrice() {
//原价销售,根据输入和输出的值是否相等进行断言
super.assertEquals(this.price, this.novelBook.getPrice());
}
}

单元测试通过,显示绿条。在单元测试中,有一句非常有名的话,叫做”Keep the bar green to keep the code clean”,即保持绿条有利于代码整洁,这是什么意思呢?绿条就是Junit 运行的两种结果中的一种:要么是红条,单元测试失败;要么是绿条,单元测试通过。一个方法的测试方法一般不少于3种,为什么呢?首先是正常的业务逻辑要保证测试到,其次是边界条件要测试到,然后是异常要测试到,比较重要的方法的测试方法甚至有十多种,而且单元测试是对类的测试,类中的方法耦合是允许的,在这样的条件下,如果再想着通过修改一个方法或多个方法代码来完成变化,基本上就是痴人说梦,该类的所有测试方法都要重构,想象一下你在一堆你并不熟悉的代码中进行重构时的感觉吧!

在书店售书的例子中,增加了一个打折销售的需求,如果我们直接修改getPrice方法来实现业务需求的变化,那就要修改单元测试类。想想看,我们举的这个例子是非常简单的, 如果是一个复杂的逻辑,你的测试类就要修改得面目全非。还有,在实际的项目中,一个类一般只有一个测试类,其中可以有很多的测试方法,在一堆本来就很复杂的断言中进行大量修改,难免会出现测试遗漏情况,这是项目经理很难容忍的事情。

所以,我们需要通过扩展来实现业务逻辑的变化,而不是修改。上面的例子中通过增加一个子类OffNovelBook来完成了业务需求的变化,这对测试有什么好处呢?我们重新生成一个测试文件OffNovelBookTest,然后对getPrice进行测试,单元测试是孤立测试,只要保证我提供的方法正确就成了,其他的我不管,OffNovelBookTest如代码清单6-7所示。

代码清单6-7 打折销售的小说类单元测试

1
2
3
4
5
6
7
8
9
10
11
12
public class OffNovelBookTest extends TestCase {
private IBook below40NovelBook = new OffNovelBook("平凡的世界",3000,"路遥");
private IBook above40NovelBook = new OffNovelBook("平凡的世界",6000,"路遥");
//测试低于40元的数据是否是打8折
public void testGetPriceBelow40() {
super.assertEquals(2400, this.below40NovelBook.getPrice());
}
//测试大于40的书籍是否是打9折
public void testGetPriceAbove40(){
super.assertEquals(5400, this.above40NovelBook.getPrice());
}
}

新增加的类,新增加的测试方法,只要保证新增加类是正确的就可以了。

2. 开闭原则可以提高复用性

在面向对象的设计中,所有的逻辑都是从原子逻辑组合而来的,而不是在一个类中独立实现一个业务逻辑。只有这样代码才可以复用,粒度越小,被复用的可能性就越大。那为什么要复用呢?减少代码量,避免相同的逻辑分散在多个角落,避免日后的维护人员为了修改一个微小的缺陷或增加新功能而要在整个项目中到处查找相关的代码,然后发出对开发人员“极度失望”的感慨。那怎么才能提高复用率呢?缩小逻辑粒度,直到一个逻辑不可再拆分为止。

3. 开闭原则可以提高可维护性

一款软件投产后,维护人员的工作不仅仅是对数据进行维护,还可能要对程序进行扩展,维护人员最乐意做的事情就是扩展一个类,而不是修改一个类,甭管原有的代码写得多么优秀还是多么糟糕,让维护人员读懂原有的代码,然后再修改,是一件很痛苦的事情,不要让他在原有的代码海洋里游弋完毕后再修改,那是对维护人员的一种折磨和摧残。

4. 面向对象开发的要求

万物皆对象,我们需要把所有的事物都抽象成对象,然后针对对象进行操作,但是万物皆运动,有运动就有变化,有变化就要有策略去应对,怎么快速应对呢?这就需要在设计之初考虑到所有可能变化的因素,然后留下接口,等待“可能”转变为“现实”。

6.2 开闭原则的庐山真面目

开闭原则的定义已经非常明确地告诉我们:软件实体应该对扩展开放,对修改关闭,其含义是说一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。 那什么又是软件实体呢?软件实体包括以下几个部分:

  • 项目或软件产品中按照一定的逻辑规则划分的模块。
  • 抽象和类。
  • 方法。

一个软件产品只要在生命期内,都会发生变化,既然变化是一个既定的事实,我们就应该在设计时尽量适应这些变化,以提高项目的稳定性和灵活性,真正实现“拥抱变化”。开闭原则告诉我们应尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来完成变化,它是为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。我们举例说明什么是开闭原则,以书店销售书籍为例,其类图如图6-1所示。

image-20210927205446981

图6-1 书店售书类图

IBook定义了数据的三个属性:名称、价格和作者。小说类NovelBook是一个具体的实现类,是所有小说书籍的总称,BookStore指的是书店,IBook接口如代码清单6-1所示。

代码清单6-1 书籍接口

1
2
3
4
5
6
7
8
public interface IBook {
//书籍有名称
public String getName();
//书籍有售价
public int getPrice();
//书籍有作者
public String getAuthor();
}

目前书店只出售小说类书籍,小说类如代码清单6-2所示。

代码清单6-2 小说类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class NovelBook implements IBook {
//书籍名称
private String name;
//书籍的价格
private int price;
//书籍的作者
private String author;
//通过构造函数传递书籍数据
public NovelBook(String _name,int _price,String _author){
this.name = _name;
this.price = _price;
this.author = _author;
}
//获得作者是谁
public String getAuthor() {
return this.author;
}
//书籍叫什么名字
public String getName() {
return this.name;
}
//获得书籍的价格
public int getPrice() {
return this.price;
}
}

注意 我们把价格定义为int类型并不是错误,在非金融类项目中对货币处理时,一般取2位精度,通常的设计方法是在运算过程中扩大100倍,在需要展示时再缩小100倍,减少精度带来的误差。


书店售书的过程如代码清单6-3所示。

代码清单6-3 书店售书类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//static静态模块初始化数据,实际项目中一般是由持久层完成
static{
bookList.add(new NovelBook("天龙八部",3200,"金庸"));
bookList.add(new NovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new NovelBook("悲惨世界",3500,"雨果"));
bookList.add(new NovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("-----------书店卖出去的书籍记录如下:-----------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + book.getAuthor()+"\t书籍价格:"+ formatter.format (book.getPrice()/ 100.0)+"元");
}
}
}

在BookStore中声明了一个静态模块,实现了数据的初始化,这部分应该是从持久层产生的,由持久层框架进行管理,运行结果如下:

1
2
3
4
5
-----------书店卖出去的书籍记录如下:-----------
书籍名称:天龙八部 书籍作者:金庸 书籍价格:¥32.00元
书籍名称:巴黎圣母院 书籍作者:雨果 书籍价格:¥56.00元
书籍名称:悲惨世界 书籍作者:雨果 书籍价格:¥35.00元
书籍名称:金瓶梅 书籍作者:兰陵笑笑生 书籍价格:¥43.00元

项目投产了,书籍正常销售出去,书店也赢利了。从2008年开始,全球经济开始下滑, 对零售业影响比较大,书店为了生存开始打折销售:所有40元以上的书籍9折销售,其他的8折销售。对已经投产的项目来说,这就是一个变化,我们应该如何应对这样一个需求变化? 有如下三种方法可以解决这个问题:

  • 修改接口

在IBook上新增加一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现该方法。但是这样修改的后果就是,实现类NovelBook要修改,BookStore中的main方法也修改,同时IBook作为接口应该是稳定且可靠的,不应该经常发生变化,否则接口作为契约的作用就失去了效能。因此,该方案否定。

  • 修改实现类

修改NovelBook类中的方法,直接在getPrice()中实现打折处理,好办法,我相信大家在项目中经常使用的就是这样的办法,通过class文件替换的方式可以完成部分业务变化(或是缺陷修复)。该方法在项目有明确的章程(团队内约束)或优良的架构设计时,是一个非常优秀的方法,但是该方法还是有缺陷的。例如采购书籍人员也是要看价格的,由于该方法已经实现了打折处理价格,因此采购人员看到的也是打折后的价格,会因信息不对称而出现决策失误的情况。因此,该方案也不是一个最优的方案。

  • 通过扩展实现变化

增加一个子类OffNovelBook,覆写getPrice方法,高层次的模块(也就是static静态模块区)通过OffNovelBook类产生新的对象,完成业务变化对系统的最小化开发。好办法,修改也少,风险也小,修改后的类图如图6-2所示。

image-20210927210114239

图6-2 扩展后的书店售书类图

OffNovelBook类继承了NovelBook,并覆写了getPrice方法,不修改原有的代码。新增加的子类OffNovelBook如代码清单6-4所示。

代码清单6-4 打折销售的小说类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class OffNovelBook extends NovelBook {
public OffNovelBook(String _name,int _price,String _author){
super(_name,_price,_author);
}
//覆写销售价格
@Override
public int getPrice(){
//原价
int selfPrice = super.getPrice();
int offPrice=0;

if(selfPrice>4000){
//原价大于40元,则打9折 offPrice = selfPrice * 90 /100;
}
else{
offPrice = selfPrice * 80 /100;
}
return offPrice;
}
}

很简单,仅仅覆写了getPrice方法,通过扩展完成了新增加的业务。书店类BookStore需要依赖子类,代码稍作修改,如代码清单6-5所示。

代码清单6-5 书店打折销售类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class BookStore {
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
//static静态模块初始化数据,实际项目中一般是由持久层完成
static{
bookList.add(new OffNovelBook("天龙八部",3200,"金庸"));
bookList.add(new OffNovelBook("巴黎圣母院",5600,"雨果"));
bookList.add(new OffNovelBook("悲惨世界",3500,"雨果"));
bookList.add(new OffNovelBook("金瓶梅",4300,"兰陵笑笑生"));
}
//模拟书店买书
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("-----------书店卖出去的书籍记录如下:-----------");
for(IBook book:bookList){
System.out.println("书籍名称:" + book.getName()+"\t书籍作者:" + book.getAuthor()+ "\t书籍价格:" + formatter.format (book.getPrice()/100.0)+"元");
}
}
}

我们只修改了粗体部分,其他的部分没有任何改动,运行结果如下所示。

1
2
3
4
5
----------------------书店卖出去的书籍记录如下:--------------------- 
书籍名称:天龙八部 书籍作者:金庸 书籍价格:¥25.60元
书籍名称:巴黎圣母院 书籍作者:雨果 书籍价格:¥50.40元
书籍名称:悲惨世界 书籍作者:雨果 书籍价格:¥28.00元
书籍名称:金瓶梅 书籍作者:兰陵笑笑生 书籍价格:¥38.70元

OK,打折销售开发完成了。看到这里,各位可能有想法了:增加了一个OffNoveBook类后,你的业务逻辑还是修改了,你修改了static静态模块区域。这部分确实修改了,该部分属于高层次的模块,是由持久层产生的,在业务规则改变的情况下高层模块必须有部分改变以适应新业务,改变要尽量地少,防止变化风险的扩散。


注意 开闭原则对扩展开放,对修改关闭,并不意味着不做任何修改,低层模块的变更,必然要有高层模块进行耦合,否则就是一个孤立无意义的代码片段。


我们可以把变化归纳为以下三种类型:

  • 逻辑变化

只变化一个逻辑,而不涉及其他模块,比如原有的一个算法是a*b+c,现在需要修改为a*b*c,可以通过修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。

  • 子模块变化

一个模块变化,会对其他的模块产生影响,特别是一个低层次的模块变化必然引起高层模块的变化,因此在通过扩展完成变化时,高层次的模块修改是必然的,刚刚的书籍打折处理就是类似的处理模块,该部分的变化甚至会引起界面的变化。

  • 可见视图变化

可见视图是提供给客户使用的界面,如JSP程序、Swing界面等,该部分的变化一般会引起连锁反应(特别是在国内做项目,做欧美的外包项目一般不会影响太大)。如果仅仅是界面上按钮、文字的重新排布倒是简单,最司空见惯的是业务耦合变化,什么意思呢?一个展示数据的列表,按照原有的需求是6列,突然有一天要增加1列,而且这一列要跨N张表,处理M个逻辑才能展现出来,这样的变化是比较恐怖的,但还是可以通过扩展来完成变化,这就要看我们原有的设计是否灵活。

我们再来回顾一下书店销售书籍的程序,首先是我们有一个还算灵活的设计(不灵活是什么样子?BookStore中所有使用到IBook的地方全部修改为实现类,然后再扩展一个ComputerBook书籍,你就知道什么是不灵活了);然后有一个需求变化,我们通过扩展一个子类拥抱了变化;最后把子类投入运行环境中,新逻辑正式投产。通过分析,我们发现并没有修改原有的模块代码,IBook接口没有改变,NovelBook类没有改变,这属于已有的业务代码,我们保持了历史的纯洁性。放弃修改历史的想法吧,一个项目的基本路径应该是这样的:项目开发、重构、测试、投产、运维,其中的重构可以对原有的设计和代码进行修改, 运维尽量减少对原有代码的修改,保持历史代码的纯洁性,提高系统的稳定性。

6.1 开闭原则的定义

在哲学上,矛盾法则即对立统一的法则,是唯物辩证法的最根本法则。本章要讲的开闭原则是不是也有同样的重要性且具有普遍性呢?确实,开闭原则是Java世界里最基础的设计原则,它指导我们如何建立一个稳定的、灵活的系统,先来看开闭原则的定义:

Software entities like classes,modules and functions should be open for extension but closed for modifications.(一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。

初看到这个定义,可能会很迷惑,对扩展开放?开放什么?对修改关闭,怎么关闭?没关系,我会一步一步带领大家解开这些疑惑。

我们做一件事情,或者选择一个方向,一般需要经历三个步骤:What——是什么,Why ——为什么,How——怎么做(简称3W原则,How取最后一个w)。对于开闭原则,我们也采用这三步来分析,即什么是开闭原则,为什么要使用开闭原则,怎么使用开闭原则。

5.3 最佳实践

迪米特法则的核心观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高。其要求的结果就是产生了大量的中转或跳转类,导致系统的复杂性提高,同时也为维护带来了难度。读者在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。

不知道大家有没有听过这样一个理论:“任何两个素不相识的人中间最多只隔着6个人, 即只通过6个人就可以将他们联系在一起”,这就是著名的“六度分隔理论”。如果将这个理论应用到我们的项目中,也就是说,我和我要调用的类之间最多有6次传递。呵呵,这只能让大家当个乐子来看,在实际应用中,如果一个类跳转两次以上才能访问到另一个类,就需要想办法进行重构了,为什么是两次以上呢?因为一个系统的成功不仅仅是一个标准或是原则就能够决定的,有非常多的外在因素决定,跳转次数越多,系统越复杂,维护就越困难,所以只要跳转不超过两次都是可以忍受的,这需要具体问题具体分析。

迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的0和1。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做项目。原则只是供参考,如果违背了这个原则,项目也未必会失败,这就需要大家在采用原则时反复度量,不遵循是不对的,严格执行就是“过犹不及”。

5.2 我的知识你知道得越少越好

迪米特法则对类的低耦合提出了明确的要求,其包含以下4层含义。

1. 只和朋友交流

迪米特法则还有一个英文解释是:Only talk to your immediate friends(只与直接的朋友通信。)什么叫做直接的朋友呢?每个对象都必然会与其他对象有耦合关系,两个对象之间的耦合就成为朋友关系,这种关系的类型有很多,例如组合、聚合、依赖等。下面我们将举例说明如何才能做到只与直接的朋友交流。

传说中有这样一个故事,老师想让体育委员确认一下全班女生来齐没有,就对他说:“你去把全班女生清一下。”体育委员没听清楚,就问道:“呀,……那亲哪个?”老师无语了,我们来看这个笑话怎么用程序来实现,类图如图5-1所示。

image-20210927203555499

图5-1 老师要求清点女生类图

Teacher类的command方法负责发送命令给体育委员,命令他清点女生,其实现过程如代码清单5-1所示。

代码清单5-1 老师类

1
2
3
4
5
6
7
8
9
10
11
12
public class Teacher {
//老师对学生发布命令,清一下女生
public void command(GroupLeader groupLeader){
List listGirls = new ArrayList();
//初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
//告诉体育委员开始执行清查任务
groupLeader.countGirls(listGirls);
}
}

老师只有一个方法command,先定义出所有的女生,然后发布命令给体育委员,去清点一下女生的数量。体育委员GroupLeader的实现过程如代码清单5-2所示。

代码清单5-2 体育委员类实现过程

1
2
3
4
5
6
public class GroupLeader {
//清查女生数量
public void countGirls(List<Girl> listGirls){
System.out.println("女生数量是:"+listGirls.size());
}
}

老师类和体育委员类都对女生类产生依赖,而且女生类不需要执行任何动作,因此定义一个空类,其实现过程如代码清单5-3所示。

代码清单5-3 女生类

1
2
public class Girl {
}

故事中的三个角色都已经有了,再定义一个场景类来描述这个故事,其实现过程如代码清单5-4所示。

代码清单5-4 场景类

1
2
3
4
5
6
7
public class Client {
public static void main(String[] args) {
Teacher teacher= new Teacher();
//老师发布命令
teacher.command(new GroupLeader());
}
}

运行结果如下所示:

1
女生数量是:20

体育委员按照老师的要求对女生进行了清点,并得出了数量。我们回过头来思考一下这个程序有什么问题,首先确定Teacher类有几个朋友类,它仅有一个朋友类—— GroupLeader。为什么Girl不是朋友类呢?Teacher也对它产生了依赖关系呀!朋友类的定义是这样的:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类,而Girl这个类就是出现在command方法体内,因此不属于Teacher类的朋友类。迪米特法则告诉我们一个类只和朋友类交流,但是我们刚刚定义的command方法却与Girl类有了交流,声明了一个List<Girls>动态数组,也就是与一个陌生的类Girl有了交流, 这样就破坏了Teacher的健壮性。方法是类的一个行为,类竟然不知道自己的行为与其他类产生依赖关系,这是不允许的,严重违反了迪米特法则。

问题已经发现,我们修改一下程序,将类图稍作修改,如图5-2所示。

image-20210927204026382

图5-2 修改后的类图

在类图中去掉Teacher对Girl类的依赖关系,修改后的Teacher类如代码清单5-5所示。

代码清单5-5 修改后的老师类

1
2
3
4
5
6
7
public class Teacher {
//老师对学生发布命令,清一下女生
public void command(GroupLeader groupLeader){
//告诉体育委员开始执行清查任务
groupLeader.countGirls();
}
}

修改后的GroupLeader类如代码清代5-6所示。

代码清单5-6 修改后的体育委员类

1
2
3
4
5
6
7
8
9
10
11
public class GroupLeader {
private List<Girl> listGirls;
//传递全班的女生进来
public GroupLeader(List<Girl> _listGirls){
this.listGirls = _listGirls;
}
//清查女生数量
public void countGirls(){
System.out.println("女生数量是:"+this.listGirls.size());
}
}

在GroupLeader类中定义了一个构造函数,通过构造函数传递了依赖关系。同时,对场景类也进行了一些修改,如代码清单5-7所示。

代码清单5-7 修改后的场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//产生一个女生群体
List<Girl> listGirls = new ArrayList<Girl>();
//初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
Teacher teacher= new Teacher();
//老师发布命令
teacher.command(new GroupLeader(listGirls));
}
}

对程序进行了简单的修改,把Teacher中对List<Girl>的初始化移动到了场景类中,同时在GroupLeader中增加了对Girl的注入,避开了Teacher类对陌生类Girl的访问,降低了系统间的耦合,提高了系统的健壮性。


注意 一个类只和朋友交流,不与陌生类交流,不要出现getA().getB().getC().getD()这种情况(在一种极端的情况下允许出现这种访问,即每一个点号后面的返回类型都相同),类与类之间的关系是建立在类间的,而不是方法间,因此一个方法尽量不引入一个类中不存在的对象,当然,JDK API提供的类除外。


2. 朋友间也是有距离的

人和人之间是有距离的,太远关系逐渐疏远,最终形同陌路;太近就相互刺伤。对朋友关系描述最贴切的故事就是:两只刺猬取暖,太远取不到暖,太近刺伤了对方,必须保持一个既能取暖又不刺伤对方的距离。迪米特法则就是对这个距离进行描述,即使是朋友类之间也不能无话不说,无所不知。

我们在安装软件的时候,经常会有一个导向动作,第一步是确认是否安装,第二步确认License,再然后选择安装目录……这是一个典型的顺序执行动作,具体到程序中就是:调用一个或多个类,先执行第一个方法,然后是第二个方法,根据返回结果再来看是否可以调用第三个方法,或者第四个方法,等等,其类图如图5-3所示。

image-20210927204354968

图5-3 软件安装过程类图

很简单的类图,实现软件安装的过程,其中first方法定义第一步做什么,second方法定义第二步做什么,third方法定义第三步做什么,其实现过程如代码清单5-8所示。

代码清单5-8 导向类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Wizard {
private Random rand = new Random(System.currentTimeMillis());
//第一步
public int first(){
System.out.println("执行第一个方法...");
return rand.nextInt(100);
}
//第二步
public int second(){
System.out.println("执行第二个方法...");
return rand.nextInt(100);
}
//第三个方法
public int third(){
System.out.println("执行第三个方法...");
return rand.nextInt(100);
}
}

在Wizard类中分别定义了三个步骤方法,每个步骤中都有相关的业务逻辑完成指定的任务,我们使用一个随机函数来代替业务执行的返回值。软件安装InstallSoftware类如代码清单5-9所示。

代码清单5-9 InstallSoftware类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class InstallSoftware {
public void installWizard(Wizard wizard){
int first = wizard.first();
//根据first返回的结果,看是否需要执行second
if(first>50){
int second = wizard.second();
if(second>50){
int third = wizard.third();
if(third >50){
wizard.first();
}
}
}
}
}

根据每个方法执行的结果决定是否继续执行下一个方法,模拟人工的选择操作。场景类如代码清单5-10所示。

代码清单5-10 场景类

1
2
3
4
5
6
public class Client {
public static void main(String[] args) {
InstallSoftware invoker = new InstallSoftware();
invoker.installWizard(new Wizard());
}
}

以上程序很简单,运行结果和随机数有关,每次的执行结果都不相同,需要读者自己运行并查看结果。程序虽然简单,但是隐藏的问题可不简单,思考一下程序有什么问题。 Wizard类把太多的方法暴露给InstallSoftware类,两者的朋友关系太亲密了,耦合关系变得异常牢固。如果要将Wizard类中的first方法返回值的类型由int改为boolean,就需要修改InstallSoftware类,从而把修改变更的风险扩散开了。因此,这样的耦合是极度不合适的,我们需要对设计进行重构,重构后的类图如图5-4所示。

image-20210927204707805

图5-4 重构后的软件安装过程类图

在Wizard类中增加一个installWizard方法,对安装过程进行封装,同时把原有的三个public方法修改为private方法,如代码清单5-11所示。

代码清单5-11 修改后的导向类实现过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Wizard {
private Random rand = new Random(System.currentTimeMillis());
//第一步
private int first(){
System.out.println("执行第一个方法...");
return rand.nextInt(100);
}
//第二步
private int second(){
System.out.println("执行第二个方法...");
return rand.nextInt(100);
}
//第三个方法
private int third(){
System.out.println("执行第三个方法...");

return rand.nextInt(100);
}
//软件安装过程
public void installWizard(){
int first = this.first();
//根据first返回的结果,看是否需要执行second
if(first>50){
int second = this.second();
if(second>50){
int third = this.third();
if(third >50){
this.first();
}
}
}
}
}

将三个步骤的访问权限修改为private,同时把InstallSoftware中的方法installWizad移动到Wizard方法中。通过这样的重构后,Wizard类就只对外公布了一个public方法,即使要修改first方法的返回值,影响的也仅仅只是Wizard本身,其他类不受影响,这显示了类的高内聚特性。

对InstallSoftware类进行少量的修改,如代码清单5-12所示。

代码清单5-12 修改后的InstallSoftware类

1
2
3
4
5
6
public class InstallSoftware {
public void installWizard(Wizard wizard){
//直接调用
wizard.installWizard();
}
}

场景类Client没有任何改变,如代码清单5-10所示。通过进行重构,类间的耦合关系变弱了,结构也清晰了,变更引起的风险也变小了。

一个类公开的public属性或方法越多,修改时涉及的面也就越大,变更引起的风险扩散也就越大。因此,为了保持朋友类间的距离,在设计时需要反复衡量:是否还可以再减少public方法和属性,是否可以修改为private、package-private(包类型,在类、方法、变量前不加访问权限,则默认为包类型)、protected等访问权限,是否可以加上final关键字等


注意 迪米特法则要求类“羞涩”一点,尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected等访问权限


3. 是自己的就是自己的

在实际应用中经常会出现这样一个方法:放在本类中也可以,放在其他类中也没有错, 那怎么去衡量呢?你可以坚持这样一个原则:如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,那就放置在本类中。

4. 谨慎使用Serializable

在实际应用中,这个问题是很少出现的,即使出现也会立即被发现并得到解决。是怎么回事呢?举个例子来说,在一个项目中使用RMI(Remote Method Invocation,远程方法调用)方式传递一个VO(Value Object,值对象),这个对象就必须实现Serializable接口(仅仅是一个标志性接口,不需要实现具体的方法),也就是把需要网络传输的对象进行序列化,否则就会出现NotSerializableException异常。突然有一天,客户端的VO修改了一个属性的访问权限,从private变更为public,访问权限扩大了,如果服务器上没有做出相应的变更,就会报序列化失败,就这么简单。但是这个问题的产生应该属于项目管理范畴,一个类或接口在客户端已经变更了,而服务器端却没有同步更新,难道不是项目管理的失职吗?

5.1 迪米特法则的定义

迪米特法则(Law of Demeter,LoD)也称为最少知识原则(Least Knowledge Principle,LKP),虽然名字不同,但描述的是同一个规则:一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)的内部是如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多public方法,我就调用这么多,其他的我一概不关心。

4.4 最佳实践

接口隔离原则是对接口的定义,同时也是对类的定义,接口和类尽量使用原子接口或原子类来组装。但是,这个原子该怎么划分是设计模式中的一大难题,在实践中可以根据以下几个规则来衡量:

  • 一个接口只服务于一个子模块或业务逻辑;
  • 通过业务逻辑压缩接口中的public方法,接口时常去回顾,尽量让接口达到“满身筋骨肉”,而不是“肥嘟嘟”的一大堆方法;
  • 已经被污染了的接口,尽量去修改,若变更的风险较大,则采用适配器模式进行转化处理;
  • 了解环境,拒绝盲从。每个项目或产品都有特定的环境因素,别看到大师是这样做的你就照抄。千万别,环境不同,接口拆分的标准就不同。深入了解业务逻辑,最好的接口设计就出自你的手中!

接口隔离原则和其他设计原则一样,都需要花费较多的时间和精力来进行设计和筹划, 但是它带来了设计的灵活性,让你可以在业务人员提出“无理”要求时轻松应付。贯彻使用接口隔离原则最好的方法就是一个接口一个方法,保证绝对符合接口隔离原则(有可能不符合单一职责原则),但你会采用吗?不会,除非你是疯子!那怎么才能正确地使用接口隔离原则呢?答案是根据经验和常识决定接口的粒度大小,接口粒度太小,导致接口数据剧增,开发人员呛死在接口的海洋里;接口粒度太大,灵活性降低,无法提供定制服务,给整体项目带来无法预料的风险。

怎么准确地实践接口隔离原则?实践、经验和领悟!

4.3 保证接口的纯洁性

接口隔离原则是对接口进行规范约束,其包含以下4层含义:

接口要尽量小

这是接口隔离原则的核心定义,不出现臃肿的接口(Fat Interface),但是“小”是有限度的,首先就是不能违反单一职责原则,什么意思呢?我们在单一职责原则中提到一个IPhone 的例子,在这里,我们使用单一职责原则把两个职责分解到两个接口中,类图如图4-3所示。

image-20210927202320347

图4-3 电话类图

仔细分析一下IConnectionManager接口是否还可以再继续拆分下去,挂电话有两种方式:一种是正常的电话挂断,一种是电话异常挂机,比如突然没电了,通信当然就断了。这两种方式的处理应该是不同的,为什么呢?正常挂电话,对方接受到挂机信号,计费系统也就停止计费了,那手机没电了这种方式就不同了,它是信号丢失了,中继服务器检查到了, 然后通知计费系统停止计费,否则你的费用不是要疯狂地增长了吗?

思考到这里,我们是不是就要动手把IConnectionManager接口拆封成两个,一个接口是负责连接,一个接口是负责挂电话?是要这样做吗?且慢,让我们再思考一下,如果拆分了,那就不符合单一职责原则了,因为从业务逻辑上来讲,通信的建立和关闭已经是最小的业务单位了,再细分下去就是对业务或是协议(其他业务逻辑)的拆分了。想想看,一个电话要关心3G协议,要考虑中继服务器,等等,这个电话还怎么设计得出来呢?从业务层次来看,这样的设计就是一个失败的设计。一个原则要拆,一个原则又不要拆,那该怎么办? 好办,根据接口隔离原则拆分接口时,首先必须满足单一职责原则

接口要高内聚

什么是高内聚?高内聚就是提高接口、类、模块的处理能力,减少对外的交互。比如你告诉下属“到奥巴马的办公室偷一个×××文件”,然后听到下属用坚定的口吻回答你:“是,保证完成任务!”一个月后,你的下属还真的把×××文件放到你的办公桌上了,这种不讲任何条件、立刻完成任务的行为就是高内聚的表现。具体到接口隔离原则就是,要求在接口中尽量少公布public方法,接口是对外的承诺,承诺越少对系统的开发越有利,变更的风险也就越少,同时也有利于降低成本。

定制服务

一个系统或系统内的模块之间必然会有耦合,有耦合就要有相互访问的接口(并不一定就是Java中定义的Interface,也可能是一个类或单纯的数据交换),我们设计时就需要为各个访问者(即客户端)定制服务,什么是定制服务?定制服务就是单独为一个个体提供优良的服务。我们在做系统设计时也需要考虑对系统之间或模块之间的接口采用定制服务。采用定制服务就必然有一个要求:只提供访问者需要的方法,这是什么意思?我们举个例子来说明,比如我们开发了一个图书管理系统,其中有一个查询接口,方便管理员查询图书,其类图如图4-4所示。

image-20210927202534740

图4-4 图书查询类图

在接口中定义了多个查询方法,分别可以按照作者、标题、出版社、分类进行查询,最后还提供了混合查询方式。程序写好了,投产上线了,突然有一天发现系统速度非常慢,然后就开始痛苦地分析,最终发现是访问接口中的complexSearch(Map map)方法并发量太大, 导致应用服务器性能下降,然后继续跟踪下去发现这些查询都是从公网上发起的,进一步分析,找到问题:提供给公网(公网项目是另外一个项目组开发的)的查询接口和提供给系统内管理人员的接口是相同的,都是IBookSearcher接口,但是权限不同,系统管理人员可以通过接口的complexSearch方法查询到所有的书籍,而公网的这个方法是被限制的,不返回任何值,在设计时通过口头约束,这个方法是不可被调用的,但是由于公网项目组的疏忽,这个方法还是公布了出去,虽然不能返回结果,但是还是引起了应用服务器的性能巨慢的情况发生,这就是一个臃肿接口引起性能故障的案例。

问题找到了,就需要把这个接口进行重构,将IBookSearcher拆分为两个接口,分别为两个模块提供定制服务,修改后的类图如图4-5所示。

image-20210927202633304

图4-5 修改后的图书查询类图

提供给管理人员的实现类同时实现了ISimpleBookSearcher和IComplexBookSearcher两个接口,原有程序不用做任何改变,而提供给公网的接口变为ISimpleBookSearcher,只允许进行简单的查询,单独为其定制服务,减少可能引起的风险。

接口设计是有限度的

接口的设计粒度越小,系统越灵活,这是不争的事实。但是,灵活的同时也带来了结构的复杂化,开发难度增加,可维护性降低,这不是一个项目或产品所期望看到的,所以接口设计一定要注意适度,这个“度”如何来判断呢?根据经验和常识判断,没有一个固化或可测量的标准。

4.2 美女何其多 观点各不同

我们举例来说明接口隔离原则到底对我们提出了什么要求。现在男生对小姑娘的称呼, 使用频率最高的应该是“美女”了吧,你在大街上叫一声:“嗨,美女!”估计10个有8个回头,其中包括那位著名的如花。美女的标准各不相同,首先就需要定义一下什么是美女:首先要面貌好看,其次是身材要窈窕,然后要有气质,当然了,这三者各人的排列顺序不一样,总之要成为一名美女就必须具备:面貌、身材和气质,我们用类图体现一下星探(当然,你也可以把自己想象成星探)找美女的过程,如图4-1所示。

image-20210927201425027

图4-1 星探寻找美女的类图

定义了一个IPettyGirl接口,声明所有的美女都应该有goodLooking、niceFigure和great- Temperament,然后又定义了一个抽象类AbstractSearcher,其作用就是搜索美女并显示其信息,只要美女都按照这个规范定义,Searcher(星探)就轻松多了,美女类的实现如代码清单4-1所示。

代码清单4-1 美女类

1
2
3
4
5
6
7
8
9
public interface IPettyGirl {
//要有姣好的面孔
public void goodLooking();
//要有好身材
public void niceFigure();
//要有气质
public void greatTemperament();

}

美女的标准定义完毕,具体的美女实现类如代码清单4-2所示。

代码清单4-2 美女实现类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class PettyGirl implements IPettyGirl {
private String name;
//美女都有名字
public PettyGirl(String _name){
this.name=_name;
}
//脸蛋漂亮
public void goodLooking() {
System.out.println(this.name + "---脸蛋很漂亮!");
}
//气质要好
public void greatTemperament() {
System.out.println(this.name + "---气质非常好!");
}
//身材要好
public void niceFigure() {
System.out.println(this.name + "---身材非常棒!");
}
}

通过三个方法,把对美女的要求都定义出来了,按照这个标准,如花姑娘被排除在美女标准之外了。有美女,就有搜索美女的星探,其具体实现如代码清单4-3所示。

代码清单4-3 星探抽象类源代码

1
2
3
4
5
6
7
8
public abstract class AbstractSearcher {
protected IPettyGirl pettyGirl;
public AbstractSearcher(IPettyGirl _pettyGirl){
this.pettyGirl = _pettyGirl;
}
//搜索美女,列出美女信息
public abstract void show();
}

星探的实现类就比较简单了,其源代码如代码清单4-4所示。

代码清单4-4 星探类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Searcher extends AbstractSearcher{
public Searcher(IPettyGirl _pettyGirl){
super(_pettyGirl);
}

//展示美女的信息
public void show(){
System.out.println("--------美女的信息如下:---------------");
//展示面容
super.pettyGirl.goodLooking();
//展示身材
super.pettyGirl.niceFigure();
//展示气质
super.pettyGirl.greatTemperament();
}
}

场景中的两个角色美女和星探都已经出现了,需要写一个场景类来串联起各个角色,场景类的实现如代码清单4-5所示。

代码清单4-5 场景类

1
2
3
4
5
6
7
8
9
public class Client {
//搜索并展示美女信息
public static void main(String[] args) {
//定义一个美女
IPettyGirl yanYan = new PettyGirl("嫣嫣");
AbstractSearcher searcher = new Searcher(yanYan);
searcher.show();
}
}

星探搜索美女的运行结果如下所示:

1
2
3
4
--------美女的信息如下:--------------- 
嫣嫣---脸蛋很漂亮!
嫣嫣---身材非常棒!
嫣嫣---气质非常好!

星探寻找美女的程序开发完毕了,运行结果也正确。我们回头来想想这个程序有没有问题,思考一下IPettyGirl这个接口,这个接口是否做到了最优化设计?答案是没有,还可以对接口进行优化。

我们的审美观点都在改变,美女的定义也在变化。唐朝的杨贵妃如果活在现在这个年代非羞愧而死不可,为什么?胖呀!但是胖并不影响她入选中国四大美女,说明当时的审美观与现在是有差异的。当然,随着时代的发展我们的审美观也在变化,当你发现有一个女孩,脸蛋不怎么样,身材也一般般,但是气质非常好,我相信大部分人都会把这样的女孩叫美女,审美素质提升了,就产生了气质型美女,但是我们的接口却定义了美女必须是三者都具备,按照这个标准,气质型美女就不能算美女,那怎么办?可能你要说了,我重新扩展一个美女类,只实现greatTemperament方法,其他两个方法置空,什么都不写,不就可以了吗? 聪明,但是行不通!为什么呢?星探AbstractSearcher依赖的是IPettyGirl接口,它有三个方法,你只实现了两个方法,星探的方法是不是要修改?我们上面的程序打印出来的信息少了两条,还让星探怎么去辨别是不是美女呢?

分析到这里,我们发现接口IPettyGirl的设计是有缺陷的,过于庞大了,容纳了一些可变的因素,根据接口隔离原则,星探AbstractSearcher应该依赖于具有部分特质的女孩子,而我们却把这些特质都封装了起来,放到了一个接口中,封装过度了!问题找到了,我们重新设计一下类图,修改后的类图如图4-2所示。

把原IPettyGirl接口拆分为两个接口,一种是外形美的美女IGoodBodyGirl,这类美女的特点就是脸蛋和身材极棒,超一流,但是没有审美素质,比如随地吐痰,文化程度比较低;另外一种是气质美的美女IGreatTemperamentGirl,谈吐和修养都非常高。我们把一个比较臃肿的接口拆分成了两个专门的接口,灵活性提高了,可维护性也增加了,不管以后是要外形美的美女还是气质美的美女都可以轻松地通过PettyGirl定义。两种类型的美女定义如代码清单4-6所示。

image-20210927202017223

图4-2 修改后的星探寻找美女类图

代码清单4-6 两种类型的美女定义

1
2
3
4
5
6
7
8
9
10
public interface IGoodBodyGirl {
//要有姣好的面孔
public void goodLooking();
//要有好身材
public void niceFigure();
}
public interface IGreatTemperamentGirl {
//要有气质
public void greatTemperament();
}

按照脸蛋、身材、气质都具备才算美女,实现类实现两个接口,如代码清单4-7所示。

代码清单4-7 最标准的美女

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class PettyGirl implements IGoodBodyGirl,IGreatTemperamentGirl {
private String name;
//美女都有名字
public PettyGirl(String _name){
this.name=_name;
}
//脸蛋漂亮
public void goodLooking() {
System.out.println(this.name + "---脸蛋很漂亮!");
}
//气质要好
public void greatTemperament() {
System.out.println(this.name + "---气质非常好!");
}

//身材要好
public void niceFigure() {
System.out.println(this.name + "---身材非常棒!");
}
}

通过这样的重构以后,不管以后是要气质美女还是要外形美女,都可以保持接口的稳定。当然,你可能要说了,以后可能审美观点再发生改变,只有脸蛋好看就是美女,那这个IGoodBody接口还是要修改的呀,确实是,但是设计是有限度的,不能无限地考虑未来的变更情况,否则就会陷入设计的泥潭中而不能自拔。

以上把一个臃肿的接口变更为两个独立的接口所依赖的原则就是接口隔离原则,让星探AbstractSearcher依赖两个专用的接口比依赖一个综合的接口要灵活。接口是我们设计时对外提供的契约,通过分散定义多个接口,可以预防未来变更的扩散,提高系统的灵活性和可维护性。

4.1 接口隔离原则的定义

在讲接口隔离原则之前,先明确一下我们的主角——接口。接口分为两种:

  • 实例接口(Object Interface),在Java中声明一个类,然后用new关键字产生一个实例,它是对一个类型的事物的描述,这是一种接口。比如你定义Person这个类,然后使用Person zhangSan=new Person()产生了一个实例,这个实例要遵从的标准就是Person这个类,Person类就是zhangSan的接口。疑惑?看不懂?不要紧,那是因为让Java语言浸染的时间太长了,只要知道从这个角度来看,Java中的类也是一种接口。
  • 类接口(Class Interface),Java中经常使用的interface关键字定义的接口。

主角已经定义清楚了,那什么是隔离呢?它有两种定义,如下所示:

  • Clients should not be forced to depend upon interfaces that they don’t use.(客户端不应该依赖它不需要的接口。)
  • The dependency of one class to another one should depend on the smallest possible interface.(类间的依赖关系应该建立在最小的接口上。)

新事物的定义一般都比较难理解,晦涩难懂是正常的。我们把这两个定义剖析一下,先说第一种定义:“客户端不应该依赖它不需要的接口”,那依赖什么?依赖它需要的接口,客户端需要什么接口就提供什么接口,把不需要的接口剔除掉,那就需要对接口进行细化,保证其纯洁性;再看第二种定义:“类间的依赖关系应该建立在最小的接口上”,它要求是最小的接口,也是要求接口细化,接口纯洁,与第一个定义如出一辙,只是一个事物的两种不同描述。

我们可以把这两个定义概括为一句话:建立单一接口,不要建立臃肿庞大的接口。再通俗一点讲:接口尽量细化,同时接口中的方法尽量少。看到这里大家有可能要疑惑了,这与单一职责原则不是相同的吗?错,接口隔离原则与单一职责的审视角度是不相同的,单一职责要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分,而接口隔离原则要求接口的方法尽量少。例如一个接口的职责可能包含10个方法,这10个方法都放在一个接口中,并且提供给多个模块访问,各个模块按照规定的权限来访问,在系统外通过文档约束“不使用的方法不要访问”,按照单一职责原则是允许的,按照接口隔离原则是不允许的, 因为它要求“尽量使用多个专门的接口”。专门的接口指什么?就是指提供给每个模块的都应该是单一接口,提供给几个模块就应该有几个接口,而不是建立一个庞大的臃肿的接口,容纳所有的客户端访问。