DesignTemplate

本文介绍了软件设计的准则和常用的设计模式

01. 代码质量常用的评价标准

仔细看所有代码质量评价标准,你会发现,有些词语过于笼统、抽象,比较偏向对于整体的描述,比如优雅、好、坏、整洁、清晰等;有些过于细节、偏重方法论,比如模块化、高内聚低耦合、文档详尽、分层清晰等;有些可能并不仅仅局限于编码,跟架构设计等也有关系,比如可伸缩性、可用性、稳定性等。

挑选了其中几个最常用的、最重要的评价标准,来详细讲解,其中就包括:可维护性、可读性、可扩展性、灵活性、简洁性(简单、复杂)、可复用性、可测试性。

1.可维护性(maintainability)

落实到编码开发,所谓的“维护”无外乎就是修改bug、修改老的代码、添加新的代码之类的工作。所谓“代码易维护”就是指,在不破坏原有代码设计、不引入新的bug的情况下,能够快速地修改或者添加代码。所谓“代码不易维护”就是指,修改或者添加代码需要冒着极大的引入新bug的风险,并且需要花费很长的时间才能完成。

我们知道,对于一个项目来说,维护代码的时间远远大于编写代码的时间。工程师大部分的时间可能都是花在修修bug、改改老的功能逻辑、添加一些新的功能逻辑之类的工作上。所以,代码的可维护性就显得格外重要。

维护、易维护、不易维护这三个概念不难理解。不过,对于实际的软件开发来说,更重要的是搞清楚,如何来判断代码可维护性的好坏。

实际上,可维护性也是一个很难量化、偏向对代码整体的评价标准,它有点类似之前提到的“好”“坏”“优雅”之类的笼统评价。代码的可维护性是由很多因素协同作用的结果。代码的可读性好、简洁、可扩展性好,就会使得代码易维护;相反,就会使得代码不易维护。更细化地讲,如果代码分层清晰、模块化好、高内聚低耦合、遵从基于接口而非实现编程的设计原则等等,那就可能意味着代码易维护。除此之外,代码的易维护性还跟项目代码量的多少、业务的复杂程度、利用到的技术的复杂程度、文档是否全面、团队成员的开发水平等诸多因素有关。

所以,从正面去分析一个代码是否易维护稍微有点难度。不过,我们可以从侧面上给出一个比较主观但又比较准确的感受。如果bug容易修复,修改、添加功能能够轻松完成,那我们就可以主观地认为代码对我们来说易维护。相反,如果修改一个bug,修改、添加一个功能,需要花费很长的时间,那我们就可以主观地认为代码对我们来说不易维护。

你可能会说,这样的评价方式也太主观了吧?没错,是否易维护本来就是针对维护的人来说的。不同水平的人对于同一份代码的维护能力并不是相同的。对于同样一个系统,熟悉它的资深工程师会觉得代码的可维护性还不错,而一些新人因为不熟悉代码,修改bug、修改添加代码要花费很长的时间,就有可能会觉得代码的可维护性不那么好。这实际上也印证了我们之前的观点:代码质量的评价有很强的主观性。

2.可读性(readability)

软件设计大师Martin Fowler曾经说过:“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”翻译成中文就是:“任何傻瓜都会编写计算机能理解的代码。好的程序员能够编写人能够理解的代码。”Google内部甚至专门有个认证就叫作Readability。只有拿到这个认证的工程师,才有资格在code review的时候,批准别人提交代码。可见代码的可读性有多重要,毕竟,代码被阅读的次数远远超过被编写和执行的次数。

我个人认为,代码的可读性应该是评价代码质量最重要的指标之一。我们在编写代码的时候,时刻要考虑到代码是否易读、易理解。除此之外,代码的可读性在非常大程度上会影响代码的可维护性。毕竟,不管是修改bug,还是修改添加功能代码,我们首先要做的事情就是读懂代码。代码读不大懂,就很有可能因为考虑不周全,而引入新的bug。

既然可读性如此重要,那我们又该如何评价一段代码的可读性呢?

我们需要看代码是否符合编码规范、命名是否达意、注释是否详尽、函数是否长短合适、模块划分是否清晰、是否符合高内聚低耦合等等。你应该也能感觉到,从正面上,我们很难给出一个覆盖所有评价指标的列表。这也是我们无法量化可读性的原因。

实际上,code review是一个很好的测验代码可读性的手段。如果你的同事可以轻松地读懂你写的代码,那说明你的代码可读性很好;如果同事在读你的代码时,有很多疑问,那就说明你的代码可读性有待提高了。

3.可扩展性(extensibility)

可扩展性也是一个评价代码质量非常重要的标准。它表示我们的代码应对未来需求变化的能力。跟可读性一样,代码是否易扩展也很大程度上决定代码是否易维护。那到底什么是代码的可扩展性呢?

代码的可扩展性表示,我们在不修改或少量修改原有代码的情况下,通过扩展的方式添加新的功能代码。说直白点就是,代码预留了一些功能扩展点,你可以把新功能代码,直接插到扩展点上,而不需要因为要添加一个功能而大动干戈,改动大量的原始代码。

关于代码的扩展性,在后面讲到“对修改关闭,对扩展开放”这条设计原则的时候,我会来详细讲解,今天我们只需要知道,代码的可扩展性是评价代码质量非常重要的标准就可以了。

4.灵活性(flexibility)

灵活性也是描述代码质量的一个常用词汇。比如我们经常会听到这样的描述:“代码写得很灵活”。那这里的“灵活”该如何理解呢?

尽管有很多人用这个词汇来描述代码的质量。但实际上,灵活性是一个挺抽象的评价标准,要给灵活性下个定义也是挺难的。不过,我们可以想一下,什么情况下我们才会说代码写得好灵活呢?我这里罗列了几个场景,希望能引发你自己对什么是灵活性的思考。

  • 当我们添加一个新的功能代码的时候,原有的代码已经预留好了扩展点,我们不需要修改原有的代码,只要在扩展点上添加新的代码即可。这个时候,我们除了可以说代码易扩展,还可以说代码写得好灵活。
  • 当我们要实现一个功能的时候,发现原有代码中,已经抽象出了很多底层可以复用的模块、类等代码,我们可以拿来直接使用。这个时候,我们除了可以说代码易复用之外,还可以说代码写得好灵活。
  • 当我们使用某组接口的时候,如果这组接口可以应对各种使用场景,满足各种不同的需求,我们除了可以说接口易用之外,还可以说这个接口设计得好灵活或者代码写得好灵活。

从刚刚举的场景来看,如果一段代码易扩展、易复用或者易用,我们都可以称这段代码写得比较灵活。所以,灵活这个词的含义非常宽泛,很多场景下都可以使用。

5.简洁性(simplicity)

有一条非常著名的设计原则,你一定听过,那就是KISS原则:“Keep It Simple,Stupid”。这个原则说的意思就是,尽量保持代码简单。代码简单、逻辑清晰,也就意味着易读、易维护。我们在编写代码的时候,往往也会把简单、清晰放到首位。

不过,很多编程经验不足的程序员会觉得,简单的代码没有技术含量,喜欢在项目中引入一些复杂的设计模式,觉得这样才能体现自己的技术水平。实际上,思从深而行从简,真正的高手能云淡风轻地用最简单的方法解决最复杂的问题。这也是一个编程老手跟编程新手的本质区别之一。

除此之外,虽然我们都能认识到,代码要尽量写得简洁,符合KISS原则,但怎么样的代码才算足够简洁?不是每个人都能很准确地判断出来这一点。所以,在后面的章节中,当我们讲到KISS原则的时候,我会通过具体的代码实例,详细给你解释,“为什么KISS原则看似非常简单、好理解,但实际上用好并不容易”。今天,我们就暂且不展开详细讲解了。

6.可复用性(reusability)

代码的可复用性可以简单地理解为,尽量减少重复代码的编写,复用已有的代码。在后面的很多章节中,我们都会经常提到“可复用性”这一代码评价标准。

比如,当讲到面向对象特性的时候,我们会讲到继承、多态存在的目的之一,就是为了提高代码的可复用性;当讲到设计原则的时候,我们会讲到单一职责原则也跟代码的可复用性相关;当讲到重构技巧的时候,我们会讲到解耦、高内聚、模块化等都能提高代码的可复用性。可见,可复用性也是一个非常重要的代码评价标准,是很多设计原则、思想、模式等所要达到的最终效果。

实际上,代码可复用性跟DRY(Don’t Repeat Yourself)这条设计原则的关系挺紧密的,所以,在后面的章节中,当我们讲到DRY设计原则的时候,我还会讲更多代码复用相关的知识,比如,“有哪些编程方法可以提高代码的复用性”等。

7.可测试性(testability)

相对于前面六个评价标准,代码的可测试性是一个相对较少被提及,但又非常重要的代码质量评价标准。代码可测试性的好坏,能从侧面上非常准确地反应代码质量的好坏。代码的可测试性差,比较难写单元测试,那基本上就能说明代码设计得有问题。关于代码的可测试性,我们在重构那一部分,会花两节课的时间来详细讲解。现在,你暂时只需要知道,代码的可测试性非常重要就可以了。

02. 面向对象、设计原则、设计模式、编程规范、重构

面向对象

现在,主流的编程范式或者是编程风格有三种,它们分别是面向过程、面向对象和函数式编程。面向对象这种编程风格又是这其中最主流的。现在比较流行的编程语言大部分都是面向对象编程语言。大部分项目也都是基于面向对象编程风格开发的。面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式编码实现的基础。

主要有下面这7个大的知识点。

  • 面向对象的四大特性:封装、抽象、继承、多态
  • 面向对象编程与面向过程编程的区别和联系
  • 面向对象分析、面向对象设计、面向对象编程
  • 接口和抽象类的区别以及各自的应用场景
  • 基于接口而非实现编程的设计思想
  • 多用组合少用继承的设计思想
  • 面向过程的贫血模型和面向对象的充血模型

设计原则

设计原则是指导我们代码设计的一些经验总结。设计原则这块儿的知识有一个非常大的特点,那就是这些原则听起来都比较抽象,定义描述都比较模糊,不同的人会有不同的解读。所以,如果单纯地去记忆定义,对于编程、设计能力的提高,意义并不大。对于每一种设计原则,我们需要掌握它的设计初衷,能解决哪些编程问题,有哪些应用场景。只有这样,我们才能在项目中灵活恰当地应用这些原则。

对于这一部分内容,你需要透彻理解并且掌握,如何应用下面这样几个常用的设计原则。

  • SOLID原则-SRP单一职责原则
  • SOLID原则-OCP开闭原则
  • SOLID原则-LSP里式替换原则
  • SOLID原则-ISP接口隔离原则
  • SOLID原则-DIP依赖倒置原则
  • DRY原则、KISS原则、YAGNI原则、LOD法则

设计模式

设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。大部分设计模式要解决的都是代码的可扩展性问题。设计模式相对于设计原则来说,没有那么抽象,而且大部分都不难理解,代码实现也并不复杂。这一块的学习难点是了解它们都能解决哪些问题,掌握典型的应用场景,并且懂得不过度应用。

经典的设计模式有23种。随着编程语言的演进,一些设计模式(比如Singleton)也随之过时,甚至成了反模式,一些则被内置在编程语言中(比如Iterator),另外还有一些新的模式诞生(比如Monostate)。

在专栏中,我们会重点讲解23种经典的设计模式。它们又可以分为三大类:创建型、结构型、行为型。对于这23种设计模式的学习,我们要有侧重点,因为有些模式是比较常用的,有些模式是很少被用到的。对于常用的设计模式,我们要花多点时间理解掌握。对于不常用的设计模式,我们只需要稍微了解即可。

我按照类型和是否常用,对专栏中讲到的这些设计模式,进行了简单的分类,具体如下所示。

  • 创建型
    • 常用的有:单例模式、工厂模式(工厂方法和抽象工厂)、建造者模式。
    • 不常用的有:原型模式。
  • 结构型
    • 常用的有:代理模式、桥接模式、装饰者模式、适配器模式。
    • 不常用的有:门面模式、组合模式、享元模式。
  • 行为型
    • 常用的有:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式。
    • 不常用的有:访问者模式、备忘录模式、命令模式、解释器模式、中介模式。

编程规范

编程规范主要解决的是代码的可读性问题。编码规范相对于设计原则、设计模式,更加具体、更加偏重代码细节。即便你可能对设计原则不熟悉、对设计模式不了解,但你最起码要掌握基本的编码规范,比如,如何给变量、类、函数命名,如何写代码注释,函数不宜过长、参数不能过多等等。

对于编码规范,考虑到很多书籍已经讲得很好了(比如《重构》《代码大全》《代码整洁之道》等)。而且,每条编码规范都非常简单、非常明确,比较偏向于记忆,你只要照着来做可以。它不像设计原则,需要融入很多个人的理解和思考。所以,在这个专栏中,我并没有花太多的篇幅来讲解所有的编码规范,而是总结了我认为的最能改善代码质量的20条规范。如果你暂时没有时间去看那些经典的书籍,看我这些就够了。

除此之外,专栏并没有将编码规范单独作为一个模块来讲解,而是跟重构放到了一起。之所以这样做,那是因为我把重构分为大重构和小重构两种类型,而小重构利用的知识基本上就是编码规范。

除了编码规范,我们还会介绍一些代码的坏味道,让你知道什么样的代码是不符合规范的,应该如何优化。参照编码规范,你可以写出可读性好的代码;参照代码的坏味道,你可以找出代码存在的可读性问题。

代码重构

在软件开发中,只要软件在不停地迭代,就没有一劳永逸的设计。随着需求的变化,代码的不停堆砌,原有的设计必定会存在这样那样的问题。针对这些问题,我们就需要进行代码重构。重构是软件开发中非常重要的一个环节。持续重构是保持代码质量不下降的有效手段,能有效避免代码腐化到无可救药的地步。

而重构的工具就是我们前面罗列的那些面向对象设计思想、设计原则、设计模式、编码规范。实际上,设计思想、设计原则、设计模式一个最重要的应用场景就是在重构的时候。我们前面讲过,虽然使用设计模式可以提高代码的可扩展性,但过度不恰当地使用,也会增加代码的复杂度,影响代码的可读性。在开发初期,除非特别必须,我们一定不要过度设计,应用复杂的设计模式。而是当代码出现问题的时候,我们再针对问题,应用原则和模式进行重构。这样就能有效避免前期的过度设计。

对于重构这部分内容,你需要掌握以下几个知识点:

  • 重构的目的(why)、对象(what)、时机(when)、方法(how);
  • 保证重构不出错的技术手段:单元测试和代码的可测试性;
  • 两种不同规模的重构:大重构(大规模高层次)和小重构(小规模低层次)。

希望你学完这部分内容之后,不仅仅是掌握一些重构技巧、套路,更重要的是建立持续重构意识,把重构当作开发的一部分,融入到日常的开发中。

五者之间的联系

关于面向对象、设计原则、设计模式、编程规范和代码重构,这五者的关系总结梳理:

  • 面向对象编程因为其具有丰富的特性(封装、抽象、继承、多态),可以实现很多复杂的设计思路,是很多设计原则、设计模式等编码实现的基础。
  • 设计原则是指导我们代码设计的一些经验总结,对于某些场景下,是否应该应用某种设计模式,具有指导意义。比如,“开闭原则”是很多设计模式(策略、模板等)的指导原则。
  • 设计模式是针对软件开发中经常遇到的一些设计问题,总结出来的一套解决方案或者设计思路。应用设计模式的主要目的是提高代码的可扩展性。从抽象程度上来讲,设计原则比设计模式更抽象。设计模式更加具体、更加可执行。
  • 编程规范主要解决的是代码的可读性问题。编码规范相对于设计原则、设计模式,更加具体、更加偏重代码细节、更加能落地。持续的小重构依赖的理论基础主要就是编程规范。
  • 重构作为保持代码质量不下降的有效手段,利用的就是面向对象、设计原则、设计模式、编码规范这些理论。

实际上,面向对象、设计原则、设计模式、编程规范、代码重构,这五者都是保持或者提高代码质量的方法论,本质上都是服务于编写高质量代码这一件事的。当我们追本逐源,看清这个本质之后,很多事情怎么做就清楚了,很多选择怎么选也清楚了。比如,在某个场景下,该不该用这个设计模式,那就看能不能提高代码的可扩展性;要不要重构,那就看重代码是否存在可读、可维护问题等。

03. 封装、抽象、继承、多态

封装(Encapsulation)

首先,我们来看封装特性。封装也叫作信息隐藏或者数据访问保护。类通过暴露有限的访问接口,授权外部仅能通过类提供的方式(或者叫函数)来访问内部信息或者数据。这句话怎么理解呢?我们通过一个简单的例子来解释一下。

下面这段代码是金融系统中一个简化版的虚拟钱包的代码实现。在金融系统中,我们会给每个用户创建一个虚拟钱包,用来记录用户在我们的系统中的虚拟货币量。

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
34
35
36
37
38
39
public class Wallet {
private String id;
private long createTime;
private BigDecimal balance;
private long balanceLastModifiedTime;
// ...省略其他属性...

public Wallet() {
this.id = IdGenerator.getInstance().generate();
this.createTime = System.currentTimeMillis();
this.balance = BigDecimal.ZERO;
this.balanceLastModifiedTime = System.currentTimeMillis();
}

// 注意:下面对get方法做了代码折叠,是为了减少代码所占文章的篇幅
public String getId() { return this.id; }
public long getCreateTime() { return this.createTime; }
public BigDecimal getBalance() { return this.balance; }
public long getBalanceLastModifiedTime() { return this.balanceLastModifiedTime; }

public void increaseBalance(BigDecimal increasedAmount) {
if (increasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
this.balance.add(increasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}

public void decreaseBalance(BigDecimal decreasedAmount) {
if (decreasedAmount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException("...");
}
if (decreasedAmount.compareTo(this.balance) > 0) {
throw new InsufficientAmountException("...");
}
this.balance.subtract(decreasedAmount);
this.balanceLastModifiedTime = System.currentTimeMillis();
}
}

从代码中,我们可以发现,Wallet类主要有四个属性(也可以叫作成员变量),也就是我们前面定义中提到的信息或者数据。其中,id表示钱包的唯一编号,createTime表示钱包创建的时间,balance表示钱包中的余额,balanceLastModifiedTime表示上次钱包余额变更的时间。

我们参照封装特性,对钱包的这四个属性的访问方式进行了限制。调用者只允许通过下面这六个方法来访问或者修改钱包里的数据。

  • String getId()
  • long getCreateTime()
  • BigDecimal getBalance()
  • long getBalanceLastModifiedTime()
  • void increaseBalance(BigDecimal increasedAmount)
  • void decreaseBalance(BigDecimal decreasedAmount)

之所以这样设计,是因为从业务的角度来说,id、createTime在创建钱包的时候就确定好了,之后不应该再被改动,所以,我们并没有在Wallet类中,暴露id、createTime这两个属性的任何修改方法,比如set方法。而且,这两个属性的初始化设置,对于Wallet类的调用者来说,也应该是透明的,所以,我们在Wallet类的构造函数内部将其初始化设置好,而不是通过构造函数的参数来外部赋值。

对于钱包余额balance这个属性,从业务的角度来说,只能增或者减,不会被重新设置。所以,我们在Wallet类中,只暴露了increaseBalance()和decreaseBalance()方法,并没有暴露set方法。对于balanceLastModifiedTime这个属性,它完全是跟balance这个属性的修改操作绑定在一起的。只有在balance修改的时候,这个属性才会被修改。所以,我们把balanceLastModifiedTime这个属性的修改操作完全封装在了increaseBalance()和decreaseBalance()两个方法中,不对外暴露任何修改这个属性的方法和业务细节。这样也可以保证balance和balanceLastModifiedTime两个数据的一致性。

对于封装这个特性,我们需要编程语言本身提供一定的语法机制来支持。这个语法机制就是访问权限控制。例子中的private、public等关键字就是Java语言中的访问权限控制语法。private关键字修饰的属性只能类本身访问,可以保护其不被类之外的代码直接访问。如果Java语言没有提供访问权限控制语法,所有的属性默认都是public的,那任意外部代码都可以通过类似wallet.id=123;这样的方式直接访问、修改属性,也就没办法达到隐藏信息和保护数据的目的了,也就无法支持封装特性了。

封装特性的定义讲完了,我们再来看一下,封装的意义是什么?它能解决什么编程问题?

如果我们对类中属性的访问不做限制,那任何代码都可以访问、修改类中的属性,虽然这样看起来更加灵活,但从另一方面来说,过度灵活也意味着不可控,属性可以随意被以各种奇葩的方式修改,而且修改逻辑可能散落在代码中的各个角落,势必影响代码的可读性、可维护性。比如某个同事在不了解业务逻辑的情况下,在某段代码中“偷偷地”重设了wallet中的balanceLastModifiedTime属性,这就会导致balance和balanceLastModifiedTime两个数据不一致。

除此之外,类仅仅通过有限的方法暴露必要的操作,也能提高类的易用性。如果我们把类属性都暴露给类的调用者,调用者想要正确地操作这些属性,就势必要对业务细节有足够的了解。而这对于调用者来说也是一种负担。相反,如果我们将属性封装起来,暴露少许的几个必要的方法给调用者使用,调用者就不需要了解太多背后的业务细节,用错的概率就减少很多。这就好比,如果一个冰箱有很多按钮,你就要研究很长时间,还不一定能操作正确。相反,如果只有几个必要的按钮,比如开、停、调节温度,你一眼就能知道该如何来操作,而且操作出错的概率也会降低很多。

抽象(Abstraction)

讲完了封装特性,我们再来看抽象特性。 封装主要讲的是如何隐藏信息、保护数据,而抽象讲的是如何隐藏方法的具体实现,让调用者只需要关心方法提供了哪些功能,并不需要知道这些功能是如何实现的。

在面向对象编程中,我们常借助编程语言提供的接口类(比如Java中的interface关键字语法)或者抽象类(比如Java中的abstract关键字语法)这两种语法机制,来实现抽象这一特性。

这里我稍微说明一下,在专栏中,我们把编程语言提供的接口语法叫作“接口类”而不是“接口”。之所以这么做,是因为“接口”这个词太泛化,可以指好多概念,比如API接口等,所以,我们用“接口类”特指编程语言提供的接口语法。

对于抽象这个特性,我举一个例子来进一步解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public interface IPictureStorage {
void savePicture(Picture picture);
Image getPicture(String pictureId);
void deletePicture(String pictureId);
void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo);
}

public class PictureStorage implements IPictureStorage {
// ...省略其他属性...
@Override
public void savePicture(Picture picture) { ... }
@Override
public Image getPicture(String pictureId) { ... }
@Override
public void deletePicture(String pictureId) { ... }
@Override
public void modifyMetaInfo(String pictureId, PictureMetaInfo metaInfo) { ... }
}

在上面的这段代码中,我们利用Java中的interface接口语法来实现抽象特性。调用者在使用图片存储功能的时候,只需要了解IPictureStorage这个接口类暴露了哪些方法就可以了,不需要去查看PictureStorage类里的具体实现逻辑。

实际上,抽象这个特性是非常容易实现的,并不需要非得依靠接口类或者抽象类这些特殊语法机制来支持。换句话说,并不是说一定要为实现类(PictureStorage)抽象出接口类(IPictureStorage),才叫作抽象。即便不编写IPictureStorage接口类,单纯的PictureStorage类本身就满足抽象特性。

之所以这么说,那是因为,类的方法是通过编程语言中的“函数”这一语法机制来实现的。通过函数包裹具体的实现逻辑,这本身就是一种抽象。调用者在使用函数的时候,并不需要去研究函数内部的实现逻辑,只需要通过函数的命名、注释或者文档,了解其提供了什么功能,就可以直接使用了。比如,我们在使用C语言的malloc()函数的时候,并不需要了解它的底层代码是怎么实现的。

除此之外,抽象有时候会被排除在面向对象的四大特性之外,现在解释一下为什么。

抽象这个概念是一个非常通用的设计思想,并不单单用在面向对象编程中,也可以用来指导架构设计等。而且这个特性也并不需要编程语言提供特殊的语法机制来支持,只需要提供“函数”这一非常基础的语法机制,就可以实现抽象特性、所以,它没有很强的“特异性”,有时候并不被看作面向对象编程的特性之一。

抽象特性的定义讲完了,我们再来看一下,抽象的意义是什么?它能解决什么编程问题?

实际上,如果上升一个思考层面的话,抽象及其前面讲到的封装都是人类处理复杂性的有效手段。在面对复杂系统的时候,人脑能承受的信息复杂程度是有限的,所以我们必须忽略掉一些非关键性的实现细节。而抽象作为一种只关注功能点不关注实现的设计思路,正好帮我们的大脑过滤掉许多非必要的信息。

除此之外,抽象作为一个非常宽泛的设计思想,在代码设计中,起到非常重要的指导作用。很多设计原则都体现了抽象这种设计思想,比如基于接口而非实现编程、开闭原则(对扩展开放、对修改关闭)、代码解耦(降低代码的耦合性)等。

换一个角度来考虑,我们在定义(或者叫命名)类的方法的时候,也要有抽象思维,不要在方法定义中,暴露太多的实现细节,以保证在某个时间点需要改变方法的实现逻辑的时候,不用去修改其定义。举个简单例子,比如getAliyunPictureUrl()就不是一个具有抽象思维的命名,因为某一天如果我们不再把图片存储在阿里云上,而是存储在私有云上,那这个命名也要随之被修改。相反,如果我们定义一个比较抽象的函数,比如叫作getPictureUrl(),那即便内部存储方式修改了,我们也不需要修改命名。

继承(Inheritance)

学习完了封装和抽象两个特性,我们再来看继承特性。如果你熟悉的是类似Java、C++这样的面向对象的编程语言,那你对继承这一特性,应该不陌生了。继承是用来表示类之间的is-a关系,比如猫是一种哺乳动物。从继承关系上来讲,继承可以分为两种模式,单继承和多继承。单继承表示一个子类只继承一个父类,多继承表示一个子类可以继承多个父类,比如猫既是哺乳动物,又是爬行动物。

为了实现继承这个特性,编程语言需要提供特殊的语法机制来支持,比如Java使用extends关键字来实现继承,C++使用冒号(class B : public A),Python使用parentheses (),Ruby使用<。不过,有些编程语言只支持单继承,不支持多重继承,比如Java、PHP、C#、Ruby等,而有些编程语言既支持单重继承,也支持多重继承,比如C++、Python、Perl等。

继承特性的定义讲完了,我们再来看,继承存在的意义是什么?它能解决什么编程问题?

继承最大的一个好处就是代码复用。假如两个类有一些相同的属性和方法,我们就可以将这些相同的部分,抽取到父类中,让两个子类继承父类。这样,两个子类就可以重用父类中的代码,避免代码重复写多遍。不过,这一点也并不是继承所独有的,我们也可以通过其他方式来解决这个代码复用的问题,比如利用组合关系而不是继承关系。

如果我们再上升一个思维层面,去思考继承这一特性,可以这么理解:我们代码中有一个猫类,有一个哺乳动物类。猫属于哺乳动物,从人类认知的角度上来说,是一种is-a关系。我们通过继承来关联两个类,反应真实世界中的这种关系,非常符合人类的认知,而且,从设计的角度来说,也有一种结构美感。

继承的概念很好理解,也很容易使用。不过,过度使用继承,继承层次过深过复杂,就会导致代码可读性、可维护性变差。为了了解一个类的功能,我们不仅需要查看这个类的代码,还需要按照继承关系一层一层地往上查看“父类、父类的父类……”的代码。还有,子类和父类高度耦合,修改父类的代码,会直接影响到子类。

所以,继承这个特性也是一个非常有争议的特性。很多人觉得继承是一种反模式。我们应该尽量少用,甚至不用。关于这个问题,在后面讲到“多用组合少用继承”这种设计思想的时候,我会非常详细地再讲解,这里暂时就不展开讲解了。

多态(Polymorphism)

学习完了封装、抽象、继承之后,我们再来看面向对象编程的最后一个特性,多态。多态是指,子类可以替换父类,在实际的代码运行过程中,调用子类的方法实现。看一个具体的例子。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public class DynamicArray {
private static final int DEFAULT_CAPACITY = 10;
protected int size = 0;
protected int capacity = DEFAULT_CAPACITY;
protected Integer[] elements = new Integer[DEFAULT_CAPACITY];

public int size() { return this.size; }
public Integer get(int index) { return elements[index];}
//...省略n多方法...

public void add(Integer e) {
ensureCapacity();
elements[size++] = e;
}

protected void ensureCapacity() {
//...如果数组满了就扩容...代码省略...
}
}

public class SortedDynamicArray extends DynamicArray {
@Override
public void add(Integer e) {
ensureCapacity();
int i;
for (i = size-1; i>=0; --i) { //保证数组中的数据有序
if (elements[i] > e) {
elements[i+1] = elements[i];
} else {
break;
}
}
elements[i+1] = e;
++size;
}
}

public class Example {
public static void test(DynamicArray dynamicArray) {
dynamicArray.add(5);
dynamicArray.add(1);
dynamicArray.add(3);
for (int i = 0; i < dynamicArray.size(); ++i) {
System.out.println(dynamicArray.get(i));
}
}

public static void main(String args[]) {
DynamicArray dynamicArray = new SortedDynamicArray();
test(dynamicArray); // 打印结果:1、3、5
}
}

多态这种特性也需要编程语言提供特殊的语法机制来实现。在上面的例子中,我们用到了三个语法机制来实现多态。

  • 第一个语法机制是编程语言要支持父类对象可以引用子类对象,也就是可以将SortedDynamicArray传递给DynamicArray。
  • 第二个语法机制是编程语言要支持继承,也就是SortedDynamicArray继承了DynamicArray,才能将SortedDyamicArray传递给DynamicArray。
  • 第三个语法机制是编程语言要支持子类可以重写(override)父类中的方法,也就是SortedDyamicArray重写了DynamicArray中的add()方法。

通过这三种语法机制配合在一起,我们就实现了在test()方法中,子类SortedDyamicArray替换父类DynamicArray,执行子类SortedDyamicArray的add()方法,也就是实现了多态特性。

对于多态特性的实现方式,除了利用“继承加方法重写”这种实现方式之外,我们还有其他两种比较常见的的实现方式,一个是利用接口类语法,另一个是利用duck-typing语法。不过,并不是每种编程语言都支持接口类或者duck-typing这两种语法机制,比如C++就不支持接口类语法,而duck-typing只有一些动态语言才支持,比如Python、JavaScript等。

接下来,我们先来看如何利用接口类来实现多态特性。我们还是先来看一段代码。

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
34
35
36
37
38
39
public interface Iterator {
boolean hasNext();
String next();
String remove();
}

public class Array implements Iterator {
private String[] data;

public boolean hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//...省略其他方法...
}

public class LinkedList implements Iterator {
private LinkedListNode head;

public boolean hasNext() { ... }
public String next() { ... }
public String remove() { ... }
//...省略其他方法...
}

public class Demo {
private static void print(Iterator iterator) {
while (iterator.hasNext()) {
System.out.println(iterator.next());
}
}

public static void main(String[] args) {
Iterator arrayIterator = new Array();
print(arrayIterator);

Iterator linkedListIterator = new LinkedList();
print(linkedListIterator);
}
}

在这段代码中,Iterator是一个接口类,定义了一个可以遍历集合数据的迭代器。Array和LinkedList都实现了接口类Iterator。我们通过传递不同类型的实现类(Array、LinkedList)到print(Iterator iterator)函数中,支持动态的调用不同的next()、hasNext()实现。

具体点讲就是,当我们往print(Iterator iterator)函数传递Array类型的对象的时候,print(Iterator iterator)函数就会调用Array的next()、hasNext()的实现逻辑;当我们往print(Iterator iterator)函数传递LinkedList类型的对象的时候,print(Iterator iterator)函数就会调用LinkedList的next()、hasNext()的实现逻辑。

刚刚讲的是用接口类来实现多态特性。现在,我们再来看下,如何用duck-typing来实现多态特性。我们还是先来看一段代码。这是一段Python代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Logger:
def record(self):
print(“I write a log into file.”)

class DB:
def record(self):
print(“I insert data into db. ”)

def test(recorder):
recorder.record()

def demo():
logger = Logger()
db = DB()
test(logger)
test(db)

从这段代码中,我们发现,duck-typing实现多态的方式非常灵活。Logger和DB两个类没有任何关系,既不是继承关系,也不是接口和实现的关系,但是只要它们都有定义了record()方法,就可以被传递到test()方法中,在实际运行的时候,执行对应的record()方法。

也就是说,只要两个类具有相同的方法,就可以实现多态,并不要求两个类之间有任何关系,这就是所谓的duck-typing,是一些动态语言所特有的语法机制。而像Java这样的静态语言,通过继承实现多态特性,必须要求两个类之间有继承关系,通过接口实现多态特性,类必须实现对应的接口。

多态特性讲完了,我们再来看,多态特性存在的意义是什么?它能解决什么编程问题?

多态特性能提高代码的可扩展性和复用性。为什么这么说呢?我们回过头去看讲解多态特性的时候,举的第二个代码实例(Iterator的例子)。

在那个例子中,我们利用多态的特性,仅用一个print()函数就可以实现遍历打印不同类型(Array、LinkedList)集合的数据。当再增加一种要遍历打印的类型的时候,比如HashMap,我们只需让HashMap实现Iterator接口,重新实现自己的hasNext()、next()等方法就可以了,完全不需要改动print()函数的代码。所以说,多态提高了代码的可扩展性。

如果我们不使用多态特性,我们就无法将不同的集合类型(Array、LinkedList)传递给相同的函数(print(Iterator iterator)函数)。我们需要针对每种要遍历打印的集合,分别实现不同的print()函数,比如针对Array,我们要实现print(Array array)函数,针对LinkedList,我们要实现print(LinkedList linkedList)函数。而利用多态特性,我们只需要实现一个print()函数的打印逻辑,就能应对各种集合数据的打印操作,这显然提高了代码的复用性。

除此之外,多态也是很多设计模式、设计原则、编程技巧的代码实现基础,比如策略模式、基于接口而非实现编程、依赖倒置原则、里式替换原则、利用多态去掉冗长的if-else语句等等。关于这点,在学习后面的章节中,你慢慢会有更深的体会。

04. 面向对象与面向过程

什么是面向过程编程与面向过程编程语言

如果你是一名比较资深的程序员,最开始学习编程的时候,接触的是Basic、Pascal、C等面向过程的编程语言,那你对这两个概念肯定不陌生。但如果你是新生代的程序员,一开始学编程的时候,接触的就是面向对象编程语言,那你对这两个概念可能会比较不熟悉。所以,在对比面向对象与面向过程优劣之前,我们先把面向过程编程和面向过程编程语言这两个概念搞清楚。

实际上,我们可以对比着面向对象编程和面向对象编程语言这两个概念,来理解面向过程编程和面向过程编程语言。

  • 面向对象编程是一种编程范式或编程风格。它以类或对象作为组织代码的基本单元,并将封装、抽象、继承、多态四个特性,作为代码设计和实现的基石 。
  • 面向对象编程语言是支持类或对象的语法机制,并有现成的语法机制,能方便地实现面向对象编程四大特性(封装、抽象、继承、多态)的编程语言。

类比面向对象编程与面向对象编程语言的定义,对于面向过程编程和面向过程编程语言这两个概念,给出下面这样的定义。

  • 面向过程编程也是一种编程范式或编程风格。它以过程(可以理解为方法、函数、操作)作为组织代码的基本单元,以数据(可以理解为成员变量、属性)与方法相分离为最主要的特点。面向过程风格是一种流程化的编程风格,通过拼接一组顺序执行的方法来操作数据完成一项功能
  • 面向过程编程语言首先是一种编程语言。它最大的特点是不支持类和对象两个语法概念,不支持丰富的面向对象编程特性(比如继承、多态、封装),仅支持面向过程编程。

不过,这里我必须声明一下,就像我们在之前讲到的,面向对象编程和面向对象编程语言并没有官方的定义一样,这里我给出的面向过程编程和面向过程编程语言的定义,也并不是严格的官方定义。之所以要给出这样的定义,只是为了跟面向对象编程及面向对象编程语言做个对比,以方便你理解它们的区别。

定义不是很严格,也比较抽象,所以,我再用一个例子进一步解释一下。假设我们有一个记录了用户信息的文本文件users.txt,每行文本的格式是name&age&gender(比如,小王&28&男)。我们希望写一个程序,从users.txt文件中逐行读取用户信息,然后格式化成name\tage\tgender(其中,\t是分隔符)这种文本格式,并且按照age从小到大排序之后,重新写入到另一个文本文件formatted_users.txt中。针对这样一个小程序的开发,我们一块来看看,用面向过程和面向对象两种编程风格,编写出来的代码有什么不同。

首先,我们先来看,用面向过程这种编程风格写出来的代码是什么样子的。注意,下面的代码是用C语言这种面向过程的编程语言来编写的。

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
34
35
36
37
38
39
struct User {
char name[64];
int age;
char gender[16];
};

struct User parse_to_user(char* text) {
// 将text(“小王&28&男”)解析成结构体struct User
}

char* format_to_text(struct User user) {
// 将结构体struct User格式化成文本("小王\t28\t男")
}

void sort_users_by_age(struct User users[]) {
// 按照年龄从小到大排序users
}

void format_user_file(char* origin_file_path, char* new_file_path) {
// open files...
struct User users[1024]; // 假设最大1024个用户
int count = 0;
while(1) { // read until the file is empty
struct User user = parse_to_user(line);
users[count++] = user;
}

sort_users_by_age(users);

for (int i = 0; i < count; ++i) {
char* formatted_user_text = format_to_text(users[i]);
// write to new file...
}
// close files...
}

int main(char** args, int argv) {
format_user_file("/home/zheng/user.txt", "/home/zheng/formatted_users.txt");
}

然后,我们再来看,用面向对象这种编程风格写出来的代码是什么样子的。注意,下面的代码是用Java这种面向对象的编程语言来编写的。

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
34
35
36
37
38
39
40
41
42
43
44
 public class User {
private String name;
private int age;
private String gender;

public User(String name, int age, String gender) {
this.name = name;
this.age = age;
this.gender = gender;
}

public static User praseFrom(String userInfoText) {
// 将text(“小王&28&男”)解析成类User
}

public String formatToText() {
// 将类User格式化成文本("小王\t28\t男")
}
}

public class UserFileFormatter {
public void format(String userFile, String formattedUserFile) {
// Open files...
List users = new ArrayList<>();
while (1) { // read until file is empty
// read from file into userText...
User user = User.parseFrom(userText);
users.add(user);
}
// sort users by age...
for (int i = 0; i < users.size(); ++i) {
String formattedUserText = user.formatToText();
// write to new file...
}
// close files...
}
}

public class MainApplication {
public static void main(String[] args) {
UserFileFormatter userFileFormatter = new UserFileFormatter();
userFileFormatter.format("/home/zheng/users.txt", "/home/zheng/formatted_users.txt");
}
}

从上面的代码中,我们可以看出,面向过程和面向对象最基本的区别就是,代码的组织方式不同。面向过程风格的代码被组织成了一组方法集合及其数据结构(struct User),方法和数据结构的定义是分开的。面向对象风格的代码被组织成一组类,方法和数据结构被绑定一起,定义在类中。

看完这个例子之后,你可能会说,面向对象编程和面向过程编程,两种风格的区别就这么一点吗?当然不是,对于这两种编程风格的更多区别,我们继续往下看。

面向对象编程相比面向过程编程有哪些优势

刚刚我们介绍了面向过程编程及面向过程编程语言的定义,并跟面向对象编程及面向对象编程语言做了一个简单对比。接下来,我们再来看一下,为什么面向对象编程晚于面向过程编程出现,却能取而代之,成为现在主流的编程范式?面向对象编程跟面向过程编程比起来,到底有哪些优势?

1.OOP更加能够应对大规模复杂程序的开发

看了刚刚举的那个格式化文本文件的例子,你可能会有这样的疑问,两种编程风格实现的代码貌似差不多啊,顶多就是代码的组织方式有点区别,没有感觉到面向对象编程有什么明显的优势呀!你的感觉没错。之所以有这种感觉,主要原因是这个例子程序比较简单、不够复杂。

对于简单程序的开发来说,不管是用面向过程编程风格,还是用面向对象编程风格,差别确实不会很大,甚至有的时候,面向过程的编程风格反倒更有优势。因为需求足够简单,整个程序的处理流程只有一条主线,很容易被划分成顺序执行的几个步骤,然后逐句翻译成代码,这就非常适合采用面向过程这种面条式的编程风格来实现。

但对于大规模复杂程序的开发来说,整个程序的处理流程错综复杂,并非只有一条主线。如果把整个程序的处理流程画出来的话,会是一个网状结构。如果我们再用面向过程编程这种流程化、线性的思维方式,去翻译这个网状结构,去思考如何把程序拆解为一组顺序执行的方法,就会比较吃力。这个时候,面向对象的编程风格的优势就比较明显了。

面向对象编程是以类为思考对象。在进行面向对象编程的时候,我们并不是一上来就去思考,如何将复杂的流程拆解为一个一个方法,而是采用曲线救国的策略,先去思考如何给业务建模,如何将需求翻译为类,如何给类之间建立交互关系,而完成这些工作完全不需要考虑错综复杂的处理流程。当我们有了类的设计之后,然后再像搭积木一样,按照处理流程,将类组装起来形成整个程序。这种开发模式、思考问题的方式,能让我们在应对复杂程序开发的时候,思路更加清晰。

除此之外,面向对象编程还提供了一种更加清晰的、更加模块化的代码组织方式。比如,我们开发一个电商交易系统,业务逻辑复杂,代码量很大,可能要定义数百个函数、数百个数据结构,那如何分门别类地组织这些函数和数据结构,才能不至于看起来比较凌乱呢?类就是一种非常好的组织这些函数和数据结构的方式,是一种将代码模块化的有效手段。

你可能会说,像C语言这种面向过程的编程语言,我们也可以按照功能的不同,把函数和数据结构放到不同的文件里,以达到给函数和数据结构分类的目的,照样可以实现代码的模块化。你说得没错。只不过面向对象编程本身提供了类的概念,强制你做这件事情,而面向过程编程并不强求。这也算是面向对象编程相对于面向过程编程的一个微创新吧。

实际上,利用面向过程的编程语言照样可以写出面向对象风格的代码,只不过可能会比用面向对象编程语言来写面向对象风格的代码,付出的代价要高一些。而且,面向过程编程和面向对象编程并非完全对立的。很多软件开发中,尽管利用的是面向过程的编程语言,也都有借鉴面向对象编程的一些优点。

2.OOP风格的代码更易复用、易扩展、易维护

在刚刚的那个例子中,因为代码比较简单,所以只用到到了类、对象这两个最基本的面向对象概念,并没有用到更加高级的四大特性,封装、抽象、继承、多态。因此,面向对象编程的优势其实并没有发挥出来。

面向过程编程是一种非常简单的编程风格,并没有像面向对象编程那样提供丰富的特性。而面向对象编程提供的封装、抽象、继承、多态这些特性,能极大地满足复杂的编程需求,能方便我们写出更易复用、易扩展、易维护的代码。为什么这么说呢?还记得我们在上一节课中讲到的封装、抽象、继承、多态存在的意义吗?我们再来简单回顾一下。

首先,我们先来看下封装特性。封装特性是面向对象编程相比于面向过程编程的一个最基本的区别,因为它基于的是面向对象编程中最基本的类的概念。面向对象编程通过类这种组织代码的方式,将数据和方法绑定在一起,通过访问权限控制,只允许外部调用者通过类暴露的有限方法访问数据,而不会像面向过程编程那样,数据可以被任意方法随意修改。因此,面向对象编程提供的封装特性更有利于提高代码的易维护性。

其次,我们再来看下抽象特性。我们知道,函数本身就是一种抽象,它隐藏了具体的实现。我们在使用函数的时候,只需要了解函数具有什么功能,而不需要了解它是怎么实现的。从这一点上,不管面向过程编程还是是面向对象编程,都支持抽象特性。不过,面向对象编程还提供了其他抽象特性的实现方式。这些实现方式是面向过程编程所不具备的,比如基于接口实现的抽象。基于接口的抽象,可以让我们在不改变原有实现的情况下,轻松替换新的实现逻辑,提高了代码的可扩展性。

再次,我们来看下继承特性。继承特性是面向对象编程相比于面向过程编程所特有的两个特性之一(另一个是多态)。如果两个类有一些相同的属性和方法,我们就可以将这些相同的代码,抽取到父类中,让两个子类继承父类。这样两个子类也就可以重用父类中的代码,避免了代码重复写多遍,提高了代码的复用性。

最后,我们来看下多态特性。基于这个特性,我们在需要修改一个功能实现的时候,可以通过实现一个新的子类的方式,在子类中重写原来的功能逻辑,用子类替换父类。在实际的代码运行过程中,调用子类新的功能逻辑,而不是在原有代码上做修改。这就遵从了“对修改关闭、对扩展开放”的设计原则,提高代码的扩展性。除此之外,利用多态特性,不同的类对象可以传递给相同的方法,执行不同的代码逻辑,提高了代码的复用性。

所以说,基于这四大特性,利用面向对象编程,我们可以更轻松地写出易复用、易扩展、易维护的代码。当然,我们不能说,利用面向过程风格就不可以写出易复用、易扩展、易维护的代码,但没有四大特性的帮助,付出的代价可能就要高一些。

3.OOP语言更加人性化、更加高级、更加智能

人类最开始跟机器打交道是通过0、1这样的二进制指令,然后是汇编语言,再之后才出现了高级编程语言。在高级编程语言中,面向过程编程语言又早于面向对象编程语言出现。之所以先出现面向过程编程语言,那是因为跟机器交互的方式,从二进制指令、汇编语言到面向过程编程语言,是一个非常自然的过渡,都是一种流程化的、面条式的编程风格,用一组指令顺序操作数据,来完成一项任务。

从指令到汇编再到面向过程编程语言,跟机器打交道的方式在不停地演进,从中我们很容易发现这样一条规律,那就是编程语言越来越人性化,让人跟机器打交道越来越容易。笼统点讲,就是编程语言越来越高级。实际上,在面向过程编程语言之后,面向对象编程语言的出现,也顺应了这样的发展规律,也就是说,面向对象编程语言比面向过程编程语言更加高级!

跟二进制指令、汇编语言、面向过程编程语言相比,面向对象编程语言的编程套路、思考问题的方式,是完全不一样的。前三者是一种计算机思维方式,而面向对象是一种人类的思维方式。我们在用前面三种语言编程的时候,我们是在思考,如何设计一组指令,告诉机器去执行这组指令,操作某些数据,帮我们完成某个任务。而在进行面向对象编程时候,我们是在思考,如何给业务建模,如何将真实的世界映射为类或者对象,这让我们更加能聚焦到业务本身,而不是思考如何跟机器打交道。可以这么说,越高级的编程语言离机器越“远”,离我们人类越“近”,越“智能”。

这里多聊几句,顺着刚刚这个编程语言的发展规律来想,如果一种新的突破性的编程语言出现,那它肯定是更加“智能”的。大胆想象一下,使用这种编程语言,我们可以无需对计算机知识有任何了解,无需像现在这样一行一行地敲很多代码,只需要把需求文档写清楚,就能自动生成我们想要的软件了。

05. 哪些代码设计看似是面向对象,实际是面向过程的?

在用面向对象编程语言进行软件开发的时候,我们有时候会写出面向过程风格的代码。有些是有意为之,并无不妥;而有些是无意为之,会影响到代码的质量。下面我就通过三个典型的代码案例,给你展示一下,什么样的代码看似是面向对象风格,实际上是面向过程风格的。我也希望你通过对这三个典型例子的学习,能够做到举一反三,在平时的开发中,多留心一下自己编写的代码是否满足面向对象风格。

1.滥用getter、setter方法

在之前参与的项目开发中,我经常看到,有同事定义完类的属性之后,就顺手把这些属性的getter、setter方法都定义上。有些同事更加省事,直接用IDE或者Lombok插件(如果是Java项目的话)自动生成所有属性的getter、setter方法。

当我问起,为什么要给每个属性都定义getter、setter方法的时候,他们的理由一般是,为了以后可能会用到,现在事先定义好,类用起来就更加方便,而且即便用不到这些getter、setter方法,定义上它们也无伤大雅。

实际上,这样的做法我是非常不推荐的。它违反了面向对象编程的封装特性,相当于将面向对象编程风格退化成了面向过程编程风格。我通过下面这个例子来给你解释一下这句话。

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
public class ShoppingCart {
private int itemsCount;
private double totalPrice;
private List<ShoppingCartItem> items = new ArrayList<>();

public int getItemsCount() {
return this.itemsCount;
}

public void setItemsCount(int itemsCount) {
this.itemsCount = itemsCount;
}

public double getTotalPrice() {
return this.totalPrice;
}

public void setTotalPrice(double totalPrice) {
this.totalPrice = totalPrice;
}

public List<ShoppingCartItem> getItems() {
return this.items;
}

public void addItem(ShoppingCartItem item) {
items.add(item);
itemsCount++;
totalPrice += item.getPrice();
}
// ...省略其他方法...
}

在这段代码中,ShoppingCart是一个简化后的购物车类,有三个私有(private)属性:itemsCount、totalPrice、items。对于itemsCount、totalPrice两个属性,我们定义了它们的getter、setter方法。对于items属性,我们定义了它的getter方法和addItem()方法。代码很简单,理解起来不难。那你有没有发现,这段代码有什么问题呢?

我们先来看前两个属性,itemsCount和totalPrice。虽然我们将它们定义成private私有属性,但是提供了public的getter、setter方法,这就跟将这两个属性定义为public公有属性,没有什么两样了。外部可以通过setter方法随意地修改这两个属性的值。除此之外,任何代码都可以随意调用setter方法,来重新设置itemsCount、totalPrice属性的值,这也会导致其跟items属性的值不一致。

而面向对象封装的定义是:通过访问权限控制,隐藏内部数据,外部仅能通过类提供的有限的接口访问、修改内部数据。所以,暴露不应该暴露的setter方法,明显违反了面向对象的封装特性。数据没有访问权限控制,任何代码都可以随意修改它,代码就退化成了面向过程编程风格的了。

看完了前两个属性,我们再来看items这个属性。对于items这个属性,我们定义了它的getter方法和addItem()方法,并没有定义它的setter方法。这样的设计貌似看起来没有什么问题,但实际上并不是。

对于itemsCount和totalPrice这两个属性来说,定义一个public的getter方法,确实无伤大雅,毕竟getter方法不会修改数据。但是,对于items属性就不一样了,这是因为items属性的getter方法,返回的是一个List集合容器。外部调用者在拿到这个容器之后,是可以操作容器内部数据的,也就是说,外部代码还是能修改items中的数据。比如像下面这样:

1
2
3
ShoppingCart cart = new ShoppCart();
...
cart.getItems().clear(); // 清空购物车

你可能会说,清空购物车这样的功能需求看起来合情合理啊,上面的代码没有什么不妥啊。你说得没错,需求是合理的,但是这样的代码写法,会导致itemsCount、totalPrice、items三者数据不一致。我们不应该将清空购物车的业务逻辑暴露给上层代码。正确的做法应该是,在ShoppingCart类中定义一个clear()方法,将清空购物车的业务逻辑封装在里面,透明地给调用者使用。ShoppingCart类的clear()方法的具体代码实现如下:

1
2
3
4
5
6
7
8
public class ShoppingCart {
// ...省略其他代码...
public void clear() {
items.clear();
itemsCount = 0;
totalPrice = 0.0;
}
}

你可能还会说,我有一个需求,需要查看购物车中都买了啥,那这个时候,ShoppingCart类不得不提供items属性的getter方法了,那又该怎么办才好呢?

如果你熟悉Java语言,那解决这个问题的方法还是挺简单的。我们可以通过Java提供的Collections.unmodifiableList()方法,让getter方法返回一个不可被修改的UnmodifiableList集合容器,而这个容器类重写了List容器中跟修改数据相关的方法,比如add()、clear()等方法。一旦我们调用这些修改数据的方法,代码就会抛出UnsupportedOperationException异常,这样就避免了容器中的数据被修改。具体的代码实现如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ShoppingCart {
// ...省略其他代码...
public List<ShoppingCartItem> getItems() {
return Collections.unmodifiableList(this.items);
}
}

public class UnmodifiableList<E> extends UnmodifiableCollection<E>
implements List<E> {
public boolean add(E e) {
throw new UnsupportedOperationException();
}
public void clear() {
throw new UnsupportedOperationException();
}
// ...省略其他代码...
}

ShoppingCart cart = new ShoppingCart();
List<ShoppingCartItem> items = cart.getItems();
items.clear();//抛出UnsupportedOperationException异常

不过,这样的实现思路还是有点问题。因为当调用者通过ShoppingCart的getItems()获取到items之后,虽然我们没法修改容器中的数据,但我们仍然可以修改容器中每个对象(ShoppingCartItem)的数据。听起来有点绕,看看下面这几行代码你就明白了。

1
2
3
4
5
ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List<ShoppingCartItem> items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 这里修改了item的价格属性

这个问题该如何解决呢?我今天就不展开来讲了。在后面讲到设计模式的时候,我还会详细地讲到。

getter、setter问题我们就讲完了,我稍微总结一下,在设计实现类的时候,除非真的需要,否则,尽量不要给属性定义setter方法。除此之外,尽管getter方法相对setter方法要安全些,但是如果返回的是集合容器(比如例子中的List容器),也要防范集合内部数据被修改的危险。

2.滥用全局变量和全局方法

我们再来看,另外一个违反面向对象编程风格的例子,那就是滥用全局变量和全局方法。首先,我们先来看,什么是全局变量和全局方法?

如果你是用类似C语言这样的面向过程的编程语言来做开发,那对全局变量、全局方法肯定不陌生,甚至可以说,在代码中到处可见。但如果你是用类似Java这样的面向对象的编程语言来做开发,全局变量和全局方法就不是很多见了。

在面向对象编程中,常见的全局变量有单例类对象、静态成员变量、常量等,常见的全局方法有静态方法。单例类对象在全局代码中只有一份,所以,它相当于一个全局变量。静态成员变量归属于类上的数据,被所有的实例化对象所共享,也相当于一定程度上的全局变量。而常量是一种非常常见的全局变量,比如一些代码中的配置参数,一般都设置为常量,放到一个Constants类中。静态方法一般用来操作静态变量或者外部数据。你可以联想一下我们常用的各种Utils类,里面的方法一般都会定义成静态方法,可以在不用创建对象的情况下,直接拿来使用。静态方法将方法与数据分离,破坏了封装特性,是典型的面向过程风格。

在刚刚介绍的这些全局变量和全局方法中,Constants类和Utils类最常用到。现在,我们就结合这两个几乎在每个软件开发中都会用到的类,来深入探讨一下全局变量和全局方法的利与弊。

我们先来看一下,在我过去参与的项目中,一种常见的Constants类的定义方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Constants {
public static final String MYSQL_ADDR_KEY = "mysql_addr";
public static final String MYSQL_DB_NAME_KEY = "db_name";
public static final String MYSQL_USERNAME_KEY = "mysql_username";
public static final String MYSQL_PASSWORD_KEY = "mysql_password";

public static final String REDIS_DEFAULT_ADDR = "192.168.7.2:7234";
public static final int REDIS_DEFAULT_MAX_TOTAL = 50;
public static final int REDIS_DEFAULT_MAX_IDLE = 50;
public static final int REDIS_DEFAULT_MIN_IDLE = 20;
public static final String REDIS_DEFAULT_KEY_PREFIX = "rt:";

// ...省略更多的常量定义...
}

在这段代码中,我们把程序中所有用到的常量,都集中地放到这个Constants类中。不过,定义一个如此大而全的Constants类,并不是一种很好的设计思路。为什么这么说呢?原因主要有以下几点。

首先,这样的设计会影响代码的可维护性。

如果参与开发同一个项目的工程师有很多,在开发过程中,可能都要涉及修改这个类,比如往这个类里添加常量,那这个类就会变得越来越大,成百上千行都有可能,查找修改某个常量也会变得比较费时,而且还会增加提交代码冲突的概率。

其次,这样的设计还会增加代码的编译时间。

当Constants类中包含很多常量定义的时候,依赖这个类的代码就会很多。那每次修改Constants类,都会导致依赖它的类文件重新编译,因此会浪费很多不必要的编译时间。不要小看编译花费的时间,对于一个非常大的工程项目来说,编译一次项目花费的时间可能是几分钟,甚至几十分钟。而我们在开发过程中,每次运行单元测试,都会触发一次编译的过程,这个编译时间就有可能会影响到我们的开发效率。

最后,这样的设计还会影响代码的复用性。

如果我们要在另一个项目中,复用本项目开发的某个类,而这个类又依赖Constants类。即便这个类只依赖Constants类中的一小部分常量,我们仍然需要把整个Constants类也一并引入,也就引入了很多无关的常量到新的项目中。

那如何改进Constants类的设计呢?我这里有两种思路可以借鉴。

  • 第一种是将Constants类拆解为功能更加单一的多个类,比如跟MySQL配置相关的常量,我们放到MysqlConstants类中;跟Redis配置相关的常量,我们放到RedisConstants类中。
  • 还有一种我个人觉得更好的设计思路,那就是并不单独地设计Constants常量类,而是哪个类用到了某个常量,我们就把这个常量定义到这个类中。比如,RedisConfig类用到了Redis配置相关的常量,那我们就直接将这些常量定义在RedisConfig中,这样也提高了类设计的内聚性和代码的复用性。

讲完了Constants类,我们再来讨论一下Utils类。首先,我想问你这样一个问题,我们为什么需要Utils类?Utils类存在的意义是什么?希望你先思考一下,然后再来看我下面的讲解。

实际上,Utils类的出现是基于这样一个问题背景:如果我们有两个类A和B,它们要用到一块相同的功能逻辑,为了避免代码重复,我们不应该在两个类中,将这个相同的功能逻辑,重复地实现两遍。这个时候我们该怎么办呢?

我们在讲面向对象特性的时候,讲过继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A类和B类并不一定具有继承关系,比如Crawler类和PageAnalyzer类,它们都用到了URL拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现Crawler类和PageAnalyzer类继承同一个父类,而父类中定义的却是URL相关的操作,会觉得这个代码写得莫名其妙,理解不了。

既然继承不能解决这个问题,我们可以定义一个新的类,实现URL拼接和分割的方法。而拼接和分割两个方法,不需要共享任何数据,所以新的类不需要定义任何属性,这个时候,我们就可以把它定义为只包含静态方法的Utils类了。

实际上,只包含静态方法不包含任何属性的Utils类,是彻彻底底的面向过程的编程风格。但这并不是说,我们就要杜绝使用Utils类了。实际上,从刚刚讲的Utils类存在的目的来看,它在软件开发中还是挺有用的,能解决代码复用问题。所以,这里并不是说完全不能用Utils类,而是说,要尽量避免滥用,不要不加思考地随意去定义Utils类

在定义Utils类之前,你要问一下自己,你真的需要单独定义这样一个Utils类吗?是否可以把Utils类中的某些方法定义到其他类中呢?如果在回答完这些问题之后,你还是觉得确实有必要去定义这样一个Utils类,那就大胆地去定义它吧。因为即便在面向对象编程中,我们也并不是完全排斥面向过程风格的代码。只要它能为我们写出好的代码贡献力量,我们就可以适度地去使用。

除此之外,类比Constants类的设计,我们设计Utils类的时候,最好也能细化一下,针对不同的功能,设计不同的Utils类,比如FileUtils、IOUtils、StringUtils、UrlUtils等,不要设计一个过于大而全的Utils类

3.定义数据和方法分离的类

我们再来看最后一种面向对象编程过程中,常见的面向过程风格的代码。那就是,数据定义在一个类中,方法定义在另一个类中。你可能会觉得,这么明显的面向过程风格的代码,谁会这么写呢?实际上,如果你是基于MVC三层结构做Web方面的后端开发,这样的代码你可能天天都在写。

传统的MVC结构分为Model层、Controller层、View层这三层。不过,在做前后端分离之后,三层结构在后端开发中,会稍微有些调整,被分为Controller层、Service层、Repository层。Controller层负责暴露接口给前端调用,Service层负责核心业务逻辑,Repository层负责数据读写。而在每一层中,我们又会定义相应的VO(View Object)、BO(Business Object)、Entity。一般情况下,VO、BO、Entity中只会定义数据,不会定义方法,所有操作这些数据的业务逻辑都定义在对应的Controller类、Service类、Repository类中。这就是典型的面向过程的编程风格。

实际上,这种开发模式叫作基于贫血模型的开发模式,也是我们现在非常常用的一种Web项目的开发模式。看到这里,你内心里应该有很多疑惑吧?既然这种开发模式明显违背面向对象的编程风格,为什么大部分Web项目都是基于这种开发模式来开发呢?

关于这个问题,我今天不打算展开讲解。因为它跟我们平时的项目开发结合得非常紧密,所以,更加细致、全面的讲解,我把它安排在面向对象实战环节里了,希望用两节课的时间,把这个问题给你讲透彻。

在面向对象编程中,为什么容易写出面向过程风格的代码?

我们在进行面向对象编程的时候,很容易不由自主地就写出面向过程风格的代码,或者说感觉面向过程风格的代码更容易写。这是为什么呢?

你可以联想一下,在生活中,你去完成一个任务,你一般都会思考,应该先做什么、后做什么,如何一步一步地顺序执行一系列操作,最后完成整个任务。面向过程编程风格恰恰符合人的这种流程化思维方式。而面向对象编程风格正好相反。它是一种自底向上的思考方式。它不是先去按照执行流程来分解任务,而是将任务翻译成一个一个的小的模块(也就是类),设计类之间的交互,最后按照流程将类组装起来,完成整个任务。我们在上一节课讲到了,这样的思考路径比较适合复杂程序的开发,但并不是特别符合人类的思考习惯。

除此之外,面向对象编程要比面向过程编程难一些。在面向对象编程中,类的设计还是挺需要技巧,挺需要一定设计经验的。你要去思考如何封装合适的数据和方法到一个类里,如何设计类之间的关系,如何设计类之间的交互等等诸多设计问题。

所以,基于这两点原因,很多工程师在开发的过程,更倾向于用不太需要动脑子的方式去实现需求,也就不由自主地就将代码写成面向过程风格的了。

面向过程编程及面向过程编程语言就真的无用武之地了吗?

前面我们讲了面向对象编程相比面向过程编程的各种优势,又讲了哪些代码看起来像面向对象风格,而实际上是面向过程编程风格的。那是不是面向过程编程风格就过时了被淘汰了呢?是不是在面向对象编程开发中,我们就要杜绝写面向过程风格的代码呢?

前面我们有讲到,如果我们开发的是微小程序,或者是一个数据处理相关的代码,以算法为主,数据为辅,那脚本式的面向过程的编程风格就更适合一些。当然,面向过程编程的用武之地还不止这些。实际上,面向过程编程是面向对象编程的基础,面向对象编程离不开基础的面向过程编程。为什么这么说?我们仔细想想,类中每个方法的实现逻辑,不就是面向过程风格的代码吗?

除此之外,面向对象和面向过程两种编程风格,也并不是非黑即白、完全对立的。在用面向对象编程语言开发的软件中,面向过程风格的代码并不少见,甚至在一些标准的开发库(比如JDK、Apache Commons、Google Guava)中,也有很多面向过程风格的代码。

不管使用面向过程还是面向对象哪种风格来写代码,我们最终的目的还是写出易维护、易读、易复用、易扩展的高质量代码。只要我们能避免面向过程编程风格的一些弊端,控制好它的副作用,在掌控范围内为我们所用,我们就大可不用避讳在面向对象编程中写面向过程风格的代码。

06. 接口和抽象类

不同的编程语言对接口和抽象类的定义方式可能有些差别,但差别并不会很大。Java这种编程语言,既支持抽象类,也支持接口,所以,为了让你对这两个语法概念有比较直观的认识,我们拿Java这种编程语言来举例讲解。

首先,我们来看一下,在Java这种编程语言中,我们是如何定义抽象类的。

下面这段代码是一个比较典型的抽象类的使用场景(模板设计模式)。Logger是一个记录日志的抽象类,FileLogger和MessageQueueLogger继承Logger,分别实现两种不同的日志记录方式:记录日志到文件中和记录日志到消息队列中。FileLogger和MessageQueueLogger两个子类复用了父类Logger中的name、enabled、minPermittedLevel属性和log()方法,但因为这两个子类写日志的方式不同,它们又各自重写了父类中的doLog()方法。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// 抽象类
public abstract class Logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;

public Logger(String name, boolean enabled, Level minPermittedLevel) {
this.name = name;
this.enabled = enabled;
this.minPermittedLevel = minPermittedLevel;
}

public void log(Level level, String message) {
boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
if (!loggable) return;
doLog(level, message);
}

protected abstract void doLog(Level level, String message);
}
// 抽象类的子类:输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;

public FileLogger(String name, boolean enabled,
Level minPermittedLevel, String filepath) {
super(name, enabled, minPermittedLevel);
this.fileWriter = new FileWriter(filepath);
}

@Override
public void doLog(Level level, String mesage) {
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
// 抽象类的子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;

public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) {
super(name, enabled, minPermittedLevel);
this.msgQueueClient = msgQueueClient;
}

@Override
protected void doLog(Level level, String mesage) {
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}

通过上面的这个例子,我们来看一下,抽象类具有哪些特性。我总结了下面三点。

  • 抽象类不允许被实例化,只能被继承。也就是说,你不能new一个抽象类的对象出来(Logger logger = new Logger(…);会报编译错误)。
  • 抽象类可以包含属性和方法。方法既可以包含代码实现(比如Logger中的log()方法),也可以不包含代码实现(比如Logger中的doLog()方法)。不包含代码实现的方法叫作抽象方法。
  • 子类继承抽象类,必须实现抽象类中的所有抽象方法。对应到例子代码中就是,所有继承Logger抽象类的子类,都必须重写doLog()方法。

刚刚我们讲了如何定义抽象类,现在我们再来看一下,在Java这种编程语言中,我们如何定义接口。

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
34
35
// 接口
public interface Filter {
void doFilter(RpcRequest req) throws RpcException;
}
// 接口实现类:鉴权过滤器
public class AuthencationFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//...鉴权逻辑..
}
}
// 接口实现类:限流过滤器
public class RateLimitFilter implements Filter {
@Override
public void doFilter(RpcRequest req) throws RpcException {
//...限流逻辑...
}
}
// 过滤器使用Demo
public class Application {
// filters.add(new AuthencationFilter());
// filters.add(new RateLimitFilter());
private List<Filter> filters = new ArrayList<>();

public void handleRpcRequest(RpcRequest req) {
try {
for (Filter filter : filters) {
filter.doFilter(req);
}
} catch(RpcException e) {
// ...处理过滤结果...
}
// ...省略其他处理逻辑...
}
}

上面这段代码是一个比较典型的接口的使用场景。我们通过Java中的interface关键字定义了一个Filter接口。AuthencationFilter和RateLimitFilter是接口的两个实现类,分别实现了对RPC请求鉴权和限流的过滤功能。

代码非常简洁。结合代码,我们再来看一下,接口都有哪些特性。我也总结了三点。

  • 接口不能包含属性(也就是成员变量)。
  • 接口只能声明方法,方法不能包含代码实现。
  • 类实现接口的时候,必须实现接口中声明的所有方法。

前面我们讲了抽象类和接口的定义,以及各自的语法特性。从语法特性上对比,这两者有比较大的区别,比如抽象类中可以定义属性、方法的实现,而接口中不能定义属性,方法也不能包含代码实现等等。除了语法特性,从设计的角度,两者也有比较大的区别。

抽象类实际上就是类,只不过是一种特殊的类,这种类不能被实例化为对象,只能被子类继承。我们知道,继承关系是一种is-a的关系,那抽象类既然属于类,也表示一种is-a的关系。相对于抽象类的is-a关系来说,接口表示一种has-a关系,表示具有某些功能。对于接口,有一个更加形象的叫法,那就是协议(contract)。

抽象类和接口能解决什么编程问题?

刚刚我们学习了抽象类和接口的定义和区别,现在我们再来学习一下,抽象类和接口存在的意义,让你知其然知其所以然。

首先,我们来看一下,我们为什么需要抽象类?它能够解决什么编程问题?

刚刚我们讲到,抽象类不能实例化,只能被继承。而前面的章节中,我们还讲到,继承能解决代码复用的问题。所以,抽象类也是为代码复用而生的。多个子类可以继承抽象类中定义的属性和方法,避免在子类中,重复编写相同的代码。

不过,既然继承本身就能达到代码复用的目的,而继承也并不要求父类一定是抽象类,那我们不使用抽象类,照样也可以实现继承和复用。从这个角度上来讲,我们貌似并不需要抽象类这种语法呀。那抽象类除了解决代码复用的问题,还有什么其他存在的意义吗?

我们还是拿之前那个打印日志的例子来讲解。我们先对上面的代码做下改造。在改造之后的代码中,Logger不再是抽象类,只是一个普通的父类,删除了Logger中log()、doLog()方法,新增了isLoggable()方法。FileLogger和MessageQueueLogger还是继承Logger父类,以达到代码复用的目的。具体的代码如下:

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
34
35
36
37
38
39
40
41
42
43
44
45
// 父类:非抽象类,就是普通的类. 删除了log(),doLog(),新增了isLoggable().
public class Logger {
private String name;
private boolean enabled;
private Level minPermittedLevel;

public Logger(String name, boolean enabled, Level minPermittedLevel) {
//...构造函数不变,代码省略...
}

protected boolean isLoggable() {
boolean loggable = enabled && (minPermittedLevel.intValue() <= level.intValue());
return loggable;
}
}
// 子类:输出日志到文件
public class FileLogger extends Logger {
private Writer fileWriter;

public FileLogger(String name, boolean enabled,
Level minPermittedLevel, String filepath) {
//...构造函数不变,代码省略...
}

public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
// 子类: 输出日志到消息中间件(比如kafka)
public class MessageQueueLogger extends Logger {
private MessageQueueClient msgQueueClient;

public MessageQueueLogger(String name, boolean enabled,
Level minPermittedLevel, MessageQueueClient msgQueueClient) {
//...构造函数不变,代码省略...
}

public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}

这个设计思路虽然达到了代码复用的目的,但是无法使用多态特性了。像下面这样编写代码,就会出现编译错误,因为Logger中并没有定义log()方法。

1
2
Logger logger = new FileLogger("access-log", true, Level.WARN, "/users/wangzheng/access.log");
logger.log(Level.ERROR, "This is a test log message.");

你可能会说,这个问题解决起来很简单啊。我们在Logger父类中,定义一个空的log()方法,让子类重写父类的log()方法,实现自己的记录日志的逻辑,不就可以了吗?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Logger {
// ...省略部分代码...
public void log(Level level, String mesage) { // do nothing... }
}
public class FileLogger extends Logger {
// ...省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到日志文件
fileWriter.write(...);
}
}
public class MessageQueueLogger extends Logger {
// ...省略部分代码...
@Override
public void log(Level level, String mesage) {
if (!isLoggable()) return;
// 格式化level和message,输出到消息中间件
msgQueueClient.send(...);
}
}

这个设计思路能用,但是,它显然没有之前通过抽象类的实现思路优雅。我为什么这么说呢?主要有以下几点原因。

  • 在Logger中定义一个空的方法,会影响代码的可读性。如果我们不熟悉Logger背后的设计思想,代码注释又不怎么给力,我们在阅读Logger代码的时候,就可能对为什么定义一个空的log()方法而感到疑惑,需要查看Logger、FileLogger、MessageQueueLogger之间的继承关系,才能弄明白其设计意图。
  • 当创建一个新的子类继承Logger父类的时候,我们有可能会忘记重新实现log()方法。之前基于抽象类的设计思路,编译器会强制要求子类重写log()方法,否则会报编译错误。你可能会说,我既然要定义一个新的Logger子类,怎么会忘记重新实现log()方法呢?我们举的例子比较简单,Logger中的方法不多,代码行数也很少。但是,如果Logger有几百行,有n多方法,除非你对Logger的设计非常熟悉,否则忘记重新实现log()方法,也不是不可能的。
  • Logger可以被实例化,换句话说,我们可以new一个Logger出来,并且调用空的log()方法。这也增加了类被误用的风险。当然,这个问题可以通过设置私有的构造函数的方式来解决。不过,显然没有通过抽象类来的优雅。

其次,我们再来看一下,我们为什么需要接口?它能够解决什么编程问题?

抽象类更多的是为了代码复用,而接口就更侧重于解耦。接口是对行为的一种抽象,相当于一组协议或者契约,你可以联想类比一下API接口。调用者只需要关注抽象的接口,不需要了解具体的实现,具体的实现代码对调用者透明。接口实现了约定和实现相分离,可以降低代码间的耦合性,提高代码的可扩展性。

实际上,接口是一个比抽象类应用更加广泛、更加重要的知识点。比如,我们经常提到的“基于接口而非实现编程”,就是一条几乎天天会用到,并且能极大地提高代码的灵活性、扩展性的设计思想。关于接口这个知识点,我会单独再用一节课的时间,更加详细全面的讲解,这里就不展开了。

如何模拟抽象类和接口两个语法概念?

在前面举的例子中,我们使用Java的接口语法实现了一个Filter过滤器。不过,如果你熟悉的是C++这种编程语言,你可能会说,C++只有抽象类,并没有接口,那从代码实现的角度上来说,是不是就无法实现Filter的设计思路了呢?

实际上,我们可以通过抽象类来模拟接口。怎么来模拟呢?这是一个不错的面试题,你可以先思考一下,然后再来看我的讲解。

我们先来回忆一下接口的定义:接口中没有成员变量,只有方法声明,没有方法实现,实现接口的类必须实现接口中的所有方法。只要满足这样几点,从设计的角度上来说,我们就可以把它叫作接口。实际上,要满足接口的这些语法特性并不难。在下面这段C++代码中,我们就用抽象类模拟了一个接口(下面这段代码实际上是策略模式中的一段代码)。

1
2
3
4
5
6
7
class Strategy { // 用抽象类模拟接口
public:
~Strategy();
virtual void algorithm()=0;
protected:
Strategy();
};

抽象类Strategy没有定义任何属性,并且所有的方法都声明为virtual类型(等同于Java中的abstract关键字),这样,所有的方法都不能有代码实现,并且所有继承这个抽象类的子类,都要实现这些方法。从语法特性上来看,这个抽象类就相当于一个接口。

不过,如果你熟悉的既不是Java,也不是C++,而是现在比较流行的动态编程语言,比如Python、Ruby等,你可能还会有疑问:在这些动态语言中,不仅没有接口的概念,也没有类似abstract、virtual这样的关键字来定义抽象类,那该如何实现上面的讲到的Filter、Logger的设计思路呢?实际上,除了用抽象类来模拟接口之外,我们还可以用普通类来模拟接口。具体的Java代码实现如下所示。

1
2
3
4
5
6
public class MockInteface {
protected MockInteface() {}
public void funcA() {
throw new MethodUnSupportedException();
}
}

我们知道类中的方法必须包含实现,这个不符合接口的定义。但是,我们可以让类中的方法抛出MethodUnSupportedException异常,来模拟不包含实现的接口,并且能强迫子类在继承这个父类的时候,都去主动实现父类的方法,否则就会在运行时抛出异常。那又如何避免这个类被实例化呢?实际上很简单,我们只需要将这个类的构造函数声明为protected访问权限就可以了。

刚刚我们讲了如何用抽象类来模拟接口,以及如何用普通类来模拟接口,那如何用普通类来模拟抽象类呢?这个问题留给你自己思考,你可以留言说说你的实现方法。

实际上,对于动态编程语言来说,还有一种对接口支持的策略,那就是duck-typing。我们在上一节课中讲到多态的时候也有讲过,你可以再回忆一下。

如何决定该用抽象类还是接口?

刚刚的讲解可能有些偏理论,现在,我们就从真实项目开发的角度来看一下,在代码设计、编程开发的时候,什么时候该用抽象类?什么时候该用接口?

实际上,判断的标准很简单。如果我们要表示一种is-a的关系,并且是为了解决代码复用的问题,我们就用抽象类;如果我们要表示一种has-a关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口。

从类的继承层次上来看,抽象类是一种自下而上的设计思路,先有子类的代码重复,然后再抽象成上层的父类(也就是抽象类)。而接口正好相反,它是一种自上而下的设计思路。我们在编程的时候,一般都是先设计接口,再去考虑具体的实现。

07. 基于接口而非实现编程

如何解读原则中的“接口”二字?

“基于接口而非实现编程”这条原则的英文描述是:“Program to an interface, not an implementation”。我们理解这条原则的时候,千万不要一开始就与具体的编程语言挂钩,局限在编程语言的“接口”语法中(比如Java中的interface接口语法)。这条原则最早出现于1994年GoF的《设计模式》这本书,它先于很多编程语言而诞生(比如Java语言),是一条比较抽象、泛化的设计思想。

实际上,理解这条原则的关键,就是理解其中的“接口”两个字。还记得我们上一节课讲的“接口”的定义吗?从本质上来看,“接口”就是一组“协议”或者“约定”,是功能提供者提供给使用者的一个“功能列表”。“接口”在不同的应用场景下会有不同的解读,比如服务端与客户端之间的“接口”,类库提供的“接口”,甚至是一组通信的协议都可以叫作“接口”。刚刚对“接口”的理解,都比较偏上层、偏抽象,与实际的写代码离得有点远。如果落实到具体的编码,“基于接口而非实现编程”这条原则中的“接口”,可以理解为编程语言中的接口或者抽象类。

前面我们提到,这条原则能非常有效地提高代码质量,之所以这么说,那是因为,应用这条原则,可以将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低耦合性,提高扩展性。

实际上,“基于接口而非实现编程”这条原则的另一个表述方式,是“基于抽象而非实现编程”。后者的表述方式其实更能体现这条原则的设计初衷。在软件开发中,最大的挑战之一就是需求的不断变化,这也是考验代码设计好坏的一个标准。越抽象、越顶层、越脱离具体某一实现的设计,越能提高代码的灵活性,越能应对未来的需求变化。好的代码设计,不仅能应对当下的需求,而且在将来需求发生变化的时候,仍然能够在不破坏原有代码设计的情况下灵活应对。而抽象就是提高代码扩展性、灵活性、可维护性最有效的手段之一。

如何将这条原则应用到实战中?

对于这条原则,我们结合一个具体的实战案例来进一步讲解一下。

假设我们的系统中有很多涉及图片处理和存储的业务逻辑。图片经过处理之后被上传到阿里云上。为了代码复用,我们封装了图片存储相关的代码逻辑,提供了一个统一的AliyunImageStore类,供整个系统来使用。具体的代码实现如下所示:

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
34
35
36
public class AliyunImageStore {
//...省略属性、构造函数等...

public void createBucketIfNotExisting(String bucketName) {
// ...创建bucket代码逻辑...
// ...失败会抛出异常..
}

public String generateAccessToken() {
// ...根据accesskey/secrectkey等生成access token
}

public String uploadToAliyun(Image image, String bucketName, String accessToken) {
//...上传图片到阿里云...
//...返回图片存储在阿里云上的地址(url)...
}

public Image downloadFromAliyun(String url, String accessToken) {
//...从阿里云下载图片...
}
}

// AliyunImageStore类的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他无关代码...

public void process() {
Image image = ...; //处理图片,并封装为Image对象
AliyunImageStore imageStore = new AliyunImageStore(/*省略参数*/);
imageStore.createBucketIfNotExisting(BUCKET_NAME);
String accessToken = imageStore.generateAccessToken();
imagestore.uploadToAliyun(image, BUCKET_NAME, accessToken);
}

}

整个上传流程包含三个步骤:创建bucket(你可以简单理解为存储目录)、生成access token访问凭证、携带access token上传图片到指定的bucket中。代码实现非常简单,类中的几个方法定义得都很干净,用起来也很清晰,乍看起来没有太大问题,完全能满足我们将图片存储在阿里云的业务需求。

不过,软件开发中唯一不变的就是变化。过了一段时间后,我们自建了私有云,不再将图片存储到阿里云了,而是将图片存储到自建私有云上。为了满足这样一个需求的变化,我们该如何修改代码呢?

我们需要重新设计实现一个存储图片到私有云的PrivateImageStore类,并用它替换掉项目中所有的AliyunImageStore类对象。这样的修改听起来并不复杂,只是简单替换而已,对整个代码的改动并不大。不过,我们经常说,“细节是魔鬼”。这句话在软件开发中特别适用。实际上,刚刚的设计实现方式,就隐藏了很多容易出问题的“魔鬼细节”,我们一块来看看都有哪些。

新的PrivateImageStore类需要设计实现哪些方法,才能在尽量最小化代码修改的情况下,替换掉AliyunImageStore类呢?这就要求我们必须将AliyunImageStore类中所定义的所有public方法,在PrivateImageStore类中都逐一定义并重新实现一遍。而这样做就会存在一些问题,我总结了下面两点。

首先,AliyunImageStore类中有些函数命名暴露了实现细节,比如,uploadToAliyun()和downloadFromAliyun()。如果开发这个功能的同事没有接口意识、抽象思维,那这种暴露实现细节的命名方式就不足为奇了,毕竟最初我们只考虑将图片存储在阿里云上。而我们把这种包含“aliyun”字眼的方法,照抄到PrivateImageStore类中,显然是不合适的。如果我们在新类中重新命名uploadToAliyun()、downloadFromAliyun()这些方法,那就意味着,我们要修改项目中所有使用到这两个方法的代码,代码修改量可能就会很大。

其次,将图片存储到阿里云的流程,跟存储到私有云的流程,可能并不是完全一致的。比如,阿里云的图片上传和下载的过程中,需要生产access token,而私有云不需要access token。一方面,AliyunImageStore中定义的generateAccessToken()方法不能照抄到PrivateImageStore中;另一方面,我们在使用AliyunImageStore上传、下载图片的时候,代码中用到了generateAccessToken()方法,如果要改为私有云的上传下载流程,这些代码都需要做调整。

那这两个问题该如何解决呢?解决这个问题的根本方法就是,在编写代码的时候,要遵从“基于接口而非实现编程”的原则,具体来讲,我们需要做到下面这3点。

  1. 函数的命名不能暴露任何实现细节。比如,前面提到的uploadToAliyun()就不符合要求,应该改为去掉aliyun这样的字眼,改为更加抽象的命名方式,比如:upload()。
  2. 封装具体的实现细节。比如,跟阿里云相关的特殊上传(或下载)流程不应该暴露给调用者。我们对上传(或下载)流程进行封装,对外提供一个包裹所有上传(或下载)细节的方法,给调用者使用。
  3. 为实现类定义抽象的接口。具体的实现类都依赖统一的接口定义,遵从一致的上传功能协议。使用者依赖接口,而不是具体的实现类来编程。

我们按照这个思路,把代码重构一下。重构后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public interface ImageStore {
String upload(Image image, String bucketName);
Image download(String url);
}

public class AliyunImageStore implements ImageStore {
//...省略属性、构造函数等...

public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
String accessToken = generateAccessToken();
//...上传图片到阿里云...
//...返回图片在阿里云上的地址(url)...
}

public Image download(String url) {
String accessToken = generateAccessToken();
//...从阿里云下载图片...
}

private void createBucketIfNotExisting(String bucketName) {
// ...创建bucket...
// ...失败会抛出异常..
}

private String generateAccessToken() {
// ...根据accesskey/secrectkey等生成access token
}
}

// 上传下载流程改变:私有云不需要支持access token
public class PrivateImageStore implements ImageStore {
public String upload(Image image, String bucketName) {
createBucketIfNotExisting(bucketName);
//...上传图片到私有云...
//...返回图片的url...
}

public Image download(String url) {
//...从私有云下载图片...
}

private void createBucketIfNotExisting(String bucketName) {
// ...创建bucket...
// ...失败会抛出异常..
}
}

// ImageStore的使用举例
public class ImageProcessingJob {
private static final String BUCKET_NAME = "ai_images_bucket";
//...省略其他无关代码...

public void process() {
Image image = ...;//处理图片,并封装为Image对象
ImageStore imageStore = new PrivateImageStore(...);
imagestore.upload(image, BUCKET_NAME);
}
}

除此之外,很多人在定义接口的时候,希望通过实现类来反推接口的定义。先把实现类写好,然后看实现类中有哪些方法,照抄到接口定义中。如果按照这种思考方式,就有可能导致接口定义不够抽象,依赖具体的实现。这样的接口设计就没有意义了。不过,如果你觉得这种思考方式更加顺畅,那也没问题,只是将实现类的方法搬移到接口定义中的时候,要有选择性的搬移,不要将跟具体实现相关的方法搬移到接口中,比如AliyunImageStore中的generateAccessToken()方法。

总结一下,我们在做软件开发的时候,一定要有抽象意识、封装意识、接口意识。在定义接口的时候,不要暴露任何实现细节。接口的定义只表明做什么,而不是怎么做。而且,在设计接口的时候,我们要多思考一下,这样的接口设计是否足够通用,是否能够做到在替换具体的接口实现的时候,不需要任何接口定义的改动。

是否需要为每个类定义接口?

看了刚刚的讲解,你可能会有这样的疑问:为了满足这条原则,我是不是需要给每个实现类都定义对应的接口呢?在开发的时候,是不是任何代码都要只依赖接口,完全不依赖实现编程呢?

做任何事情都要讲求一个“度”,过度使用这条原则,非得给每个类都定义接口,接口满天飞,也会导致不必要的开发负担。至于什么时候,该为某个类定义接口,实现基于接口的编程,什么时候不需要定义接口,直接使用实现类编程,我们做权衡的根本依据,还是要回归到设计原则诞生的初衷上来。只要搞清楚了这条原则是为了解决什么样的问题而产生的,你就会发现,很多之前模棱两可的问题,都会变得豁然开朗。

前面我们也提到,这条原则的设计初衷是,将接口和实现相分离,封装不稳定的实现,暴露稳定的接口。上游系统面向接口而非实现编程,不依赖不稳定的实现细节,这样当实现发生变化的时候,上游系统的代码基本上不需要做改动,以此来降低代码间的耦合性,提高代码的扩展性。

从这个设计初衷上来看,如果在我们的业务场景中,某个功能只有一种实现方式,未来也不可能被其他实现方式替换,那我们就没有必要为其设计接口,也没有必要基于接口编程,直接使用实现类就可以了。

除此之外,越是不稳定的系统,我们越是要在代码的扩展性、维护性上下功夫。相反,如果某个系统特别稳定,在开发完之后,基本上不需要做维护,那我们就没有必要为其扩展性,投入不必要的开发时间。

08. 多用组合少用继承

为什么不推荐使用继承?

继承是面向对象的四大特性之一,用来表示类之间的is-a关系,可以解决代码复用的问题。虽然继承有诸多作用,但继承层次过深、过复杂,也会影响到代码的可维护性。所以,对于是否应该在项目中使用继承,网上有很多争议。很多人觉得继承是一种反模式,应该尽量少用,甚至不用。为什么会有这样的争议?我们通过一个例子来解释一下。

假设我们要设计一个关于鸟的类。我们将“鸟类”这样一个抽象的事物概念,定义为一个抽象类AbstractBird。所有更细分的鸟,比如麻雀、鸽子、乌鸦等,都继承这个抽象类。

我们知道,大部分鸟都会飞,那我们可不可以在AbstractBird抽象类中,定义一个fly()方法呢?答案是否定的。尽管大部分鸟都会飞,但也有特例,比如鸵鸟就不会飞。鸵鸟继承具有fly()方法的父类,那鸵鸟就具有“飞”这样的行为,这显然不符合我们对现实世界中事物的认识。当然,你可能会说,我在鸵鸟这个子类中重写(override)fly()方法,让它抛出UnSupportedMethodException异常不就可以了吗?具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class AbstractBird {
//...省略其他属性和方法...
public void fly() { //... }
}

public class Ostrich extends AbstractBird { //鸵鸟
//...省略其他属性和方法...
public void fly() {
throw new UnSupportedMethodException("I can't fly.'");
}
}

这种设计思路虽然可以解决问题,但不够优美。因为除了鸵鸟之外,不会飞的鸟还有很多,比如企鹅。对于这些不会飞的鸟来说,我们都需要重写fly()方法,抛出异常。这样的设计,一方面,徒增了编码的工作量;另一方面,也违背了我们之后要讲的最小知识原则(Least Knowledge Principle,也叫最少知识原则或者迪米特法则),暴露不该暴露的接口给外部,增加了类使用过程中被误用的概率。

你可能又会说,那我们再通过AbstractBird类派生出两个更加细分的抽象类:会飞的鸟类AbstractFlyableBird和不会飞的鸟类AbstractUnFlyableBird,让麻雀、乌鸦这些会飞的鸟都继承AbstractFlyableBird,让鸵鸟、企鹅这些不会飞的鸟,都继承AbstractUnFlyableBird类,不就可以了吗?具体的继承关系如下图所示:

img

从图中我们可以看出,继承关系变成了三层。不过,整体上来讲,目前的继承关系还比较简单,层次比较浅,也算是一种可以接受的设计思路。我们再继续加点难度。在刚刚这个场景中,我们只关注“鸟会不会飞”,但如果我们还关注“鸟会不会叫”,那这个时候,我们又该如何设计类之间的继承关系呢?

是否会飞?是否会叫?两个行为搭配起来会产生四种情况:会飞会叫、不会飞会叫、会飞不会叫、不会飞不会叫。如果我们继续沿用刚才的设计思路,那就需要再定义四个抽象类(AbstractFlyableTweetableBird、AbstractFlyableUnTweetableBird、AbstractUnFlyableTweetableBird、AbstractUnFlyableUnTweetableBird)。

img

如果我们还需要考虑“是否会下蛋”这样一个行为,那估计就要组合爆炸了。类的继承层次会越来越深、继承关系会越来越复杂。而这种层次很深、很复杂的继承关系,一方面,会导致代码的可读性变差。因为我们要搞清楚某个类具有哪些方法、属性,必须阅读父类的代码、父类的父类的代码……一直追溯到最顶层父类的代码。另一方面,这也破坏了类的封装特性,将父类的实现细节暴露给了子类。子类的实现依赖父类的实现,两者高度耦合,一旦父类代码修改,就会影响所有子类的逻辑。

总之,继承最大的问题就在于:继承层次过深、继承关系过于复杂会影响到代码的可读性和可维护性。这也是为什么我们不推荐使用继承。那刚刚例子中继承存在的问题,我们又该如何来解决呢?你可以先自己思考一下,再听我下面的讲解。

组合相比继承有哪些优势?

实际上,我们可以利用组合(composition)、接口、委托(delegation)三个技术手段,一块儿来解决刚刚继承存在的问题。

我们前面讲到接口的时候说过,接口表示具有某种行为特性。针对“会飞”这样一个行为特性,我们可以定义一个Flyable接口,只让会飞的鸟去实现这个接口。对于会叫、会下蛋这些行为特性,我们可以类似地定义Tweetable接口、EggLayable接口。我们将这个设计思路翻译成Java代码的话,就是下面这个样子:

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
public interface Flyable {
void fly();
}
public interface Tweetable {
void tweet();
}
public interface EggLayable {
void layEgg();
}
public class Ostrich implements Tweetable, EggLayable {//鸵鸟
//... 省略其他属性和方法...
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}
public class Sparrow impelents Flayable, Tweetable, EggLayable {//麻雀
//... 省略其他属性和方法...
@Override
public void fly() { //... }
@Override
public void tweet() { //... }
@Override
public void layEgg() { //... }
}

不过,我们知道,接口只声明方法,不定义实现。也就是说,每个会下蛋的鸟都要实现一遍layEgg()方法,并且实现逻辑是一样的,这就会导致代码重复的问题。那这个问题又该如何解决呢?

我们可以针对三个接口再定义三个实现类,它们分别是:实现了fly()方法的FlyAbility类、实现了tweet()方法的TweetAbility类、实现了layEgg()方法的EggLayAbility类。然后,通过组合和委托技术来消除代码重复。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public interface Flyable {
void fly()
}
public class FlyAbility implements Flyable {
@Override
public void fly() { //... }
}
//省略Tweetable/TweetAbility/EggLayable/EggLayAbility

public class Ostrich implements Tweetable, EggLayable {//鸵鸟
private TweetAbility tweetAbility = new TweetAbility(); //组合
private EggLayAbility eggLayAbility = new EggLayAbility(); //组合
//... 省略其他属性和方法...
@Override
public void tweet() {
tweetAbility.tweet(); // 委托
}
@Override
public void layEgg() {
eggLayAbility.layEgg(); // 委托
}
}

我们知道继承主要有三个作用:表示is-a关系,支持多态特性,代码复用。而这三个作用都可以通过其他技术手段来达成。比如is-a关系,我们可以通过组合和接口的has-a关系来替代;多态特性我们可以利用接口来实现;代码复用我们可以通过组合和委托来实现。所以,从理论上讲,通过组合、接口、委托三个技术手段,我们完全可以替换掉继承,在项目中不用或者少用继承关系,特别是一些复杂的继承关系。

如何判断该用组合还是继承?

尽管我们鼓励多用组合少用继承,但组合也并不是完美的,继承也并非一无是处。从上面的例子来看,继承改写成组合意味着要做更细粒度的类的拆分。这也就意味着,我们要定义更多的类和接口。类和接口的增多也就或多或少地增加代码的复杂程度和维护成本。所以,在实际的项目开发中,我们还是要根据具体的情况,来具体选择该用继承还是组合。

如果类之间的继承结构稳定(不会轻易改变),继承层次比较浅(比如,最多有两层继承关系),继承关系不复杂,我们就可以大胆地使用继承。反之,系统越不稳定,继承层次很深,继承关系复杂,我们就尽量使用组合来替代继承。

除此之外,还有一些设计模式会固定使用继承或者组合。比如,装饰者模式(decorator pattern)、策略模式(strategy pattern)、组合模式(composite pattern)等都使用了组合关系,而模板模式(template pattern)使用了继承关系。

前面我们讲到继承可以实现代码复用。利用继承特性,我们把相同的属性和方法,抽取出来,定义到父类中。子类复用父类中的属性和方法,达到代码复用的目的。但是,有的时候,从业务含义上,A类和B类并不一定具有继承关系。比如,Crawler类和PageAnalyzer类,它们都用到了URL拼接和分割的功能,但并不具有继承关系(既不是父子关系,也不是兄弟关系)。仅仅为了代码复用,生硬地抽象出一个父类出来,会影响到代码的可读性。如果不熟悉背后设计思路的同事,发现Crawler类和PageAnalyzer类继承同一个父类,而父类中定义的却只是URL相关的操作,会觉得这个代码写得莫名其妙,理解不了。这个时候,使用组合就更加合理、更加灵活。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Url {
//...省略属性和方法
}

public class Crawler {
private Url url; // 组合
public Crawler() {
this.url = new Url();
}
//...
}

public class PageAnalyzer {
private Url url; // 组合
public PageAnalyzer() {
this.url = new Url();
}
//..
}

还有一些特殊的场景要求我们必须使用继承。如果你不能改变一个函数的入参类型,而入参又非接口,为了支持多态,只能采用继承来实现。比如下面这样一段代码,其中FeignClient是一个外部类,我们没有权限去修改这部分代码,但是我们希望能重写这个类在运行时执行的encode()函数。这个时候,我们只能采用继承来实现了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class FeignClient { // Feign Client框架代码
//...省略其他代码...
public void encode(String url) { //... }
}

public void demofunction(FeignClient feignClient) {
//...
feignClient.encode(url);
//...
}

public class CustomizedFeignClient extends FeignClient {
@Override
public void encode(String url) { //...重写encode的实现...}
}

// 调用
FeignClient client = new CustomizedFeignClient();
demofunction(client);

尽管有些人说,要杜绝继承,100%用组合代替继承,但是我的观点没那么极端!之所以“多用组合少用继承”这个口号喊得这么响,只是因为,长期以来,我们过度使用继承。还是那句话,组合并不完美,继承也不是一无是处。只要我们控制好它们的副作用、发挥它们各自的优势,在不同的场合下,恰当地选择使用继承还是组合,这才是我们所追求的境界。

09. 基于贫血模型的MVC架构

什么是基于贫血模型的传统开发模式?

我相信,对于大部分的后端开发工程师来说,MVC三层架构都不会陌生。不过,为了统一我们之间对MVC的认识,我还是带你一块来回顾一下,什么是MVC三层架构。

MVC三层架构中的M表示Model,V表示View,C表示Controller。它将整个项目分为三层:展示层、逻辑层、数据层。MVC三层开发架构是一个比较笼统的分层方式,落实到具体的开发层面,很多项目也并不会100%遵从MVC固定的分层方式,而是会根据具体的项目需求,做适当的调整。

比如,现在很多Web或者App项目都是前后端分离的,后端负责暴露接口给前端调用。这种情况下,我们一般就将后端项目分为Repository层、Service层、Controller层。其中,Repository层负责数据访问,Service层负责业务逻辑,Controller层负责暴露接口。当然,这只是其中一种分层和命名方式。不同的项目、不同的团队,可能会对此有所调整。不过,万变不离其宗,只要是依赖数据库开发的Web项目,基本的分层思路都大差不差。

刚刚我们回顾了MVC三层开发架构。现在,我们再来看一下,什么是贫血模型?

实际上,你可能一直都在用贫血模型做开发,只是自己不知道而已。不夸张地讲,据我了解,目前几乎所有的业务后端系统,都是基于贫血模型的。我举一个简单的例子来给你解释一下。

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
34
35
36
37
38
39
40
41
42
43
44
////////// Controller+VO(View Object) //////////
public class UserController {
private UserService userService; //通过构造函数或者IOC框架注入

public UserVo getUserById(Long userId) {
UserBo userBo = userService.getUserById(userId);
UserVo userVo = [...convert userBo to userVo...];
return userVo;
}
}

public class UserVo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}

////////// Service+BO(Business Object) //////////
public class UserService {
private UserRepository userRepository; //通过构造函数或者IOC框架注入

public UserBo getUserById(Long userId) {
UserEntity userEntity = userRepository.getUserById(userId);
UserBo userBo = [...convert userEntity to userBo...];
return userBo;
}
}

public class UserBo {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}

////////// Repository+Entity //////////
public class UserRepository {
public UserEntity getUserById(Long userId) { //... }
}

public class UserEntity {//省略其他属性、get/set/construct方法
private Long id;
private String name;
private String cellphone;
}

我们平时开发Web后端项目的时候,基本上都是这么组织代码的。其中,UserEntity和UserRepository组成了数据访问层,UserBo和UserService组成了业务逻辑层,UserVo和UserController在这里属于接口层。

从代码中,我们可以发现,UserBo是一个纯粹的数据结构,只包含数据,不包含任何业务逻辑。业务逻辑集中在UserService中。我们通过UserService来操作UserBo。换句话说,Service层的数据和业务逻辑,被分割为BO和Service两个类中。像UserBo这样,只包含数据,不包含业务逻辑的类,就叫作贫血模型(Anemic Domain Model)。同理,UserEntity、UserVo都是基于贫血模型设计的。这种贫血模型将数据与操作分离,破坏了面向对象的封装特性,是一种典型的面向过程的编程风格。

什么是基于充血模型的DDD开发模式?

刚刚我们讲了基于贫血模型的传统的开发模式。现在我们再讲一下,另外一种最近更加被推崇的开发模式:基于充血模型的DDD开发模式。

首先,我们先来看一下,什么是充血模型?

在贫血模型中,数据和业务逻辑被分割到不同的类中。充血模型(Rich Domain Model)正好相反,数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。

接下来,我们再来看一下,什么是领域驱动设计?

领域驱动设计,即DDD,主要是用来指导如何解耦业务系统,划分业务模块,定义业务领域模型及其交互。领域驱动设计这个概念并不新颖,早在2004年就被提出了,到现在已经有十几年的历史了。不过,它被大众熟知,还是基于另一个概念的兴起,那就是微服务。

我们知道,除了监控、调用链追踪、API网关等服务治理系统的开发之外,微服务还有另外一个更加重要的工作,那就是针对公司的业务,合理地做微服务拆分。而领域驱动设计恰好就是用来指导划分服务的。所以,微服务加速了领域驱动设计的盛行。

不过,我个人觉得,领域驱动设计有点儿类似敏捷开发、SOA、PAAS等概念,听起来很高大上,但实际上只值“五分钱”。即便你没有听说过领域驱动设计,对这个概念一无所知,只要你是在开发业务系统,也或多或少都在使用它。做好领域驱动设计的关键是,看你对自己所做业务的熟悉程度,而并不是对领域驱动设计这个概念本身的掌握程度。即便你对领域驱动搞得再清楚,但是对业务不熟悉,也并不一定能做出合理的领域设计。所以,不要把领域驱动设计当银弹,不要花太多的时间去过度地研究它。

实际上,基于充血模型的DDD开发模式实现的代码,也是按照MVC三层架构分层的。Controller层还是负责暴露接口,Repository层还是负责数据存取,Service层负责核心业务逻辑。它跟基于贫血模型的传统开发模式的区别主要在Service层。

在基于贫血模型的传统开发模式中,Service层包含Service类和BO类两部分,BO是贫血模型,只包含数据,不包含具体的业务逻辑。业务逻辑集中在Service类中。在基于充血模型的DDD开发模式中,Service层包含Service类和Domain类两部分。Domain就相当于贫血模型中的BO。不过,Domain与BO的区别在于它是基于充血模型开发的,既包含数据,也包含业务逻辑。而Service类变得非常单薄。总结一下的话就是,基于贫血模型的传统的开发模式,重Service轻BO;基于充血模型的DDD开发模式,轻Service重Domain。

基于充血模型的DDD设计模式的概念,今天我们只是简单地介绍了一下。在下一节课中,我会结合具体的项目,通过代码来给你展示,如何基于这种开发模式来开发一个系统。

为什么基于贫血模型的传统开发模式如此受欢迎?

前面我们讲过,基于贫血模型的传统开发模式,将数据与业务逻辑分离,违反了OOP的封装特性,实际上是一种面向过程的编程风格。但是,现在几乎所有的Web项目,都是基于这种贫血模型的开发模式,甚至连Java Spring框架的官方demo,都是按照这种开发模式来编写的。

我们前面也讲过,面向过程编程风格有种种弊端,比如,数据和操作分离之后,数据本身的操作就不受限制了。任何代码都可以随意修改数据。既然基于贫血模型的这种传统开发模式是面向过程编程风格的,那它又为什么会被广大程序员所接受呢?关于这个问题,我总结了下面三点原因。

第一点原因是,大部分情况下,我们开发的系统业务可能都比较简单,简单到就是基于SQL的CRUD操作,所以,我们根本不需要动脑子精心设计充血模型,贫血模型就足以应付这种简单业务的开发工作。除此之外,因为业务比较简单,即便我们使用充血模型,那模型本身包含的业务逻辑也并不会很多,设计出来的领域模型也会比较单薄,跟贫血模型差不多,没有太大意义。

第二点原因是,充血模型的设计要比贫血模型更加有难度。因为充血模型是一种面向对象的编程风格。我们从一开始就要设计好针对数据要暴露哪些操作,定义哪些业务逻辑。而不是像贫血模型那样,我们只需要定义数据,之后有什么功能开发需求,我们就在Service层定义什么操作,不需要事先做太多设计。

第三点原因是,思维已固化,转型有成本。基于贫血模型的传统开发模式经历了这么多年,已经深得人心、习以为常。你随便问一个旁边的大龄同事,基本上他过往参与的所有Web项目应该都是基于这个开发模式的,而且也没有出过啥大问题。如果转向用充血模型、领域驱动设计,那势必有一定的学习成本、转型成本。很多人在没有遇到开发痛点的情况下,是不愿意做这件事情的。

什么项目应该考虑使用基于充血模型的DDD开发模式?

既然基于贫血模型的开发模式已经成为了一种约定俗成的开发习惯,那什么样的项目应该考虑使用基于充血模型的DDD开发模式呢?

刚刚我们讲到,基于贫血模型的传统的开发模式,比较适合业务比较简单的系统开发。相对应的,基于充血模型的DDD开发模式,更适合业务复杂的系统开发。比如,包含各种利息计算模型、还款模型等复杂业务的金融系统。

你可能会有一些疑问,这两种开发模式,落实到代码层面,区别不就是一个将业务逻辑放到Service类中,一个将业务逻辑放到Domain领域模型中吗?为什么基于贫血模型的传统开发模式,就不能应对复杂业务系统的开发?而基于充血模型的DDD开发模式就可以呢?

实际上,除了我们能看到的代码层面的区别之外(一个业务逻辑放到Service层,一个放到领域模型中),还有一个非常重要的区别,那就是两种不同的开发模式会导致不同的开发流程。基于充血模型的DDD开发模式的开发流程,在应对复杂业务系统的开发的时候更加有优势。为什么这么说呢?我们先来回忆一下,我们平时基于贫血模型的传统的开发模式,都是怎么实现一个功能需求的。

不夸张地讲,我们平时的开发,大部分都是SQL驱动(SQL-Driven)的开发模式。我们接到一个后端接口的开发需求的时候,就去看接口需要的数据对应到数据库中,需要哪张表或者哪几张表,然后思考如何编写SQL语句来获取数据。之后就是定义Entity、BO、VO,然后模板式地往对应的Repository、Service、Controller类中添加代码。

业务逻辑包裹在一个大的SQL语句中,而Service层可以做的事情很少。SQL都是针对特定的业务功能编写的,复用性差。当我要开发另一个业务功能的时候,只能重新写个满足新需求的SQL语句,这就可能导致各种长得差不多、区别很小的SQL语句满天飞。

所以,在这个过程中,很少有人会应用领域模型、OOP的概念,也很少有代码复用意识。对于简单业务系统来说,这种开发方式问题不大。但对于复杂业务系统的开发来说,这样的开发方式会让代码越来越混乱,最终导致无法维护。

如果我们在项目中,应用基于充血模型的DDD的开发模式,那对应的开发流程就完全不一样了。在这种开发模式下,我们需要事先理清楚所有的业务,定义领域模型所包含的属性和方法。领域模型相当于可复用的业务中间层。新功能需求的开发,都基于之前定义好的这些领域模型来完成。

我们知道,越复杂的系统,对代码的复用性、易维护性要求就越高,我们就越应该花更多的时间和精力在前期设计上。而基于充血模型的DDD开发模式,正好需要我们前期做大量的业务调研、领域模型设计,所以它更加适合这种复杂系统的开发。

10. 基于充血模型的DDD架构

钱包业务背景介绍

很多具有支付、购买功能的应用(比如淘宝、滴滴出行、极客时间等)都支持钱包的功能。应用为每个用户开设一个系统内的虚拟钱包账户,支持用户充值、提现、支付、冻结、透支、转赠、查询账户余额、查询交易流水等操作。下图是一张典型的钱包功能界面,你可以直观地感受一下。

img

一般来讲,每个虚拟钱包账户都会对应用户的一个真实的支付账户,有可能是银行卡账户,也有可能是三方支付账户(比如支付宝、微信钱包)。为了方便后续的讲解,我们限定钱包暂时只支持充值、提现、支付、查询余额、查询交易流水这五个核心的功能,其他比如冻结、透支、转赠等不常用的功能,我们暂不考虑。为了让你理解这五个核心功能是如何工作的,接下来,我们来一块儿看下它们的业务实现流程。

1.充值

用户通过三方支付渠道,把自己银行卡账户内的钱,充值到虚拟钱包账号中。这整个过程,我们可以分解为三个主要的操作流程:第一个操作是从用户的银行卡账户转账到应用的公共银行卡账户;第二个操作是将用户的充值金额加到虚拟钱包余额上;第三个操作是记录刚刚这笔交易流水。

img

2.支付

用户用钱包内的余额,支付购买应用内的商品。实际上,支付的过程就是一个转账的过程,从用户的虚拟钱包账户划钱到商家的虚拟钱包账户上。除此之外,我们也需要记录这笔支付的交易流水信息。

img

3.提现

除了充值、支付之外,用户还可以将虚拟钱包中的余额,提现到自己的银行卡中。这个过程实际上就是扣减用户虚拟钱包中的余额,并且触发真正的银行转账操作,从应用的公共银行账户转钱到用户的银行账户。同样,我们也需要记录这笔提现的交易流水信息。

img

4.查询余额

查询余额功能比较简单,我们看一下虚拟钱包中的余额数字即可。

5.查询交易流水

查询交易流水也比较简单。我们只支持三种类型的交易流水:充值、支付、提现。在用户充值、支付、提现的时候,我们会记录相应的交易信息。在需要查询的时候,我们只需要将之前记录的交易流水,按照时间、类型等条件过滤之后,显示出来即可。

钱包系统的设计思路

根据刚刚讲的业务实现流程和数据流转图,我们可以把整个钱包系统的业务划分为两部分,其中一部分单纯跟应用内的虚拟钱包账户打交道,另一部分单纯跟银行账户打交道。我们基于这样一个业务划分,给系统解耦,将整个钱包系统拆分为两个子系统:虚拟钱包系统和三方支付系统。

img

为了能在有限的篇幅内,将今天的内容讲透彻,我们接来下只聚焦于虚拟钱包系统的设计与实现。对于三方支付系统以及整个钱包系统的设计与实现,我们不做讲解。你可以自己思考下。

现在我们来看下,如果要支持钱包的这五个核心功能,虚拟钱包系统需要对应实现哪些操作。我画了一张图,列出了这五个功能都会对应虚拟钱包的哪些操作。注意,交易流水的记录和查询,我暂时在图中打了个问号,那是因为这块比较特殊,我们待会再讲。

img

从图中我们可以看出,虚拟钱包系统要支持的操作非常简单,就是余额的加加减减。其中,充值、提现、查询余额三个功能,只涉及一个账户余额的加减操作,而支付功能涉及两个账户的余额加减操作:一个账户减余额,另一个账户加余额。

现在,我们再来看一下图中问号的那部分,也就是交易流水该如何记录和查询?我们先来看一下,交易流水都需要包含哪些信息。我觉得下面这几个信息是必须包含的。

img

从图中我们可以发现,交易流水的数据格式包含两个钱包账号,一个是入账钱包账号,一个是出账钱包账号。为什么要有两个账号信息呢?这主要是为了兼容支付这种涉及两个账户的交易类型。不过,对于充值、提现这两种交易类型来说,我们只需要记录一个钱包账户信息就够了,所以,这样的交易流水数据格式的设计稍微有点浪费存储空间。

实际上,我们还有另外一种交易流水数据格式的设计思路,可以解决这个问题。我们把“支付”这个交易类型,拆为两个子类型:支付和被支付。支付单纯表示出账,余额扣减,被支付单纯表示入账,余额增加。这样我们在设计交易流水数据格式的时候,只需要记录一个账户信息即可。我画了一张两种交易流水数据格式的对比图,你可以对比着看一下。

img

那以上两种交易流水数据格式的设计思路,你觉得哪一个更好呢?

答案是第一种设计思路更好些。因为交易流水有两个功能:一个是业务功能,比如,提供用户查询交易流水信息;另一个是非业务功能,保证数据的一致性。这里主要是指支付操作数据的一致性。

支付实际上就是一个转账的操作,在一个账户上加上一定的金额,在另一个账户上减去相应的金额。我们需要保证加金额和减金额这两个操作,要么都成功,要么都失败。如果一个成功,一个失败,就会导致数据的不一致,一个账户明明减掉了钱,另一个账户却没有收到钱。

保证数据一致性的方法有很多,比如依赖数据库事务的原子性,将两个操作放在同一个事务中执行。但是,这样的做法不够灵活,因为我们的有可能做了分库分表,支付涉及的两个账户可能存储在不同的库中,无法直接利用数据库本身的事务特性,在一个事务中执行两个账户的操作。当然,我们还有一些支持分布式事务的开源框架,但是,为了保证数据的强一致性,它们的实现逻辑一般都比较复杂、本身的性能也不高,会影响业务的执行时间。所以,更加权衡的一种做法就是,不保证数据的强一致性,只实现数据的最终一致性,也就是我们刚刚提到的交易流水要实现的非业务功能。

对于支付这样的类似转账的操作,我们在操作两个钱包账户余额之前,先记录交易流水,并且标记为“待执行”,当两个钱包的加减金额都完成之后,我们再回过头来,将交易流水标记为“成功”。在给两个钱包加减金额的过程中,如果有任意一个操作失败,我们就将交易记录的状态标记为“失败”。我们通过后台补漏Job,拉取状态为“失败”或者长时间处于“待执行”状态的交易记录,重新执行或者人工介入处理。

如果选择第二种交易流水的设计思路,使用两条交易流水来记录支付操作,那记录两条交易流水本身又存在数据的一致性问题,有可能入账的交易流水记录成功,出账的交易流水信息记录失败。所以,权衡利弊,我们选择第一种稍微有些冗余的数据格式设计思路。

现在,我们再思考这样一个问题:充值、提现、支付这些业务交易类型,是否应该让虚拟钱包系统感知?换句话说,我们是否应该在虚拟钱包系统的交易流水中记录这三种类型?

答案是否定的。虚拟钱包系统不应该感知具体的业务交易类型。我们前面讲到,虚拟钱包支持的操作,仅仅是余额的加加减减操作,不涉及复杂业务概念,职责单一、功能通用。如果耦合太多业务概念到里面,势必影响系统的通用性,而且还会导致系统越做越复杂。因此,我们不希望将充值、支付、提现这样的业务概念添加到虚拟钱包系统中。

但是,如果我们不在虚拟钱包系统的交易流水中记录交易类型,那在用户查询交易流水的时候,如何显示每条交易流水的交易类型呢?

从系统设计的角度,我们不应该在虚拟钱包系统的交易流水中记录交易类型。从产品需求的角度来说,我们又必须记录交易流水的交易类型。听起来比较矛盾,这个问题该如何解决呢?

我们可以通过记录两条交易流水信息的方式来解决。我们前面讲到,整个钱包系统分为两个子系统,上层钱包系统的实现,依赖底层虚拟钱包系统和三方支付系统。对于钱包系统来说,它可以感知充值、支付、提现等业务概念,所以,我们在钱包系统这一层额外再记录一条包含交易类型的交易流水信息,而在底层的虚拟钱包系统中记录不包含交易类型的交易流水信息。

为了让你更好地理解刚刚的设计思路,我画了一张图,你可以对比着我的讲解一块儿来看。

img

我们通过查询上层钱包系统的交易流水信息,去满足用户查询交易流水的功能需求,而虚拟钱包中的交易流水就只是用来解决数据一致性问题。实际上,它的作用还有很多,比如用来对账等。限于篇幅,这里我们就不展开讲了。

整个虚拟钱包的设计思路到此讲完了。接下来,我们来看一下,如何分别用基于贫血模型的传统开发模式和基于充血模型的DDD开发模式,来实现这样一个虚拟钱包系统?

基于贫血模型的传统开发模式

实际上,如果你有一定Web项目的开发经验,并且听明白了我刚刚讲的设计思路,那对你来说,利用基于贫血模型的传统开发模式来实现这样一个系统,应该是一件挺简单的事情。不过,为了对比两种开发模式,我还是带你一块儿来实现一遍。

这是一个典型的Web后端项目的三层结构。其中,Controller和VO负责暴露接口,具体的代码实现如下所示。注意,Controller中,接口实现比较简单,主要就是调用Service的方法,所以,我省略了具体的代码实现。

1
2
3
4
5
6
7
8
9
public class VirtualWalletController {
// 通过构造函数或者IOC框架注入
private VirtualWalletService virtualWalletService;

public BigDecimal getBalance(Long walletId) { ... } //查询余额
public void debit(Long walletId, BigDecimal amount) { ... } //出账
public void credit(Long walletId, BigDecimal amount) { ... } //入账
public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) { ...} //转账
}

Service和BO负责核心业务逻辑,Repository和Entity负责数据存取。Repository这一层的代码实现比较简单,不是我们讲解的重点,所以我也省略掉了。Service层的代码如下所示。注意,这里我省略了一些不重要的校验代码,比如,对amount是否小于0、钱包是否存在的校验等等。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class VirtualWalletBo {//省略getter/setter/constructor方法
private Long id;
private Long createTime;
private BigDecimal balance;
}

public class VirtualWalletService {
// 通过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;

public VirtualWalletBo getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWalletBo walletBo = convert(walletEntity);
return walletBo;
}

public BigDecimal getBalance(Long walletId) {
return walletRepo.getBalance(walletId);
}

public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
if (balance.compareTo(amount) < 0) {
throw new NoSufficientBalanceException(...);
}
walletRepo.updateBalance(walletId, balance.subtract(amount));
}

public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
BigDecimal balance = walletEntity.getBalance();
walletRepo.updateBalance(walletId, balance.add(amount));
}

public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
VirtualWalletTransactionEntity transactionEntity = new VirtualWalletTransactionEntity();
transactionEntity.setAmount(amount);
transactionEntity.setCreateTime(System.currentTimeMillis());
transactionEntity.setFromWalletId(fromWalletId);
transactionEntity.setToWalletId(toWalletId);
transactionEntity.setStatus(Status.TO_BE_EXECUTED);
Long transactionId = transactionRepo.saveTransaction(transactionEntity);
try {
debit(fromWalletId, amount);
credit(toWalletId, amount);
} catch (InsufficientBalanceException e) {
transactionRepo.updateStatus(transactionId, Status.CLOSED);
...rethrow exception e...
} catch (Exception e) {
transactionRepo.updateStatus(transactionId, Status.FAILED);
...rethrow exception e...
}
transactionRepo.updateStatus(transactionId, Status.EXECUTED);
}
}

以上便是利用基于贫血模型的传统开发模式来实现的虚拟钱包系统。尽管我们对代码稍微做了简化,但整体的业务逻辑就是上面这样子。其中大部分代码逻辑都非常简单,最复杂的是Service中的transfer()转账函数。我们为了保证转账操作的数据一致性,添加了一些跟transaction相关的记录和状态更新的代码,理解起来稍微有点难度,你可以对照着之前讲的设计思路,自己多思考一下。

基于充血模型的DDD开发模式

刚刚讲了如何利用基于贫血模型的传统开发模式来实现虚拟钱包系统,现在,我们再来看一下,如何利用基于充血模型的DDD开发模式来实现这个系统?

在上一节课中,我们讲到,基于充血模型的DDD开发模式,跟基于贫血模型的传统开发模式的主要区别就在Service层,Controller层和Repository层的代码基本上相同。所以,我们重点看一下,Service层按照基于充血模型的DDD开发模式该如何来实现。

在这种开发模式下,我们把虚拟钱包VirtualWallet类设计成一个充血的Domain领域模型,并且将原来在Service类中的部分业务逻辑移动到VirtualWallet类中,让Service类的实现依赖VirtualWallet类。具体的代码实现如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class VirtualWallet { // Domain领域模型(充血模型)
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;

public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}

public BigDecimal balance() {
return this.balance;
}

public void debit(BigDecimal amount) {
if (this.balance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance = this.balance.subtract(amount);
}

public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance = this.balance.add(amount);
}
}

public class VirtualWalletService {
// 通过构造函数或者IOC框架注入
private VirtualWalletRepository walletRepo;
private VirtualWalletTransactionRepository transactionRepo;

public VirtualWallet getVirtualWallet(Long walletId) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
return wallet;
}

public BigDecimal getBalance(Long walletId) {
return walletRepo.getBalance(walletId);
}

public void debit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.debit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}

public void credit(Long walletId, BigDecimal amount) {
VirtualWalletEntity walletEntity = walletRepo.getWalletEntity(walletId);
VirtualWallet wallet = convert(walletEntity);
wallet.credit(amount);
walletRepo.updateBalance(walletId, wallet.balance());
}

public void transfer(Long fromWalletId, Long toWalletId, BigDecimal amount) {
//...跟基于贫血模型的传统开发模式的代码一样...
}
}

看了上面的代码,你可能会说,领域模型VirtualWallet类很单薄,包含的业务逻辑很简单。相对于原来的贫血模型的设计思路,这种充血模型的设计思路,貌似并没有太大优势。你说得没错!这也是大部分业务系统都使用基于贫血模型开发的原因。不过,如果虚拟钱包系统需要支持更复杂的业务逻辑,那充血模型的优势就显现出来了。比如,我们要支持透支一定额度和冻结部分余额的功能。这个时候,我们重新来看一下VirtualWallet类的实现代码。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
public class VirtualWallet {
private Long id;
private Long createTime = System.currentTimeMillis();;
private BigDecimal balance = BigDecimal.ZERO;
private boolean isAllowedOverdraft = true;
private BigDecimal overdraftAmount = BigDecimal.ZERO;
private BigDecimal frozenAmount = BigDecimal.ZERO;

public VirtualWallet(Long preAllocatedId) {
this.id = preAllocatedId;
}

public void freeze(BigDecimal amount) { ... }
public void unfreeze(BigDecimal amount) { ...}
public void increaseOverdraftAmount(BigDecimal amount) { ... }
public void decreaseOverdraftAmount(BigDecimal amount) { ... }
public void closeOverdraft() { ... }
public void openOverdraft() { ... }

public BigDecimal balance() {
return this.balance;
}

public BigDecimal getAvailableBalance() {
BigDecimal totalAvaliableBalance = this.balance.subtract(this.frozenAmount);
if (isAllowedOverdraft) {
totalAvaliableBalance += this.overdraftAmount;
}
return totalAvaliableBalance;
}

public void debit(BigDecimal amount) {
BigDecimal totalAvaliableBalance = getAvailableBalance();
if (totoalAvaliableBalance.compareTo(amount) < 0) {
throw new InsufficientBalanceException(...);
}
this.balance.subtract(amount);
}

public void credit(BigDecimal amount) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new InvalidAmountException(...);
}
this.balance.add(amount);
}
}

领域模型VirtualWallet类添加了简单的冻结和透支逻辑之后,功能看起来就丰富了很多,代码也没那么单薄了。如果功能继续演进,我们可以增加更加细化的冻结策略、透支策略、支持钱包账号(VirtualWallet id字段)自动生成的逻辑(不是通过构造函数经外部传入ID,而是通过分布式ID生成算法来自动生成ID)等等。VirtualWallet类的业务逻辑会变得越来越复杂,也就很值得设计成充血模型了。

辩证思考与灵活应用

对于虚拟钱包系统的设计与两种开发模式的代码实现,我想你应该有个比较清晰的了解了。不过,我觉得还有两个问题值得讨论一下。

第一个要讨论的问题是:在基于充血模型的DDD开发模式中,将业务逻辑移动到Domain中,Service类变得很薄,但在我们的代码设计与实现中,并没有完全将Service类去掉,这是为什么?或者说,Service类在这种情况下担当的职责是什么?哪些功能逻辑会放到Service类中?

区别于Domain的职责,Service类主要有下面这样几个职责。

1.Service类负责与Repository交流。在我的设计与代码实现中,VirtualWalletService类负责与Repository层打交道,调用Respository类的方法,获取数据库中的数据,转化成领域模型VirtualWallet,然后由领域模型VirtualWallet来完成业务逻辑,最后调用Repository类的方法,将数据存回数据库。

这里我再稍微解释一下,之所以让VirtualWalletService类与Repository打交道,而不是让领域模型VirtualWallet与Repository打交道,那是因为我们想保持领域模型的独立性,不与任何其他层的代码(Repository层的代码)或开发框架(比如Spring、MyBatis)耦合在一起,将流程性的代码逻辑(比如从DB中取数据、映射数据)与领域模型的业务逻辑解耦,让领域模型更加可复用。

2.Service类负责跨领域模型的业务聚合功能。VirtualWalletService类中的transfer()转账函数会涉及两个钱包的操作,因此这部分业务逻辑无法放到VirtualWallet类中,所以,我们暂且把转账业务放到VirtualWalletService类中了。当然,虽然功能演进,使得转账业务变得复杂起来之后,我们也可以将转账业务抽取出来,设计成一个独立的领域模型。

3.Service类负责一些非功能性及与三方系统交互的工作。比如幂等、事务、发邮件、发消息、记录日志、调用其他系统的RPC接口等,都可以放到Service类中。

第二个要讨论问题是:在基于充血模型的DDD开发模式中,尽管Service层被改造成了充血模型,但是Controller层和Repository层还是贫血模型,是否有必要也进行充血领域建模呢?

答案是没有必要。Controller层主要负责接口的暴露,Repository层主要负责与数据库打交道,这两层包含的业务逻辑并不多,前面我们也提到了,如果业务逻辑比较简单,就没必要做充血建模,即便设计成充血模型,类也非常单薄,看起来也很奇怪。

尽管这样的设计是一种面向过程的编程风格,但我们只要控制好面向过程编程风格的副作用,照样可以开发出优秀的软件。那这里的副作用怎么控制呢?

就拿Repository的Entity来说,即便它被设计成贫血模型,违反面向对象编程的封装特性,有被任意代码修改数据的风险,但Entity的生命周期是有限的。一般来讲,我们把它传递到Service层之后,就会转化成BO或者Domain来继续后面的业务逻辑。Entity的生命周期到此就结束了,所以也并不会被到处任意修改。

我们再来说说Controller层的VO。实际上VO是一种DTO(Data Transfer Object,数据传输对象)。它主要是作为接口的数据传输承载体,将数据发送给其他系统。从功能上来讲,它理应不包含业务逻辑、只包含数据。所以,我们将它设计成贫血模型也是比较合理的。

11. 对接口鉴权功能作面向对象分析

假设,你正在参与开发一个微服务。微服务通过HTTP协议暴露接口给其他系统调用,说直白点就是,其他系统通过URL来调用微服务的接口。有一天,你的leader找到你说,“为了保证接口调用的安全性,我们希望设计实现一个接口调用鉴权功能,只有经过认证之后的系统才能调用我们的接口,没有认证过的系统调用我们的接口会被拒绝。我希望由你来负责这个任务的开发,争取尽快上线。”

leader丢下这些话就走了。这个时候,你该如何来做呢?有没有脑子里一团浆糊,一时间无从下手的感觉呢?为什么会有这种感觉呢?我个人觉得主要有下面两点原因。

1.需求不明确

leader给到的需求过于模糊、笼统,不够具体、细化,离落地到设计、编码还有一定的距离。而人的大脑不擅长思考这种过于抽象的问题。这也是真实的软件开发区别于应试教育的地方。应试教育中的考试题目,一般都是一个非常具体的问题,我们去解答就好了。而真实的软件开发中,需求几乎都不是很明确。

我们前面讲过,面向对象分析主要的分析对象是“需求”,因此,面向对象分析可以粗略地看成“需求分析”。实际上,不管是需求分析还是面向对象分析,我们首先要做的都是将笼统的需求细化到足够清晰、可执行。我们需要通过沟通、挖掘、分析、假设、梳理,搞清楚具体的需求有哪些,哪些是现在要做的,哪些是未来可能要做的,哪些是不用考虑做的。

2.缺少锻炼

相比单纯的业务CRUD开发,鉴权这个开发任务,要更有难度。鉴权作为一个跟具体业务无关的功能,我们完全可以把它开发成一个独立的框架,集成到很多业务系统中。而作为被很多系统复用的通用框架,比起普通的业务代码,我们对框架的代码质量要求要更高。

开发这样通用的框架,对工程师的需求分析能力、设计能力、编码能力,甚至逻辑思维能力的要求,都是比较高的。如果你平时做的都是简单的CRUD业务开发,那这方面的锻炼肯定不会很多,所以,一旦遇到这种开发需求,很容易因为缺少锻炼,脑子放空,不知道从何入手,完全没有思路。

对案例进行需求分析

实际上,需求分析的工作很琐碎,也没有太多固定的章法可寻,所以,我不打算很牵强地罗列那些听着有用、实际没用的方法论,而是希望通过鉴权这个例子,来给你展示一下,面对需求分析的时候,我的完整的思考路径是什么样的。希望你能自己去体会,举一反三地类比应用到其他项目的需求分析中。

尽管针对框架、组件、类库等非业务系统的开发,我们一定要有组件化意识、框架意识、抽象意识,开发出来的东西要足够通用,不能局限于单一的某个业务需求,但这并不代表我们就可以脱离具体的应用场景,闷头拍脑袋做需求分析。多跟业务团队聊聊天,甚至自己去参与几个业务系统的开发,只有这样,我们才能真正知道业务系统的痛点,才能分析出最有价值的需求。不过,针对鉴权这一功能的开发,最大的需求方还是我们自己,所以,我们也可以先从满足我们自己系统的需求开始,然后再迭代优化。

现在,我们来看一下,针对鉴权这个功能的开发,我们该如何做需求分析?

实际上,这跟做算法题类似,先从最简单的方案想起,然后再优化。所以,我把整个的分析过程分为了循序渐进的四轮。每一轮都是对上一轮的迭代优化,最后形成一个可执行、可落地的需求列表。

1.第一轮基础分析

对于如何做鉴权这样一个问题,最简单的解决方案就是,通过用户名加密码来做认证。我们给每个允许访问我们服务的调用方,派发一个应用名(或者叫应用ID、AppID)和一个对应的密码(或者叫秘钥)。调用方每次进行接口请求的时候,都携带自己的AppID和密码。微服务在接收到接口调用请求之后,会解析出AppID和密码,跟存储在微服务端的AppID和密码进行比对。如果一致,说明认证成功,则允许接口调用请求;否则,就拒绝接口调用请求。

2.第二轮分析优化

不过,这样的验证方式,每次都要明文传输密码。密码很容易被截获,是不安全的。那如果我们借助加密算法(比如SHA),对密码进行加密之后,再传递到微服务端验证,是不是就可以了呢?实际上,这样也是不安全的,因为加密之后的密码及AppID,照样可以被未认证系统(或者说黑客)截获,未认证系统可以携带这个加密之后的密码以及对应的AppID,伪装成已认证系统来访问我们的接口。这就是典型的“重放攻击”。

提出问题,然后再解决问题,是一个非常好的迭代优化方法。对于刚刚这个问题,我们可以借助OAuth的验证思路来解决。调用方将请求接口的URL跟AppID、密码拼接在一起,然后进行加密,生成一个token。调用方在进行接口请求的的时候,将这个token及AppID,随URL一块传递给微服务端。微服务端接收到这些数据之后,根据AppID从数据库中取出对应的密码,并通过同样的token生成算法,生成另外一个token。用这个新生成的token跟调用方传递过来的token对比。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。

这个方案稍微有点复杂,我画了一张示例图,来帮你理解整个流程。

img

3.第三轮分析优化

不过,这样的设计仍然存在重放攻击的风险,还是不够安全。每个URL拼接上AppID、密码生成的token都是固定的。未认证系统截获URL、token和AppID之后,还是可以通过重放攻击的方式,伪装成认证系统,调用这个URL对应的接口。

为了解决这个问题,我们可以进一步优化token生成算法,引入一个随机变量,让每次接口请求生成的token都不一样。我们可以选择时间戳作为随机变量。原来的token是对URL、AppID、密码三者进行加密生成的,现在我们将URL、AppID、密码、时间戳四者进行加密来生成token。调用方在进行接口请求的时候,将token、AppID、时间戳,随URL一并传递给微服务端。

微服务端在收到这些数据之后,会验证当前时间戳跟传递过来的时间戳,是否在一定的时间窗口内(比如一分钟)。如果超过一分钟,则判定token过期,拒绝接口请求。如果没有超过一分钟,则说明token没有过期,就再通过同样的token生成算法,在服务端生成新的token,与调用方传递过来的token比对,看是否一致。如果一致,则允许接口调用请求;否则,就拒绝接口调用请求。

优化之后的认证流程如下图所示。

img

4.第四轮分析优化

不过,你可能会说,这样还是不够安全啊。未认证系统还是可以在这一分钟的token失效窗口内,通过截获请求、重放请求,来调用我们的接口啊!

你说得没错。不过,攻与防之间,本来就没有绝对的安全。我们能做的就是,尽量提高攻击的成本。这个方案虽然还有漏洞,但是实现起来足够简单,而且不会过度影响接口本身的性能(比如响应时间)。所以,权衡安全性、开发成本、对系统性能的影响,这个方案算是比较折中、比较合理的了。

实际上,还有一个细节我们没有考虑到,那就是,如何在微服务端存储每个授权调用方的AppID和密码。当然,这个问题并不难。最容易想到的方案就是存储到数据库里,比如MySQL。不过,开发像鉴权这样的非业务功能,最好不要与具体的第三方系统有过度的耦合。

针对AppID和密码的存储,我们最好能灵活地支持各种不同的存储方式,比如ZooKeeper、本地配置文件、自研配置中心、MySQL、Redis等。我们不一定针对每种存储方式都去做代码实现,但起码要留有扩展点,保证系统有足够的灵活性和扩展性,能够在我们切换存储方式的时候,尽可能地减少代码的改动。

5.最终确定需求

到此,需求已经足够细化和具体了。现在,我们按照鉴权的流程,对需求再重新描述一下。如果你熟悉UML,也可以用时序图、流程图来描述。不过,用什么描述不是重点,描述清楚才是最重要的。考虑到在接下来的面向对象设计环节中,我会基于文字版本的需求描述,来进行类、属性、方法、交互等的设计,所以,这里我给出的最终需求描述是文字版本的。

  • 调用方进行接口请求的时候,将URL、AppID、密码、时间戳拼接在一起,通过加密算法生成token,并且将token、AppID、时间戳拼接在URL中,一并发送到微服务端。
  • 微服务端在接收到调用方的接口请求之后,从请求中拆解出token、AppID、时间戳。
  • 微服务端首先检查传递过来的时间戳跟当前时间,是否在token失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
  • 如果token验证没有过期失效,微服务端再从自己的存储中,取出AppID对应的密码,通过同样的token生成算法,生成另外一个token,与调用方传递过来的token进行匹配;如果一致,则鉴权成功,允许接口调用,否则就拒绝接口调用。

这就是我们需求分析的整个思考过程,从最粗糙、最模糊的需求开始,通过“提出问题-解决问题”的方式,循序渐进地进行优化,最后得到一个足够清晰、可落地的需求描述。

12. 开发接口鉴权功能

如何进行面向对象设计?

我们知道,面向对象分析的产出是详细的需求描述,那面向对象设计的产出就是类。在面向对象设计环节,我们将需求描述转化为具体的类的设计。我们把这一设计环节拆解细化一下,主要包含以下几个部分:

  • 划分职责进而识别出有哪些类;
  • 定义类及其属性和方法;
  • 定义类与类之间的交互关系;
  • 将类组装起来并提供执行入口。

实话讲,不管是面向对象分析还是面向对象设计,理论的东西都不多,所以我们还是结合鉴权这个例子,在实战中体会如何做面向对象设计。

1.划分职责进而识别出有哪些类

在面向对象有关书籍中经常讲到,类是现实世界中事物的一个建模。但是,并不是每个需求都能映射到现实世界,也并不是每个类都与现实世界中的事物一一对应。对于一些抽象的概念,我们是无法通过映射现实世界中的事物的方式来定义类的。

所以,大多数讲面向对象的书籍中,还会讲到另外一种识别类的方法,那就是把需求描述中的名词罗列出来,作为可能的候选类,然后再进行筛选。对于没有经验的初学者来说,这个方法比较简单、明确,可以直接照着做。

不过,我个人更喜欢另外一种方法,那就是根据需求描述,把其中涉及的功能点,一个一个罗列出来,然后再去看哪些功能点职责相近,操作同样的属性,是否应该归为同一个类。我们来看一下,针对鉴权这个例子,具体该如何来做。

在上一节课中,我们已经给出了详细的需求描述,为了方便你查看,我把它重新贴在了下面。

  • 调用方进行接口请求的时候,将URL、AppID、密码、时间戳拼接在一起,通过加密算法生成token,并且将token、AppID、时间戳拼接在URL中,一并发送到微服务端。
  • 微服务端在接收到调用方的接口请求之后,从请求中拆解出token、AppID、时间戳。
  • 微服务端首先检查传递过来的时间戳跟当前时间,是否在token失效时间窗口内。如果已经超过失效时间,那就算接口调用鉴权失败,拒绝接口调用请求。
  • 如果token验证没有过期失效,微服务端再从自己的存储中,取出AppID对应的密码,通过同样的token生成算法,生成另外一个token,与调用方传递过来的token进行匹配。如果一致,则鉴权成功,允许接口调用;否则就拒绝接口调用。

首先,我们要做的是逐句阅读上面的需求描述,拆解成小的功能点,一条一条罗列下来。注意,拆解出来的每个功能点要尽可能的小。每个功能点只负责做一件很小的事情(专业叫法是“单一职责”,后面章节中我们会讲到)。下面是我逐句拆解上述需求描述之后,得到的功能点列表:

  1. 把URL、AppID、密码、时间戳拼接为一个字符串;
  2. 对字符串通过加密算法加密生成token;
  3. 将token、AppID、时间戳拼接到URL中,形成新的URL;
  4. 解析URL,得到token、AppID、时间戳等信息;
  5. 从存储中取出AppID和对应的密码;
  6. 根据时间戳判断token是否过期失效;
  7. 验证两个token是否匹配;

从上面的功能列表中,我们发现,1、2、6、7都是跟token有关,负责token的生成、验证;3、4都是在处理URL,负责URL的拼接、解析;5是操作AppID和密码,负责从存储中读取AppID和密码。所以,我们可以粗略地得到三个核心的类:AuthToken、Url、CredentialStorage。AuthToken负责实现1、2、6、7这四个操作;Url负责3、4两个操作;CredentialStorage负责5这个操作。

当然,这是一个初步的类的划分,其他一些不重要的、边边角角的类,我们可能暂时没法一下子想全,但这也没关系,面向对象分析、设计、编程本来就是一个循环迭代、不断优化的过程。根据需求,我们先给出一个粗糙版本的设计方案,然后基于这样一个基础,再去迭代优化,会更加容易一些,思路也会更加清晰一些。

不过,我还要再强调一点,接口调用鉴权这个开发需求比较简单,所以,需求对应的面向对象设计并不复杂,识别出来的类也并不多。但如果我们面对的是更加大型的软件开发、更加复杂的需求开发,涉及的功能点可能会很多,对应的类也会比较多,像刚刚那样根据需求逐句罗列功能点的方法,最后会得到一个长长的列表,就会有点凌乱、没有规律。针对这种复杂的需求开发,我们首先要做的是进行模块划分,将需求先简单划分成几个小的、独立的功能模块,然后再在模块内部,应用我们刚刚讲的方法,进行面向对象设计。而模块的划分和识别,跟类的划分和识别,是类似的套路。

2.定义类及其属性和方法

刚刚我们通过分析需求描述,识别出了三个核心的类,它们分别是AuthToken、Url和CredentialStorage。现在我们来看下,每个类都有哪些属性和方法。我们还是从功能点列表中挖掘。

AuthToken类相关的功能点有四个:

  • 把URL、AppID、密码、时间戳拼接为一个字符串;
  • 对字符串通过加密算法加密生成token;
  • 根据时间戳判断token是否过期失效;
  • 验证两个token是否匹配。

对于方法的识别,很多面向对象相关的书籍,一般都是这么讲的,识别出需求描述中的动词,作为候选的方法,再进一步过滤筛选。类比一下方法的识别,我们可以把功能点中涉及的名词,作为候选属性,然后同样进行过滤筛选。

我们可以借用这个思路,根据功能点描述,识别出来AuthToken类的属性和方法,如下所示:

img

从上面的类图中,我们可以发现这样三个小细节。

  • 第一个细节:并不是所有出现的名词都被定义为类的属性,比如URL、AppID、密码、时间戳这几个名词,我们把它作为了方法的参数。
  • 第二个细节:我们还需要挖掘一些没有出现在功能点描述中属性,比如createTime,expireTimeInterval,它们用在isExpired()函数中,用来判定token是否过期。
  • 第三个细节:我们还给AuthToken类添加了一个功能点描述中没有提到的方法getToken()。

第一个细节告诉我们,从业务模型上来说,不应该属于这个类的属性和方法,不应该被放到这个类里。比如URL、AppID这些信息,从业务模型上来说,不应该属于AuthToken,所以我们不应该放到这个类中。

第二、第三个细节告诉我们,在设计类具有哪些属性和方法的时候,不能单纯地依赖当下的需求,还要分析这个类从业务模型上来讲,理应具有哪些属性和方法。这样可以一方面保证类定义的完整性,另一方面不仅为当下的需求还为未来的需求做些准备。

Url类相关的功能点有两个:

  • 将token、AppID、时间戳拼接到URL中,形成新的URL;
  • 解析URL,得到token、AppID、时间戳等信息。

虽然需求描述中,我们都是以URL来代指接口请求,但是,接口请求并不一定是以URL的形式来表达,还有可能是Dubbo、RPC等其他形式。为了让这个类更加通用,命名更加贴切,我们接下来把它命名为ApiRequest。下面是我根据功能点描述设计的ApiRequest类。

img

CredentialStorage类相关的功能点有一个:

  • 从存储中取出AppID和对应的密码。

CredentialStorage类非常简单,类图如下所示。为了做到抽象封装具体的存储方式,我们将CredentialStorage设计成了接口,基于接口而非具体的实现编程。

img

3.定义类与类之间的交互关系

类与类之间都有哪些交互关系呢?UML统一建模语言中定义了六种类之间的关系。它们分别是:泛化、实现、关联、聚合、组合、依赖。关系比较多,而且有些还比较相近,比如聚合和组合,接下来我就逐一讲解一下。

泛化(Generalization)可以简单理解为继承关系。具体到Java代码就是下面这样:

1
2
public class A { ... }
public class B extends A { ... }

实现(Realization)一般是指接口和实现类之间的关系。具体到Java代码就是下面这样:

1
2
public interface A {...}
public class B implements A { ... }

聚合(Aggregation)是一种包含关系,A类对象包含B类对象,B类对象的生命周期可以不依赖A类对象的生命周期,也就是说可以单独销毁A类对象而不影响B对象,比如课程与学生之间的关系。具体到Java代码就是下面这样:

1
2
3
4
5
6
public class A {
private B b;
public A(B b) {
this.b = b;
}
}

组合(Composition)也是一种包含关系。A类对象包含B类对象,B类对象的生命周期依赖A类对象的生命周期,B类对象不可单独存在,比如鸟与翅膀之间的关系。具体到Java代码就是下面这样:

1
2
3
4
5
6
public class A {
private B b;
public A() {
this.b = new B();
}
}

关联(Association)是一种非常弱的关系,包含聚合、组合两种关系。具体到代码层面,如果B类对象是A类的成员变量,那B类和A类就是关联关系。具体到Java代码就是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}

依赖(Dependency)是一种比关联关系更加弱的关系,包含关联关系。不管是B类对象是A类对象的成员变量,还是A类的方法使用B类对象作为参数或者返回值、局部变量,只要B类对象和A类对象有任何使用关系,我们都称它们有依赖关系。具体到Java代码就是下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class A {
private B b;
public A(B b) {
this.b = b;
}
}
或者
public class A {
private B b;
public A() {
this.b = new B();
}
}
或者
public class A {
public void func(B b) { ... }
}

看完了UML六种类关系的详细介绍,不知道你有何感受?我个人觉得这样拆分有点太细,增加了学习成本,对于指导编程开发没有太大意义。所以,我从更加贴近编程的角度,对类与类之间的关系做了调整,只保留了四个关系:泛化、实现、组合、依赖,这样你掌握起来会更加容易。

其中,泛化、实现、依赖的定义不变,组合关系替代UML中组合、聚合、关联三个概念,也就相当于重新命名关联关系为组合关系,并且不再区分UML中的组合和聚合两个概念。之所以这样重新命名,是为了跟我们前面讲的“多用组合少用继承”设计原则中的“组合”统一含义。只要B类对象是A类对象的成员变量,那我们就称,A类跟B类是组合关系。

理论的东西讲完了,让我们来看一下,刚刚我们定义的类之间都有哪些关系呢?因为目前只有三个核心的类,所以只用到了实现关系,也即CredentialStorage和MysqlCredentialStorage之间的关系。接下来讲到组装类的时候,我们还会用到依赖关系、组合关系,但是泛化关系暂时没有用到。

4.将类组装起来并提供执行入口

类定义好了,类之间必要的交互关系也设计好了,接下来我们要将所有的类组装在一起,提供一个执行入口。这个入口可能是一个main()函数,也可能是一组给外部用的API接口。通过这个入口,我们能触发整个代码跑起来。

接口鉴权并不是一个独立运行的系统,而是一个集成在系统上运行的组件,所以,我们封装所有的实现细节,设计了一个最顶层的ApiAuthenticator接口类,暴露一组给外部调用者使用的API接口,作为触发执行鉴权逻辑的入口。具体的类的设计如下所示:

img

如何进行面向对象编程?

面向对象设计完成之后,我们已经定义清晰了类、属性、方法、类之间的交互,并且将所有的类组装起来,提供了统一的执行入口。接下来,面向对象编程的工作,就是将这些设计思路翻译成代码实现。有了前面的类图,这部分工作相对来说就比较简单了。所以,这里我只给出比较复杂的ApiAuthenticator的实现。

对于AuthToken、ApiRequest、CredentialStorage这三个类,在这里我就不给出具体的代码实现了。给你留一个课后作业,你可以试着把整个鉴权框架自己去实现一遍。

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
34
35
36
37
38
39
40
41
public interface ApiAuthenticator {
void auth(String url);
void auth(ApiRequest apiRequest);
}

public class DefaultApiAuthenticatorImpl implements ApiAuthenticator {
private CredentialStorage credentialStorage;

public DefaultApiAuthenticatorImpl() {
this.credentialStorage = new MysqlCredentialStorage();
}

public DefaultApiAuthenticatorImpl(CredentialStorage credentialStorage) {
this.credentialStorage = credentialStorage;
}

@Override
public void auth(String url) {
ApiRequest apiRequest = ApiRequest.buildFromUrl(url);
auth(apiRequest);
}

@Override
public void auth(ApiRequest apiRequest) {
String appId = apiRequest.getAppId();
String token = apiRequest.getToken();
long timestamp = apiRequest.getTimestamp();
String originalUrl = apiRequest.getOriginalUrl();

AuthToken clientAuthToken = new AuthToken(token, timestamp);
if (clientAuthToken.isExpired()) {
throw new RuntimeException("Token is expired.");
}

String password = credentialStorage.getPasswordByAppId(appId);
AuthToken serverAuthToken = AuthToken.generate(originalUrl, appId, password, timestamp);
if (!serverAuthToken.match(clientAuthToken)) {
throw new RuntimeException("Token verfication failed.");
}
}
}

辩证思考与灵活应用

在之前的讲解中,面向对象分析、设计、实现,每个环节的界限划分都比较清楚。而且,设计和实现基本上是按照功能点的描述,逐句照着翻译过来的。这样做的好处是先做什么、后做什么,非常清晰、明确,有章可循,即便是没有太多设计经验的初级工程师,都可以按部就班地参照着这个流程来做分析、设计和实现。

不过,在平时的工作中,大部分程序员往往都是在脑子里或者草纸上完成面向对象分析和设计,然后就开始写代码了,边写边思考边重构,并不会严格地按照刚刚的流程来执行。而且,说实话,即便我们在写代码之前,花很多时间做分析和设计,绘制出完美的类图、UML图,也不可能把每个细节、交互都想得很清楚。在落实到代码的时候,我们还是要反复迭代、重构、打破重写。

毕竟,整个软件开发本来就是一个迭代、修修补补、遇到问题解决问题的过程,是一个不断重构的过程。我们没法严格地按照顺序执行各个步骤。这就类似你去学驾照,驾校教的都是比较正规的流程,先做什么,后做什么,你只要照着做就能顺利倒车入库,但实际上,等你开熟练了,倒车入库很多时候靠的都是经验和感觉。

13. SOLID:单一职责原则

如何理解单一职责原则(SRP)?

文章的开头我们提到了SOLID原则,实际上,SOLID原则并非单纯的1个原则,而是由5个设计原则组成的,它们分别是:单一职责原则、开闭原则、里式替换原则、接口隔离原则和依赖反转原则,依次对应SOLID中的S、O、L、I、D这5个英文字母。我们今天要学习的是SOLID原则中的第一个原则:单一职责原则。

单一职责原则的英文是Single Responsibility Principle,缩写为SRP。这个原则的英文描述是这样的:A class or module should have a single responsibility。如果我们把它翻译成中文,那就是:一个类或者模块只负责完成一个职责(或者功能)

注意,这个原则描述的对象包含两个,一个是类(class),一个是模块(module)。关于这两个概念,在专栏中,有两种理解方式。一种理解是:把模块看作比类更加抽象的概念,类也可以看作模块。另一种理解是:把模块看作比类更加粗粒度的代码块,模块中包含多个类,多个类组成一个模块。

不管哪种理解方式,单一职责原则在应用到这两个描述对象的时候,道理都是相通的。为了方便你理解,接下来我只从“类”设计的角度,来讲解如何应用这个设计原则。对于“模块”来说,你可以自行引申。

单一职责原则的定义描述非常简单,也不难理解。一个类只负责完成一个职责或者功能。也就是说,不要设计大而全的类,要设计粒度小、功能单一的类。换个角度来讲就是,一个类包含了两个或者两个以上业务不相干的功能,那我们就说它职责不够单一,应该将它拆分成多个功能更加单一、粒度更细的类。

我举一个例子来解释一下。比如,一个类里既包含订单的一些操作,又包含用户的一些操作。而订单和用户是两个独立的业务领域模型,我们将两个不相干的功能放到同一个类中,那就违反了单一职责原则。为了满足单一职责原则,我们需要将这个类拆分成两个粒度更细、功能更加单一的两个类:订单类和用户类。

如何判断类的职责是否足够单一?

从刚刚这个例子来看,单一职责原则看似不难应用。那是因为我举的这个例子比较极端,一眼就能看出订单和用户毫不相干。但大部分情况下,类里的方法是归为同一类功能,还是归为不相关的两类功能,并不是那么容易判定的。在真实的软件开发中,对于一个类是否职责单一的判定,是很难拿捏的。我举一个更加贴近实际的例子来给你解释一下。

在一个社交产品中,我们用下面的UserInfo类来记录用户的信息。你觉得,UserInfo类的设计是否满足单一职责原则呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class UserInfo {
private long userId;
private String username;
private String email;
private String telephone;
private long createTime;
private long lastLoginTime;
private String avatarUrl;
private String provinceOfAddress; // 省
private String cityOfAddress; // 市
private String regionOfAddress; // 区
private String detailedAddress; // 详细地址
// ...省略其他属性和方法...
}

对于这个问题,有两种不同的观点。一种观点是,UserInfo类包含的都是跟用户相关的信息,所有的属性和方法都隶属于用户这样一个业务模型,满足单一职责原则;另一种观点是,地址信息在UserInfo类中,所占的比重比较高,可以继续拆分成独立的UserAddress类,UserInfo只保留除Address之外的其他信息,拆分之后的两个类的职责更加单一。

哪种观点更对呢?实际上,要从中做出选择,我们不能脱离具体的应用场景。如果在这个社交产品中,用户的地址信息跟其他信息一样,只是单纯地用来展示,那UserInfo现在的设计就是合理的。但是,如果这个社交产品发展得比较好,之后又在产品中添加了电商的模块,用户的地址信息还会用在电商物流中,那我们最好将地址信息从UserInfo中拆分出来,独立成用户物流信息(或者叫地址信息、收货信息等)。

我们再进一步延伸一下。如果做这个社交产品的公司发展得越来越好,公司内部又开发出了很多其他产品(可以理解为其他App)。公司希望支持统一账号系统,也就是用户一个账号可以在公司内部的所有产品中登录。这个时候,我们就需要继续对UserInfo进行拆分,将跟身份认证相关的信息(比如,email、telephone等)抽取成独立的类。

从刚刚这个例子,我们可以总结出,不同的应用场景、不同阶段的需求背景下,对同一个类的职责是否单一的判定,可能都是不一样的。在某种应用场景或者当下的需求背景下,一个类的设计可能已经满足单一职责原则了,但如果换个应用场景或着在未来的某个需求背景下,可能就不满足了,需要继续拆分成粒度更细的类。

除此之外,从不同的业务层面去看待同一个类的设计,对类是否职责单一,也会有不同的认识。比如,例子中的UserInfo类。如果我们从“用户”这个业务层面来看,UserInfo包含的信息都属于用户,满足职责单一原则。如果我们从更加细分的“用户展示信息”“地址信息”“登录认证信息”等等这些更细粒度的业务层面来看,那UserInfo就应该继续拆分。

综上所述,评价一个类的职责是否足够单一,我们并没有一个非常明确的、可以量化的标准,可以说,这是件非常主观、仁者见仁智者见智的事情。实际上,在真正的软件开发中,我们也没必要过于未雨绸缪,过度设计。所以,我们可以先写一个粗粒度的类,满足业务需求。随着业务的发展,如果粗粒度的类越来越庞大,代码越来越多,这个时候,我们就可以将这个粗粒度的类,拆分成几个更细粒度的类。这就是所谓的持续重构(后面的章节中我们会讲到)。

听到这里,你可能会说,这个原则如此含糊不清、模棱两可,到底该如何拿捏才好啊?我这里还有一些小技巧,能够很好地帮你,从侧面上判定一个类的职责是否够单一。而且,我个人觉得,下面这几条判断原则,比起很主观地去思考类是否职责单一,要更有指导意义、更具有可执行性:

  • 类中的代码行数、函数或属性过多,会影响代码的可读性和可维护性,我们就需要考虑对类进行拆分;
  • 类依赖的其他类过多,或者依赖类的其他类过多,不符合高内聚、低耦合的设计思想,我们就需要考虑对类进行拆分;
  • 私有方法过多,我们就要考虑能否将私有方法独立到新的类中,设置为public方法,供更多的类使用,从而提高代码的复用性;
  • 比较难给类起一个合适名字,很难用一个业务名词概括,或者只能用一些笼统的Manager、Context之类的词语来命名,这就说明类的职责定义得可能不够清晰;
  • 类中大量的方法都是集中操作类中的某几个属性,比如,在UserInfo例子中,如果一半的方法都是在操作address信息,那就可以考虑将这几个属性和对应的方法拆分出来。

不过,你可能还会有这样的疑问:在上面的判定原则中,我提到类中的代码行数、函数或者属性过多,就有可能不满足单一职责原则。那多少行代码才算是行数过多呢?多少个函数、属性才称得上过多呢?

比较初级的工程师经常会问这类问题。实际上,这个问题并不好定量地回答,就像你问大厨“放盐少许”中的“少许”是多少,大厨也很难告诉你一个特别具体的量值。

如果继续深究一下的话,你可能还会说,一些菜谱确实给出了,做某某菜需要放多少克盐,放多少克油的具体量值啊。我想说的是,那是给家庭主妇用的,那不是给专业的大厨看的。类比一下做饭,如果你是没有太多项目经验的编程初学者,实际上,我也可以给你一个凑活能用、比较宽泛的、可量化的标准,那就是一个类的代码行数最好不能超过200行,函数个数及属性个数都最好不要超过10个。

实际上, 从另一个角度来看,当一个类的代码,读起来让你头大了,实现某个功能时不知道该用哪个函数了,想用哪个函数翻半天都找不到了,只用到一个小功能要引入整个类(类中包含很多无关此功能实现的函数)的时候,这就说明类的行数、函数、属性过多了。实际上,等你做多项目了,代码写多了,在开发中慢慢“品尝”,自然就知道什么是“放盐少许”了,这就是所谓的“专业第六感”。

类的职责是否设计得越单一越好?

为了满足单一职责原则,是不是把类拆得越细就越好呢?答案是否定的。我们还是通过一个例子来解释一下。Serialization类实现了一个简单协议的序列化和反序列功能,具体代码如下:

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
/**
* Protocol format: identifier-string;{gson string}
* For example: UEUEUE;{"a":"A","b":"B"}
*/
public class Serialization {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;

public Serialization() {
this.gson = new Gson();
}

public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}

public Map<String, String> deserialize(String text) {
if (!text.startsWith(IDENTIFIER_STRING)) {
return Collections.emptyMap();
}
String gsonStr = text.substring(IDENTIFIER_STRING.length());
return gson.fromJson(gsonStr, Map.class);
}
}

如果我们想让类的职责更加单一,我们对Serialization类进一步拆分,拆分成一个只负责序列化工作的Serializer类和另一个只负责反序列化工作的Deserializer类。拆分后的具体代码如下所示:

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
public class Serializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;

public Serializer() {
this.gson = new Gson();
}

public String serialize(Map<String, String> object) {
StringBuilder textBuilder = new StringBuilder();
textBuilder.append(IDENTIFIER_STRING);
textBuilder.append(gson.toJson(object));
return textBuilder.toString();
}
}

public class Deserializer {
private static final String IDENTIFIER_STRING = "UEUEUE;";
private Gson gson;

public Deserializer() {
this.gson = new Gson();
}

public Map<String, String> deserialize(String text) {
if (!text.startsWith(IDENTIFIER_STRING)) {
return Collections.emptyMap();
}
String gsonStr = text.substring(IDENTIFIER_STRING.length());
return gson.fromJson(gsonStr, Map.class);
}
}

虽然经过拆分之后,Serializer类和Deserializer类的职责更加单一了,但也随之带来了新的问题。如果我们修改了协议的格式,数据标识从“UEUEUE”改为“DFDFDF”,或者序列化方式从JSON改为了XML,那Serializer类和Deserializer类都需要做相应的修改,代码的内聚性显然没有原来Serialization高了。而且,如果我们仅仅对Serializer类做了协议修改,而忘记了修改Deserializer类的代码,那就会导致序列化、反序列化不匹配,程序运行出错,也就是说,拆分之后,代码的可维护性变差了。

实际上,不管是应用设计原则还是设计模式,最终的目的还是提高代码的可读性、可扩展性、复用性、可维护性等。我们在考虑应用某一个设计原则是否合理的时候,也可以以此作为最终的考量标准。

14. SOLID:开闭原则

个人觉得,开闭原则是SOLID中最难理解、最难掌握,同时也是最有用的一条原则。

之所以说这条原则难理解,那是因为,“怎样的代码改动才被定义为‘扩展’?怎样的代码改动才被定义为‘修改’?怎么才算满足或违反‘开闭原则’?修改代码就一定意味着违反‘开闭原则’吗?”等等这些问题,都比较难理解。

之所以说这条原则难掌握,那是因为,“如何做到‘对扩展开放、修改关闭’?如何在项目中灵活地应用‘开闭原则’,以避免在追求扩展性的同时影响到代码的可读性?”等等这些问题,都比较难掌握。

之所以说这条原则最有用,那是因为,扩展性是代码质量最重要的衡量标准之一。在23种经典设计模式中,大部分设计模式都是为了解决代码的扩展性问题而存在的,主要遵从的设计原则就是开闭原则。

如何理解“对扩展开放、修改关闭”?

开闭原则的英文全称是Open Closed Principle,简写为OCP。它的英文描述是:software entities (modules, classes, functions, etc.) should be open for extension , but closed for modification。我们把它翻译成中文就是:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。

这个描述比较简略,如果我们详细表述一下,那就是,添加一个新的功能应该是,在已有代码基础上扩展代码(新增模块、类、方法等),而非修改已有代码(修改模块、类、方法等)。

为了让你更好地理解这个原则,我举一个例子来进一步解释一下。这是一段API接口监控告警的代码。

其中,AlertRule存储告警规则,可以自由设置。Notification是告警通知类,支持邮件、短信、微信、手机等多种通知渠道。NotificationEmergencyLevel表示通知的紧急程度,包括SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要),不同的紧急程度对应不同的发送渠道。关于API接口监控告警这部分,更加详细的业务需求分析和设计,我们会在后面的设计模式模块再拿出来进一步讲解,这里你只要简单知道这些,就够我们今天用了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Alert {
private AlertRule rule;
private Notification notification;

public Alert(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}

public void check(String api, long requestCount, long errorCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}

上面这段代码非常简单,业务逻辑主要集中在check()函数中。当接口的TPS(Transactions Per Second,每秒传输的事物处理个数)超过某个预先设置的最大值时,以及当接口请求出错数大于某个最大允许值时,就会触发告警,通知接口的相关负责人或者团队。

现在,如果我们需要添加一个功能,当每秒钟接口超时请求个数,超过某个预先设置的最大阈值时,我们也要触发告警发送通知。这个时候,我们该如何改动代码呢?主要的改动有两处:第一处是修改check()函数的入参,添加一个新的统计数据timeoutCount,表示超时接口请求数;第二处是在check()函数中添加新的告警逻辑。具体的代码改动如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Alert {
// ...省略AlertRule/Notification属性和构造函数...

// 改动一:添加参数timeoutCount
public void check(String api, long requestCount, long errorCount, long timeoutCount, long durationOfSeconds) {
long tps = requestCount / durationOfSeconds;
if (tps > rule.getMatchedRule(api).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
if (errorCount > rule.getMatchedRule(api).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
// 改动二:添加接口超时处理逻辑
long timeoutTps = timeoutCount / durationOfSeconds;
if (timeoutTps > rule.getMatchedRule(api).getMaxTimeoutTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}

这样的代码修改实际上存在挺多问题的。一方面,我们对接口进行了修改,这就意味着调用这个接口的代码都要做相应的修改。另一方面,修改了check()函数,相应的单元测试都需要修改(关于单元测试的内容我们在重构那部分会详细介绍)。

上面的代码改动是基于“修改”的方式来实现新功能的。如果我们遵循开闭原则,也就是“对扩展开放、对修改关闭”。那如何通过“扩展”的方式,来实现同样的功能呢?

我们先重构一下之前的Alert代码,让它的扩展性更好一些。重构的内容主要包含两部分:

  • 第一部分是将check()函数的多个入参封装成ApiStatInfo类
  • 第二部分是引入handler的概念,将if判断逻辑分散在各个handler中

具体的代码实现如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
public class Alert {
private List<AlertHandler> alertHandlers = new ArrayList<>();

public void addAlertHandler(AlertHandler alertHandler) {
this.alertHandlers.add(alertHandler);
}

public void check(ApiStatInfo apiStatInfo) {
for (AlertHandler handler : alertHandlers) {
handler.check(apiStatInfo);
}
}
}

public class ApiStatInfo {//省略constructor/getter/setter方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
}

public abstract class AlertHandler {
protected AlertRule rule;
protected Notification notification;
public AlertHandler(AlertRule rule, Notification notification) {
this.rule = rule;
this.notification = notification;
}
public abstract void check(ApiStatInfo apiStatInfo);
}

public class TpsAlertHandler extends AlertHandler {
public TpsAlertHandler(AlertRule rule, Notification notification) {
super(rule, notification);
}

@Override
public void check(ApiStatInfo apiStatInfo) {
long tps = apiStatInfo.getRequestCount()/ apiStatInfo.getDurationOfSeconds();
if (tps > rule.getMatchedRule(apiStatInfo.getApi()).getMaxTps()) {
notification.notify(NotificationEmergencyLevel.URGENCY, "...");
}
}
}

public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}

@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}

上面的代码是对Alert的重构,我们再来看下,重构之后的Alert该如何使用呢?具体的使用代码我也写在这里了。

其中,ApplicationContext是一个单例类,负责Alert的创建、组装(alertRule和notification的依赖注入)、初始化(添加handlers)工作。

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
public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;

public void initializeBeans() {
alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码
notification = new Notification(/*.省略参数.*/); //省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
}
public Alert getAlert() { return alert; }

// 饿汉式单例
private static final ApplicationContext instance = new ApplicationContext();
private ApplicationContext() {
initializeBeans();
}
public static ApplicationContext getInstance() {
return instance;
}
}

public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ...省略设置apiStatInfo数据值的代码
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}
}

现在,我们再来看下,基于重构之后的代码,如果再添加上面讲到的那个新功能,每秒钟接口超时请求个数超过某个最大阈值就告警,我们又该如何改动代码呢?主要的改动有下面四处。

  • 第一处改动是:在ApiStatInfo类中添加新的属性timeoutCount。
  • 第二处改动是:添加新的TimeoutAlertHander类。
  • 第三处改动是:在ApplicationContext类的initializeBeans()方法中,往alert对象中注册新的timeoutAlertHandler。
  • 第四处改动是:在使用Alert类的时候,需要给check()函数的入参apiStatInfo对象设置timeoutCount的值。

改动之后的代码如下所示:

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
34
35
36
37
38
public class Alert { // 代码未改动... }
public class ApiStatInfo {//省略constructor/getter/setter方法
private String api;
private long requestCount;
private long errorCount;
private long durationOfSeconds;
private long timeoutCount; // 改动一:添加新字段
}
public abstract class AlertHandler { //代码未改动... }
public class TpsAlertHandler extends AlertHandler {//代码未改动...}
public class ErrorAlertHandler extends AlertHandler {//代码未改动...}
// 改动二:添加新的handler
public class TimeoutAlertHandler extends AlertHandler {//省略代码...}

public class ApplicationContext {
private AlertRule alertRule;
private Notification notification;
private Alert alert;

public void initializeBeans() {
alertRule = new AlertRule(/*.省略参数.*/); //省略一些初始化代码
notification = new Notification(/*.省略参数.*/); //省略一些初始化代码
alert = new Alert();
alert.addAlertHandler(new TpsAlertHandler(alertRule, notification));
alert.addAlertHandler(new ErrorAlertHandler(alertRule, notification));
// 改动三:注册handler
alert.addAlertHandler(new TimeoutAlertHandler(alertRule, notification));
}
//...省略其他未改动代码...
}

public class Demo {
public static void main(String[] args) {
ApiStatInfo apiStatInfo = new ApiStatInfo();
// ...省略apiStatInfo的set字段代码
apiStatInfo.setTimeoutCount(289); // 改动四:设置tiemoutCount值
ApplicationContext.getInstance().getAlert().check(apiStatInfo);
}

重构之后的代码更加灵活和易扩展。如果我们要想添加新的告警逻辑,只需要基于扩展的方式创建新的handler类即可,不需要改动原来的check()函数的逻辑。而且,我们只需要为新的handler类添加单元测试,老的单元测试都不会失败,也不用修改。

修改代码就意味着违背开闭原则吗?

看了上面重构之后的代码,你可能还会有疑问:在添加新的告警逻辑的时候,尽管改动二(添加新的handler类)是基于扩展而非修改的方式来完成的,但改动一、三、四貌似不是基于扩展而是基于修改的方式来完成的,那改动一、三、四不就违背了开闭原则吗?

我们先来分析一下改动一:往ApiStatInfo类中添加新的属性timeoutCount。

实际上,我们不仅往ApiStatInfo类中添加了属性,还添加了对应的getter/setter方法。那这个问题就转化为:给类中添加新的属性和方法,算作“修改”还是“扩展”?

我们再一块回忆一下开闭原则的定义:软件实体(模块、类、方法等)应该“对扩展开放、对修改关闭”。从定义中,我们可以看出,开闭原则可以应用在不同粒度的代码中,可以是模块,也可以类,还可以是方法(及其属性)。同样一个代码改动,在粗代码粒度下,被认定为“修改”,在细代码粒度下,又可以被认定为“扩展”。比如,改动一,添加属性和方法相当于修改类,在类这个层面,这个代码改动可以被认定为“修改”;但这个代码改动并没有修改已有的属性和方法,在方法(及其属性)这一层面,它又可以被认定为“扩展”。

实际上,我们也没必要纠结某个代码改动是“修改”还是“扩展”,更没必要太纠结它是否违反“开闭原则”。我们回到这条原则的设计初衷:只要它没有破坏原有的代码的正常运行,没有破坏原有的单元测试,我们就可以说,这是一个合格的代码改动。

我们再来分析一下改动三和改动四:在ApplicationContext类的initializeBeans()方法中,往alert对象中注册新的timeoutAlertHandler;在使用Alert类的时候,需要给check()函数的入参apiStatInfo对象设置timeoutCount的值。

这两处改动都是在方法内部进行的,不管从哪个层面(模块、类、方法)来讲,都不能算是“扩展”,而是地地道道的“修改”。不过,有些修改是在所难免的,是可以被接受的。为什么这么说呢?我来解释一下。

在重构之后的Alert代码中,我们的核心逻辑集中在Alert类及其各个handler中,当我们在添加新的告警逻辑的时候,Alert类完全不需要修改,而只需要扩展一个新handler类。如果我们把Alert类及各个handler类合起来看作一个“模块”,那模块本身在添加新的功能的时候,完全满足开闭原则。

而且,我们要认识到,添加一个新功能,不可能任何模块、类、方法的代码都不“修改”,这个是做不到的。类需要创建、组装、并且做一些初始化操作,才能构建成可运行的的程序,这部分代码的修改是在所难免的。我们要做的是尽量让修改操作更集中、更少、更上层,尽量让最核心、最复杂的那部分逻辑代码满足开闭原则。

如何做到“对扩展开放、修改关闭”?

在刚刚的例子中,我们通过引入一组handler的方式来实现支持开闭原则。如果你没有太多复杂代码的设计和开发经验,你可能会有这样的疑问:这样的代码设计思路我怎么想不到呢?你是怎么想到的呢?

先给你个结论,之所以我能想到,靠的就是理论知识和实战经验,这些需要你慢慢学习和积累。对于如何做到“对扩展开放、修改关闭”,我们也有一些指导思想和具体的方法论,我们一块来看一下。

实际上,开闭原则讲的就是代码的扩展性问题,是判断一段代码是否易扩展的“金标准”。如果某段代码在应对未来需求变化的时候,能够做到“对扩展开放、对修改关闭”,那就说明这段代码的扩展性比较好。所以,问如何才能做到“对扩展开放、对修改关闭”,也就粗略地等同于在问,如何才能写出扩展性好的代码。

在讲具体的方法论之前,我们先来看一些更加偏向顶层的指导思想。为了尽量写出扩展性好的代码,我们要时刻具备扩展意识、抽象意识、封装意识。这些“潜意识”可能比任何开发技巧都重要。

在写代码的时候后,我们要多花点时间往前多思考一下,这段代码未来可能有哪些需求变更、如何设计代码结构,事先留好扩展点,以便在未来需求变更的时候,不需要改动代码整体结构、做到最小代码改动的情况下,新的代码能够很灵活地插入到扩展点上,做到“对扩展开放、对修改关闭”。

还有,在识别出代码可变部分和不可变部分之后,我们要将可变部分封装起来,隔离变化,提供抽象化的不可变接口,给上层系统使用。当具体的实现发生变化的时候,我们只需要基于相同的抽象接口,扩展一个新的实现,替换掉老的实现即可,上游系统的代码几乎不需要修改。

刚刚我们讲了实现开闭原则的一些偏向顶层的指导思想,现在我们再来看下,支持开闭原则的一些更加具体的方法论。

我们前面讲到,代码的扩展性是代码质量评判的最重要的标准之一。实际上,我们整个专栏的大部分知识点都是围绕扩展性问题来讲解的。专栏中讲到的很多设计原则、设计思想、设计模式,都是以提高代码的扩展性为最终目的的。特别是23种经典设计模式,大部分都是为了解决代码的扩展性问题而总结出来的,都是以开闭原则为指导原则的。

在众多的设计原则、思想、模式中,最常用来提高代码扩展性的方法有:多态、依赖注入、基于接口而非实现编程,以及大部分的设计模式(比如,装饰、策略、模板、职责链、状态等)。设计模式这一部分内容比较多,后面课程中我们能会详细讲到,这里就不展开了。今天我重点讲一下,如何利用多态、依赖注入、基于接口而非实现编程,来实现“对扩展开放、对修改关闭”。

实际上,多态、依赖注入、基于接口而非实现编程,以及前面提到的抽象意识,说的都是同一种设计思路,只是从不同的角度、不同的层面来阐述而已。这也体现了“很多设计原则、思想、模式都是相通的”这一思想。

接下来,我就通过一个例子来解释一下,如何利用这几个设计思想或原则来实现“对扩展开放、对修改关闭”。注意,依赖注入后面会讲到,如果你对这块不了解,可以暂时先忽略这个概念,只关注多态、基于接口而非实现编程以及抽象意识。

比如,我们代码中通过Kafka来发送异步消息。对于这样一个功能的开发,我们要学会将其抽象成一组跟具体消息队列(Kafka)无关的异步消息接口。所有上层系统都依赖这组抽象的接口编程,并且通过依赖注入的方式来调用。当我们要替换新的消息队列的时候,比如将Kafka替换成RocketMQ,可以很方便地拔掉老的消息队列实现,插入新的消息队列实现。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 这一部分体现了抽象意识
public interface MessageQueue { //... }
public class KafkaMessageQueue implements MessageQueue { //... }
public class RocketMQMessageQueue implements MessageQueue {//...}

public interface MessageFromatter { //... }
public class JsonMessageFromatter implements MessageFromatter {//...}
public class ProtoBufMessageFromatter implements MessageFromatter {//...}

public class Demo {
private MessageQueue msgQueue; // 基于接口而非实现编程
public Demo(MessageQueue msgQueue) { // 依赖注入
this.msgQueue = msgQueue;
}

// msgFormatter:多态、依赖注入
public void sendNotification(Notification notification, MessageFormatter msgFormatter) {
//...
}
}

对于如何写出扩展性好的代码、如何实现“对扩展开放、对修改关闭”这个问题,我今天只是比较笼统地总结了一下,详细的知识我们在后面的章节中慢慢学习。

如何在项目中灵活应用开闭原则?

前面我们提到,写出支持“对扩展开放、对修改关闭”的代码的关键是预留扩展点。那问题是如何才能识别出所有可能的扩展点呢?

如果你开发的是一个业务导向的系统,比如金融系统、电商系统、物流系统等,要想识别出尽可能多的扩展点,就要对业务有足够的了解,能够知道当下以及未来可能要支持的业务需求。如果你开发的是跟业务无关的、通用的、偏底层的系统,比如,框架、组件、类库,你需要了解“它们会被如何使用?今后你打算添加哪些功能?使用者未来会有哪些更多的功能需求?”等问题。

不过,有一句话说得好,“唯一不变的只有变化本身”。即便我们对业务、对系统有足够的了解,那也不可能识别出所有的扩展点,即便你能识别出所有的扩展点,为这些地方都预留扩展点,这样做的成本也是不可接受的。我们没必要为一些遥远的、不一定发生的需求去提前买单,做过度设计。

最合理的做法是,对于一些比较确定的、短期内可能就会扩展,或者需求改动对代码结构影响比较大的情况,或者实现成本不高的扩展点,在编写代码的时候之后,我们就可以事先做些扩展性设计。但对于一些不确定未来是否要支持的需求,或者实现起来比较复杂的扩展点,我们可以等到有需求驱动的时候,再通过重构代码的方式来支持扩展的需求。

而且,开闭原则也并不是免费的。有些情况下,代码的扩展性会跟可读性相冲突。比如,我们之前举的Alert告警的例子。为了更好地支持扩展性,我们对代码进行了重构,重构之后的代码要比之前的代码复杂很多,理解起来也更加有难度。很多时候,我们都需要在扩展性和可读性之间做权衡。在某些场景下,代码的扩展性很重要,我们就可以适当地牺牲一些代码的可读性;在另一些场景下,代码的可读性更加重要,那我们就适当地牺牲一些代码的可扩展性。

在我们之前举的Alert告警的例子中,如果告警规则并不是很多、也不复杂,那check()函数中的if语句就不会很多,代码逻辑也不复杂,代码行数也不多,那最初的第一种代码实现思路简单易读,就是比较合理的选择。相反,如果告警规则很多、很复杂,check()函数的if语句、代码逻辑就会很多、很复杂,相应的代码行数也会很多,可读性、可维护性就会变差,那重构之后的第二种代码实现思路就是更加合理的选择了。总之,这里没有一个放之四海而皆准的参考标准,全凭实际的应用场景来决定。

15. SOLID:里式替换原则

主要通过几个反例,带你看看,哪些代码是违反里式替换原则的?我们该如何将它们改造成满足里式替换原则?除此之外,这条原则从定义上看起来,跟我们之前讲过的“多态”有点类似。所以,我今天也会讲一下,它跟多态的区别。

如何理解“里式替换原则”?

里式替换原则的英文翻译是:Liskov Substitution Principle,缩写为LSP。这个原则最早是在1986年由Barbara Liskov提出,他是这么描述这条原则的:

If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program。

在1996年,Robert Martin在他的SOLID原则中,重新描述了这个原则,英文原话是这样的:

Functions that use pointers of references to base classes must be able to use objects of derived classes without knowing it。

我们综合两者的描述,将这条原则用中文描述出来,是这样的:子类对象(object of subtype/derived class)能够替换程序(program)中父类对象(object of base/parent class)出现的任何地方,并且保证原来程序的逻辑行为(behavior)不变及正确性不被破坏。

这么说还是比较抽象,我们通过一个例子来解释一下。如下代码中,父类Transporter使用org.apache.http库中的HttpClient类来传输网络数据。子类SecurityTransporter继承父类Transporter,增加了额外的功能,支持传输appId和appToken安全认证信息。

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
34
35
36
37
38
39
40
41
42
43
44
public class Transporter {
private HttpClient httpClient;

public Transporter(HttpClient httpClient) {
this.httpClient = httpClient;
}

public Response sendRequest(Request request) {
// ...use httpClient to send request
}
}

public class SecurityTransporter extends Transporter {
private String appId;
private String appToken;

public SecurityTransporter(HttpClient httpClient, String appId, String appToken) {
super(httpClient);
this.appId = appId;
this.appToken = appToken;
}

@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}

public class Demo {
public void demoFunction(Transporter transporter) {
Reuqest request = new Request();
//...省略设置request中数据值的代码...
Response response = transporter.sendRequest(request);
//...省略其他逻辑...
}
}

// 里式替换原则
Demo demo = new Demo();
demo.demofunction(new SecurityTransporter(/*省略参数*/););

在上面的代码中,子类SecurityTransporter的设计完全符合里式替换原则,可以替换父类出现的任何位置,并且原来代码的逻辑行为不变且正确性也没有被破坏。

不过,你可能会有这样的疑问,刚刚的代码设计不就是简单利用了面向对象的多态特性吗?多态和里式替换原则说的是不是一回事呢?从刚刚的例子和定义描述来看,里式替换原则跟多态看起来确实有点类似,但实际上它们完全是两回事。为什么这么说呢?

我们还是通过刚才这个例子来解释一下。不过,我们需要对SecurityTransporter类中sendRequest()函数稍加改造一下。改造前,如果appId或者appToken没有设置,我们就不做校验;改造后,如果appId或者appToken没有设置,则直接抛出NoAuthorizationRuntimeException未授权异常。改造前后的代码对比如下所示:

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 SecurityTransporter extends Transporter {
//...省略其他代码..
@Override
public Response sendRequest(Request request) {
if (StringUtils.isNotBlank(appId) && StringUtils.isNotBlank(appToken)) {
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
}
return super.sendRequest(request);
}
}

// 改造后:
public class SecurityTransporter extends Transporter {
//...省略其他代码..
@Override
public Response sendRequest(Request request) {
if (StringUtils.isBlank(appId) || StringUtils.isBlank(appToken)) {
throw new NoAuthorizationRuntimeException(...);
}
request.addPayload("app-id", appId);
request.addPayload("app-token", appToken);
return super.sendRequest(request);
}
}

在改造之后的代码中,如果传递进demoFunction()函数的是父类Transporter对象,那demoFunction()函数并不会有异常抛出,但如果传递给demoFunction()函数的是子类SecurityTransporter对象,那demoFunction()有可能会有异常抛出。尽管代码中抛出的是运行时异常(Runtime Exception),我们可以不在代码中显式地捕获处理,但子类替换父类传递进demoFunction函数之后,整个程序的逻辑行为有了改变。

虽然改造之后的代码仍然可以通过Java的多态语法,动态地用子类SecurityTransporter来替换父类Transporter,也并不会导致程序编译或者运行报错。但是,从设计思路上来讲,SecurityTransporter的设计是不符合里式替换原则的。

好了,我们稍微总结一下。虽然从定义描述和代码实现上来看,多态和里式替换有点类似,但它们关注的角度是不一样的:

  • 多态是面向对象编程的一大特性,也是面向对象编程语言的一种语法。它是一种代码实现的思路。
  • 里式替换是一种设计原则,是用来指导继承关系中子类该如何设计的,子类的设计要保证在替换父类的时候,不改变原有程序的逻辑以及不破坏原有程序的正确性。

哪些代码明显违背了LSP?

实际上,里式替换原则还有另外一个更加能落地、更有指导意义的描述,那就是“Design By Contract”,中文翻译就是“按照协议来设计”。

看起来比较抽象,我来进一步解读一下。子类在设计的时候,要遵守父类的行为约定(或者叫协议)。父类定义了函数的行为约定,那子类可以改变函数的内部实现逻辑,但不能改变函数原有的行为约定。这里的行为约定包括:函数声明要实现的功能;对输入、输出、异常的约定;甚至包括注释中所罗列的任何特殊说明。实际上,定义中父类和子类之间的关系,也可以替换成接口和实现类之间的关系。

为了更好地理解这句话,我举几个违反里式替换原则的例子来解释一下。

1.子类违背父类声明要实现的功能

父类中提供的sortOrdersByAmount()订单排序函数,是按照金额从小到大来给订单排序的,而子类重写这个sortOrdersByAmount()订单排序函数之后,是按照创建日期来给订单排序的。那子类的设计就违背里式替换原则。

2.子类违背父类对输入、输出、异常的约定

在父类中,某个函数约定:运行出错的时候返回null;获取数据为空的时候返回空集合(empty collection)。而子类重载函数之后,实现变了,运行出错返回异常(exception),获取不到数据返回null。那子类的设计就违背里式替换原则。

在父类中,某个函数约定,输入数据可以是任意整数,但子类实现的时候,只允许输入数据是正整数,负数就抛出,也就是说,子类对输入的数据的校验比父类更加严格,那子类的设计就违背了里式替换原则。

在父类中,某个函数约定,只会抛出ArgumentNullException异常,那子类的设计实现中只允许抛出ArgumentNullException异常,任何其他异常的抛出,都会导致子类违背里式替换原则。

3.子类违背父类注释中所罗列的任何特殊说明

父类中定义的withdraw()提现函数的注释是这么写的:“用户的提现金额不得超过账户余额……”,而子类重写withdraw()函数之后,针对VIP账号实现了透支提现的功能,也就是提现金额可以大于账户余额,那这个子类的设计也是不符合里式替换原则的。

以上便是三种典型的违背里式替换原则的情况。除此之外,判断子类的设计实现是否违背里式替换原则,还有一个小窍门,那就是拿父类的单元测试去验证子类的代码。如果某些单元测试运行失败,就有可能说明,子类的设计实现没有完全地遵守父类的约定,子类有可能违背了里式替换原则。

实际上,你有没有发现,里式替换这个原则是非常宽松的。一般情况下,我们写的代码都不怎么会违背它。所以,只要你能看懂我今天讲的这些,这个原则就不难掌握,也不难应用。

16. SOLID:接口隔离原则

如何理解“接口隔离原则”?

接口隔离原则的英文翻译是“ Interface Segregation Principle”,缩写为ISP。Robert Martin在SOLID原则中是这样定义它的:“Clients should not be forced to depend upon interfaces that they do not use。”直译成中文的话就是:客户端不应该被强迫依赖它不需要的接口。其中的“客户端”,可以理解为接口的调用者或者使用者。

实际上,“接口”这个名词可以用在很多场合中。生活中我们可以用它来指插座接口等。在软件开发中,我们既可以把它看作一组抽象的约定,也可以具体指系统与系统之间的API接口,还可以特指面向对象编程语言中的接口等。

前面我提到,理解接口隔离原则的关键,就是理解其中的“接口”二字。在这条原则中,我们可以把“接口”理解为下面三种东西:

  • 一组API接口集合
  • 单个API接口或函数
  • OOP中的接口概念

接下来,我就按照这三种理解方式来详细讲一下,在不同的场景下,这条原则具体是如何解读和应用的。

把“接口”理解为一组API接口集合

我们还是结合一个例子来讲解。微服务用户系统提供了一组跟用户相关的API给其他系统使用,比如:注册、登录、获取用户信息等。具体代码如下所示:

1
2
3
4
5
6
7
8
9
10
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}

public class UserServiceImpl implements UserService {
//...
}

现在,我们的后台管理系统要实现删除用户的功能,希望用户系统提供一个删除用户的接口。这个时候我们该如何来做呢?你可能会说,这不是很简单吗,我只需要在UserService中新添加一个deleteUserByCellphone()或deleteUserById()接口就可以了。这个方法可以解决问题,但是也隐藏了一些安全隐患。

删除用户是一个非常慎重的操作,我们只希望通过后台管理系统来执行,所以这个接口只限于给后台管理系统使用。如果我们把它放到UserService中,那所有使用到UserService的系统,都可以调用这个接口。不加限制地被其他业务系统调用,就有可能导致误删用户。

当然,最好的解决方案是从架构设计的层面,通过接口鉴权的方式来限制接口的调用。不过,如果暂时没有鉴权框架来支持,我们还可以从代码设计的层面,尽量避免接口被误用。我们参照接口隔离原则,调用者不应该强迫依赖它不需要的接口,将删除接口单独放到另外一个接口RestrictedUserService中,然后将RestrictedUserService只打包提供给后台管理系统来使用。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface UserService {
boolean register(String cellphone, String password);
boolean login(String cellphone, String password);
UserInfo getUserInfoById(long id);
UserInfo getUserInfoByCellphone(String cellphone);
}

public interface RestrictedUserService {
boolean deleteUserByCellphone(String cellphone);
boolean deleteUserById(long id);
}

public class UserServiceImpl implements UserService, RestrictedUserService {
// ...省略实现代码...
}

在刚刚的这个例子中,我们把接口隔离原则中的接口,理解为一组接口集合,它可以是某个微服务的接口,也可以是某个类库的接口等等。在设计微服务或者类库接口的时候,如果部分接口只被部分调用者使用,那我们就需要将这部分接口隔离出来,单独给对应的调用者使用,而不是强迫其他调用者也依赖这部分不会被用到的接口。

把“接口”理解为单个API接口或函数

现在我们再换一种理解方式,把接口理解为单个接口或函数(以下为了方便讲解,我都简称为“函数”)。那接口隔离原则就可以理解为:函数的设计要功能单一,不要将多个不同的功能逻辑在一个函数中实现。接下来,我们还是通过一个例子来解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Statistics {
private Long max;
private Long min;
private Long average;
private Long sum;
private Long percentile99;
private Long percentile999;
//...省略constructor/getter/setter等方法...
}

public Statistics count(Collection<Long> dataSet) {
Statistics statistics = new Statistics();
//...省略计算逻辑...
return statistics;
}

在上面的代码中,count()函数的功能不够单一,包含很多不同的统计功能,比如,求最大值、最小值、平均值等等。按照接口隔离原则,我们应该把count()函数拆成几个更小粒度的函数,每个函数负责一个独立的统计功能。拆分之后的代码如下所示:

1
2
3
4
public Long max(Collection<Long> dataSet) { //... }
public Long min(Collection<Long> dataSet) { //... }
public Long average(Colletion<Long> dataSet) { //... }
// ...省略其他统计函数...

不过,你可能会说,在某种意义上讲,count()函数也不能算是职责不够单一,毕竟它做的事情只跟统计相关。我们在讲单一职责原则的时候,也提到过类似的问题。实际上,判定功能是否单一,除了很强的主观性,还需要结合具体的场景。

如果在项目中,对每个统计需求,Statistics定义的那几个统计信息都有涉及,那count()函数的设计就是合理的。相反,如果每个统计需求只涉及Statistics罗列的统计信息中一部分,比如,有的只需要用到max、min、average这三类统计信息,有的只需要用到average、sum。而count()函数每次都会把所有的统计信息计算一遍,就会做很多无用功,势必影响代码的性能,特别是在需要统计的数据量很大的时候。所以,在这个应用场景下,count()函数的设计就有点不合理了,我们应该按照第二种设计思路,将其拆分成粒度更细的多个统计函数。

不过,你应该已经发现,接口隔离原则跟单一职责原则有点类似,不过稍微还是有点区别。单一职责原则针对的是模块、类、接口的设计。而接口隔离原则相对于单一职责原则,一方面它更侧重于接口的设计,另一方面它的思考的角度不同。它提供了一种判断接口是否职责单一的标准:通过调用者如何使用接口来间接地判定。如果调用者只使用部分接口或接口的部分功能,那接口的设计就不够职责单一。

把“接口”理解为OOP中的接口概念

除了刚讲过的两种理解方式,我们还可以把“接口”理解为OOP中的接口概念,比如Java中的interface。我还是通过一个例子来给你解释。

假设我们的项目中用到了三个外部系统:Redis、MySQL、Kafka。每个系统都对应一系列配置信息,比如地址、端口、访问超时时间等。为了在内存中存储这些配置信息,供项目中的其他模块来使用,我们分别设计实现了三个Configuration类:RedisConfig、MysqlConfig、KafkaConfig。具体的代码实现如下所示。注意,这里我只给出了RedisConfig的代码实现,另外两个都是类似的,我这里就不贴了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class RedisConfig {
private ConfigSource configSource; //配置中心(比如zookeeper)
private String address;
private int timeout;
private int maxTotal;
//省略其他配置: maxWaitMillis,maxIdle,minIdle...

public RedisConfig(ConfigSource configSource) {
this.configSource = configSource;
}

public String getAddress() {
return this.address;
}
//...省略其他get()、init()方法...

public void update() {
//从configSource加载配置到address/timeout/maxTotal...
}
}

public class KafkaConfig { //...省略... }
public class MysqlConfig { //...省略... }

现在,我们有一个新的功能需求,希望支持Redis和Kafka配置信息的热更新。所谓“热更新(hot update)”就是,如果在配置中心中更改了配置信息,我们希望在不用重启系统的情况下,能将最新的配置信息加载到内存中(也就是RedisConfig、KafkaConfig类中)。但是,因为某些原因,我们并不希望对MySQL的配置信息进行热更新。

为了实现这样一个功能需求,我们设计实现了一个ScheduledUpdater类,以固定时间频率(periodInSeconds)来调用RedisConfig、KafkaConfig的update()方法更新配置信息。具体的代码实现如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public interface Updater {
void update();
}

public class RedisConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}

public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}

public class MysqlConfig { //...省略其他属性和方法... }

public class ScheduledUpdater {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();;
private long initialDelayInSeconds;
private long periodInSeconds;
private Updater updater;

public ScheduleUpdater(Updater updater, long initialDelayInSeconds, long periodInSeconds) {
this.updater = updater;
this.initialDelayInSeconds = initialDelayInSeconds;
this.periodInSeconds = periodInSeconds;
}

public void run() {
executor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
updater.update();
}
}, this.initialDelayInSeconds, this.periodInSeconds, TimeUnit.SECONDS);
}
}

public class Application {
ConfigSource configSource = new ZookeeperConfigSource(/*省略参数*/);
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MysqlConfig(configSource);

public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater = new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();

ScheduledUpdater kafkaConfigUpdater = new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();
}
}

刚刚的热更新的需求我们已经搞定了。现在,我们又有了一个新的监控功能需求。通过命令行来查看Zookeeper中的配置信息是比较麻烦的。所以,我们希望能有一种更加方便的配置信息查看方式。

我们可以在项目中开发一个内嵌的SimpleHttpServer,输出项目的配置信息到一个固定的HTTP地址,比如:http://127.0.0.1:2389/config 。我们只需要在浏览器中输入这个地址,就可以显示出系统的配置信息。不过,出于某些原因,我们只想暴露MySQL和Redis的配置信息,不想暴露Kafka的配置信息。

为了实现这样一个功能,我们还需要对上面的代码做进一步改造。改造之后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
public interface Updater {
void update();
}

public interface Viewer {
String outputInPlainText();
Map<String, String> output();
}

public class RedisConfig implements Updater, Viewer {
//...省略其他属性和方法...
@Override
public void update() { //... }
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}

public class KafkaConfig implements Updater {
//...省略其他属性和方法...
@Override
public void update() { //... }
}

public class MysqlConfig implements Viewer {
//...省略其他属性和方法...
@Override
public String outputInPlainText() { //... }
@Override
public Map<String, String> output() { //...}
}

public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Viewer>> viewers = new HashMap<>();

public SimpleHttpServer(String host, int port) {//...}

public void addViewers(String urlDirectory, Viewer viewer) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Viewer>());
}
this.viewers.get(urlDirectory).add(viewer);
}

public void run() { //... }
}

public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mysqlConfig = new MySqlConfig(configSource);

public static void main(String[] args) {
ScheduledUpdater redisConfigUpdater =
new ScheduledUpdater(redisConfig, 300, 300);
redisConfigUpdater.run();

ScheduledUpdater kafkaConfigUpdater =
new ScheduledUpdater(kafkaConfig, 60, 60);
redisConfigUpdater.run();

SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mysqlConfig);
simpleHttpServer.run();
}
}

至此,热更新和监控的需求我们就都实现了。我们来回顾一下这个例子的设计思想。

我们设计了两个功能非常单一的接口:Updater和Viewer。ScheduledUpdater只依赖Updater这个跟热更新相关的接口,不需要被强迫去依赖不需要的Viewer接口,满足接口隔离原则。同理,SimpleHttpServer只依赖跟查看信息相关的Viewer接口,不依赖不需要的Updater接口,也满足接口隔离原则。

你可能会说,如果我们不遵守接口隔离原则,不设计Updater和Viewer两个小接口,而是设计一个大而全的Config接口,让RedisConfig、KafkaConfig、MysqlConfig都实现这个Config接口,并且将原来传递给ScheduledUpdater的Updater和传递给SimpleHttpServer的Viewer,都替换为Config,那会有什么问题呢?我们先来看一下,按照这个思路来实现的代码是什么样的。

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
34
35
36
37
38
39
40
41
42
43
44
45
public interface Config {
void update();
String outputInPlainText();
Map<String, String> output();
}

public class RedisConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}

public class KafkaConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}

public class MysqlConfig implements Config {
//...需要实现Config的三个接口update/outputIn.../output
}

public class ScheduledUpdater {
//...省略其他属性和方法..
private Config config;

public ScheduleUpdater(Config config, long initialDelayInSeconds, long periodInSeconds) {
this.config = config;
//...
}
//...
}

public class SimpleHttpServer {
private String host;
private int port;
private Map<String, List<Config>> viewers = new HashMap<>();

public SimpleHttpServer(String host, int port) {//...}

public void addViewer(String urlDirectory, Config config) {
if (!viewers.containsKey(urlDirectory)) {
viewers.put(urlDirectory, new ArrayList<Config>());
}
viewers.get(urlDirectory).add(config);
}

public void run() { //... }
}

这样的设计思路也是能工作的,但是对比前后两个设计思路,在同样的代码量、实现复杂度、同等可读性的情况下,第一种设计思路显然要比第二种好很多。为什么这么说呢?主要有两点原因。

首先,第一种设计思路更加灵活、易扩展、易复用。因为Updater、Viewer职责更加单一,单一就意味了通用、复用性好。比如,我们现在又有一个新的需求,开发一个Metrics性能统计模块,并且希望将Metrics也通过SimpleHttpServer显示在网页上,以方便查看。这个时候,尽管Metrics跟RedisConfig等没有任何关系,但我们仍然可以让Metrics类实现非常通用的Viewer接口,复用SimpleHttpServer的代码实现。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ApiMetrics implements Viewer {//...}
public class DbMetrics implements Viewer {//...}

public class Application {
ConfigSource configSource = new ZookeeperConfigSource();
public static final RedisConfig redisConfig = new RedisConfig(configSource);
public static final KafkaConfig kafkaConfig = new KakfaConfig(configSource);
public static final MySqlConfig mySqlConfig = new MySqlConfig(configSource);
public static final ApiMetrics apiMetrics = new ApiMetrics();
public static final DbMetrics dbMetrics = new DbMetrics();

public static void main(String[] args) {
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(“127.0.0.1”, 2389);
simpleHttpServer.addViewer("/config", redisConfig);
simpleHttpServer.addViewer("/config", mySqlConfig);
simpleHttpServer.addViewer("/metrics", apiMetrics);
simpleHttpServer.addViewer("/metrics", dbMetrics);
simpleHttpServer.run();
}
}

其次,第二种设计思路在代码实现上做了一些无用功。因为Config接口中包含两类不相关的接口,一类是update(),一类是output()和outputInPlainText()。理论上,KafkaConfig只需要实现update()接口,并不需要实现output()相关的接口。同理,MysqlConfig只需要实现output()相关接口,并需要实现update()接口。但第二种设计思路要求RedisConfig、KafkaConfig、MySqlConfig必须同时实现Config的所有接口函数(update、output、outputInPlainText)。除此之外,如果我们要往Config中继续添加一个新的接口,那所有的实现类都要改动。相反,如果我们的接口粒度比较小,那涉及改动的类就比较少。

17. SOLID:依赖反转原则

这个原则用起来比较简单,但概念理解起来比较难。比如,下面这几个问题,你看看能否清晰地回答出来:

  • “依赖反转”这个概念指的是“谁跟谁”的“什么依赖”被反转了?“反转”两个字该如何理解?
  • 我们还经常听到另外两个概念:“控制反转”和“依赖注入”。这两个概念跟“依赖反转”有什么区别和联系呢?它们说的是同一个事情吗?
  • 如果你熟悉Java语言,那Spring框架中的IOC跟这些概念又有什么关系呢?

控制反转(IOC)

在讲“依赖反转原则”之前,我们先讲一讲“控制反转”。控制反转的英文翻译是Inversion Of Control,缩写为IOC。此处我要强调一下,如果你是Java工程师的话,暂时别把这个“IOC”跟Spring框架的IOC联系在一起。关于Spring的IOC,我们待会儿还会讲到。

我们先通过一个例子来看一下,什么是控制反转。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserServiceTest {
public static boolean doTest() {
// ...
}

public static void main(String[] args) {//这部分逻辑可以放到框架中
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}
}

在上面的代码中,所有的流程都由程序员来控制。如果我们抽象出一个下面这样一个框架,我们再来看,如何利用框架来实现同样的功能。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class TestCase {
public void run() {
if (doTest()) {
System.out.println("Test succeed.");
} else {
System.out.println("Test failed.");
}
}

public abstract boolean doTest();
}

public class JunitApplication {
private static final List<TestCase> testCases = new ArrayList<>();

public static void register(TestCase testCase) {
testCases.add(testCase);
}

public static final void main(String[] args) {
for (TestCase case: testCases) {
case.run();
}
}

把这个简化版本的测试框架引入到工程中之后,我们只需要在框架预留的扩展点,也就是TestCase类中的doTest()抽象函数中,填充具体的测试代码就可以实现之前的功能了,完全不需要写负责执行流程的main()函数了。 具体的代码如下所示:

1
2
3
4
5
6
7
8
9
public class UserServiceTest extends TestCase {
@Override
public boolean doTest() {
// ...
}
}

// 注册操作还可以通过配置的方式来实现,不需要程序员显示调用register()
JunitApplication.register(new UserServiceTest();

刚刚举的这个例子,就是典型的通过框架来实现“控制反转”的例子。框架提供了一个可扩展的代码骨架,用来组装对象、管理整个执行流程。程序员利用框架进行开发的时候,只需要往预留的扩展点上,添加跟自己业务相关的代码,就可以利用框架来驱动整个程序流程的执行。

这里的“控制”指的是对程序执行流程的控制,而“反转”指的是在没有使用框架之前,程序员自己控制整个程序的执行。在使用框架之后,整个程序的执行流程可以通过框架来控制。流程的控制权从程序员“反转”到了框架。

实际上,实现控制反转的方法有很多,除了刚才例子中所示的类似于模板设计模式的方法之外,还有马上要讲到的依赖注入等方法,所以,控制反转并不是一种具体的实现技巧,而是一个比较笼统的设计思想,一般用来指导框架层面的设计。

依赖注入(DI)

接下来,我们再来看依赖注入。依赖注入跟控制反转恰恰相反,它是一种具体的编码技巧。依赖注入的英文翻译是Dependency Injection,缩写为DI。对于这个概念,有一个非常形象的说法,那就是:依赖注入是一个标价25美元,实际上只值5美分的概念。也就是说,这个概念听起来很“高大上”,实际上,理解、应用起来非常简单。

那到底什么是依赖注入呢?我们用一句话来概括就是:不通过new()的方式在类内部创建依赖类对象,而是将依赖的类对象在外部创建好之后,通过构造函数、函数参数等方式传递(或注入)给类使用。

我们还是通过一个例子来解释一下。在这个例子中,Notification类负责消息推送,依赖MessageSender类实现推送商品促销、验证码等消息给用户。我们分别用依赖注入和非依赖注入两种方式来实现一下。具体的实现代码如下所示:

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
34
35
36
37
38
39
// 非依赖注入实现方式
public class Notification {
private MessageSender messageSender;

public Notification() {
this.messageSender = new MessageSender(); //此处有点像hardcode
}

public void sendMessage(String cellphone, String message) {
//...省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}

public class MessageSender {
public void send(String cellphone, String message) {
//....
}
}
// 使用Notification
Notification notification = new Notification();

// 依赖注入的实现方式
public class Notification {
private MessageSender messageSender;

// 通过构造函数将messageSender传递进来
public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}

public void sendMessage(String cellphone, String message) {
//...省略校验逻辑等...
this.messageSender.send(cellphone, message);
}
}
//使用Notification
MessageSender messageSender = new MessageSender();
Notification notification = new Notification(messageSender);

通过依赖注入的方式来将依赖的类对象传递进来,这样就提高了代码的扩展性,我们可以灵活地替换依赖的类。这一点在我们之前讲“开闭原则”的时候也提到过。当然,上面代码还有继续优化的空间,我们还可以把MessageSender定义成接口,基于接口而非实现编程。改造后的代码如下所示:

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
34
35
public class Notification {
private MessageSender messageSender;

public Notification(MessageSender messageSender) {
this.messageSender = messageSender;
}

public void sendMessage(String cellphone, String message) {
this.messageSender.send(cellphone, message);
}
}

public interface MessageSender {
void send(String cellphone, String message);
}

// 短信发送类
public class SmsSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}

// 站内信发送类
public class InboxSender implements MessageSender {
@Override
public void send(String cellphone, String message) {
//....
}
}

//使用Notification
MessageSender messageSender = new SmsSender();
Notification notification = new Notification(messageSender);

实际上,你只需要掌握刚刚举的这个例子,就等于完全掌握了依赖注入。尽管依赖注入非常简单,但却非常有用,在后面的章节中,我们会讲到,它是编写可测试性代码最有效的手段

依赖注入框架(DI Framework)

弄懂了什么是“依赖注入”,我们再来看一下,什么是“依赖注入框架”。我们还是借用刚刚的例子来解释。

在采用依赖注入实现的Notification类中,虽然我们不需要用类似hard code的方式,在类内部通过new来创建MessageSender对象,但是,这个创建对象、组装(或注入)对象的工作仅仅是被移动到了更上层代码而已,还是需要我们程序员自己来实现。具体代码如下所示:

1
2
3
4
5
6
7
public class Demo {
public static final void main(String args[]) {
MessageSender sender = new SmsSender(); //创建对象
Notification notification = new Notification(sender);//依赖注入
notification.sendMessage("13918942177", "短信验证码:2346");
}
}

在实际的软件开发中,一些项目可能会涉及几十、上百、甚至几百个类,类对象的创建和依赖注入会变得非常复杂。如果这部分工作都是靠程序员自己写代码来完成,容易出错且开发成本也比较高。而对象创建和依赖注入的工作,本身跟具体的业务无关,我们完全可以抽象成框架来自动完成。

你可能已经猜到,这个框架就是“依赖注入框架”。我们只需要通过依赖注入框架提供的扩展点,简单配置一下所有需要创建的类对象、类与类之间的依赖关系,就可以实现由框架来自动创建对象、管理对象的生命周期、依赖注入等原本需要程序员来做的事情。

实际上,现成的依赖注入框架有很多,比如Google Guice、Java Spring、Pico Container、Butterfly Container等。不过,如果你熟悉Java Spring框架,你可能会说,Spring框架自己声称是控制反转容器(Inversion Of Control Container)。

实际上,这两种说法都没错。只是控制反转容器这种表述是一种非常宽泛的描述,DI依赖注入框架的表述更具体、更有针对性。因为我们前面讲到实现控制反转的方式有很多,除了依赖注入,还有模板模式等,而Spring框架的控制反转主要是通过依赖注入来实现的。不过这点区分并不是很明显,也不是很重要,你稍微了解一下就可以了。

依赖反转原则(DIP)

前面讲了控制反转、依赖注入、依赖注入框架,现在,我们来讲一讲今天的主角:依赖反转原则。依赖反转原则的英文翻译是Dependency Inversion Principle,缩写为DIP。中文翻译有时候也叫依赖倒置原则。

为了追本溯源,我先给出这条原则最原汁原味的英文描述:

High-level modules shouldn’t depend on low-level modules. Both modules should depend on abstractions. In addition, abstractions shouldn’t depend on details. Details depend on abstractions.

我们将它翻译成中文,大概意思就是:高层模块(high-level modules)不要依赖低层模块(low-level)。高层模块和低层模块应该通过抽象(abstractions)来互相依赖。除此之外,抽象(abstractions)不要依赖具体实现细节(details),具体实现细节(details)依赖抽象(abstractions)。

所谓高层模块和低层模块的划分,简单来说就是,在调用链上,调用者属于高层,被调用者属于低层。在平时的业务代码开发中,高层模块依赖底层模块是没有任何问题的。实际上,这条原则主要还是用来指导框架层面的设计,跟前面讲到的控制反转类似。我们拿Tomcat这个Servlet容器作为例子来解释一下。

Tomcat是运行Java Web应用程序的容器。我们编写的Web应用程序代码只需要部署在Tomcat容器下,便可以被Tomcat容器调用执行。按照之前的划分原则,Tomcat就是高层模块,我们编写的Web应用程序代码就是低层模块。Tomcat和应用程序代码之间并没有直接的依赖关系,两者都依赖同一个“抽象”,也就是Servlet规范。Servlet规范不依赖具体的Tomcat容器和应用程序的实现细节,而Tomcat容器和应用程序依赖Servlet规范。

18. KISS原则和YAGNI原则

如何理解“KISS原则”?

KISS原则的英文描述有好几个版本,比如下面这几个。

  • Keep It Simple and Stupid.
  • Keep It Short and Simple.
  • Keep It Simple and Straightforward.

不过,仔细看你就会发现,它们要表达的意思其实差不多,翻译成中文就是:尽量保持简单。

KISS原则算是一个万金油类型的设计原则,可以应用在很多场景中。它不仅经常用来指导软件开发,还经常用来指导更加广泛的系统设计、产品设计等,比如,冰箱、建筑、iPhone手机的设计等等。不过,咱们的专栏是讲代码设计的,所以,接下来,我还是重点讲解如何在编码开发中应用这条原则。

我们知道,代码的可读性和可维护性是衡量代码质量非常重要的两个标准。而KISS原则就是保持代码可读和可维护的重要手段。代码足够简单,也就意味着很容易读懂,bug比较难隐藏。即便出现bug,修复起来也比较简单。

不过,这条原则只是告诉我们,要保持代码“Simple and Stupid”,但并没有讲到,什么样的代码才是“Simple and Stupid”的,更没有给出特别明确的方法论,来指导如何开发出“Simple and Stupid”的代码。所以,看着非常简单,但不能落地,这就有点像我们常说的“心灵鸡汤”。哦,咱们这里应该叫“技术鸡汤”。

所以,接下来,为了能让这条原则切实地落地,能够指导实际的项目开发,我就针对刚刚的这些问题来进一步讲讲我的理解。

代码行数越少就越“简单”吗?

我们先一起看一个例子。下面这三段代码可以实现同样一个功能:检查输入的字符串ipAddress是否是合法的IP地址。

一个合法的IP地址由四个数字组成,并且通过“.”来进行分割。每组数字的取值范围是0~255。第一组数字比较特殊,不允许为0。对比这三段代码,你觉得哪一段代码最符合KISS原则呢?如果让你来实现这个功能,你会选择用哪种实现方法呢?你可以先自己思考一下,然后再看我下面的讲解。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 第一种实现方式: 使用正则表达式
public boolean isValidIpAddressV1(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}

// 第二种实现方式: 使用现成的工具类
public boolean isValidIpAddressV2(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}

// 第三种实现方式: 不使用任何工具类
public boolean isValidIpAddressV3(String ipAddress) {
char[] ipChars = ipAddress.toCharArray();
int length = ipChars.length;
int ipUnitIntValue = -1;
boolean isFirstUnit = true;
int unitsCount = 0;
for (int i = 0; i < length; ++i) {
char c = ipChars[i];
if (c == '.') {
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (isFirstUnit && ipUnitIntValue == 0) return false;
if (isFirstUnit) isFirstUnit = false;
ipUnitIntValue = -1;
unitsCount++;
continue;
}
if (c < '0' || c > '9') {
return false;
}
if (ipUnitIntValue == -1) ipUnitIntValue = 0;
ipUnitIntValue = ipUnitIntValue * 10 + (c - '0');
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) return false;
if (unitsCount != 3) return false;
return true;
}

第一种实现方式利用的是正则表达式,只用三行代码就把这个问题搞定了。它的代码行数最少,那是不是就最符合KISS原则呢?答案是否定的。虽然代码行数最少,看似最简单,实际上却很复杂。这正是因为它使用了正则表达式。

一方面,正则表达式本身是比较复杂的,写出完全没有bug的正则表达本身就比较有挑战;另一方面,并不是每个程序员都精通正则表达式。对于不怎么懂正则表达式的同事来说,看懂并且维护这段正则表达式是比较困难的。这种实现方式会导致代码的可读性和可维护性变差,所以,从KISS原则的设计初衷上来讲,这种实现方式并不符合KISS原则。

讲完了第一种实现方式,我们再来看下其他两种实现方式。

第二种实现方式使用了StringUtils类、Integer类提供的一些现成的工具函数,来处理IP地址字符串。第三种实现方式,不使用任何工具函数,而是通过逐一处理IP地址中的字符,来判断是否合法。从代码行数上来说,这两种方式差不多。但是,第三种要比第二种更加有难度,更容易写出bug。从可读性上来说,第二种实现方式的代码逻辑更清晰、更好理解。所以,在这两种实现方式中,第二种实现方式更加“简单”,更加符合KISS原则。

不过,你可能会说,第三种实现方式虽然实现起来稍微有点复杂,但性能要比第二种实现方式高一些啊。从性能的角度来说,选择第三种实现方式是不是更好些呢?

在回答这个问题之前,我先解释一下,为什么说第三种实现方式性能会更高一些。一般来说,工具类的功能都比较通用和全面,所以,在代码实现上,需要考虑和处理更多的细节,执行效率就会有所影响。而第三种实现方式,完全是自己操作底层字符,只针对IP地址这一种格式的数据输入来做处理,没有太多多余的函数调用和其他不必要的处理逻辑,所以,在执行效率上,这种类似定制化的处理代码方式肯定比通用的工具类要高些。

不过,尽管第三种实现方式性能更高些,但我还是更倾向于选择第二种实现方法。那是因为第三种实现方式实际上是一种过度优化。除非isValidIpAddress()函数是影响系统性能的瓶颈代码,否则,这样优化的投入产出比并不高,增加了代码实现的难度、牺牲了代码的可读性,性能上的提升却并不明显。

代码逻辑复杂就违背KISS原则吗?

刚刚我们提到,并不是代码行数越少就越“简单”,还要考虑逻辑复杂度、实现难度、代码的可读性等。那如果一段代码的逻辑复杂、实现难度大、可读性也不太好,是不是就一定违背KISS原则呢?在回答这个问题之前,我们先来看下面这段代码:

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
34
// KMP algorithm: a, b分别是主串和模式串;n, m分别是主串和模式串的长度。
public static int kmp(char[] a, int n, char[] b, int m) {
int[] next = getNexts(b, m);
int j = 0;
for (int i = 0; i < n; ++i) {
while (j > 0 && a[i] != b[j]) { // 一直找到a[i]和b[j]
j = next[j - 1] + 1;
}
if (a[i] == b[j]) {
++j;
}
if (j == m) { // 找到匹配模式串的了
return i - m + 1;
}
}
return -1;
}

// b表示模式串,m表示模式串的长度
private static int[] getNexts(char[] b, int m) {
int[] next = new int[m];
next[0] = -1;
int k = -1;
for (int i = 1; i < m; ++i) {
while (k != -1 && b[k + 1] != b[i]) {
k = next[k];
}
if (b[k + 1] == b[i]) {
++k;
}
next[i] = k;
}
return next;
}

这段代码来自我的另一个专栏《数据结构与算法之美》中KMP字符串匹配算法的代码实现。这段代码完全符合我们刚提到的逻辑复杂、实现难度大、可读性差的特点,但它并不违反KISS原则。为什么这么说呢?

KMP算法以快速高效著称。当我们需要处理长文本字符串匹配问题(几百MB大小文本内容的匹配),或者字符串匹配是某个产品的核心功能(比如Vim、Word等文本编辑器),又或者字符串匹配算法是系统性能瓶颈的时候,我们就应该选择尽可能高效的KMP算法。而KMP算法本身具有逻辑复杂、实现难度大、可读性差的特点。本身就复杂的问题,用复杂的方法解决,并不违背KISS原则。

不过,平时的项目开发中涉及的字符串匹配问题,大部分都是针对比较小的文本。在这种情况下,直接调用编程语言提供的现成的字符串匹配函数就足够了。如果非得用KMP算法、BM算法来实现字符串匹配,那就真的违背KISS原则了。也就是说,同样的代码,在某个业务场景下满足KISS原则,换一个应用场景可能就不满足了。

如何写出满足KISS原则的代码?

实际上,我们前面已经讲到了一些方法。这里我稍微总结一下。

  • 不要使用同事可能不懂的技术来实现代码。比如前面例子中的正则表达式,还有一些编程语言中过于高级的语法等。
  • 不要重复造轮子,要善于使用已经有的工具类库。经验证明,自己去实现这些类库,出bug的概率会更高,维护的成本也比较高。
  • 不要过度优化。不要过度使用一些奇技淫巧(比如,位运算代替算术运算、复杂的条件语句代替if-else、使用一些过于底层的函数等)来优化代码,牺牲代码的可读性。

实际上,代码是否足够简单是一个挺主观的评判。同样的代码,有的人觉得简单,有的人觉得不够简单。而往往自己编写的代码,自己都会觉得够简单。所以,评判代码是否简单,还有一个很有效的间接方法,那就是code review。如果在code review的时候,同事对你的代码有很多疑问,那就说明你的代码有可能不够“简单”,需要优化啦。

这里我还想多说两句,我们在做开发的时候,一定不要过度设计,不要觉得简单的东西就没有技术含量。实际上,越是能用简单的方法解决复杂的问题,越能体现一个人的能力。

YAGNI跟KISS说的是一回事吗?

YAGNI原则的英文全称是:You Ain’t Gonna Need It。直译就是:你不会需要它。这条原则也算是万金油了。当用在软件开发中的时候,它的意思是:不要去设计当前用不到的功能;不要去编写当前用不到的代码。实际上,这条原则的核心思想就是:不要做过度设计。

比如,我们的系统暂时只用Redis存储配置信息,以后可能会用到ZooKeeper。根据YAGNI原则,在未用到ZooKeeper之前,我们没必要提前编写这部分代码。当然,这并不是说我们就不需要考虑代码的扩展性。我们还是要预留好扩展点,等到需要的时候,再去实现ZooKeeper存储配置信息这部分代码。

再比如,我们不要在项目中提前引入不需要依赖的开发包。对于Java程序员来说,我们经常使用Maven或者Gradle来管理依赖的类库(library)。我发现,有些同事为了避免开发中library包缺失而频繁地修改Maven或者Gradle配置文件,提前往项目里引入大量常用的library包。实际上,这样的做法也是违背YAGNI原则的。

从刚刚的分析我们可以看出,YAGNI原则跟KISS原则并非一回事儿。KISS原则讲的是“如何做”的问题(尽量保持简单),而YAGNI原则说的是“要不要做”的问题(当前不需要的就不要做)。

19. DRY原则

DRY原则(Don’t Repeat Yourself)

DRY原则的定义非常简单,我就不再过度解读。今天,我们主要讲三种典型的代码重复情况,它们分别是:实现逻辑重复、功能语义重复和代码执行重复。这三种代码重复,有的看似违反DRY,实际上并不违反;有的看似不违反,实际上却违反了。

实现逻辑重复

我们先来看下面这样一段代码是否违反了DRY原则。如果违反了,你觉得应该如何重构,才能让它满足DRY原则?如果没有违反,那又是为什么呢?

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class UserAuthenticator {
public void authenticate(String username, String password) {
if (!isValidUsername(username)) {
// ...throw InvalidUsernameException...
}
if (!isValidPassword(password)) {
// ...throw InvalidPasswordException...
}
//...省略其他代码...
}

private boolean isValidUsername(String username) {
// check not null, not empty
if (StringUtils.isBlank(username)) {
return false;
}
// check length: 4~64
int length = username.length();
if (length < 4 || length > 64) {
return false;
}
// contains only lowcase characters
if (!StringUtils.isAllLowerCase(username)) {
return false;
}
// contains only a~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = username.charAt(i);
if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
return false;
}
}
return true;
}

private boolean isValidPassword(String password) {
// check not null, not empty
if (StringUtils.isBlank(password)) {
return false;
}
// check length: 4~64
int length = password.length();
if (length < 4 || length > 64) {
return false;
}
// contains only lowcase characters
if (!StringUtils.isAllLowerCase(password)) {
return false;
}
// contains only a~z,0~9,dot
for (int i = 0; i < length; ++i) {
char c = password.charAt(i);
if (!(c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.') {
return false;
}
}
return true;
}
}

代码很简单,我就不做过多解释了。在代码中,有两处非常明显的重复的代码片段:isValidUserName()函数和isValidPassword()函数。重复的代码被敲了两遍,或者简单copy-paste了一下,看起来明显违反DRY原则。为了移除重复的代码,我们对上面的代码做下重构,将isValidUserName()函数和isValidPassword()函数,合并为一个更通用的函数isValidUserNameOrPassword()。重构后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class UserAuthenticatorV2 {

public void authenticate(String userName, String password) {
if (!isValidUsernameOrPassword(userName)) {
// ...throw InvalidUsernameException...
}

if (!isValidUsernameOrPassword(password)) {
// ...throw InvalidPasswordException...
}
}

private boolean isValidUsernameOrPassword(String usernameOrPassword) {
//省略实现逻辑
//跟原来的isValidUsername()或isValidPassword()的实现逻辑一样...
return true;
}
}

经过重构之后,代码行数减少了,也没有重复的代码了,是不是更好了呢?答案是否定的,这可能跟你预期的不一样,我来解释一下为什么。

单从名字上看,我们就能发现,合并之后的isValidUserNameOrPassword()函数,负责两件事情:验证用户名和验证密码,违反了“单一职责原则”和“接口隔离原则”。实际上,即便将两个函数合并成isValidUserNameOrPassword(),代码仍然存在问题。

因为isValidUserName()和isValidPassword()两个函数,虽然从代码实现逻辑上看起来是重复的,但是从语义上并不重复。所谓“语义不重复”指的是:从功能上来看,这两个函数干的是完全不重复的两件事情,一个是校验用户名,另一个是校验密码。尽管在目前的设计中,两个校验逻辑是完全一样的,但如果按照第二种写法,将两个函数的合并,那就会存在潜在的问题。在未来的某一天,如果我们修改了密码的校验逻辑,比如,允许密码包含大写字符,允许密码的长度为8到64个字符,那这个时候,isValidUserName()和isValidPassword()的实现逻辑就会不相同。我们就要把合并后的函数,重新拆成合并前的那两个函数。

尽管代码的实现逻辑是相同的,但语义不同,我们判定它并不违反DRY原则。对于包含重复代码的问题,我们可以通过抽象成更细粒度函数的方式来解决。比如将校验只包含az、09、dot的逻辑封装成boolean onlyContains(String str, String charlist);函数。

功能语义重复

现在我们再来看另外一个例子。在同一个项目代码中有下面两个函数:isValidIp()和checkIfIpValid()。尽管两个函数的命名不同,实现逻辑不同,但功能是相同的,都是用来判定IP地址是否合法的。

之所以在同一个项目中会有两个功能相同的函数,那是因为这两个函数是由两个不同的同事开发的,其中一个同事在不知道已经有了isValidIp()的情况下,自己又定义并实现了同样用来校验IP地址是否合法的checkIfIpValid()函数。

那在同一项目代码中,存在如下两个函数,是否违反DRY原则呢?

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
public boolean isValidIp(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String regex = "^(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|[1-9])\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)\\."
+ "(1\\d{2}|2[0-4]\\d|25[0-5]|[1-9]\\d|\\d)$";
return ipAddress.matches(regex);
}

public boolean checkIfIpValid(String ipAddress) {
if (StringUtils.isBlank(ipAddress)) return false;
String[] ipUnits = StringUtils.split(ipAddress, '.');
if (ipUnits.length != 4) {
return false;
}
for (int i = 0; i < 4; ++i) {
int ipUnitIntValue;
try {
ipUnitIntValue = Integer.parseInt(ipUnits[i]);
} catch (NumberFormatException e) {
return false;
}
if (ipUnitIntValue < 0 || ipUnitIntValue > 255) {
return false;
}
if (i == 0 && ipUnitIntValue == 0) {
return false;
}
}
return true;
}

这个例子跟上个例子正好相反。上一个例子是代码实现逻辑重复,但语义不重复,我们并不认为它违反了DRY原则。而在这个例子中,尽管两段代码的实现逻辑不重复,但语义重复,也就是功能重复,我们认为它违反了DRY原则。我们应该在项目中,统一一种实现思路,所有用到判断IP地址是否合法的地方,都统一调用同一个函数。

假设我们不统一实现思路,那有些地方调用了isValidIp()函数,有些地方又调用了checkIfIpValid()函数,这就会导致代码看起来很奇怪,相当于给代码“埋坑”,给不熟悉这部分代码的同事增加了阅读的难度。同事有可能研究了半天,觉得功能是一样的,但又有点疑惑,觉得是不是有更高深的考量,才定义了两个功能类似的函数,最终发现居然是代码设计的问题。

除此之外,如果哪天项目中IP地址是否合法的判定规则改变了,比如:255.255.255.255不再被判定为合法的了,相应地,我们对isValidIp()的实现逻辑做了相应的修改,但却忘记了修改checkIfIpValid()函数。又或者,我们压根就不知道还存在一个功能相同的checkIfIpValid()函数,这样就会导致有些代码仍然使用老的IP地址判断逻辑,导致出现一些莫名其妙的bug。

代码执行重复

前两个例子一个是实现逻辑重复,一个是语义重复,我们再来看第三个例子。其中,UserService中login()函数用来校验用户登录是否成功。如果失败,就返回异常;如果成功,就返回用户信息。具体代码如下所示:

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 UserService {
private UserRepo userRepo;//通过依赖注入或者IOC框架注入

public User login(String email, String password) {
boolean existed = userRepo.checkIfUserExisted(email, password);
if (!existed) {
// ... throw AuthenticationFailureException...
}
User user = userRepo.getUserByEmail(email);
return user;
}
}

public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}

if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}

//...query db to check if email&password exists...
}

public User getUserByEmail(String email) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
//...query db to get user by email...
}
}

上面这段代码,既没有逻辑重复,也没有语义重复,但仍然违反了DRY原则。这是因为代码中存在“执行重复”。我们一块儿来看下,到底哪些代码被重复执行了?

重复执行最明显的一个地方,就是在login()函数中,email的校验逻辑被执行了两次。一次是在调用checkIfUserExisted()函数的时候,另一次是调用getUserByEmail()函数的时候。这个问题解决起来比较简单,我们只需要将校验逻辑从UserRepo中移除,统一放到UserService中就可以了。

除此之外,代码中还有一处比较隐蔽的执行重复,不知道你发现了没有?实际上,login()函数并不需要调用checkIfUserExisted()函数,只需要调用一次getUserByEmail()函数,从数据库中获取到用户的email、password等信息,然后跟用户输入的email、password信息做对比,依次判断是否登录成功。

实际上,这样的优化是很有必要的。因为checkIfUserExisted()函数和getUserByEmail()函数都需要查询数据库,而数据库这类的I/O操作是比较耗时的。我们在写代码的时候,应当尽量减少这类I/O操作。

按照刚刚的修改思路,我们把代码重构一下,移除“重复执行”的代码,只校验一次email和password,并且只查询一次数据库。重构之后的代码如下所示:

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
public class UserService {
private UserRepo userRepo;//通过依赖注入或者IOC框架注入

public User login(String email, String password) {
if (!EmailValidation.validate(email)) {
// ... throw InvalidEmailException...
}
if (!PasswordValidation.validate(password)) {
// ... throw InvalidPasswordException...
}
User user = userRepo.getUserByEmail(email);
if (user == null || !password.equals(user.getPassword()) {
// ... throw AuthenticationFailureException...
}
return user;
}
}

public class UserRepo {
public boolean checkIfUserExisted(String email, String password) {
//...query db to check if email&password exists
}

public User getUserByEmail(String email) {
//...query db to get user by email...
}
}

代码复用性(Code Reusability)

在专栏的最开始,我们有提到,代码的复用性是评判代码质量的一个非常重要的标准。当时只是点到为止,没有展开讲解,今天,我再带你深入地学习一下这个知识点。

什么是代码的复用性?

我们首先来区分三个概念:代码复用性(Code Reusability)、代码复用(Code Resue)和DRY原则。

代码复用表示一种行为:我们在开发新功能的时候,尽量复用已经存在的代码。代码的可复用性表示一段代码可被复用的特性或能力:我们在编写代码的时候,让代码尽量可复用。DRY原则是一条原则:不要写重复的代码。从定义描述上,它们好像有点类似,但深究起来,三者的区别还是蛮大的。

首先,“不重复”并不代表“可复用”。在一个项目代码中,可能不存在任何重复的代码,但也并不表示里面有可复用的代码,不重复和可复用完全是两个概念。所以,从这个角度来说,DRY原则跟代码的可复用性讲的是两回事。

其次,“复用”和“可复用性”关注角度不同。代码“可复用性”是从代码开发者的角度来讲的,“复用”是从代码使用者的角度来讲的。比如,A同事编写了一个UrlUtils类,代码的“可复用性”很好。B同事在开发新功能的时候,直接“复用”A同事编写的UrlUtils类。

尽管复用、可复用性、DRY原则这三者从理解上有所区别,但实际上要达到的目的都是类似的,都是为了减少代码量,提高代码的可读性、可维护性。除此之外,复用已经经过测试的老代码,bug会比从零重新开发要少。

“复用”这个概念不仅可以指导细粒度的模块、类、函数的设计开发,实际上,一些框架、类库、组件等的产生也都是为了达到复用的目的。比如,Spring框架、Google Guava类库、UI组件等等。

怎么提高代码复用性?

实际上,我们前面已经讲到过很多提高代码可复用性的手段,今天算是集中总结一下,我总结了7条,具体如下。

  • 减少代码耦合

对于高度耦合的代码,当我们希望复用其中的一个功能,想把这个功能的代码抽取出来成为一个独立的模块、类或者函数的时候,往往会发现牵一发而动全身。移动一点代码,就要牵连到很多其他相关的代码。所以,高度耦合的代码会影响到代码的复用性,我们要尽量减少代码耦合。

  • 满足单一职责原则

我们前面讲过,如果职责不够单一,模块、类设计得大而全,那依赖它的代码或者它依赖的代码就会比较多,进而增加了代码的耦合。根据上一点,也就会影响到代码的复用性。相反,越细粒度的代码,代码的通用性会越好,越容易被复用。

  • 模块化

这里的“模块”,不单单指一组类构成的模块,还可以理解为单个类、函数。我们要善于将功能独立的代码,封装成模块。独立的模块就像一块一块的积木,更加容易复用,可以直接拿来搭建更加复杂的系统。

  • 业务与非业务逻辑分离

越是跟业务无关的代码越是容易复用,越是针对特定业务的代码越难复用。所以,为了复用跟业务无关的代码,我们将业务和非业务逻辑代码分离,抽取成一些通用的框架、类库、组件等。

  • 通用代码下沉

从分层的角度来看,越底层的代码越通用、会被越多的模块调用,越应该设计得足够可复用。一般情况下,在代码分层之后,为了避免交叉调用导致调用关系混乱,我们只允许上层代码调用下层代码及同层代码之间的调用,杜绝下层代码调用上层代码。所以,通用的代码我们尽量下沉到更下层。

  • 继承、多态、抽象、封装

在讲面向对象特性的时候,我们讲到,利用继承,可以将公共的代码抽取到父类,子类复用父类的属性和方法。利用多态,我们可以动态地替换一段代码的部分逻辑,让这段代码可复用。除此之外,抽象和封装,从更加广义的层面、而非狭义的面向对象特性的层面来理解的话,越抽象、越不依赖具体的实现,越容易复用。代码封装成模块,隐藏可变的细节、暴露不变的接口,就越容易复用。

  • 应用模板等设计模式

一些设计模式,也能提高代码的复用性。比如,模板模式利用了多态来实现,可以灵活地替换其中的部分代码,整个流程模板代码可复用。关于应用设计模式提高代码复用性这一部分,我们留在后面慢慢来讲解。

除了刚刚我们讲到的几点,还有一些跟编程语言相关的特性,也能提高代码的复用性,比如泛型编程等。实际上,除了上面讲到的这些方法之外,复用意识也非常重要。在写代码的时候,我们要多去思考一下,这个部分代码是否可以抽取出来,作为一个独立的模块、类或者函数供多处使用。在设计每个模块、类、函数的时候,要像设计一个外部API那样,去思考它的复用性。

辩证思考和灵活应用

实际上,编写可复用的代码并不简单。如果我们在编写代码的时候,已经有复用的需求场景,那根据复用的需求去开发可复用的代码,可能还不算难。但是,如果当下并没有复用的需求,我们只是希望现在编写的代码具有可复用的特点,能在未来某个同事开发某个新功能的时候复用得上。在这种没有具体复用需求的情况下,我们就需要去预测将来代码会如何复用,这就比较有挑战了。

实际上,除非有非常明确的复用需求,否则,为了暂时用不到的复用需求,花费太多的时间、精力,投入太多的开发成本,并不是一个值得推荐的做法。这也违反我们之前讲到的YAGNI原则。

除此之外,有一个著名的原则,叫作“Rule of Three”。这条原则可以用在很多行业和场景中,你可以自己去研究一下。如果把这个原则用在这里,那就是说,我们在第一次写代码的时候,如果当下没有复用的需求,而未来的复用需求也不是特别明确,并且开发可复用代码的成本比较高,那我们就不需要考虑代码的复用性。在之后我们开发新的功能的时候,发现可以复用之前写的这段代码,那我们就重构这段代码,让其变得更加可复用。

也就是说,第一次编写代码的时候,我们不考虑复用性;第二次遇到复用场景的时候,再进行重构使其复用。需要注意的是,“Rule of Three”中的“Three”并不是真的就指确切的“三”,这里就是指“二”。

20. LOD法则

最后一个设计原则:迪米特法则。尽管它不像SOLID、KISS、DRY原则那样,人尽皆知,但它却非常实用。利用这个原则,能够帮我们实现代码的“高内聚、松耦合”。我们围绕下面几个问题,并结合两个代码实战案例,来深入地学习这个法则。

  • 什么是“高内聚、松耦合”?
  • 如何利用迪米特法则来实现“高内聚、松耦合”?
  • 有哪些代码设计是明显违背迪米特法则的?对此又该如何重构?

何为“高内聚、松耦合”?

“高内聚、松耦合”是一个非常重要的设计思想,能够有效地提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际上,在前面的章节中,我们已经多次提到过这个设计思想。很多设计原则都以实现代码的“高内聚、松耦合”为目的,比如单一职责原则、基于接口而非实现编程等。

实际上,“高内聚、松耦合”是一个比较通用的设计思想,可以用来指导不同粒度代码的设计与开发,比如系统、模块、类,甚至是函数,也可以应用到不同的开发场景中,比如微服务、框架、组件、类库等。为了方便我讲解,接下来我以“类”作为这个设计思想的应用对象来展开讲解,其他应用场景你可以自行类比。

在这个设计思想中,“高内聚”用来指导类本身的设计,“松耦合”用来指导类与类之间依赖关系的设计。不过,这两者并非完全独立不相干。高内聚有助于松耦合,松耦合又需要高内聚的支持。

那到底什么是“高内聚”呢?

所谓高内聚,就是指相近的功能应该放到同一个类中,不相近的功能不要放到同一个类中。相近的功能往往会被同时修改,放到同一个类中,修改会比较集中,代码容易维护。实际上,我们前面讲过的单一职责原则是实现代码高内聚非常有效的设计原则。

我们再来看一下,什么是“松耦合”?

所谓松耦合是说,在代码中,类与类之间的依赖关系简单清晰。即使两个类有依赖关系,一个类的代码改动不会或者很少导致依赖类的代码改动。实际上,我们前面讲的依赖注入、接口隔离、基于接口而非实现编程,以及今天讲的迪米特法则,都是为了实现代码的松耦合。

最后,我们来看一下,“内聚”和“耦合”之间的关系。

前面也提到,“高内聚”有助于“松耦合”,同理,“低内聚”也会导致“紧耦合”。关于这一点,我画了一张对比图来解释。图中左边部分的代码结构是“高内聚、松耦合”;右边部分正好相反,是“低内聚、紧耦合”。

img

图中左边部分的代码设计中,类的粒度比较小,每个类的职责都比较单一。相近的功能都放到了一个类中,不相近的功能被分割到了多个类中。这样类更加独立,代码的内聚性更好。因为职责单一,所以每个类被依赖的类就会比较少,代码低耦合。一个类的修改,只会影响到一个依赖类的代码改动。我们只需要测试这一个依赖类是否还能正常工作就行了。

图中右边部分的代码设计中,类粒度比较大,低内聚,功能大而全,不相近的功能放到了一个类中。这就导致很多其他类都依赖这个类。当我们修改这个类的某一个功能代码的时候,会影响依赖它的多个类。我们需要测试这三个依赖类,是否还能正常工作。这也就是所谓的“牵一发而动全身”。

除此之外,从图中我们也可以看出,高内聚、低耦合的代码结构更加简单、清晰,相应地,在可维护性和可读性上确实要好很多。

“迪米特法则”理论描述

迪米特法则的英文翻译是:Law of Demeter,缩写是LOD。单从这个名字上来看,我们完全猜不出这个原则讲的是什么。不过,它还有另外一个更加达意的名字,叫作最小知识原则,英文翻译为:The Least Knowledge Principle。

关于这个设计原则,我们先来看一下它最原汁原味的英文定义:

Each unit should have only limited knowledge about other units: only units “closely” related to the current unit. Or: Each unit should only talk to its friends; Don’t talk to strangers.

我们把它直译成中文,就是下面这个样子:

每个模块(unit)只应该了解那些与它关系密切的模块(units: only units “closely” related to the current unit)的有限知识(knowledge)。或者说,每个模块只和自己的朋友“说话”(talk),不和陌生人“说话”(talk)。

我们之前讲过,大部分设计原则和思想都非常抽象,有各种各样的解读,要想灵活地应用到实际的开发中,需要有实战经验的积累。迪米特法则也不例外。所以,我结合我自己的理解和经验,对刚刚的定义重新描述一下。注意,为了统一讲解,我把定义描述中的“模块”替换成了“类”。

不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口(也就是定义中的“有限知识”)。

从上面的描述中,我们可以看出,迪米特法则包含前后两部分,这两部分讲的是两件事情,我用两个实战案例分别来解读一下。

理论解读与代码实战一

我们先来看这条原则中的前半部分,“不该有直接依赖关系的类之间,不要有依赖”。我举个例子解释一下。

这个例子实现了简化版的搜索引擎爬取网页的功能。代码中包含三个主要的类。其中,NetworkTransporter类负责底层网络通信,根据请求获取数据;HtmlDownloader类用来通过URL获取网页;Document表示网页文档,后续的网页内容抽取、分词、索引都是以此为处理对象。具体的代码实现如下所示:

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
public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(HtmlRequest htmlRequest) {
//...
}
}

public class HtmlDownloader {
private NetworkTransporter transporter;//通过构造函数或IOC注入

public Html downloadHtml(String url) {
Byte[] rawHtml = transporter.send(new HtmlRequest(url));
return new Html(rawHtml);
}
}

public class Document {
private Html html;
private String url;

public Document(String url) {
this.url = url;
HtmlDownloader downloader = new HtmlDownloader();
this.html = downloader.downloadHtml(url);
}
//...
}

这段代码虽然“能用”,能实现我们想要的功能,但是它不够“好用”,有比较多的设计缺陷。你可以先试着思考一下,看看都有哪些缺陷,然后再来看我下面的讲解。

首先,我们来看NetworkTransporter类。作为一个底层网络通信类,我们希望它的功能尽可能通用,而不只是服务于下载HTML,所以,我们不应该直接依赖太具体的发送对象HtmlRequest。从这一点上讲,NetworkTransporter类的设计违背迪米特法则,依赖了不该有直接依赖关系的HtmlRequest类。

我们应该如何进行重构,让NetworkTransporter类满足迪米特法则呢?我这里有个形象的比喻。假如你现在要去商店买东西,你肯定不会直接把钱包给收银员,让收银员自己从里面拿钱,而是你从钱包里把钱拿出来交给收银员。这里的HtmlRequest对象就相当于钱包,HtmlRequest里的address和content对象就相当于钱。我们应该把address和content交给NetworkTransporter,而非是直接把HtmlRequest交给NetworkTransporter。根据这个思路,NetworkTransporter重构之后的代码如下所示:

1
2
3
4
5
6
public class NetworkTransporter {
// 省略属性和其他方法...
public Byte[] send(String address, Byte[] data) {
//...
}
}

我们再来看HtmlDownloader类。这个类的设计没有问题。不过,我们修改了NetworkTransporter的send()函数的定义,而这个类用到了send()函数,所以我们需要对它做相应的修改,修改后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class HtmlDownloader {
private NetworkTransporter transporter;//通过构造函数或IOC注入

// HtmlDownloader这里也要有相应的修改
public Html downloadHtml(String url) {
HtmlRequest htmlRequest = new HtmlRequest(url);
Byte[] rawHtml = transporter.send(
htmlRequest.getAddress(), htmlRequest.getContent().getBytes());
return new Html(rawHtml);
}
}

最后,我们来看下Document类。这个类的问题比较多,主要有三点。

  • 第一,构造函数中的downloader.downloadHtml()逻辑复杂,耗时长,不应该放到构造函数中,会影响代码的可测试性。代码的可测试性我们后面会讲到,这里你先知道有这回事就可以了。
  • 第二,HtmlDownloader对象在构造函数中通过new来创建,违反了基于接口而非实现编程的设计思想,也会影响到代码的可测试性。
  • 第三,从业务含义上来讲,Document网页文档没必要依赖HtmlDownloader类,违背了迪米特法则。

虽然Document类的问题很多,但修改起来比较简单,只要一处改动就可以解决所有问题。修改之后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Document {
private Html html;
private String url;

public Document(String url, Html html) {
this.html = html;
this.url = url;
}
//...
}

// 通过一个工厂方法来创建Document
public class DocumentFactory {
private HtmlDownloader downloader;

public DocumentFactory(HtmlDownloader downloader) {
this.downloader = downloader;
}

public Document createDocument(String url) {
Html html = downloader.downloadHtml(url);
return new Document(url, html);
}
}

理论解读与代码实战二

现在,我们再来看一下这条原则中的后半部分:“有依赖关系的类之间,尽量只依赖必要的接口”。我们还是结合一个例子来讲解。下面这段代码非常简单,Serialization类负责对象的序列化和反序列化。提醒你一下,有个类似的例子在之前讲过,你可以结合着一块儿看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Serialization {
public String serialize(Object object) {
String serializedResult = ...;
//...
return serializedResult;
}

public Object deserialize(String str) {
Object deserializedResult = ...;
//...
return deserializedResult;
}
}

单看这个类的设计,没有一点问题。不过,如果我们把它放到一定的应用场景里,那就还有继续优化的空间。假设在我们的项目中,有些类只用到了序列化操作,而另一些类只用到反序列化操作。那基于迪米特法则后半部分“有依赖关系的类之间,尽量只依赖必要的接口”,只用到序列化操作的那部分类不应该依赖反序列化接口。同理,只用到反序列化操作的那部分类不应该依赖序列化接口。

根据这个思路,我们应该将Serialization类拆分为两个更小粒度的类,一个只负责序列化(Serializer类),一个只负责反序列化(Deserializer类)。拆分之后,使用序列化操作的类只需要依赖Serializer类,使用反序列化操作的类只需要依赖Deserializer类。拆分之后的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Serializer {
public String serialize(Object object) {
String serializedResult = ...;
...
return serializedResult;
}
}

public class Deserializer {
public Object deserialize(String str) {
Object deserializedResult = ...;
...
return deserializedResult;
}
}

不知道你有没有看出来,尽管拆分之后的代码更能满足迪米特法则,但却违背了高内聚的设计思想。高内聚要求相近的功能要放到同一个类中,这样可以方便功能修改的时候,修改的地方不至于过于分散。对于刚刚这个例子来说,如果我们修改了序列化的实现方式,比如从JSON换成了XML,那反序列化的实现逻辑也需要一并修改。在未拆分的情况下,我们只需要修改一个类即可。在拆分之后,我们需要修改两个类。显然,这种设计思路的代码改动范围变大了。

如果我们既不想违背高内聚的设计思想,也不想违背迪米特法则,那我们该如何解决这个问题呢?实际上,通过引入两个接口就能轻松解决这个问题,具体的代码如下所示。实际上,我们在讲到“接口隔离原则”的时候,第三个例子就使用了类似的实现思路,你可以结合着一块儿来看。

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
34
35
36
37
38
39
40
41
public interface Serializable {
String serialize(Object object);
}

public interface Deserializable {
Object deserialize(String text);
}

public class Serialization implements Serializable, Deserializable {
@Override
public String serialize(Object object) {
String serializedResult = ...;
...
return serializedResult;
}

@Override
public Object deserialize(String str) {
Object deserializedResult = ...;
...
return deserializedResult;
}
}

public class DemoClass_1 {
private Serializable serializer;

public Demo(Serializable serializer) {
this.serializer = serializer;
}
//...
}

public class DemoClass_2 {
private Deserializable deserializer;

public Demo(Deserializable deserializer) {
this.deserializer = deserializer;
}
//...
}

尽管我们还是要往DemoClass_1的构造函数中,传入包含序列化和反序列化的Serialization实现类,但是,我们依赖的Serializable接口只包含序列化操作,DemoClass_1无法使用Serialization类中的反序列化接口,对反序列化操作无感知,这也就符合了迪米特法则后半部分所说的“依赖有限接口”的要求。

实际上,上面的的代码实现思路,也体现了“基于接口而非实现编程”的设计原则,结合迪米特法则,我们可以总结出一条新的设计原则,那就是“基于最小接口而非最大实现编程”。有些同学之前问,新的设计模式和设计原则是怎么创造出来的,实际上,就是在大量的实践中,针对开发痛点总结归纳出来的套路。

辩证思考与灵活应用

对于实战二最终的设计思路,你有没有什么不同的观点呢?

整个类只包含序列化和反序列化两个操作,只用到序列化操作的使用者,即便能够感知到仅有的一个反序列化函数,问题也不大。那为了满足迪米特法则,我们将一个非常简单的类,拆分出两个接口,是否有点过度设计的意思呢?

设计原则本身没有对错,只有能否用对之说。不要为了应用设计原则而应用设计原则,我们在应用设计原则的时候,一定要具体问题具体分析。

对于刚刚这个Serialization类来说,只包含两个操作,确实没有太大必要拆分成两个接口。但是,如果我们对Serialization类添加更多的功能,实现更多更好用的序列化、反序列化函数,我们来重新考虑一下这个问题。修改之后的具体的代码如下:

1
2
3
4
5
6
7
8
9
public class Serializer { // 参看JSON的接口定义
public String serialize(Object object) { //... }
public String serializeMap(Map map) { //... }
public String serializeList(List list) { //... }

public Object deserialize(String objectString) { //... }
public Map deserializeMap(String mapString) { //... }
public List deserializeList(String listString) { //... }
}

在这种场景下,第二种设计思路要更好些。因为基于之前的应用场景来说,大部分代码只需要用到序列化的功能。对于这部分使用者,没必要了解反序列化的“知识”,而修改之后的Serialization类,反序列化的“知识”,从一个函数变成了三个。一旦任一反序列化操作有代码改动,我们都需要检查、测试所有依赖Serialization类的代码是否还能正常工作。为了减少耦合和测试工作量,我们应该按照迪米特法则,将反序列化和序列化的功能隔离开来。

21. 重构的目的、对象、时机和方法

重构代码对一个工程师能力的要求,要比单纯写代码高得多。重构需要你能洞察出代码存在的坏味道或者设计上的不足,并且能合理、熟练地利用设计思想、原则、模式、编程规范等理论知识解决这些问题。

另一方面,很多工程师对为什么要重构、到底重构什么、什么时候重构、又该如何重构等相关问题理解不深,对重构没有系统性、全局性的认识,面对一堆烂代码,没有重构技巧的指导,只能想到哪改到哪,并不能全面地改善代码质量。

重构的目的:为什么要重构(why)?

虽然对于你来说,重构这个词可能不需要过多解释,但我们还是简单来看一下,大师是怎么描述它的。软件设计大师Martin Fowler 是这样定义重构的:“重构是一种对软件内部结构的改善,目的是在不改变软件的可见行为的情况下,使其更易理解,修改成本更低。”

实际上,当讲到重构的时候,很多书籍都会引用这个定义。这个定义中有一个值得强调的点:“重构不改变外部的可见行为”。我们可以把重构理解为,在保持功能不变的前提下,利用设计思想、原则、模式、编程规范等理论来优化代码,修改设计上的不足,提高代码质量

为什么要进行代码重构?

首先,重构是时刻保证代码质量的一个极其有效的手段,不至于让代码腐化到无可救药的地步。项目在演进,代码不停地在堆砌。如果没有人为代码的质量负责任,代码总是会往越来越混乱的方向演进。当混乱到一定程度之后,量变引起质变,项目的维护成本已经高过重新开发一套新代码的成本,想要再去重构,已经没有人能做到了。

其次,优秀的代码或架构不是一开始就能完全设计好的,就像优秀的公司和产品也都是迭代出来的。我们无法100%遇见未来的需求,也没有足够的精力、时间、资源为遥远的未来买单,所以,随着系统的演进,重构代码也是不可避免的。

最后,重构是避免过度设计的有效手段。在我们维护代码的过程中,真正遇到问题的时候,再对代码进行重构,能有效避免前期投入太多时间做过度的设计,做到有的放矢。

除此之外,重构对一个工程师本身技术的成长也有重要的意义。

从前面我给出的重构的定义来看,重构实际上是对我们学习的经典设计思想、设计原则、设计模式、编程规范的一种应用。重构实际上就是将这些理论知识,应用到实践的一个很好的场景,能够锻炼我们熟练使用这些理论知识的能力。除此之外,平时堆砌业务逻辑,你可能总觉得没啥成长,而将一个比较烂的代码重构成一个比较好的代码,会让你很有成就感。

除此之外,重构能力也是衡量一个工程师代码能力的有效手段。所谓“初级工程师在维护代码,高级工程师在设计代码,资深工程师在重构代码”,这句话的意思是说,初级工程师在已有代码框架下修改bug、修改添加功能代码;高级工程师从零开始设计代码结构、搭建代码框架;而资深工程师为代码质量负责,需要发觉代码存在的问题,重构代码,时刻保证代码质量处于一个可控的状态(当然这里的初级、高级、资深只是一个相对概念,并不是一个确定的职级)。

重构的对象:到底重构什么(what)?

根据重构的规模,我们可以笼统地分为大规模高层次重构(以下简称为“大型重构”)和小规模低层次的重构(以下简称为“小型重构”)。

大型重构指的是对顶层代码设计的重构,包括:系统、模块、代码结构、类与类之间的关系等的重构,重构的手段有:分层、模块化、解耦、抽象可复用组件等等。这类重构的工具就是我们学习过的那些设计思想、原则和模式。这类重构涉及的代码改动会比较多,影响面会比较大,所以难度也较大,耗时会比较长,引入bug的风险也会相对比较大。

小型重构指的是对代码细节的重构,主要是针对类、函数、变量等代码级别的重构,比如规范命名、规范注释、消除超大类或函数、提取重复代码等等。小型重构更多的是利用我们能后面要讲到的编码规范。这类重构要修改的地方比较集中,比较简单,可操作性较强,耗时会比较短,引入bug的风险相对来说也会比较小。你只需要熟练掌握各种编码规范,就可以做到得心应手。

关于具体如何来做大型重构和小型重构,我会在后面的课程中详细讲解。

重构的时机:什么时候重构(when)?

搞清楚了为什么重构,到底重构什么,我们再来看一下,什么时候重构?是代码烂到一定程度之后才去重构吗?当然不是。因为当代码真的烂到出现“开发效率低,招了很多人,天天加班,出活却不多,线上bug频发,领导发飙,中层束手无策,工程师抱怨不断,查找bug困难”的时候,基本上重构也无法解决问题了。

我个人比较反对,平时不注重代码质量,堆砌烂代码,实在维护不了了就大刀阔斧地重构、甚至重写的行为。有时候项目代码太多了,重构很难做得彻底,最后又搞出来一个“四不像的怪物”,这就更麻烦了!所以,寄希望于在代码烂到一定程度之后,集中重构解决所有问题是不现实的,我们必须探索一条可持续、可演进的方式。

所以,我特别提倡的重构策略是持续重构。这也是我在工作中特别喜欢干的事情。平时没有事情的时候,你可以看看项目中有哪些写得不够好的、可以优化的代码,主动去重构一下。或者,在修改、添加某个功能代码的时候,你也可以顺手把不符合编码规范、不好的设计重构一下。总之,就像把单元测试、Code Review作为开发的一部分,我们如果能把持续重构也作为开发的一部分,成为一种开发习惯,对项目、对自己都会很有好处。

尽管我们说重构能力很重要,但持续重构意识更重要。我们要正确地看待代码质量和重构这件事情。技术在更新、需求在变化、人员在流动,代码质量总会在下降,代码总会存在不完美,重构就会持续在进行。时刻具有持续重构意识,才能避免开发初期就过度设计,避免代码维护的过程中质量的下降。而那些看到别人代码有点瑕疵就一顿乱骂,或者花尽心思去构思一个完美设计的人,往往都是因为没有树立正确的代码质量观,没有持续重构意识。

重构的方法:又该如何重构(how)?

前面我们讲到,按照重构的规模,重构可以笼统地分为大型重构和小型重构。对于这两种不同规模的重构,我们要区别对待。

对于大型重构来说,因为涉及的模块、代码会比较多,如果项目代码质量又比较差,耦合比较严重,往往会牵一发而动全身,本来觉得一天就能完成的重构,你会发现越改越多、越改越乱,没一两个礼拜都搞不定。而新的业务开发又与重构相冲突,最后只能半途而废,revert掉所有的改动,很失落地又去堆砌烂代码了。

在进行大型重构的时候,我们要提前做好完善的重构计划,有条不紊地分阶段来进行。每个阶段完成一小部分代码的重构,然后提交、测试、运行,发现没有问题之后,再继续进行下一阶段的重构,保证代码仓库中的代码一直处于可运行、逻辑正确的状态。每个阶段,我们都要控制好重构影响到的代码范围,考虑好如何兼容老的代码逻辑,必要的时候还需要写一些兼容过渡代码。只有这样,我们才能让每一阶段的重构都不至于耗时太长(最好一天就能完成),不至于与新的功能开发相冲突。

大规模高层次的重构一定是有组织、有计划,并且非常谨慎的,需要有经验、熟悉业务的资深同事来主导。而小规模低层次的重构,因为影响范围小,改动耗时短,所以,只要你愿意并且有时间,随时都可以去做。实际上,除了人工去发现低层次的质量问题,我们还可以借助很多成熟的静态代码分析工具(比如CheckStyle、FindBugs、PMD),来自动发现代码中的问题,然后针对性地进行重构优化。

对于重构这件事情,资深的工程师、项目leader要负起责任来,没事就重构一下代码,时刻保证代码质量处在一个良好的状态。否则,一旦出现“破窗效应”,一个人往里堆了一些烂代码,之后就会有更多的人往里堆更烂的代码。毕竟往项目里堆砌烂代码的成本太低了。不过,保持代码质量最好的方法还是打造一种好的技术氛围,以此来驱动大家主动去关注代码质量,持续重构代码。

22. 单元测试

如何保证重构不出错呢?你需要熟练掌握各种设计原则、思想、模式,还需要对所重构的业务和代码有足够的了解。除了这些个人能力因素之外,最可落地执行、最有效的保证重构不出错的手段应该就是单元测试(Unit Testing)了。当重构完成之后,如果新的代码仍然能通过单元测试,那就说明代码原有逻辑的正确性未被破坏,原有的外部可见行为未变,符合上一节课中我们对重构的定义。

什么是单元测试?

单元测试由研发工程师自己来编写,用来测试自己写的代码的正确性。我们常常将它跟集成测试放到一块来对比。单元测试相对于集成测试(Integration Testing)来说,测试的粒度更小一些。集成测试的测试对象是整个系统或者某个功能模块,比如测试用户注册、登录功能是否正常,是一种端到端(end to end)的测试。而单元测试的测试对象是类或者函数,用来测试一个类和函数是否都按照预期的逻辑执行。这是代码层级的测试。

这么说比较理论,我举个例子来解释一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Text {
private String content;

public Text(String content) {
this.content = content;
}

/**
* 将字符串转化成数字,忽略字符串中的首尾空格;
* 如果字符串中包含除首尾空格之外的非数字字符,则返回null。
*/
public Integer toNumber() {
if (content == null || content.isEmpty()) {
return null;
}
//...省略代码实现...
return null;
}
}

如果我们要测试Text类中的toNumber()函数的正确性,应该如何编写单元测试呢?

实际上,写单元测试本身不需要什么高深技术。它更多的是考验程序员思维的缜密程度,看能否设计出覆盖各种正常及异常情况的测试用例,来保证代码在任何预期或非预期的情况下都能正确运行。

为了保证测试的全面性,针对toNumber()函数,我们需要设计下面这样几个测试用例。

  • 如果字符串只包含数字:“123”,toNumber()函数输出对应的整数:123。
  • 如果字符串是空或者null,toNumber()函数返回:null。
  • 如果字符串包含首尾空格:“ 123”,“123 ”,“ 123 ”,toNumber()返回对应的整数:123。
  • 如果字符串包含多个首尾空格:“ 123 ”,toNumber()返回对应的整数:123;
  • 如果字符串包含非数字字符:“123a4”,“123 4”,toNumber()返回null;

当我们设计好测试用例之后,剩下的就是将其翻译成代码了。翻译成代码的过程非常简单,我把代码贴在下面了,你可以参考一下(注意,我们这里没有使用任何测试框架)。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
public class Assert {
public static void assertEquals(Integer expectedValue, Integer actualValue) {
if (actualValue != expectedValue) {
String message = String.format(
"Test failed, expected: %d, actual: %d.", expectedValue, actualValue);
System.out.println(message);
} else {
System.out.println("Test succeeded.");
}
}

public static boolean assertNull(Integer actualValue) {
boolean isNull = actualValue == null;
if (isNull) {
System.out.println("Test succeeded.");
} else {
System.out.println("Test failed, the value is not null:" + actualValue);
}
return isNull;
}
}

public class TestCaseRunner {
public static void main(String[] args) {
System.out.println("Run testToNumber()");
new TextTest().testToNumber();

System.out.println("Run testToNumber_nullorEmpty()");
new TextTest().testToNumber_nullorEmpty();

System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsLeadingAndTrailingSpaces();

System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces()");
new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();

System.out.println("Run testToNumber_containsInvalidCharaters()");
new TextTest().testToNumber_containsInvalidCharaters();
}
}

public class TextTest {
public void testToNumber() {
Text text = new Text("123");
Assert.assertEquals(123, text.toNumber());
}

public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());

Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}

public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(123, text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(123, text3.toNumber());
}

public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(123, text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(123, text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(123, text3.toNumber());
}

public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());

Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}

为什么要写单元测试?

单元测试除了能有效地为重构保驾护航之外,也是保证代码质量最有效的两个手段之一(另一个是Code Review)。我在Google工作的时候,写了大量的单元测试代码,结合我的这些开发经验,我总结了以下几点单元测试的好处。尽管有些听起来有点“务虚”,但如果你认真写过一些单元测试的话,应该会很有共鸣。

1.单元测试能有效地帮你发现代码中的bug

能否写出bug free的代码,是判断工程师编码能力的重要标准之一,也是很多大厂面试考察的重点,特别是像FLAG这样的外企。通过单元测试也常常会发现代码中的很多考虑不全面的地方。

在离开Google之后,尽管我就职的很多公司,其开发模式都是“快、糙、猛”,对单元测试根本没有要求,但我还是坚持为自己提交的每一份代码,都编写完善的单元测试。得益于此,我写的代码几乎是bug free的。这也节省了我很多fix低级bug的时间,能够有时间去做其他更有意义的事情,我也因此在工作上赢得了很多人的认可。可以这么说,坚持写单元测试是保证我的代码质量的一个“杀手锏”,也是帮助我拉开与其他人差距的一个“小秘密”。

2.写单元测试能帮你发现代码设计上的问题

前面我们提到,代码的可测试性是评判代码质量的一个重要标准。对于一段代码,如果很难为其编写单元测试,或者单元测试写起来很吃力,需要依靠单元测试框架里很高级的特性才能完成,那往往就意味着代码设计得不够合理,比如,没有使用依赖注入、大量使用静态函数、全局变量、代码高度耦合等。

3.单元测试是对集成测试的有力补充

程序运行的bug往往出现在一些边界条件、异常情况下,比如,除数未判空、网络超时。而大部分异常情况都比较难在测试环境中模拟。而单元测试可以利用下一节课中讲到的mock的方式,控制mock的对象返回我们需要模拟的异常,来测试代码在这些异常情况的表现。

除此之外,对于一些复杂系统来说,集成测试也无法覆盖得很全面。复杂系统往往有很多模块。每个模块都有各种输入、输出、异常情况,组合起来,整个系统就有无数测试场景需要模拟,无数的测试用例需要设计,再强大的测试团队也无法穷举完备。

尽管单元测试无法完全替代集成测试,但如果我们能保证每个类、每个函数都能按照我们的预期来执行,底层bug少了,那组装起来的整个系统,出问题的概率也就相应减少了。

4.写单元测试的过程本身就是代码重构的过程

上一节课中,我们提到,要把持续重构作为开发的一部分来执行,那写单元测试实际上就是落地执行持续重构的一个有效途径。设计和实现代码的时候,我们很难把所有的问题都想清楚。而编写单元测试就相当于对代码的一次自我Code Review,在这个过程中,我们可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当)等,然后针对性的进行重构。

5.阅读单元测试能帮助你快速熟悉代码

阅读代码最有效的手段,就是先了解它的业务背景和设计思路,然后再去看代码,这样代码读起来就会轻松很多。但据我了解,程序员都不怎么喜欢写文档和注释,而大部分程序员写的代码又很难做到“不言自明”。在没有文档和注释的情况下,单元测试就起了替代性作用。单元测试用例实际上就是用户用例,反映了代码的功能和如何使用。借助单元测试,我们不需要深入的阅读代码,便能知道代码实现了什么功能,有哪些特殊情况需要考虑,有哪些边界条件需要处理。

6.单元测试是TDD可落地执行的改进方案

测试驱动开发(Test-Driven Development,简称TDD)是一个经常被提及但很少被执行的开发模式。它的核心指导思想就是测试用例先于代码编写。不过,要让程序员能彻底地接受和习惯这种开发模式还是挺难的,毕竟很多程序员连单元测试都懒得写,更何况在编写代码之前先写好测试用例了。

我个人觉得,单元测试正好是对TDD的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了TDD的优点。

如何编写单元测试?

前面在讲什么是单元测试的时候,我们举了一个给toNumber()函数写单元测试的例子。根据那个例子,我们可以总结得出,写单元测试就是针对代码设计覆盖各种输入、异常、边界条件的测试用例,并将这些测试用例翻译成代码的过程。

在把测试用例翻译成代码的时候,我们可以利用单元测试框架,来简化测试代码的编写。比如,Java中比较出名的单元测试框架有Junit、TestNG、Spring Test等。这些框架提供了通用的执行流程(比如执行测试用例的TestCaseRunner)和工具类库(比如各种Assert判断函数)等。借助它们,我们在编写测试代码的时候,只需要关注测试用例本身的编写即可。

针对toNumber()函数的测试用例,我们利用Junit单元测试框架重新实现一下,具体代码如下所示。你可以拿它跟之前没有利用测试框架的实现方式对比一下,看是否简化了很多呢?

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import org.junit.Assert;
import org.junit.Test;

public class TextTest {
@Test
public void testToNumber() {
Text text = new Text("123");
Assert.assertEquals(new Integer(123), text.toNumber());
}

@Test
public void testToNumber_nullorEmpty() {
Text text1 = new Text(null);
Assert.assertNull(text1.toNumber());

Text text2 = new Text("");
Assert.assertNull(text2.toNumber());
}

@Test
public void testToNumber_containsLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}

@Test
public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
Text text1 = new Text(" 123");
Assert.assertEquals(new Integer(123), text1.toNumber());

Text text2 = new Text("123 ");
Assert.assertEquals(new Integer(123), text2.toNumber());

Text text3 = new Text(" 123 ");
Assert.assertEquals(new Integer(123), text3.toNumber());
}

@Test
public void testToNumber_containsInvalidCharaters() {
Text text1 = new Text("123a4");
Assert.assertNull(text1.toNumber());

Text text2 = new Text("123 4");
Assert.assertNull(text2.toNumber());
}
}

对于如何使用这些单元测试框架,大部分框架都给出了非常详细的官方文档,你可以自行查阅。这些东西理解和掌握起来没有太大难度,所以这不是专栏要讲解的重点。关于如何编写单元测试,我更希望传达给你一些我的经验总结。具体包括以下几点。

1.写单元测试真的是件很耗时的事情吗?

尽管单元测试的代码量可能是被测代码本身的1~2倍,写的过程很繁琐,但并不是很耗时。毕竟我们不需要考虑太多代码设计上的问题,测试代码实现起来也比较简单。不同测试用例之间的代码差别可能并不是很大,简单copy-paste改改就行。

2.对单元测试的代码质量有什么要求吗?

单元测试毕竟不会在产线上运行,而且每个类的测试代码也比较独立,基本不互相依赖。所以,相对于被测代码,我们对单元测试代码的质量可以放低一些要求。命名稍微有些不规范,代码稍微有些重复,也都是没有问题的。

3.单元测试只要覆盖率高就够了吗?

单元测试覆盖率是比较容易量化的指标,常常作为单元测试写得好坏的评判标准。有很多现成的工具专门用来做覆盖率统计,比如,JaCoCo、Cobertura、Emma、Clover。覆盖率的计算方式有很多种,比较简单的是语句覆盖,稍微高级点的有:条件覆盖、判定覆盖、路径覆盖。

不管覆盖率的计算方式如何高级,将覆盖率作为衡量单元测试质量的唯一标准是不合理的。实际上,更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些corner case。我来举个简单的例子解释一下。

像下面这段代码,我们只需要一个测试用例就可以做到100%覆盖率,比如cal(10.0, 2.0),但并不代表测试足够全面了,我们还需要考虑,当除数等于0的情况下,代码执行是否符合预期。

1
2
3
4
5
public double cal(double a, double b) {
if (b != 0) {
return a / b;
}
}

实际上,过度关注单元测试的覆盖率会导致开发人员为了提高覆盖率,写很多没有必要的测试代码,比如get、set方法非常简单,没有必要测试。从过往的经验上来讲,一个项目的单元测试覆盖率在60~70%即可上线。如果项目对代码质量要求比较高,可以适当提高单元测试覆盖率的要求。

4.写单元测试需要了解代码的实现逻辑吗?

单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。我们切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试。否则,一旦对代码进行重构,在代码的外部行为不变的情况下,对代码的实现逻辑进行了修改,那原本的单元测试都会运行失败,也就起不到为重构保驾护航的作用了,也违背了我们写单元测试的初衷。

5.如何选择单元测试框架?

写单元测试本身不需要太复杂的技术,大部分单元测试框架都能满足。在公司内部,起码团队内部需要统一单元测试框架。如果自己写的代码用已经选定的单元测试框架无法测试,那多半是代码写得不够好,代码的可测试性不够好。这个时候,我们要重构自己的代码,让其更容易测试,而不是去找另一个更加高级的单元测试框架。

单元测试为何难落地执行?

虽然很多书籍中都会讲到,单元测试是保证重构不出错的有效手段;也有非常多人已经认识到单元测试的重要性。但是有多少项目有完善的、高质量的单元测试呢?据我了解,真的非常非常少,包括BAT这样级别公司的项目。如果不相信的话,你可以去看一下国内很多大厂开源的项目,有很多项目完全没有单元测试,还有很多项目的单元测试写得非常不完备,仅仅测试了逻辑是否运行正确而已。所以,100%落实执行单元测试是件“知易行难”的事。

写单元测试确实是一件考验耐心的活儿。一般情况下,单元测试的代码量要大于被测试代码量,甚至是要多出好几倍。很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现破窗效应,慢慢的,大家就都不写了,这种情况很常见。

还有一种情况就是,由于历史遗留问题,原来的代码都没有写单元测试,代码已经堆砌了十几万行了,不可能再一个一个去补单元测试。这种情况下,我们首先要保证新写的代码都要有单元测试,其次,每次在改动到某个类时,如果没有单元测试就顺便补上,不过这要求工程师们有足够强的主人翁意识(ownership),毕竟光靠leader督促,很多事情是很难执行到位的。

除此之外,还有人觉得,有了测试团队,写单元测试就是浪费时间,没有必要。程序员这一行业本该是智力密集型的,但现在很多公司把它搞成劳动密集型的,包括一些大厂,在开发过程中,既没有单元测试,也没有Code Review流程。即便有,做的也是差强人意。写好代码直接提交,然后丢给黑盒测试狠命去测,测出问题就反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复。

在这样的开发模式下,团队往往觉得没有必要写单元测试,但如果我们把单元测试写好、做好Code Review,重视起代码质量,其实可以很大程度上减少黑盒测试的投入。我在Google的时候,很多项目几乎没有测试团队参与,代码的正确性完全靠开发团队来保障,线上bug反倒非常少。

以上是我对单元测试的认知和实践心得。现在互联网信息如此的公开透明,网上有很多文章可以参考,对于程序员这个具有很强学习能力的群体来说,学会如何写单元测试并不是一件难事,难的是能够真正感受到它的作用,并且打心底认可、能100%落地执行。这也是我今天的课程特别想传达给你的一点。

23. 解耦代码

“解耦”为何如此重要?

软件设计与开发最重要的工作之一就是应对复杂性。人处理复杂性的能力是有限的。过于复杂的代码往往在可读性、可维护性上都不友好。那如何来控制代码的复杂性呢?手段有很多,我个人认为,最关键的就是解耦,保证代码松耦合、高内聚。如果说重构是保证代码质量不至于腐化到无可救药地步的有效手段,那么利用解耦的方法对代码重构,就是保证代码不至于复杂到无法控制的有效手段。

“高内聚、松耦合”是一个比较通用的设计思想,不仅可以指导细粒度的类和类之间关系的设计,还能指导粗粒度的系统、架构、模块的设计。相对于编码规范,它能够在更高层次上提高代码的可读性和可维护性。

不管是阅读代码还是修改代码,“高内聚、松耦合”的特性可以让我们聚焦在某一模块或类中,不需要了解太多其他模块或类的代码,让我们的焦点不至于过于发散,降低了阅读和修改代码的难度。而且,因为依赖关系简单,耦合小,修改代码不至于牵一发而动全身,代码改动比较集中,引入bug的风险也就减少了很多。同时,“高内聚、松耦合”的代码可测试性也更加好,容易mock或者很少需要mock外部依赖的模块或者类。

除此之外,代码“高内聚、松耦合”,也就意味着,代码结构清晰、分层和模块化合理、依赖关系简单、模块或类之间的耦合小,那代码整体的质量就不会差。即便某个具体的类或者模块设计得不怎么合理,代码质量不怎么高,影响的范围是非常有限的。我们可以聚焦于这个模块或者类,做相应的小型重构。而相对于代码结构的调整,这种改动范围比较集中的小型重构的难度就容易多了。

代码是否需要“解耦”?

那现在问题来了,我们该怎么判断代码的耦合程度呢?或者说,怎么判断代码是否符合“高内聚、松耦合”呢?再或者说,如何判断系统是否需要解耦重构呢?

间接的衡量标准有很多,前面我们讲到了一些,比如,看修改代码会不会牵一发而动全身。除此之外,还有一个直接的衡量标准,也是我在阅读源码的时候经常会用到的,那就是把模块与模块之间、类与类之间的依赖关系画出来,根据依赖关系图的复杂性来判断是否需要解耦重构。

如果依赖关系复杂、混乱,那从代码结构上来讲,可读性和可维护性肯定不是太好,那我们就需要考虑是否可以通过解耦的方法,让依赖关系变得清晰、简单。当然,这种判断还是有比较强的主观色彩,但是可以作为一种参考和梳理依赖的手段,配合间接的衡量标准一块来使用。

如何给代码“解耦”?

前面我们能讲了解耦的重要性,以及如何判断是否需要解耦,接下来,我们再来看一下,如何进行解耦。

1.封装与抽象

封装和抽象作为两个非常通用的设计思想,可以应用在很多设计场景中,比如系统、模块、lib、组件、接口、类等等的设计。封装和抽象可以有效地隐藏实现的复杂性,隔离实现的易变性,给依赖的模块提供稳定且易用的抽象接口。

比如,Unix系统提供的open()文件操作函数,我们用起来非常简单,但是底层实现却非常复杂,涉及权限控制、并发控制、物理存储等等。我们通过将其封装成一个抽象的open()函数,能够有效控制代码复杂性的蔓延,将复杂性封装在局部代码中。除此之外,因为open()函数基于抽象而非具体的实现来定义,所以我们在改动open()函数的底层实现的时候,并不需要改动依赖它的上层代码,也符合我们前面提到的“高内聚、松耦合”代码的评判标准。

2.中间层

引入中间层能简化模块或类之间的依赖关系。下面这张图是引入中间层前后的依赖关系对比图。在引入数据存储中间层之前,A、B、C三个模块都要依赖内存一级缓存、Redis二级缓存、DB持久化存储三个模块。在引入中间层之后,三个模块只需要依赖数据存储一个模块即可。从图上可以看出,中间层的引入明显地简化了依赖关系,让代码结构更加清晰。

img

除此之外,我们在进行重构的时候,引入中间层可以起到过渡的作用,能够让开发和重构同步进行,不互相干扰。比如,某个接口设计得有问题,我们需要修改它的定义,同时,所有调用这个接口的代码都要做相应的改动。如果新开发的代码也用到这个接口,那开发就跟重构冲突了。为了让重构能小步快跑,我们可以分下面四个阶段来完成接口的修改。

  • 第一阶段:引入一个中间层,包裹老的接口,提供新的接口定义。
  • 第二阶段:新开发的代码依赖中间层提供的新接口。
  • 第三阶段:将依赖老接口的代码改为调用新接口。
  • 第四阶段:确保所有的代码都调用新接口之后,删除掉老的接口。

这样,每个阶段的开发工作量都不会很大,都可以在很短的时间内完成。重构跟开发冲突的概率也变小了。

3.模块化

模块化是构建复杂系统常用的手段。不仅在软件行业,在建筑、机械制造等行业,这个手段也非常有用。对于一个大型复杂系统来说,没有人能掌控所有的细节。之所以我们能搭建出如此复杂的系统,并且能维护得了,最主要的原因就是将系统划分成各个独立的模块,让不同的人负责不同的模块,这样即便在不了解全部细节的情况下,管理者也能协调各个模块,让整个系统有效运转。

聚焦到软件开发上面,很多大型软件(比如Windows)之所以能做到几百、上千人有条不紊地协作开发,也归功于模块化做得好。不同的模块之间通过API来进行通信,每个模块之间耦合很小,每个小的团队聚焦于一个独立的高内聚模块来开发,最终像搭积木一样将各个模块组装起来,构建成一个超级复杂的系统。

我们再聚焦到代码层面。合理地划分模块能有效地解耦代码,提高代码的可读性和可维护性。所以,我们在开发代码的时候,一定要有模块化意识,将每个模块都当作一个独立的lib一样来开发,只提供封装了内部实现细节的接口给其他模块使用,这样可以减少不同模块之间的耦合度。

实际上,从刚刚的讲解中我们也可以发现,模块化的思想无处不在,像SOA、微服务、lib库、系统内模块划分,甚至是类、函数的设计,都体现了模块化思想。如果追本溯源,模块化思想更加本质的东西就是分而治之。

4.其他设计思想和原则

“高内聚、松耦合”是一个非常重要的设计思想,能够有效提高代码的可读性和可维护性,缩小功能改动导致的代码改动范围。实际上,在前面的章节中,我们已经多次提到过这个设计思想。很多设计原则都以实现代码的“高内聚、松耦合”为目的。我们来一块总结回顾一下都有哪些原则。

  • 单一职责原则

我们前面提到,内聚性和耦合性并非独立的。高内聚会让代码更加松耦合,而实现高内聚的重要指导原则就是单一职责原则。模块或者类的职责设计得单一,而不是大而全,那依赖它的类和它依赖的类就会比较少,代码耦合也就相应的降低了。

  • 基于接口而非实现编程

基于接口而非实现编程能通过接口这样一个中间层,隔离变化和具体的实现。这样做的好处是,在有依赖关系的两个模块或类之间,一个模块或者类的改动,不会影响到另一个模块或类。实际上,这就相当于将一种强依赖关系(强耦合)解耦为了弱依赖关系(弱耦合)。

  • 依赖注入

跟基于接口而非实现编程思想类似,依赖注入也是将代码之间的强耦合变为弱耦合。尽管依赖注入无法将本应该有依赖关系的两个类,解耦为没有依赖关系,但可以让耦合关系没那么紧密,容易做到插拔替换。

  • 多用组合少用继承

我们知道,继承是一种强依赖关系,父类与子类高度耦合,且这种耦合关系非常脆弱,牵一发而动全身,父类的每一次改动都会影响所有的子类。相反,组合关系是一种弱依赖关系,这种关系更加灵活,所以,对于继承结构比较复杂的代码,利用组合来替换继承,也是一种解耦的有效手段。

  • 迪米特法则

迪米特法则讲的是,不该有直接依赖关系的类之间,不要有依赖;有依赖关系的类之间,尽量只依赖必要的接口。从定义上,我们明显可以看出,这条原则的目的就是为了实现代码的松耦合。至于如何应用这条原则来解耦代码,你可以回过头去阅读一下第22讲,这里我就不赘述了。

除了上面讲到的这些设计思想和原则之外,还有一些设计模式也是为了解耦依赖,比如观察者模式,有关这一部分的内容,我们留在设计模式模块中慢慢讲解。

24. 创建型:单例模式(1)

为什么要使用单例?

单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。

对于单例的概念,我觉得没必要解释太多,你一看就能明白。我们重点看一下,为什么我们需要单例这种设计模式?它能解决哪些问题?接下来我通过两个实战案例来讲解。

实战案例一:处理资源访问冲突

我们先来看第一个例子。在这个例子中,我们自定义实现了一个往文件中打印日志的Logger类。具体的代码实现如下所示:

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
public class Logger {
private FileWriter writer;

public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}

public void log(String message) {
writer.write(mesasge);
}
}

// Logger类的应用示例:
public class UserController {
private Logger logger = new Logger();

public void login(String username, String password) {
// ...省略业务逻辑代码...
logger.log(username + " logined!");
}
}

public class OrderController {
private Logger logger = new Logger();

public void create(OrderVo order) {
// ...省略业务逻辑代码...
logger.log("Created an order: " + order.toString());
}
}

在上面的代码中,我们注意到,所有的日志都写入到同一个文件/Users/wangzheng/log.txt中。在UserController和OrderController中,我们分别创建两个Logger对象。在Web容器的Servlet多线程环境下,如果两个Servlet线程同时分别执行login()和create()两个函数,并且同时写日志到log.txt文件中,那就有可能存在日志信息互相覆盖的情况。

为什么会出现互相覆盖呢?我们可以这么类比着理解。在多线程环境下,如果两个线程同时给同一个共享变量加1,因为共享变量是竞争资源,所以,共享变量最后的结果有可能并不是加了2,而是只加了1。同理,这里的log.txt文件也是竞争资源,两个线程同时往里面写数据,就有可能存在互相覆盖的情况。

img

那如何来解决这个问题呢?我们最先想到的就是通过加锁的方式:给log()函数加互斥锁(Java中可以通过synchronized的关键字),同一时刻只允许一个线程调用执行log()函数。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Logger {
private FileWriter writer;

public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}

public void log(String message) {
synchronized(this) {
writer.write(mesasge);
}
}
}

不过,你仔细想想,这真的能解决多线程写入日志时互相覆盖的问题吗?答案是否定的。这是因为,这种锁是一个对象级别的锁,一个对象在不同的线程下同时调用log()函数,会被强制要求顺序执行。但是,不同的对象之间并不共享同一把锁。在不同的线程下,通过不同的对象调用执行log()函数,锁并不会起作用,仍然有可能存在写入日志互相覆盖的问题。

img

我这里稍微补充一下,在刚刚的讲解和给出的代码中,我故意“隐瞒”了一个事实:我们给log()函数加不加对象级别的锁,其实都没有关系。因为FileWriter本身就是线程安全的,它的内部实现中本身就加了对象级别的锁,因此,在外层调用write()函数的时候,再加对象级别的锁实际上是多此一举。因为不同的Logger对象不共享FileWriter对象,所以,FileWriter对象级别的锁也解决不了数据写入互相覆盖的问题。

那我们该怎么解决这个问题呢?实际上,要想解决这个问题也不难,我们只需要把对象级别的锁,换成类级别的锁就可以了。让所有的对象都共享同一把锁。这样就避免了不同对象之间同时调用log()函数,而导致的日志覆盖问题。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Logger {
private FileWriter writer;

public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}

public void log(String message) {
synchronized(Logger.class) { // 类级别的锁
writer.write(mesasge);
}
}
}

除了使用类级别锁之外,实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无bug、高性能的分布式锁,并不是件容易的事情。除此之外,并发队列(比如Java中的BlockingQueue)也可以解决这个问题:多个线程同时往并发队列里写日志,一个单独的线程负责将并发队列中的数据,写入到日志文件。这种方式实现起来也稍微有点复杂。

相对于这两种解决方案,单例模式的解决思路就简单一些了。单例模式相对于之前类级别锁的好处是,不用创建那么多Logger对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)。

我们将Logger设计成一个单例类,程序中只允许创建一个Logger对象,所有的线程共享使用的这一个Logger对象,共享一个FileWriter对象,而FileWriter本身是对象级别线程安全的,也就避免了多线程情况下写日志会互相覆盖的问题。

按照这个设计思路,我们实现了Logger单例类。具体代码如下所示:

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
public class Logger {
private FileWriter writer;
private static final Logger instance = new Logger();

private Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}

public static Logger getInstance() {
return instance;
}

public void log(String message) {
writer.write(mesasge);
}
}

// Logger类的应用示例:
public class UserController {
public void login(String username, String password) {
// ...省略业务逻辑代码...
Logger.getInstance().log(username + " logined!");
}
}

public class OrderController {
public void create(OrderVo order) {
// ...省略业务逻辑代码...
Logger.getInstance().log("Created a order: " + order.toString());
}
}

实战案例二:表示全局唯一类

从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。

比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。

再比如,唯一递增ID号码生成器,如果程序中有两个对象,那就会存在生成重复ID的情况,所以,我们应该将ID生成器类设计为单例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.util.concurrent.atomic.AtomicLong;
public class IdGenerator {
// AtomicLong是一个Java并发库中提供的一个原子变量类型,
// 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
// 比如下面会用到的incrementAndGet().
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

// IdGenerator使用举例
long id = IdGenerator.getInstance().getId();

实际上,今天讲到的两个代码实例(Logger、IdGenerator),设计的都并不优雅,还存在一些问题。

如何实现一个单例?

尽管介绍如何实现一个单例模式的文章已经有很多了,但为了保证内容的完整性,我这里还是简单介绍一下几种经典实现方式。概括起来,要实现一个单例,我们需要关注的点无外乎下面几个:

  • 构造函数需要是private访问权限的,这样才能避免外部通过new创建实例;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑getInstance()性能是否高(是否加锁)。

如果你对这块已经很熟悉了,你可以当作复习。注意,下面的几种单例实现方式是针对Java语言语法的,如果你熟悉的是其他语言,不妨对比Java的这几种实现方式,自己试着总结一下,利用你熟悉的语言,该如何实现。

1.饿汉式

饿汉式的实现方式比较简单。在类加载的时候,instance静态实例就已经创建并初始化好了,所以,instance实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到IdGenerator的时候,再创建实例),从名字中我们也可以看出这一点。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class IdGenerator { 
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。不过,我个人并不认同这样的观点。

如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

如果实例占用资源多,按照fail-fast的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如Java中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。

2.懒汉式

有饿汉式,对应的,就有懒汉式。懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class IdGenerator { 
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

不过懒汉式的缺点也很明显,我们给getInstance()这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。量化一下的话,并发度是1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。

3.双重检测

饿汉式不支持延迟加载,懒汉式有性能问题,不支持高并发。那我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。

在这种实现方式中,只要instance被创建之后,即便再调用getInstance()函数也不会再进入到加锁逻辑中了。所以,这种实现方式解决了懒汉式并发度低的问题。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class IdGenerator { 
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private IdGenerator() {}
public static IdGenerator getInstance() {
if (instance == null) {
synchronized(IdGenerator.class) { // 此处为类级别的锁
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
public long getId() {
return id.incrementAndGet();
}
}

网上有人说,这种实现方式有些问题。因为指令重排序,可能会导致IdGenerator对象被new出来,并且赋值给instance之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。

要解决这个问题,我们需要给instance成员变量加上volatile关键字,禁止指令重排序才行。实际上,只有很低版本的Java才会有这个问题。我们现在用的高版本的Java已经在JDK内部实现中解决了这个问题(解决的方法很简单,只要把对象new操作和初始化操作设计为原子操作,就自然能禁止重排序)。关于这点的详细解释,跟特定语言有关,我就不展开讲了,感兴趣的同学可以自行研究一下。

4.静态内部类

我们再来看一种比双重检测更加简单的实现方法,那就是利用Java的静态内部类。它有点类似饿汉式,但又能做到了延迟加载。具体是怎么做到的呢?我们先来看它的代码实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class IdGenerator { 
private AtomicLong id = new AtomicLong(0);
private IdGenerator() {}

private static class SingletonHolder{
private static final IdGenerator instance = new IdGenerator();
}

public static IdGenerator getInstance() {
return SingletonHolder.instance;
}

public long getId() {
return id.incrementAndGet();
}
}

SingletonHolder 是一个静态内部类,当外部类IdGenerator被加载的时候,并不会创建SingletonHolder实例对象。只有当调用getInstance()方法时,SingletonHolder才会被加载,这个时候才会创建instance。instance的唯一性、创建过程的线程安全性,都由JVM来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。

5.枚举

最后,我们介绍一种最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过Java枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:

1
2
3
4
5
6
7
8
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);

public long getId() {
return id.incrementAndGet();
}
}

25. 创建型:单例模式(2)

单例存在哪些问题?

大部分情况下,我们在项目中使用单例,都是用它来表示一些全局唯一类,比如配置信息类、连接池类、ID生成器类。单例模式书写简洁、使用方便,在代码中,我们不需要创建对象,直接通过类似IdGenerator.getInstance().getId()这样的方法来调用就可以了。但是,这种使用方法有点类似硬编码(hard code),会带来诸多问题。接下来,我们就具体看看到底有哪些问题。

1.单例对OOP特性的支持不友好

我们知道,OOP的四大特性是封装、抽象、继承、多态。单例这种设计模式对于其中的抽象、继承、多态都支持得不好。为什么这么说呢?我们还是通过IdGenerator这个例子来讲解。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
//...
}
}

public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
//...
}
}

IdGenerator的使用方式违背了基于接口而非实现的设计原则,也就违背了广义上理解的OOP的抽象特性。如果未来某一天,我们希望针对不同的业务采用不同的ID生成算法。比如,订单ID和用户ID采用不同的ID生成器来生成。为了应对这个需求变化,我们需要修改所有用到IdGenerator类的地方,这样代码的改动就会比较大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Order {
public void create(...) {
//...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码,替换为下面一行代码
long id = OrderIdGenerator.getIntance().getId();
//...
}
}

public class User {
public void create(...) {
// ...
long id = IdGenerator.getInstance().getId();
// 需要将上面一行代码,替换为下面一行代码
long id = UserIdGenerator.getIntance().getId();
}
}

除此之外,单例对继承、多态特性的支持也不友好。这里我之所以会用“不友好”这个词,而非“完全不支持”,是因为从理论上来讲,单例类也可以被继承、也可以实现多态,只是实现起来会非常奇怪,会导致代码的可读性变差。不明白设计意图的人,看到这样的设计,会觉得莫名其妙。所以,一旦你选择将某个类设计成到单例类,也就意味着放弃了继承和多态这两个强有力的面向对象特性,也就相当于损失了可以应对未来需求变化的扩展性。

2.单例会隐藏类之间的依赖关系

我们知道,代码的可读性非常重要。在阅读代码的时候,我们希望一眼就能看出类与类之间的依赖关系,搞清楚这个类依赖了哪些外部类。

通过构造函数、参数传递等方式声明的类之间的依赖关系,我们通过查看函数的定义,就能很容易识别出来。但是,单例类不需要显示创建、不需要依赖参数传递,在函数中直接调用就可以了。如果代码比较复杂,这种调用关系就会非常隐蔽。在阅读代码的时候,我们就需要仔细查看每个函数的代码实现,才能知道这个类到底依赖了哪些单例类。

3.单例对代码的扩展性不友好

我们知道,单例类只能有一个对象实例。如果未来某一天,我们需要在代码中创建两个实例或多个实例,那就要对代码有比较大的改动。你可能会说,会有这样的需求吗?既然单例类大部分情况下都用来表示全局类,怎么会需要两个或者多个实例呢?

实际上,这样的需求并不少见。我们拿数据库连接池来举例解释一下。

在系统设计初期,我们觉得系统中只应该有一个数据库连接池,这样能方便我们控制对数据库连接资源的消耗。所以,我们把数据库连接池类设计成了单例类。但之后我们发现,系统中有些SQL语句运行得非常慢。这些SQL语句在执行的时候,长时间占用数据库连接资源,导致其他SQL请求无法响应。为了解决这个问题,我们希望将慢SQL与其他SQL隔离开来执行。为了实现这样的目的,我们可以在系统中创建两个数据库连接池,慢SQL独享一个数据库连接池,其他SQL独享另外一个数据库连接池,这样就能避免慢SQL影响到其他SQL的执行。

如果我们将数据库连接池设计成单例类,显然就无法适应这样的需求变更,也就是说,单例类在某些情况下会影响代码的扩展性、灵活性。所以,数据库连接池、线程池这类的资源池,最好还是不要设计成单例类。实际上,一些开源的数据库连接池、线程池也确实没有设计成单例类。

4.单例对代码的可测试性不友好

单例模式的使用会影响到代码的可测试性。如果单例类依赖比较重的外部资源,比如DB,我们在写单元测试的时候,希望能通过mock的方式将它替换掉。而单例类这种硬编码式的使用方式,导致无法实现mock替换。

除此之外,如果单例类持有成员变量(比如IdGenerator中的id成员变量),那它实际上相当于一种全局变量,被所有的代码共享。如果这个全局变量是一个可变全局变量,也就是说,它的成员变量是可以被修改的,那我们在编写单元测试的时候,还需要注意不同测试用例之间,修改了单例类中的同一个成员变量的值,从而导致测试结果互相影响的问题。

5.单例不支持有参数的构造函数

单例不支持有参数的构造函数,比如我们创建一个连接池的单例对象,我们没法通过参数来指定连接池的大小。针对这个问题,我们来看下都有哪些解决方案。

第一种解决思路是:创建完实例之后,再调用init()函数传递参数。需要注意的是,我们在使用这个单例类的时候,要先调用init()方法,然后才能调用getInstance()方法,否则代码会抛出异常。具体的代码实现如下所示:

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
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;

private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}

public static Singleton getInstance() {
if (instance == null) {
throw new RuntimeException("Run init() first.");
}
return instance;
}

public synchronized static Singleton init(int paramA, int paramB) {
if (instance != null){
throw new RuntimeException("Singleton has been created!");
}
instance = new Singleton(paramA, paramB);
return instance;
}
}

Singleton.init(10, 50); // 先init,再使用
Singleton singleton = Singleton.getInstance();

第二种解决思路是:将参数放到getIntance()方法中。具体的代码实现如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;

private Singleton(int paramA, int paramB) {
this.paramA = paramA;
this.paramB = paramB;
}

public synchronized static Singleton getInstance(int paramA, int paramB) {
if (instance == null) {
instance = new Singleton(paramA, paramB);
}
return instance;
}
}

Singleton singleton = Singleton.getInstance(10, 50);

不知道你有没有发现,上面的代码实现稍微有点问题。如果我们如下两次执行getInstance()方法,那获取到的singleton1和signleton2的paramA和paramB都是10和50。也就是说,第二次的参数(20,30)没有起作用,而构建的过程也没有给与提示,这样就会误导用户。这个问题如何解决呢?留给你自己思考,你可以在留言区说说你的解决思路。

1
2
Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);

第三种解决思路是:将参数放到另外一个全局变量中。具体的代码实现如下。Config是一个存储了paramA和paramB值的全局变量。里面的值既可以像下面的代码那样通过静态常量来定义,也可以从配置文件中加载得到。实际上,这种方式是最值得推荐的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Config {
public static final int PARAM_A = 123;
public static final int PARAM_B = 245;
}

public class Singleton {
private static Singleton instance = null;
private final int paramA;
private final int paramB;

private Singleton() {
this.paramA = Config.PARAM_A;
this.paramB = Config.PARAM_B;
}

public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

有何替代解决方案?

刚刚我们提到了单例的很多问题,你可能会说,即便单例有这么多问题,但我不用不行啊。我业务上有表示全局唯一类的需求,如果不用单例,我怎么才能保证这个类的对象全局唯一呢?

为了保证全局唯一,除了使用单例,我们还可以用静态方法来实现。这也是项目开发中经常用到的一种实现思路。比如,上一节课中讲的ID唯一递增生成器的例子,用静态方法实现一下,就是下面这个样子:

1
2
3
4
5
6
7
8
9
10
// 静态方法实现方式
public class IdGenerator {
private static AtomicLong id = new AtomicLong(0);

public static long getId() {
return id.incrementAndGet();
}
}
// 使用举例
long id = IdGenerator.getId();

不过,静态方法这种实现思路,并不能解决我们之前提到的问题。实际上,它比单例更加不灵活,比如,它无法支持延迟加载。我们再来看看有没有其他办法。实际上,单例除了我们之前讲到的使用方法之外,还有另外一种使用方法。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 1. 老的使用方式
public demofunction() {
//...
long id = IdGenerator.getInstance().getId();
//...
}

// 2. 新的使用方式:依赖注入
public demofunction(IdGenerator idGenerator) {
long id = idGenerator.getId();
}
// 外部调用demofunction()的时候,传入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

基于新的使用方式,我们将单例生成的对象,作为参数传递给函数(也可以通过构造函数传递给类的成员变量),可以解决单例隐藏类之间依赖关系的问题。不过,对于单例存在的其他问题,比如对OOP特性、扩展性、可测性不友好等问题,还是无法解决。

所以,如果要完全解决这些问题,我们可能要从根上,寻找其他方式来实现全局唯一类。实际上,类对象的全局唯一性可以通过多种不同的方式来保证。我们既可以通过单例模式来强制保证,也可以通过工厂模式、IOC容器(比如Spring IOC容器)来保证,还可以通过程序员自己来保证(自己在编写代码的时候自己保证不要创建两个类对象)。这就类似Java中内存对象的释放由JVM来负责,而C++中由程序员自己负责,道理是一样的。

对于替代方案工厂模式、IOC容器的详细讲解,我们放到后面的章节中讲解。

26. 创建型:单例模式(3)

如何理解单例模式中的唯一性?

首先,我们重新看一下单例的定义:“一个类只允许创建唯一一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。”

定义中提到,“一个类只允许创建唯一一个对象”。那对象的唯一性的作用范围是什么呢?是指线程内只允许创建一个对象,还是指进程内只允许创建一个对象?答案是后者,也就是说,单例模式创建的对象是进程唯一的。这里有点不好理解,我来详细地解释一下。

我们编写的代码,通过编译、链接,组织在一起,就构成了一个操作系统可以执行的文件,也就是我们平时所说的“可执行文件”(比如Windows下的exe文件)。可执行文件实际上就是代码被翻译成操作系统可理解的一组指令,你完全可以简单地理解为就是代码本身。

当我们使用命令行或者双击运行这个可执行文件的时候,操作系统会启动一个进程,将这个执行文件从磁盘加载到自己的进程地址空间(可以理解操作系统为进程分配的内存存储区,用来存储代码和数据)。接着,进程就一条一条地执行可执行文件中包含的代码。比如,当进程读到代码中的User user = new User();这条语句的时候,它就在自己的地址空间中创建一个user临时变量和一个User对象。

进程之间是不共享地址空间的,如果我们在一个进程中创建另外一个进程(比如,代码中有一个fork()语句,进程执行到这条语句的时候会创建一个新的进程),操作系统会给新进程分配新的地址空间,并且将老进程地址空间的所有内容,重新拷贝一份到新进程的地址空间中,这些内容包括代码、数据(比如user临时变量、User对象)。

所以,单例类在老进程中存在且只能存在一个对象,在新进程中也会存在且只能存在一个对象。而且,这两个对象并不是同一个对象,这也就说,单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的

如何实现线程唯一的单例?

刚刚我们讲了单例类对象是进程唯一的,一个进程只能有一个单例对象。那如何实现一个线程唯一的单例呢?

我们先来看一下,什么是线程唯一的单例,以及“线程唯一”和“进程唯一”的区别。

“进程唯一”指的是进程内唯一,进程间不唯一。类比一下,“线程唯一”指的是线程内唯一,线程间可以不唯一。实际上,“进程唯一”还代表了线程内、线程间都唯一,这也是“进程唯一”和“线程唯一”的区别之处。这段话听起来有点像绕口令,我举个例子来解释一下。

假设IdGenerator是一个线程唯一的单例类。在线程A内,我们可以创建一个单例对象a。因为线程内唯一,在线程A内就不能再创建新的IdGenerator对象了,而线程间可以不唯一,所以,在另外一个线程B内,我们还可以重新创建一个新的单例对象b。

尽管概念理解起来比较复杂,但线程唯一单例的代码实现很简单,如下所示。在代码中,我们通过一个HashMap来存储对象,其中key是线程ID,value是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象。实际上,Java语言本身提供了ThreadLocal工具类,可以更加轻松地实现线程唯一单例。不过,ThreadLocal底层实现原理也是基于下面代码中所示的HashMap。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);

//key-线程Id,value-对象
private static final ConcurrentHashMap<Long, IdGenerator> instances
= new ConcurrentHashMap<>();

private IdGenerator() {}

public static IdGenerator getInstance() {
Long currentThreadId = Thread.currentThread().getId();
instances.putIfAbsent(currentThreadId, new IdGenerator());
return instances.get(currentThreadId);
}

public long getId() {
return id.incrementAndGet();
}
}

如何实现集群环境下的单例?

刚刚我们讲了“进程唯一”的单例和“线程唯一”的单例,现在,我们再来看下,“集群唯一”的单例。

首先,我们还是先来解释一下,什么是“集群唯一”的单例。

我们还是将它跟“进程唯一”“线程唯一”做个对比。“进程唯一”指的是进程内唯一、进程间不唯一。“线程唯一”指的是线程内唯一、线程间不唯一。集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。

我们知道,经典的单例模式是进程内唯一的,那如何实现一个进程间也唯一的单例呢?如果严格按照不同的进程间共享同一个对象来实现,那集群唯一的单例实现起来就有点难度了。

具体来说,我们需要把这个单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区

为了保证任何时刻,在进程间都只有一份对象存在,一个进程在获取到对象之后,需要对对象加锁,避免其他进程再将其获取。在进程使用完这个对象之后,还需要显式地将对象从内存中删除,并且释放对对象的加锁。

按照这个思路,我用伪代码实现了一下这个过程,具体如下所示:

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
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static IdGenerator instance;
private static SharedObjectStorage storage = FileSharedObjectStorage(/*入参省略,比如文件地址*/);
private static DistributedLock lock = new DistributedLock();

private IdGenerator() {}

public synchronized static IdGenerator getInstance()
if (instance == null) {
lock.lock();
instance = storage.load(IdGenerator.class);
}
return instance;
}

public synchroinzed void freeInstance() {
storage.save(this, IdGeneator.class);
instance = null; //释放对象
lock.unlock();
}

public long getId() {
return id.incrementAndGet();
}
}

// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();

如何实现一个多例模式?

跟单例模式概念相对应的还有一个多例模式。那如何实现一个多例模式呢?

“单例”指的是,一个类只能创建一个对象。对应地,“多例”指的就是,一个类可以创建多个对象,但是个数是有限制的,比如只能创建3个对象。如果用代码来简单示例一下的话,就是下面这个样子:

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
public class BackendServer {
private long serverNo;
private String serverAddress;

private static final int SERVER_COUNT = 3;
private static final Map<Long, BackendServer> serverInstances = new HashMap<>();

static {
serverInstances.put(1L, new BackendServer(1L, "192.134.22.138:8080"));
serverInstances.put(2L, new BackendServer(2L, "192.134.22.139:8080"));
serverInstances.put(3L, new BackendServer(3L, "192.134.22.140:8080"));
}

private BackendServer(long serverNo, String serverAddress) {
this.serverNo = serverNo;
this.serverAddress = serverAddress;
}

public BackendServer getInstance(long serverNo) {
return serverInstances.get(serverNo);
}

public BackendServer getRandomInstance() {
Random r = new Random();
int no = r.nextInt(SERVER_COUNT)+1;
return serverInstances.get(no);
}
}

实际上,对于多例模式,还有一种理解方式:同一类型的只能创建一个对象,不同类型的可以创建多个对象。这里的“类型”如何理解呢?

我们还是通过一个例子来解释一下,具体代码如下所示。在代码中,logger name就是刚刚说的“类型”,同一个logger name获取到的对象实例是相同的,不同的logger name获取到的对象实例是不同的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Logger {
private static final ConcurrentHashMap<String, Logger> instances
= new ConcurrentHashMap<>();

private Logger() {}

public static Logger getInstance(String loggerName) {
instances.putIfAbsent(loggerName, new Logger());
return instances.get(loggerName);
}

public void log() {
//...
}
}

//l1==l2, l1!=l3
Logger l1 = Logger.getInstance("User.class");
Logger l2 = Logger.getInstance("User.class");
Logger l3 = Logger.getInstance("Order.class");

这种多例模式的理解方式有点类似工厂模式。它跟工厂模式的不同之处是,多例模式创建的对象都是同一个类的对象,而工厂模式创建的是不同子类的对象,关于这一点,下一节课中就会讲到。实际上,它还有点类似享元模式,两者的区别等到我们讲到享元模式的时候再来分析。除此之外,实际上,枚举类型也相当于多例模式,一个类型只能对应一个对象,一个类可以创建多个对象。

27. 创建型:工厂模式(1)

再来讲另外一个比较常用的创建型模式:工厂模式(Factory Design Pattern)。

一般情况下,工厂模式分为三种更加细分的类型:简单工厂、工厂方法和抽象工厂。不过,在GoF的《设计模式》一书中,它将简单工厂模式看作是工厂方法模式的一种特例,所以工厂模式只被分成了工厂方法和抽象工厂两类。实际上,前面一种分类方法更加常见,所以,在今天的讲解中,我们沿用第一种分类方法。

在这三种细分的工厂模式中,简单工厂、工厂方法原理比较简单,在实际的项目中也比较常用。而抽象工厂的原理稍微复杂点,在实际的项目中相对也不常用。所以,我们今天讲解的重点是前两种工厂模式。对于抽象工厂,你稍微了解一下即可。

除此之外,我们讲解的重点也不是原理和实现,因为这些都很简单,重点还是带你搞清楚应用场景:什么时候该用工厂模式?相对于直接new来创建对象,用工厂模式来创建究竟有什么好处呢?

简单工厂(Simple Factory)

首先,我们来看,什么是简单工厂模式。我们通过一个例子来解释一下。

在下面这段代码中,我们根据配置文件的后缀(json、xml、yaml、properties),选择不同的解析器(JsonRuleConfigParser、XmlRuleConfigParser……),将存储在文件中的配置解析成内存对象RuleConfig。

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
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) {
parser = new PropertiesRuleConfigParser();
} else {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}
}

在“规范和重构”那一部分中,我们有讲到,为了让代码逻辑更加清晰,可读性更好,我们要善于将功能独立的代码块封装成函数。按照这个设计思路,我们可以将代码中涉及parser创建的部分逻辑剥离出来,抽象成createParser()函数。重构之后的代码如下所示:

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 RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = createParser(ruleConfigFileExtension);
if (parser == null) {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}

private IRuleConfigParser createParser(String configFormat) {
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(configFormat)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(configFormat)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(configFormat)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(configFormat)) {
parser = new PropertiesRuleConfigParser();
}
return parser;
}
}

为了让类的职责更加单一、代码更加清晰,我们还可以进一步将createParser()函数剥离到一个独立的类中,让这个类只负责对象的创建。而这个类就是我们现在要讲的简单工厂模式类。具体的代码如下所示:

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
34
35
36
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);
IRuleConfigParser parser = RuleConfigParserFactory.createParser(ruleConfigFileExtension);
if (parser == null) {
throw new InvalidRuleConfigException(
"Rule config file format is not supported: " + ruleConfigFilePath);
}

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}
}

public class RuleConfigParserFactory {
public static IRuleConfigParser createParser(String configFormat) {
IRuleConfigParser parser = null;
if ("json".equalsIgnoreCase(configFormat)) {
parser = new JsonRuleConfigParser();
} else if ("xml".equalsIgnoreCase(configFormat)) {
parser = new XmlRuleConfigParser();
} else if ("yaml".equalsIgnoreCase(configFormat)) {
parser = new YamlRuleConfigParser();
} else if ("properties".equalsIgnoreCase(configFormat)) {
parser = new PropertiesRuleConfigParser();
}
return parser;
}
}

大部分工厂类都是以“Factory”这个单词结尾的,但也不是必须的,比如Java中的DateFormat、Calender。除此之外,工厂类中创建对象的方法一般都是create开头,比如代码中的createParser(),但有的也命名为getInstance()、createInstance()、newInstance(),有的甚至命名为valueOf()(比如Java String类的valueOf()函数)等等,这个我们根据具体的场景和习惯来命名就好。

在上面的代码实现中,我们每次调用RuleConfigParserFactory的createParser()的时候,都要创建一个新的parser。实际上,如果parser可以复用,为了节省内存和对象创建的时间,我们可以将parser事先创建好缓存起来。当调用createParser()函数的时候,我们从缓存中取出parser对象直接使用。

这有点类似单例模式和简单工厂模式的结合,具体的代码实现如下所示。在接下来的讲解中,我们把上一种实现方法叫作简单工厂模式的第一种实现方法,把下面这种实现方法叫作简单工厂模式的第二种实现方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class RuleConfigParserFactory {
private static final Map<String, RuleConfigParser> cachedParsers = new HashMap<>();

static {
cachedParsers.put("json", new JsonRuleConfigParser());
cachedParsers.put("xml", new XmlRuleConfigParser());
cachedParsers.put("yaml", new YamlRuleConfigParser());
cachedParsers.put("properties", new PropertiesRuleConfigParser());
}

public static IRuleConfigParser createParser(String configFormat) {
if (configFormat == null || configFormat.isEmpty()) {
return null;//返回null还是IllegalArgumentException全凭你自己说了算
}
IRuleConfigParser parser = cachedParsers.get(configFormat.toLowerCase());
return parser;
}
}

对于上面两种简单工厂模式的实现方法,如果我们要添加新的parser,那势必要改动到RuleConfigParserFactory的代码,那这是不是违反开闭原则呢?实际上,如果不是需要频繁地添加新的parser,只是偶尔修改一下RuleConfigParserFactory代码,稍微不符合开闭原则,也是完全可以接受的。

除此之外,在RuleConfigParserFactory的第一种代码实现中,有一组if分支判断逻辑,是不是应该用多态或其他设计模式来替代呢?实际上,如果if分支并不是很多,代码中有if分支也是完全可以接受的。应用多态或设计模式来替代if分支判断逻辑,也并不是没有任何缺点的,它虽然提高了代码的扩展性,更加符合开闭原则,但也增加了类的个数,牺牲了代码的可读性。关于这一点,我们在后面章节中会详细讲到。

总结一下,尽管简单工厂模式的代码实现中,有多处if分支判断逻辑,违背开闭原则,但权衡扩展性和可读性,这样的代码实现在大多数情况下(比如,不需要频繁地添加parser,也没有太多的parser)是没有问题的。

工厂方法(Factory Method)

如果我们非得要将if分支逻辑去掉,那该怎么办呢?比较经典处理方法就是利用多态。按照多态的实现思路,对上面的代码进行重构。重构之后的代码如下所示:

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
public interface IRuleConfigParserFactory {
IRuleConfigParser createParser();
}

public class JsonRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new JsonRuleConfigParser();
}
}

public class XmlRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new XmlRuleConfigParser();
}
}

public class YamlRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new YamlRuleConfigParser();
}
}

public class PropertiesRuleConfigParserFactory implements IRuleConfigParserFactory {
@Override
public IRuleConfigParser createParser() {
return new PropertiesRuleConfigParser();
}
}

实际上,这就是工厂方法模式的典型代码实现。这样当我们新增一种parser的时候,只需要新增一个实现了IRuleConfigParserFactory接口的Factory类即可。所以,工厂方法模式比起简单工厂模式更加符合开闭原则。

从上面的工厂方法的实现来看,一切都很完美,但是实际上存在挺大的问题。问题存在于这些工厂类的使用上。接下来,我们看一下,如何用这些工厂类来实现RuleConfigSource的load()函数。具体的代码如下所示:

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
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);

IRuleConfigParserFactory parserFactory = null;
if ("json".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new JsonRuleConfigParserFactory();
} else if ("xml".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new XmlRuleConfigParserFactory();
} else if ("yaml".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new YamlRuleConfigParserFactory();
} else if ("properties".equalsIgnoreCase(ruleConfigFileExtension)) {
parserFactory = new PropertiesRuleConfigParserFactory();
} else {
throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
}
IRuleConfigParser parser = parserFactory.createParser();

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}
}

从上面的代码实现来看,工厂类对象的创建逻辑又耦合进了load()函数中,跟我们最初的代码版本非常相似,引入工厂方法非但没有解决问题,反倒让设计变得更加复杂了。那怎么来解决这个问题呢?

我们可以为工厂类再创建一个简单工厂,也就是工厂的工厂,用来创建工厂类对象。这段话听起来有点绕,我把代码实现出来了,你一看就能明白了。其中,RuleConfigParserFactoryMap类是创建工厂对象的工厂类,getParserFactory()返回的是缓存好的单例工厂对象。

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
34
35
36
37
38
39
40
41
42
public class RuleConfigSource {
public RuleConfig load(String ruleConfigFilePath) {
String ruleConfigFileExtension = getFileExtension(ruleConfigFilePath);

IRuleConfigParserFactory parserFactory = RuleConfigParserFactoryMap.getParserFactory(ruleConfigFileExtension);
if (parserFactory == null) {
throw new InvalidRuleConfigException("Rule config file format is not supported: " + ruleConfigFilePath);
}
IRuleConfigParser parser = parserFactory.createParser();

String configText = "";
//从ruleConfigFilePath文件中读取配置文本到configText中
RuleConfig ruleConfig = parser.parse(configText);
return ruleConfig;
}

private String getFileExtension(String filePath) {
//...解析文件名获取扩展名,比如rule.json,返回json
return "json";
}
}

//因为工厂类只包含方法,不包含成员变量,完全可以复用,
//不需要每次都创建新的工厂类对象,所以,简单工厂模式的第二种实现思路更加合适。
public class RuleConfigParserFactoryMap { //工厂的工厂
private static final Map<String, IRuleConfigParserFactory> cachedFactories = new HashMap<>();

static {
cachedFactories.put("json", new JsonRuleConfigParserFactory());
cachedFactories.put("xml", new XmlRuleConfigParserFactory());
cachedFactories.put("yaml", new YamlRuleConfigParserFactory());
cachedFactories.put("properties", new PropertiesRuleConfigParserFactory());
}

public static IRuleConfigParserFactory getParserFactory(String type) {
if (type == null || type.isEmpty()) {
return null;
}
IRuleConfigParserFactory parserFactory = cachedFactories.get(type.toLowerCase());
return parserFactory;
}
}

当我们需要添加新的规则配置解析器的时候,我们只需要创建新的parser类和parser factory类,并且在RuleConfigParserFactoryMap类中,将新的parser factory对象添加到cachedFactories中即可。代码的改动非常少,基本上符合开闭原则。

实际上,对于规则配置文件解析这个应用场景来说,工厂模式需要额外创建诸多Factory类,也会增加代码的复杂性,而且,每个Factory类只是做简单的new操作,功能非常单薄(只有一行代码),也没必要设计成独立的类,所以,在这个应用场景下,简单工厂模式简单好用,比工厂方法模式更加合适。

那什么时候该用工厂方法模式,而非简单工厂模式呢?

我们前面提到,之所以将某个代码块剥离出来,独立为函数或者类,原因是这个代码块的逻辑过于复杂,剥离之后能让代码更加清晰,更加可读、可维护。但是,如果代码块本身并不复杂,就几行代码而已,我们完全没必要将它拆分成单独的函数或者类。

基于这个设计思想,当对象的创建逻辑比较复杂,不只是简单的new一下就可以,而是要组合其他类对象,做各种初始化操作的时候,我们推荐使用工厂方法模式,将复杂的创建逻辑拆分到多个工厂类中,让每个工厂类都不至于过于复杂。而使用简单工厂模式,将所有的创建逻辑都放到一个工厂类中,会导致这个工厂类变得很复杂。

除此之外,在某些场景下,如果对象不可复用,那工厂类每次都要返回不同的对象。如果我们使用简单工厂模式来实现,就只能选择第一种包含if分支逻辑的实现方式。如果我们还想避免烦人的if-else分支逻辑,这个时候,我们就推荐使用工厂方法模式。

抽象工厂(Abstract Factory)

讲完了简单工厂、工厂方法,我们再来看抽象工厂模式。抽象工厂模式的应用场景比较特殊,没有前两种常用,所以不是我们本节课学习的重点,你简单了解一下就可以了。

在简单工厂和工厂方法中,类只有一种分类方式。比如,在规则配置解析那个例子中,解析器类只会根据配置文件格式(Json、Xml、Yaml……)来分类。但是,如果类有两种分类方式,比如,我们既可以按照配置文件格式来分类,也可以按照解析的对象(Rule规则配置还是System系统配置)来分类,那就会对应下面这8个parser类。

1
2
3
4
5
6
7
8
9
10
11
针对规则配置的解析器:基于接口IRuleConfigParser
JsonRuleConfigParser
XmlRuleConfigParser
YamlRuleConfigParser
PropertiesRuleConfigParser

针对系统配置的解析器:基于接口ISystemConfigParser
JsonSystemConfigParser
XmlSystemConfigParser
YamlSystemConfigParser
PropertiesSystemConfigParser

针对这种特殊的场景,如果还是继续用工厂方法来实现的话,我们要针对每个parser都编写一个工厂类,也就是要编写8个工厂类。如果我们未来还需要增加针对业务配置的解析器(比如IBizConfigParser),那就要再对应地增加4个工厂类。而我们知道,过多的类也会让系统难维护。这个问题该怎么解决呢?

抽象工厂就是针对这种非常特殊的场景而诞生的。我们可以让一个工厂负责创建多个不同类型的对象(IRuleConfigParser、ISystemConfigParser等),而不是只创建一种parser对象。这样就可以有效地减少工厂类的个数。具体的代码实现如下所示:

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
public interface IConfigParserFactory {
IRuleConfigParser createRuleParser();
ISystemConfigParser createSystemParser();
//此处可以扩展新的parser类型,比如IBizConfigParser
}

public class JsonConfigParserFactory implements IConfigParserFactory {
@Override
public IRuleConfigParser createRuleParser() {
return new JsonRuleConfigParser();
}

@Override
public ISystemConfigParser createSystemParser() {
return new JsonSystemConfigParser();
}
}

public class XmlConfigParserFactory implements IConfigParserFactory {
@Override
public IRuleConfigParser createRuleParser() {
return new XmlRuleConfigParser();
}

@Override
public ISystemConfigParser createSystemParser() {
return new XmlSystemConfigParser();
}
}

// 省略YamlConfigParserFactory和PropertiesConfigParserFactory代码

28. 创建型:工厂模式(2)

当创建对象是一个“大工程”的时候,我们一般会选择使用工厂模式,来封装对象复杂的创建过程,将对象的创建和使用分离,让代码更加清晰。那何为“大工程”呢?上面我们讲了两种情况,一种是创建过程涉及复杂的if-else分支判断,另一种是对象创建需要组装多个其他类对象或者需要复杂的初始化过程。

今天,我们再来讲一个创建对象的“大工程”,依赖注入框架,或者叫依赖注入容器(Dependency Injection Container),简称DI容器。在今天的讲解中,我会带你一块搞清楚这样几个问题:DI容器跟我们讲的工厂模式又有何区别和联系?DI容器的核心功能有哪些,以及如何实现一个简单的DI容器?

话不多说,让我们正式开始今天的学习吧!

工厂模式和DI容器有何区别?

实际上,DI容器底层最基本的设计思路就是基于工厂模式的。DI容器相当于一个大的工厂类,负责在程序启动的时候,根据配置(要创建哪些类对象,每个类对象的创建需要依赖哪些其他类对象)事先创建好对象。当应用程序需要使用某个类对象的时候,直接从容器中获取即可。正是因为它持有一堆对象,所以这个框架才被称为“容器”。

DI容器相对于我们上节课讲的工厂模式的例子来说,它处理的是更大的对象创建工程。上节课讲的工厂模式中,一个工厂类只负责某个类对象或者某一组相关类对象(继承自同一抽象类或者接口的子类)的创建,而DI容器负责的是整个应用中所有类对象的创建。

除此之外,DI容器负责的事情要比单纯的工厂模式要多。比如,它还包括配置的解析、对象生命周期的管理。接下来,我们就详细讲讲,一个简单的DI容器应该包含哪些核心功能。

DI容器的核心功能有哪些?

总结一下,一个简单的DI容器的核心功能一般有三个:配置解析、对象创建和对象生命周期管理

首先,我们来看配置解析。

在上节课讲的工厂模式中,工厂类要创建哪个类对象是事先确定好的,并且是写死在工厂类代码中的。作为一个通用的框架来说,框架代码跟应用代码应该是高度解耦的,DI容器事先并不知道应用会创建哪些对象,不可能把某个应用要创建的对象写死在框架代码中。所以,我们需要通过一种形式,让应用告知DI容器要创建哪些对象。这种形式就是我们要讲的配置

我们将需要由DI容器来创建的类对象和创建类对象的必要信息(使用哪个构造函数以及对应的构造函数参数都是什么等等),放到配置文件中。容器读取配置文件,根据配置文件提供的信息来创建对象。

下面是一个典型的Spring容器的配置文件。Spring容器读取这个配置文件,解析出要创建的两个对象:rateLimiter和redisCounter,并且得到两者的依赖关系:rateLimiter依赖redisCounter。

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
public class RateLimiter {
private RedisCounter redisCounter;
public RateLimiter(RedisCounter redisCounter) {
this.redisCounter = redisCounter;
}
public void test() {
System.out.println("Hello World!");
}
//...
}

public class RedisCounter {
private String ipAddress;
private int port;
public RedisCounter(String ipAddress, int port) {
this.ipAddress = ipAddress;
this.port = port;
}
//...
}

配置文件beans.xml:
<beans>
<bean id="rateLimiter" class="com.xzg.RateLimiter">
<constructor-arg ref="redisCounter"/>
</bean>

<bean id="redisCounter" class="com.xzg.redisCounter">
<constructor-arg type="String" value="127.0.0.1">
<constructor-arg type="int" value=1234>
</bean>
</beans>

其次,我们再来看对象创建。

在DI容器中,如果我们给每个类都对应创建一个工厂类,那项目中类的个数会成倍增加,这会增加代码的维护成本。要解决这个问题并不难。我们只需要将所有类对象的创建都放到一个工厂类中完成就可以了,比如BeansFactory。

你可能会说,如果要创建的类对象非常多,BeansFactory中的代码会不会线性膨胀(代码量跟创建对象的个数成正比)呢?实际上并不会。待会讲到DI容器的具体实现的时候,我们会讲“反射”这种机制,它能在程序运行的过程中,动态地加载类、创建对象,不需要事先在代码中写死要创建哪些对象。所以,不管是创建一个对象还是十个对象,BeansFactory工厂类代码都是一样的。

最后,我们来看对象的生命周期管理。

上一节课我们讲到,简单工厂模式有两种实现方式,一种是每次都返回新创建的对象,另一种是每次都返回同一个事先创建好的对象,也就是所谓的单例对象。在Spring框架中,我们可以通过配置scope属性,来区分这两种不同类型的对象。scope=prototype表示返回新创建的对象,scope=singleton表示返回单例对象

除此之外,我们还可以配置对象是否支持懒加载。如果lazy-init=true,对象在真正被使用到的时候(比如:BeansFactory.getBean(“userService”))才被被创建;如果lazy-init=false,对象在应用启动的时候就事先创建好。

不仅如此,我们还可以配置对象的init-method和destroy-method方法,比如init-method=loadProperties(),destroy-method=updateConfigFile()。DI容器在创建好对象之后,会主动调用init-method属性指定的方法来初始化对象。在对象被最终销毁之前,DI容器会主动调用destroy-method属性指定的方法来做一些清理工作,比如释放数据库连接池、关闭文件。

如何实现一个简单的DI容器?

实际上,用Java语言来实现一个简单的DI容器,核心逻辑只需要包括这样两个部分:配置文件解析、根据配置文件通过“反射”语法来创建对象。

1.最小原型设计

因为我们主要是讲解设计模式,所以,在今天的讲解中,我们只实现一个DI容器的最小原型。像Spring框架这样的DI容器,它支持的配置格式非常灵活和复杂。为了简化代码实现,重点讲解原理,在最小原型中,我们只支持下面配置文件中涉及的配置语法。

1
2
3
4
5
6
7
8
9
10
11
配置文件beans.xml
<beans>
<bean id="rateLimiter" class="com.xzg.RateLimiter">
<constructor-arg ref="redisCounter"/>
</bean>

<bean id="redisCounter" class="com.xzg.redisCounter" scope="singleton" lazy-init="true">
<constructor-arg type="String" value="127.0.0.1">
<constructor-arg type="int" value=1234>
</bean>
</bean

最小原型的使用方式跟Spring框架非常类似,示例代码如下所示:

1
2
3
4
5
6
7
8
9
public class Demo {
public static void main(String[] args) {
ApplicationContext applicationContext = new ClassPathXmlApplicationContext(
"beans.xml");
RateLimiter rateLimiter = (RateLimiter) applicationContext.getBean("rateLimiter");
rateLimiter.test();
//...
}
}

2.提供执行入口

前面我们讲到,面向对象设计的最后一步是:组装类并提供执行入口。在这里,执行入口就是一组暴露给外部使用的接口和类。

通过刚刚的最小原型使用示例代码,我们可以看出,执行入口主要包含两部分:ApplicationContext和ClassPathXmlApplicationContext。其中,ApplicationContext是接口,ClassPathXmlApplicationContext是接口的实现类。两个类具体实现如下所示:

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
34
35
36
37
38
39
public interface ApplicationContext {
Object getBean(String beanId);
}

public class ClassPathXmlApplicationContext implements ApplicationContext {
private BeansFactory beansFactory;
private BeanConfigParser beanConfigParser;

public ClassPathXmlApplicationContext(String configLocation) {
this.beansFactory = new BeansFactory();
this.beanConfigParser = new XmlBeanConfigParser();
loadBeanDefinitions(configLocation);
}

private void loadBeanDefinitions(String configLocation) {
InputStream in = null;
try {
in = this.getClass().getResourceAsStream("/" + configLocation);
if (in == null) {
throw new RuntimeException("Can not find config file: " + configLocation);
}
List<BeanDefinition> beanDefinitions = beanConfigParser.parse(in);
beansFactory.addBeanDefinitions(beanDefinitions);
} finally {
if (in != null) {
try {
in.close();
} catch (IOException e) {
// TODO: log error
}
}
}
}

@Override
public Object getBean(String beanId) {
return beansFactory.getBean(beanId);
}
}

从上面的代码中,我们可以看出,ClassPathXmlApplicationContext负责组装BeansFactory和BeanConfigParser两个类,串联执行流程:从classpath中加载XML格式的配置文件,通过BeanConfigParser解析为统一的BeanDefinition格式,然后,BeansFactory根据BeanDefinition来创建对象。

3.配置文件解析

配置文件解析主要包含BeanConfigParser接口和XmlBeanConfigParser实现类,负责将配置文件解析为BeanDefinition结构,以便BeansFactory根据这个结构来创建对象。

配置文件的解析比较繁琐,不涉及我们专栏要讲的理论知识,不是我们讲解的重点,所以这里我只给出两个类的大致设计思路,并未给出具体的实现代码。如果感兴趣的话,你可以自行补充完整。具体的代码框架如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public interface BeanConfigParser {
List<BeanDefinition> parse(InputStream inputStream);
List<BeanDefinition> parse(String configContent);
}

public class XmlBeanConfigParser implements BeanConfigParser {

@Override
public List<BeanDefinition> parse(InputStream inputStream) {
String content = null;
// TODO:...
return parse(content);
}

@Override
public List<BeanDefinition> parse(String configContent) {
List<BeanDefinition> beanDefinitions = new ArrayList<>();
// TODO:...
return beanDefinitions;
}

}

public class BeanDefinition {
private String id;
private String className;
private List<ConstructorArg> constructorArgs = new ArrayList<>();
private Scope scope = Scope.SINGLETON;
private boolean lazyInit = false;
// 省略必要的getter/setter/constructors

public boolean isSingleton() {
return scope.equals(Scope.SINGLETON);
}


public static enum Scope {
SINGLETON,
PROTOTYPE
}

public static class ConstructorArg {
private boolean isRef;
private Class type;
private Object arg;
// 省略必要的getter/setter/constructors
}
}

4.核心工厂类设计

最后,我们来看,BeansFactory是如何设计和实现的。这也是我们这个DI容器最核心的一个类了。它负责根据从配置文件解析得到的BeanDefinition来创建对象。

如果对象的scope属性是singleton,那对象创建之后会缓存在singletonObjects这样一个map中,下次再请求此对象的时候,直接从map中取出返回,不需要重新创建。如果对象的scope属性是prototype,那每次请求对象,BeansFactory都会创建一个新的对象返回

实际上,BeansFactory创建对象用到的主要技术点就是Java中的反射语法:一种动态加载类和创建对象的机制。我们知道,JVM在启动的时候会根据代码自动地加载类、创建对象。至于都要加载哪些类、创建哪些对象,这些都是在代码中写死的,或者说提前写好的。但是,如果某个对象的创建并不是写死在代码中,而是放到配置文件中,我们需要在程序运行期间,动态地根据配置文件来加载类、创建对象,那这部分工作就没法让JVM帮我们自动完成了,我们需要利用Java提供的反射语法自己去编写代码。

搞清楚了反射的原理,BeansFactory的代码就不难看懂了。具体代码实现如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class BeansFactory {
private ConcurrentHashMap<String, Object> singletonObjects = new ConcurrentHashMap<>();
private ConcurrentHashMap<String, BeanDefinition> beanDefinitions = new ConcurrentHashMap<>();

public void addBeanDefinitions(List<BeanDefinition> beanDefinitionList) {
for (BeanDefinition beanDefinition : beanDefinitionList) {
this.beanDefinitions.putIfAbsent(beanDefinition.getId(), beanDefinition);
}

for (BeanDefinition beanDefinition : beanDefinitionList) {
if (beanDefinition.isLazyInit() == false && beanDefinition.isSingleton()) {
createBean(beanDefinition);
}
}
}

public Object getBean(String beanId) {
BeanDefinition beanDefinition = beanDefinitions.get(beanId);
if (beanDefinition == null) {
throw new NoSuchBeanDefinitionException("Bean is not defined: " + beanId);
}
return createBean(beanDefinition);
}

@VisibleForTesting
protected Object createBean(BeanDefinition beanDefinition) {
if (beanDefinition.isSingleton() && singletonObjects.contains(beanDefinition.getId())) {
return singletonObjects.get(beanDefinition.getId());
}

Object bean = null;
try {
Class beanClass = Class.forName(beanDefinition.getClassName());
List<BeanDefinition.ConstructorArg> args = beanDefinition.getConstructorArgs();
if (args.isEmpty()) {
bean = beanClass.newInstance();
} else {
Class[] argClasses = new Class[args.size()];
Object[] argObjects = new Object[args.size()];
for (int i = 0; i < args.size(); ++i) {
BeanDefinition.ConstructorArg arg = args.get(i);
if (!arg.getIsRef()) {
argClasses[i] = arg.getType();
argObjects[i] = arg.getArg();
} else {
BeanDefinition refBeanDefinition = beanDefinitions.get(arg.getArg());
if (refBeanDefinition == null) {
throw new NoSuchBeanDefinitionException("Bean is not defined: " + arg.getArg());
}
argClasses[i] = Class.forName(refBeanDefinition.getClassName());
argObjects[i] = createBean(refBeanDefinition);
}
}
bean = beanClass.getConstructor(argClasses).newInstance(argObjects);
}
} catch (ClassNotFoundException | IllegalAccessException
| InstantiationException | NoSuchMethodException | InvocationTargetException e) {
throw new BeanCreationFailureException("", e);
}

if (bean != null && beanDefinition.isSingleton()) {
singletonObjects.putIfAbsent(beanDefinition.getId(), bean);
return singletonObjects.get(beanDefinition.getId());
}
return bean;
}
}

29. 创建型:建造者模式

再来学习另外一个比较常用的创建型设计模式,Builder模式,中文翻译为建造者模式或者构建者模式,也有人叫它生成器模式

实际上,建造者模式的原理和代码实现非常简单,掌握起来并不难,难点在于应用场景。比如,你有没有考虑过这样几个问题:直接使用构造函数或者配合set方法就能创建对象,为什么还需要建造者模式来创建呢?建造者模式和工厂模式都可以创建对象,那它们两个的区别在哪里呢?

为什么需要建造者模式?

在平时的开发中,创建一个对象最常用的方式是,使用new关键字调用类的构造函数来完成。我的问题是,什么情况下这种方式就不适用了,就需要采用建造者模式来创建对象呢?你可以先思考一下,下面我通过一个例子来带你看一下。

假设有这样一道设计面试题:我们需要定义一个资源池配置类ResourcePoolConfig。这里的资源池,你可以简单理解为线程池、连接池、对象池等。在这个资源池配置类中,有以下几个成员变量,也就是可配置项。现在,请你编写代码实现这个ResourcePoolConfig类。

img

只要你稍微有点开发经验,那实现这样一个类对你来说并不是件难事。最常见、最容易想到的实现思路如下代码所示。因为maxTotal、maxIdle、minIdle不是必填变量,所以在创建ResourcePoolConfig对象的时候,我们通过往构造函数中,给这几个参数传递null值,来表示使用默认值。

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
34
35
36
37
38
39
public class ResourcePoolConfig {
private static final int DEFAULT_MAX_TOTAL = 8;
private static final int DEFAULT_MAX_IDLE = 8;
private static final int DEFAULT_MIN_IDLE = 0;

private String name;
private int maxTotal = DEFAULT_MAX_TOTAL;
private int maxIdle = DEFAULT_MAX_IDLE;
private int minIdle = DEFAULT_MIN_IDLE;

public ResourcePoolConfig(String name, Integer maxTotal, Integer maxIdle, Integer minIdle) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("name should not be empty.");
}
this.name = name;

if (maxTotal != null) {
if (maxTotal <= 0) {
throw new IllegalArgumentException("maxTotal should be positive.");
}
this.maxTotal = maxTotal;
}

if (maxIdle != null) {
if (maxIdle < 0) {
throw new IllegalArgumentException("maxIdle should not be negative.");
}
this.maxIdle = maxIdle;
}

if (minIdle != null) {
if (minIdle < 0) {
throw new IllegalArgumentException("minIdle should not be negative.");
}
this.minIdle = minIdle;
}
}
//...省略getter方法...
}

现在,ResourcePoolConfig只有4个可配置项,对应到构造函数中,也只有4个参数,参数的个数不多。但是,如果可配置项逐渐增多,变成了8个、10个,甚至更多,那继续沿用现在的设计思路,构造函数的参数列表会变得很长,代码在可读性和易用性上都会变差。在使用构造函数的时候,我们就容易搞错各参数的顺序,传递进错误的参数值,导致非常隐蔽的bug。

1
2
// 参数太多,导致可读性差、参数可能传递错误
ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool", 16, null, 8, null, false , true, 10, 20falsetrue);

解决这个问题的办法你应该也已经想到了,那就是用set()函数来给成员变量赋值,以替代冗长的构造函数。我们直接看代码,具体如下所示。其中,配置项name是必填的,所以我们把它放到构造函数中设置,强制创建类对象的时候就要填写。其他配置项maxTotal、maxIdle、minIdle都不是必填的,所以我们通过set()函数来设置,让使用者自主选择填写或者不填写。

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
34
35
36
37
38
39
public class ResourcePoolConfig {
private static final int DEFAULT_MAX_TOTAL = 8;
private static final int DEFAULT_MAX_IDLE = 8;
private static final int DEFAULT_MIN_IDLE = 0;

private String name;
private int maxTotal = DEFAULT_MAX_TOTAL;
private int maxIdle = DEFAULT_MAX_IDLE;
private int minIdle = DEFAULT_MIN_IDLE;

public ResourcePoolConfig(String name) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("name should not be empty.");
}
this.name = name;
}

public void setMaxTotal(int maxTotal) {
if (maxTotal <= 0) {
throw new IllegalArgumentException("maxTotal should be positive.");
}
this.maxTotal = maxTotal;
}

public void setMaxIdle(int maxIdle) {
if (maxIdle < 0) {
throw new IllegalArgumentException("maxIdle should not be negative.");
}
this.maxIdle = maxIdle;
}

public void setMinIdle(int minIdle) {
if (minIdle < 0) {
throw new IllegalArgumentException("minIdle should not be negative.");
}
this.minIdle = minIdle;
}
//...省略getter方法...
}

接下来,我们来看新的ResourcePoolConfig类该如何使用。我写了一个示例代码,如下所示。没有了冗长的函数调用和参数列表,代码在可读性和易用性上提高了很多。

1
2
3
4
// ResourcePoolConfig使用举例
ResourcePoolConfig config = new ResourcePoolConfig("dbconnectionpool");
config.setMaxTotal(16);
config.setMaxIdle(8);

至此,我们仍然没有用到建造者模式,通过构造函数设置必填项,通过set()方法设置可选配置项,就能实现我们的设计需求。如果我们把问题的难度再加大点,比如,还需要解决下面这三个问题,那现在的设计思路就不能满足了。

  • 我们刚刚讲到,name是必填的,所以,我们把它放到构造函数中,强制创建对象的时候就设置。如果必填的配置项有很多,把这些必填配置项都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填项也通过set()方法设置,那校验这些必填项是否已经填写的逻辑就无处安放了。
  • 除此之外,假设配置项之间有一定的依赖关系,比如,如果用户设置了maxTotal、maxIdle、minIdle其中一个,就必须显式地设置另外两个;或者配置项之间有一定的约束条件,比如,maxIdle和minIdle要小于等于maxTotal。如果我们继续使用现在的设计思路,那这些配置项之间的依赖关系或者约束条件的校验逻辑就无处安放了。
  • 如果我们希望ResourcePoolConfig类对象是不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值。要实现这个功能,我们就不能在ResourcePoolConfig类中暴露set()方法。

为了解决这些问题,建造者模式就派上用场了。

我们可以把校验逻辑放置到Builder类中,先创建建造者,并且通过set()方法设置建造者的变量值,然后在使用build()方法真正创建对象之前,做集中的校验,校验通过之后才会创建对象。除此之外,我们把ResourcePoolConfig的构造函数改为private私有权限。这样我们就只能通过建造者来创建ResourcePoolConfig类对象。并且,ResourcePoolConfig没有提供任何set()方法,这样我们创建出来的对象就是不可变对象了。

我们用建造者模式重新实现了上面的需求,具体的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
public class ResourcePoolConfig {
private String name;
private int maxTotal;
private int maxIdle;
private int minIdle;

private ResourcePoolConfig(Builder builder) {
this.name = builder.name;
this.maxTotal = builder.maxTotal;
this.maxIdle = builder.maxIdle;
this.minIdle = builder.minIdle;
}
//...省略getter方法...

//我们将Builder类设计成了ResourcePoolConfig的内部类。
//我们也可以将Builder类设计成独立的非内部类ResourcePoolConfigBuilder。
public static class Builder {
private static final int DEFAULT_MAX_TOTAL = 8;
private static final int DEFAULT_MAX_IDLE = 8;
private static final int DEFAULT_MIN_IDLE = 0;

private String name;
private int maxTotal = DEFAULT_MAX_TOTAL;
private int maxIdle = DEFAULT_MAX_IDLE;
private int minIdle = DEFAULT_MIN_IDLE;

public ResourcePoolConfig build() {
// 校验逻辑放到这里来做,包括必填项校验、依赖关系校验、约束条件校验等
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("...");
}
if (maxIdle > maxTotal) {
throw new IllegalArgumentException("...");
}
if (minIdle > maxTotal || minIdle > maxIdle) {
throw new IllegalArgumentException("...");
}

return new ResourcePoolConfig(this);
}

public Builder setName(String name) {
if (StringUtils.isBlank(name)) {
throw new IllegalArgumentException("...");
}
this.name = name;
return this;
}

public Builder setMaxTotal(int maxTotal) {
if (maxTotal <= 0) {
throw new IllegalArgumentException("...");
}
this.maxTotal = maxTotal;
return this;
}

public Builder setMaxIdle(int maxIdle) {
if (maxIdle < 0) {
throw new IllegalArgumentException("...");
}
this.maxIdle = maxIdle;
return this;
}

public Builder setMinIdle(int minIdle) {
if (minIdle < 0) {
throw new IllegalArgumentException("...");
}
this.minIdle = minIdle;
return this;
}
}
}

// 这段代码会抛出IllegalArgumentException,因为minIdle>maxIdle
ResourcePoolConfig config = new ResourcePoolConfig.Builder()
.setName("dbconnectionpool")
.setMaxTotal(16)
.setMaxIdle(10)
.setMinIdle(12)
.build();

实际上,使用建造者模式创建对象,还能避免对象存在无效状态。我再举个例子解释一下。比如我们定义了一个长方形类,如果不使用建造者模式,采用先创建后set的方式,那就会导致在第一个set之后,对象处于无效状态。具体代码如下所示:

1
2
3
Rectangle r = new Rectange(); // r is invalid
r.setWidth(2); // r is invalid
r.setHeight(3); // r is valid

为了避免这种无效状态的存在,我们就需要使用构造函数一次性初始化好所有的成员变量。如果构造函数参数过多,我们就需要考虑使用建造者模式,先设置建造者的变量,然后再一次性地创建对象,让对象一直处于有效状态

实际上,如果我们并不是很关心对象是否有短暂的无效状态,也不是太在意对象是否是可变的。比如,对象只是用来映射数据库读出来的数据,那我们直接暴露set()方法来设置类的成员变量值是完全没问题的。而且,使用建造者模式来构建对象,代码实际上是有点重复的,ResourcePoolConfig类中的成员变量,要在Builder类中重新再定义一遍。

与工厂模式有何区别?

从上面的讲解中,我们可以看出,建造者模式是让建造者类来负责对象的创建工作。上一节课中讲到的工厂模式,是由工厂类来负责对象创建的工作。那它们之间有什么区别呢?

实际上,工厂模式是用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。建造者模式是用来创建一种类型的复杂对象,通过设置不同的可选参数,“定制化”地创建不同的对象。

网上有一个经典的例子很好地解释了两者的区别。

顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨。

实际上,我们也不要太学院派,非得把工厂模式、建造者模式分得那么清楚,我们需要知道的是,每个模式为什么这么设计,能解决什么问题。只有了解了这些最本质的东西,我们才能不生搬硬套,才能灵活应用,甚至可以混用各种模式创造出新的模式,来解决特定场景的问题。

30. 创建型:原型模式

对于熟悉JavaScript语言的前端程序员来说,原型模式是一种比较常用的开发模式。这是因为,有别于Java、C++等基于类的面向对象编程语言,JavaScript是一种基于原型的面向对象编程语言。即便JavaScript现在也引入了类的概念,但它也只是基于原型的语法糖而已。不过,如果你熟悉的是Java、C++等这些编程语言,那在实际的开发中,就很少用到原型模式了。

今天的讲解跟具体某一语言的语法机制无关,而是通过一个clone散列表的例子带你搞清楚:原型模式的应用场景,以及它的两种实现方式:深拷贝和浅拷贝。

原型模式的原理与应用

如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型设计模式(Prototype Design Pattern),简称原型模式

那何为“对象的创建成本比较大”?

实际上,创建对象包含的申请内存、给成员变量赋值这一过程,本身并不会花费太多时间,或者说对于大部分业务系统来说,这点时间完全是可以忽略的。应用一个复杂的模式,只得到一点点的性能提升,这就是所谓的过度设计,得不偿失。

但是,如果对象中的数据需要经过复杂的计算才能得到(比如排序、计算哈希值),或者需要从RPC、网络、数据库、文件系统等非常慢速的IO中读取,这种情况下,我们就可以利用原型模式,从其他已有对象中直接拷贝得到,而不用每次在创建新对象的时候,都重复执行这些耗时的操作

这么说还是比较理论,接下来,我们通过一个例子来解释一下刚刚这段话。

假设数据库中存储了大约10万条“搜索关键词”信息,每条信息包含关键词、关键词被搜索的次数、信息最近被更新的时间等。系统A在启动的时候会加载这份数据到内存中,用于处理某些其他的业务需求。为了方便快速地查找某个关键词对应的信息,我们给关键词建立一个散列表索引。

如果你熟悉的是Java语言,可以直接使用语言中提供的HashMap容器来实现。其中,HashMap的key为搜索关键词,value为关键词详细信息(比如搜索次数)。我们只需要将数据从数据库中读取出来,放入HashMap就可以了。

不过,我们还有另外一个系统B,专门用来分析搜索日志,定期(比如间隔10分钟)批量地更新数据库中的数据,并且标记为新的数据版本。比如,在下面的示例图中,我们对v2版本的数据进行更新,得到v3版本的数据。这里我们假设只有更新和新添关键词,没有删除关键词的行为。

img

为了保证系统A中数据的实时性(不一定非常实时,但数据也不能太旧),系统A需要定期根据数据库中的数据,更新内存中的索引和数据。

我们该如何实现这个需求呢?

实际上,也不难。我们只需要在系统A中,记录当前数据的版本Va对应的更新时间Ta,从数据库中捞出更新时间大于Ta的所有搜索关键词,也就是找出Va版本与最新版本数据的“差集”,然后针对差集中的每个关键词进行处理。如果它已经在散列表中存在了,我们就更新相应的搜索次数、更新时间等信息;如果它在散列表中不存在,我们就将它插入到散列表中。

按照这个设计思路,我给出的示例代码如下所示:

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
public class Demo {
private ConcurrentHashMap<String, SearchWord> currentKeywords = new ConcurrentHashMap<>();
private long lastUpdateTime = -1;

public void refresh() {
// 从数据库中取出更新时间>lastUpdateTime的数据,放入到currentKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (currentKeywords.containsKey(searchWord.getKeyword())) {
currentKeywords.replace(searchWord.getKeyword(), searchWord);
} else {
currentKeywords.put(searchWord.getKeyword(), searchWord);
}
}

lastUpdateTime = maxNewUpdatedTime;
}

private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}

不过,现在,我们有一个特殊的要求:任何时刻,系统A中的所有数据都必须是同一个版本的,要么都是版本a,要么都是版本b,不能有的是版本a,有的是版本b。那刚刚的更新方式就不能满足这个要求了。除此之外,我们还要求:在更新内存数据的时候,系统A不能处于不可用状态,也就是不能停机更新数据。

那我们该如何实现现在这个需求呢?

实际上,也不难。我们把正在使用的数据的版本定义为“服务版本”,当我们要更新内存中的数据的时候,我们并不是直接在服务版本(假设是版本a数据)上更新,而是重新创建另一个版本数据(假设是版本b数据),等新的版本数据建好之后,再一次性地将服务版本从版本a切换到版本b。这样既保证了数据一直可用,又避免了中间状态的存在。

按照这个设计思路,我给出的示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();

public void refresh() {
HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>();

// 从数据库中取出所有的数据,放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords();
for (SearchWord searchWord : toBeUpdatedSearchWords) {
newKeywords.put(searchWord.getKeyword(), searchWord);
}

currentKeywords = newKeywords;
}

private List<SearchWord> getSearchWords() {
// TODO: 从数据库中取出所有的数据
return null;
}
}

不过,在上面的代码实现中,newKeywords构建的成本比较高。我们需要将这10万条数据从数据库中读出,然后计算哈希值,构建newKeywords。这个过程显然是比较耗时。为了提高效率,原型模式就派上用场了。

我们拷贝currentKeywords数据到newKeywords中,然后从数据库中只捞出新增或者有更新的关键词,更新到newKeywords中。而相对于10万条数据来说,每次新增或者更新的关键词个数是比较少的,所以,这种策略大大提高了数据更新的效率。

按照这个设计思路,我给出的示例代码如下所示:

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 Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
private long lastUpdateTime = -1;

public void refresh() {
// 原型模式就这么简单,拷贝已有对象的数据,更新少量差值
HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();

// 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (newKeywords.containsKey(searchWord.getKeyword())) {
SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
oldSearchWord.setCount(searchWord.getCount());
oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
} else {
newKeywords.put(searchWord.getKeyword(), searchWord);
}
}

lastUpdateTime = maxNewUpdatedTime;
currentKeywords = newKeywords;
}

private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}

这里我们利用了Java中的clone()语法来复制一个对象。如果你熟悉的语言没有这个语法,那把数据从currentKeywords中一个个取出来,然后再重新计算哈希值,放入到newKeywords中也是可以接受的。毕竟,最耗时的还是从数据库中取数据的操作。相对于数据库的IO操作来说,内存操作和CPU计算的耗时都是可以忽略的。

不过,不知道你有没有发现,实际上,刚刚的代码实现是有问题的。要弄明白到底有什么问题,我们需要先了解另外两个概念:深拷贝(Deep Copy)和浅拷贝(Shallow Copy)。

原型模式的实现方式:深拷贝和浅拷贝

我们来看,在内存中,用散列表组织的搜索关键词信息是如何存储的。我画了一张示意图,大致结构如下所示。从图中我们可以发现,散列表索引中,每个结点存储的key是搜索关键词,value是SearchWord对象的内存地址。SearchWord对象本身存储在散列表之外的内存空间中。

img

浅拷贝和深拷贝的区别在于,浅拷贝只会复制图中的索引(散列表),不会复制数据(SearchWord对象)本身。相反,深拷贝不仅仅会复制索引,还会复制数据本身。浅拷贝得到的对象(newKeywords)跟原始对象(currentKeywords)共享数据(SearchWord对象),而深拷贝得到的是一份完完全全独立的对象。具体的对比如下图所示:

img

img

在Java语言中,Object类的clone()方法执行的就是我们刚刚说的浅拷贝。它只会拷贝对象中的基本数据类型的数据(比如,int、long),以及引用对象(SearchWord)的内存地址,不会递归地拷贝引用对象本身

在上面的代码中,我们通过调用HashMap上的clone()浅拷贝方法来实现原型模式。当我们通过newKeywords更新SearchWord对象的时候(比如,更新“设计模式”这个搜索关键词的访问次数),newKeywords和currentKeywords因为指向相同的一组SearchWord对象,就会导致currentKeywords中指向的SearchWord,有的是老版本的,有的是新版本的,就没法满足我们之前的需求:currentKeywords中的数据在任何时刻都是同一个版本的,不存在介于老版本与新版本之间的中间状态。

现在,我们又该如何来解决这个问题呢?

我们可以将浅拷贝替换为深拷贝。newKeywords不仅仅复制currentKeywords的索引,还把SearchWord对象也复制一份出来,这样newKeywords和currentKeywords就指向不同的SearchWord对象,也就不存在更新newKeywords的数据会导致currentKeywords的数据也被更新的问题了。

那如何实现深拷贝呢?总结一下的话,有下面两种方法。

第一种方法:递归拷贝对象、对象的引用对象以及引用对象的引用对象……直到要拷贝的对象只包含基本数据类型数据,没有引用对象为止。根据这个思路对之前的代码进行重构。重构之后的代码如下所示:

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
34
35
36
37
38
39
40
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
private long lastUpdateTime = -1;

public void refresh() {
// Deep copy
HashMap<String, SearchWord> newKeywords = new HashMap<>();
for (HashMap.Entry<String, SearchWord> e : currentKeywords.entrySet()) {
SearchWord searchWord = e.getValue();
SearchWord newSearchWord = new SearchWord(
searchWord.getKeyword(), searchWord.getCount(), searchWord.getLastUpdateTime());
newKeywords.put(e.getKey(), newSearchWord);
}

// 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (newKeywords.containsKey(searchWord.getKeyword())) {
SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
oldSearchWord.setCount(searchWord.getCount());
oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
} else {
newKeywords.put(searchWord.getKeyword(), searchWord);
}
}

lastUpdateTime = maxNewUpdatedTime;
currentKeywords = newKeywords;
}

private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}

}

第二种方法:先将对象序列化,然后再反序列化成新的对象。具体的示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
public Object deepCopy(Object object) {
ByteArrayOutputStream bo = new ByteArrayOutputStream();
ObjectOutputStream oo = new ObjectOutputStream(bo);
oo.writeObject(object);

ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
ObjectInputStream oi = new ObjectInputStream(bi);

return oi.readObject();
}

刚刚的两种实现方法,不管采用哪种,深拷贝都要比浅拷贝耗时、耗内存空间。针对我们这个应用场景,有没有更快、更省内存的实现方式呢?

我们可以先采用浅拷贝的方式创建newKeywords。对于需要更新的SearchWord对象,我们再使用深度拷贝的方式创建一份新的对象,替换newKeywords中的老对象。毕竟需要更新的数据是很少的。这种方式即利用了浅拷贝节省时间、空间的优点,又能保证currentKeywords中的中数据都是老版本的数据。具体的代码实现如下所示。这也是标题中讲到的,在我们这个应用场景下,最快速clone散列表的方式。

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
public class Demo {
private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
private long lastUpdateTime = -1;

public void refresh() {
// Shallow copy
HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>) currentKeywords.clone();

// 从数据库中取出更新时间>lastUpdateTime的数据,放入到newKeywords中
List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime);
long maxNewUpdatedTime = lastUpdateTime;
for (SearchWord searchWord : toBeUpdatedSearchWords) {
if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
maxNewUpdatedTime = searchWord.getLastUpdateTime();
}
if (newKeywords.containsKey(searchWord.getKeyword())) {
newKeywords.remove(searchWord.getKeyword());
}
newKeywords.put(searchWord.getKeyword(), searchWord);
}

lastUpdateTime = maxNewUpdatedTime;
currentKeywords = newKeywords;
}

private List<SearchWord> getSearchWords(long lastUpdateTime) {
// TODO: 从数据库中取出更新时间>lastUpdateTime的数据
return null;
}
}

31. 结构型:代理模式

结构型模式主要总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景的问题。结构型模式包括:代理模式、桥接模式、装饰器模式、适配器模式、门面模式、组合模式、享元模式。今天我们要讲其中的代理模式。它也是在实际开发中经常被用到的一种设计模式。

代理模式的原理解析

代理模式(Proxy Design Pattern)的原理和代码实现都不难掌握。它在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。我们通过一个简单的例子来解释一下这段话。

这个例子来自性能计数器。当时我们开发了一个MetricsCollector类,用来收集接口请求的原始数据,比如访问时间、处理时长等。在业务系统中,我们采用如下方式来使用这个MetricsCollector类:

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
public class UserController {
//...省略其他属性和方法...
private MetricsCollector metricsCollector; // 依赖注入

public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();

// ... 省略login逻辑...

long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);

//...返回UserVo数据...
}

public UserVo register(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();

// ... 省略register逻辑...

long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);

//...返回UserVo数据...
}
}

很明显,上面的写法有两个问题。第一,性能计数器框架代码侵入到业务代码中,跟业务代码高度耦合。如果未来需要替换这个框架,那替换的成本会比较大。第二,收集接口请求的代码跟业务代码无关,本就不应该放到一个类中。业务类最好职责更加单一,只聚焦业务处理。

为了将框架代码和业务代码解耦,代理模式就派上用场了。代理类UserControllerProxy和原始类UserController实现相同的接口IUserController。UserController类只负责业务功能。代理类UserControllerProxy负责在业务代码执行前后附加其他逻辑代码,并通过委托的方式调用原始类来执行业务代码。具体的代码实现如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public interface IUserController {
UserVo login(String telephone, String password);
UserVo register(String telephone, String password);
}

public class UserController implements IUserController {
//...省略其他属性和方法...

@Override
public UserVo login(String telephone, String password) {
//...省略login逻辑...
//...返回UserVo数据...
}

@Override
public UserVo register(String telephone, String password) {
//...省略register逻辑...
//...返回UserVo数据...
}
}

public class UserControllerProxy implements IUserController {
private MetricsCollector metricsCollector;
private UserController userController;

public UserControllerProxy(UserController userController) {
this.userController = userController;
this.metricsCollector = new MetricsCollector();
}

@Override
public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();

// 委托
UserVo userVo = userController.login(telephone, password);

long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);

return userVo;
}

@Override
public UserVo register(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();

UserVo userVo = userController.register(telephone, password);

long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);

return userVo;
}
}

//UserControllerProxy使用举例
//因为原始类和代理类实现相同的接口,是基于接口而非实现编程
//将UserController类对象替换为UserControllerProxy类对象,不需要改动太多代码
IUserController userController = new UserControllerProxy(new UserController());

参照基于接口而非实现编程的设计思想,将原始类对象替换为代理类对象的时候,为了让代码改动尽量少,在刚刚的代理模式的代码实现中,代理类和原始类需要实现相同的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的(比如它来自一个第三方的类库),我们也没办法直接修改原始类,给它重新定义一个接口。在这种情况下,我们该如何实现代理模式呢?

对于这种外部类的扩展,我们一般都是采用继承的方式。这里也不例外。我们让代理类继承原始类,然后扩展附加功能。原理很简单,不需要过多解释,你直接看代码就能明白。具体代码如下所示:

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
34
35
public class UserControllerProxy extends UserController {
private MetricsCollector metricsCollector;

public UserControllerProxy() {
this.metricsCollector = new MetricsCollector();
}

public UserVo login(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();

UserVo userVo = super.login(telephone, password);

long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);

return userVo;
}

public UserVo register(String telephone, String password) {
long startTimestamp = System.currentTimeMillis();

UserVo userVo = super.register(telephone, password);

long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);

return userVo;
}
}
//UserControllerProxy使用举例
UserController userController = new UserControllerProxy();

动态代理的原理解析

不过,刚刚的代码实现还是有点问题。一方面,我们需要在代理类中,将原始类中的所有的方法,都重新实现一遍,并且为每个方法都附加相似的代码逻辑。另一方面,如果要添加的附加功能的类有不止一个,我们需要针对每个类都创建一个代理类。

如果有50个要添加附加功能的原始类,那我们就要创建50个对应的代理类。这会导致项目中类的个数成倍增加,增加了代码维护成本。并且,每个代理类中的代码都有点像模板式的“重复”代码,也增加了不必要的开发成本。那这个问题怎么解决呢?

我们可以使用动态代理来解决这个问题。所谓动态代理(Dynamic Proxy),就是我们不事先为每个原始类编写代理类,而是在运行的时候,动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。那如何实现动态代理呢?

如果你熟悉的是Java语言,实现动态代理就是件很简单的事情。因为Java语言本身就已经提供了动态代理的语法(实际上,动态代理底层依赖的就是Java的反射语法)。我们来看一下,如何用Java的动态代理来实现刚刚的功能。具体的代码如下所示。其中,MetricsCollectorProxy作为一个动态代理类,动态地给每个需要收集接口请求信息的类创建代理类

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
34
35
36
37
public class MetricsCollectorProxy {
private MetricsCollector metricsCollector;

public MetricsCollectorProxy() {
this.metricsCollector = new MetricsCollector();
}

public Object createProxy(Object proxiedObject) {
Class<?>[] interfaces = proxiedObject.getClass().getInterfaces();
DynamicProxyHandler handler = new DynamicProxyHandler(proxiedObject);
return Proxy.newProxyInstance(proxiedObject.getClass().getClassLoader(), interfaces, handler);
}

private class DynamicProxyHandler implements InvocationHandler {
private Object proxiedObject;

public DynamicProxyHandler(Object proxiedObject) {
this.proxiedObject = proxiedObject;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
long startTimestamp = System.currentTimeMillis();
Object result = method.invoke(proxiedObject, args);
long endTimeStamp = System.currentTimeMillis();
long responseTime = endTimeStamp - startTimestamp;
String apiName = proxiedObject.getClass().getName() + ":" + method.getName();
RequestInfo requestInfo = new RequestInfo(apiName, responseTime, startTimestamp);
metricsCollector.recordRequest(requestInfo);
return result;
}
}
}

//MetricsCollectorProxy使用举例
MetricsCollectorProxy proxy = new MetricsCollectorProxy();
IUserController userController = (IUserController) proxy.createProxy(new UserController());

实际上,Spring AOP底层的实现原理就是基于动态代理。用户配置好需要给哪些类创建代理,并定义好在执行原始类的业务代码前后执行哪些附加功能。Spring为这些类创建动态代理对象,并在JVM中替代原始类对象。原本在代码中执行的原始类的方法,被换作执行代理类的方法,也就实现了给原始类添加附加功能的目的。

代理模式的应用场景

代理模式的应用场景非常多,我这里列举一些比较常见的用法,希望你能举一反三地应用在你的项目开发中。

1.业务系统的非功能性需求开发

代理模式最常用的一个应用场景就是,在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。我们将这些附加功能与业务功能解耦,放到代理类中统一处理,让程序员只需要关注业务方面的开发。实际上,前面举的搜集接口请求信息的例子,就是这个应用场景的一个典型例子。

如果你熟悉Java语言和Spring开发框架,这部分工作都是可以在Spring AOP切面中完成的。前面我们也提到,Spring AOP底层的实现原理就是基于动态代理。

2.代理模式在RPC、缓存中的应用

实际上,RPC框架也可以看作一种代理模式,GoF的《设计模式》一书中把它称作远程代理。通过远程代理,将网络通信、数据编解码等细节隐藏起来。客户端在使用RPC服务的时候,就像使用本地函数一样,无需了解跟服务器交互的细节。除此之外,RPC服务的开发者也只需要开发业务逻辑,就像开发本地使用的函数一样,不需要关注跟客户端的交互细节。

关于远程代理的代码示例,我自己实现了一个简单的RPC框架Demo,放到了GitHub中,你可以点击这里的链接查看。

我们再来看代理模式在缓存中的应用。假设我们要开发一个接口请求的缓存功能,对于某些接口请求,如果入参相同,在设定的过期时间内,直接返回缓存结果,而不用重新进行逻辑处理。比如,针对获取用户个人信息的需求,我们可以开发两个接口,一个支持缓存,一个支持实时查询。对于需要实时数据的需求,我们让其调用实时查询接口,对于不需要实时数据的需求,我们让其调用支持缓存的接口。那如何来实现接口请求的缓存功能呢?

最简单的实现方法就是刚刚我们讲到的,给每个需要支持缓存的查询需求都开发两个不同的接口,一个支持缓存,一个支持实时查询。但是,这样做显然增加了开发成本,而且会让代码看起来非常臃肿(接口个数成倍增加),也不方便缓存接口的集中管理(增加、删除缓存接口)、集中配置(比如配置每个接口缓存过期时间)。

针对这些问题,代理模式就能派上用场了,确切地说,应该是动态代理。如果是基于Spring框架来开发的话,那就可以在AOP切面中完成接口缓存的功能。在应用启动的时候,我们从配置文件中加载需要支持缓存的接口,以及相应的缓存策略(比如过期时间)等。当请求到来的时候,我们在AOP切面中拦截请求,如果请求中带有支持缓存的字段(比如http://…?..&cached=true),我们便从缓存(内存缓存或者Redis缓存等)中获取数据直接返回。

32. 结构型:桥接模式

再学习另外一种结构型模式:桥接模式。桥接模式的代码实现非常简单,但是理解起来稍微有点难度,并且应用场景也比较局限,所以,相当于代理模式来说,桥接模式在实际的项目中并没有那么常用,你只需要简单了解,见到能认识就可以,并不是我们学习的重点。

桥接模式的原理解析

桥接模式,也叫作桥梁模式,英文是Bridge Design Pattern。这个模式可以说是23种设计模式中最难理解的模式之一了。我查阅了比较多的书籍和资料之后发现,对于这个模式有两种不同的理解方式。

当然,这其中“最纯正”的理解方式,当属GoF的《设计模式》一书中对桥接模式的定义。毕竟,这23种经典的设计模式,最初就是由这本书总结出来的。在GoF的《设计模式》一书中,桥接模式是这么定义的:“Decouple an abstraction from its implementation so that the two can vary independently。”翻译成中文就是:“将抽象和实现解耦,让它们可以独立变化。”

关于桥接模式,很多书籍、资料中,还有另外一种理解方式:“一个类存在两个(或多个)独立变化的维度,我们通过组合的方式,让这两个(或多个)维度可以独立进行扩展。”通过组合关系来替代继承关系,避免继承层次的指数级爆炸。这种理解方式非常类似于,我们之前讲过的“组合优于继承”设计原则,所以,这里我就不多解释了。我们重点看下GoF的理解方式。

GoF给出的定义非常的简短,单凭这一句话,估计没几个人能看懂是什么意思。所以,我们通过JDBC驱动的例子来解释一下。JDBC驱动是桥接模式的经典应用。我们先来看一下,如何利用JDBC驱动来查询数据库。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
Class.forName("com.mysql.jdbc.Driver");//加载及注册JDBC驱动程序
String url = "jdbc:mysql://localhost:3306/sample_db?user=root&password=your_password";
Connection con = DriverManager.getConnection(url);
Statement stmt = con.createStatement();
String query = "select * from test";
ResultSet rs=stmt.executeQuery(query);
while(rs.next()) {
rs.getString(1);
rs.getInt(2);
}

如果我们想要把MySQL数据库换成Oracle数据库,只需要把第一行代码中的com.mysql.jdbc.Driver换成oracle.jdbc.driver.OracleDriver就可以了。当然,也有更灵活的实现方式,我们可以把需要加载的Driver类写到配置文件中,当程序启动的时候,自动从配置文件中加载,这样在切换数据库的时候,我们都不需要修改代码,只需要修改配置文件就可以了。

不管是改代码还是改配置,在项目中,从一个数据库切换到另一种数据库,都只需要改动很少的代码,或者完全不需要改动代码,那如此优雅的数据库切换是如何实现的呢?

源码之下无秘密。要弄清楚这个问题,我们先从com.mysql.jdbc.Driver这个类的代码看起。我摘抄了部分相关代码,放到了这里,你可以看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.mysql.jdbc;
import java.sql.SQLException;

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
static {
try {
java.sql.DriverManager.registerDriver(new Driver());
} catch (SQLException E) {
throw new RuntimeException("Can't register driver!");
}
}

/**
* Construct a new driver and register it with DriverManager
* @throws SQLException if a database error occurs.
*/
public Driver() throws SQLException {
// Required for Class.forName().newInstance()
}
}

结合com.mysql.jdbc.Drive+r的代码实现,我们可以发现,当执行Class.forName(“com.mysql.jdbc.Driver”)这条语句的时候,实际上是做了两件事情。第一件事情是要求JVM查找并加载指定的Driver类,第二件事情是执行该类的静态代码,也就是将MySQL Driver注册到DriverManager类中。

现在,我们再来看一下,DriverManager类是干什么用的。具体的代码如下所示。当我们把具体的Driver实现类(比如,com.mysql.jdbc.Driver)注册到DriverManager之后,后续所有对JDBC接口的调用,都会委派到对具体的Driver实现类来执行。而Driver实现类都实现了相同的接口(java.sql.Driver ),这也是可以灵活切换Driver的原因。

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
public class DriverManager {
private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<DriverInfo>();

//...
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
//...

public static synchronized void registerDriver(java.sql.Driver driver) throws SQLException {
if (driver != null) {
registeredDrivers.addIfAbsent(new DriverInfo(driver));
} else {
throw new NullPointerException();
}
}

public static Connection getConnection(String url, String user, String password) throws SQLException {
java.util.Properties info = new java.util.Properties();
if (user != null) {
info.put("user", user);
}
if (password != null) {
info.put("password", password);
}
return (getConnection(url, info, Reflection.getCallerClass()));
}
//...
}

桥接模式的定义是“将抽象和实现解耦,让它们可以独立变化”。那弄懂定义中“抽象”和“实现”两个概念,就是理解桥接模式的关键。那在JDBC这个例子中,什么是“抽象”?什么是“实现”呢?

实际上,JDBC本身就相当于“抽象”。注意,这里所说的“抽象”,指的并非“抽象类”或“接口”,而是跟具体的数据库无关的、被抽象出来的一套“类库”。具体的Driver(比如,com.mysql.jdbc.Driver)就相当于“实现”。注意,这里所说的“实现”,也并非指“接口的实现类”,而是跟具体数据库相关的一套“类库”。JDBC和Driver独立开发,通过对象之间的组合关系,组装在一起。JDBC的所有逻辑操作,最终都委托给Driver来执行。

我画了一张图帮助你理解,你可以结合着我刚才的讲解一块看。

img

桥接模式的应用举例

我们讲过一个API接口监控告警的例子:根据不同的告警规则,触发不同类型的告警。告警支持多种通知渠道,包括:邮件、短信、微信、自动语音电话。通知的紧急程度有多种类型,包括:SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要)。不同的紧急程度对应不同的通知渠道。比如,SERVE(严重)级别的消息会通过“自动语音电话”告知相关人员。

在当时的代码实现中,关于发送告警信息那部分代码,我们只给出了粗略的设计,现在我们来一块实现一下。我们先来看最简单、最直接的一种实现方式。代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public enum NotificationEmergencyLevel {
SEVERE, URGENCY, NORMAL, TRIVIAL
}

public class Notification {
private List<String> emailAddresses;
private List<String> telephones;
private List<String> wechatIds;

public Notification() {}

public void setEmailAddress(List<String> emailAddress) {
this.emailAddresses = emailAddress;
}

public void setTelephones(List<String> telephones) {
this.telephones = telephones;
}

public void setWechatIds(List<String> wechatIds) {
this.wechatIds = wechatIds;
}

public void notify(NotificationEmergencyLevel level, String message) {
if (level.equals(NotificationEmergencyLevel.SEVERE)) {
//...自动语音电话
} else if (level.equals(NotificationEmergencyLevel.URGENCY)) {
//...发微信
} else if (level.equals(NotificationEmergencyLevel.NORMAL)) {
//...发邮件
} else if (level.equals(NotificationEmergencyLevel.TRIVIAL)) {
//...发邮件
}
}
}

//在API监控告警的例子中,我们如下方式来使用Notification类:
public class ErrorAlertHandler extends AlertHandler {
public ErrorAlertHandler(AlertRule rule, Notification notification){
super(rule, notification);
}


@Override
public void check(ApiStatInfo apiStatInfo) {
if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi()).getMaxErrorCount()) {
notification.notify(NotificationEmergencyLevel.SEVERE, "...");
}
}
}

Notification类的代码实现有一个最明显的问题,那就是有很多if-else分支逻辑。实际上,如果每个分支中的代码都不复杂,后期也没有无限膨胀的可能(增加更多if-else分支判断),那这样的设计问题并不大,没必要非得一定要摒弃if-else分支逻辑。

不过,Notification的代码显然不符合这个条件。因为每个if-else分支中的代码逻辑都比较复杂,发送通知的所有逻辑都扎堆在Notification类中。我们知道,类的代码越多,就越难读懂,越难修改,维护的成本也就越高。很多设计模式都是试图将庞大的类拆分成更细小的类,然后再通过某种更合理的结构组装在一起。

针对Notification的代码,我们将不同渠道的发送逻辑剥离出来,形成独立的消息发送类(MsgSender相关类)。其中,Notification类相当于抽象,MsgSender类相当于实现,两者可以独立开发,通过组合关系(也就是桥梁)任意组合在一起。所谓任意组合的意思就是,不同紧急程度的消息和发送渠道之间的对应关系,不是在代码中固定写死的,我们可以动态地去指定(比如,通过读取配置来获取对应关系)。

按照这个设计思路,我们对代码进行重构。重构之后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public interface MsgSender {
void send(String message);
}

public class TelephoneMsgSender implements MsgSender {
private List<String> telephones;

public TelephoneMsgSender(List<String> telephones) {
this.telephones = telephones;
}

@Override
public void send(String message) {
//...
}

}

public class EmailMsgSender implements MsgSender {
// 与TelephoneMsgSender代码结构类似,所以省略...
}

public class WechatMsgSender implements MsgSender {
// 与TelephoneMsgSender代码结构类似,所以省略...
}

public abstract class Notification {
protected MsgSender msgSender;

public Notification(MsgSender msgSender) {
this.msgSender = msgSender;
}

public abstract void notify(String message);
}

public class SevereNotification extends Notification {
public SevereNotification(MsgSender msgSender) {
super(msgSender);
}

@Override
public void notify(String message) {
msgSender.send(message);
}
}

public class UrgencyNotification extends Notification {
// 与SevereNotification代码结构类似,所以省略...
}
public class NormalNotification extends Notification {
// 与SevereNotification代码结构类似,所以省略...
}
public class TrivialNotification extends Notification {
// 与SevereNotification代码结构类似,所以省略...
}

33. 结构型:装饰器模式

通过剖析Java IO类的设计思想,再学习一种新的结构型模式,装饰器模式。它的代码结构跟桥接模式非常相似,不过,要解决的问题却大不相同。

Java IO类的“奇怪”用法

Java IO类库非常庞大和复杂,有几十个类,负责IO数据的读取和写入。如果对Java IO类做一下分类,我们可以从下面两个维度将它划分为四类。具体如下所示:

img

针对不同的读取和写入场景,Java IO又在这四个父类基础之上,扩展出了很多子类。具体如下所示:

img

在我初学Java的时候,曾经对Java IO的一些用法产生过很大疑惑,比如下面这样一段代码。我们打开文件test.txt,从中读取数据。其中,InputStream是一个抽象类,FileInputStream是专门用来读取文件流的子类。BufferedInputStream是一个支持带缓存功能的数据读取类,可以提高数据读取的效率。

1
2
3
4
5
6
InputStream in = new FileInputStream("/user/wangzheng/test.txt");
InputStream bin = new BufferedInputStream(in);
byte[] data = new byte[128];
while (bin.read(data) != -1) {
//...
}

初看上面的代码,我们会觉得Java IO的用法比较麻烦,需要先创建一个FileInputStream对象,然后再传递给BufferedInputStream对象来使用。我在想,Java IO为什么不设计一个继承FileInputStream并且支持缓存的BufferedFileInputStream类呢?这样我们就可以像下面的代码中这样,直接创建一个BufferedFileInputStream类对象,打开文件读取数据,用起来岂不是更加简单?

1
2
3
4
5
InputStream bin = new BufferedFileInputStream("/user/wangzheng/test.txt");
byte[] data = new byte[128];
while (bin.read(data) != -1) {
//...
}

基于继承的设计方案

如果InputStream只有一个子类FileInputStream的话,那我们在FileInputStream基础之上,再设计一个孙子类BufferedFileInputStream,也算是可以接受的,毕竟继承结构还算简单。但实际上,继承InputStream的子类有很多。我们需要给每一个InputStream的子类,再继续派生支持缓存读取的子类。

除了支持缓存读取之外,如果我们还需要对功能进行其他方面的增强,比如下面的DataInputStream类,支持按照基本数据类型(int、boolean、long等)来读取数据。

1
2
3
FileInputStream in = new FileInputStream("/user/wangzheng/test.txt");
DataInputStream din = new DataInputStream(in);
int data = din.readInt();

在这种情况下,如果我们继续按照继承的方式来实现的话,就需要再继续派生出DataFileInputStream、DataPipedInputStream等类。如果我们还需要既支持缓存、又支持按照基本类型读取数据的类,那就要再继续派生出BufferedDataFileInputStream、BufferedDataPipedInputStream等n多类。这还只是附加了两个增强功能,如果我们需要附加更多的增强功能,那就会导致组合爆炸,类继承结构变得无比复杂,代码既不好扩展,也不好维护。这也是我们在第10节中讲的不推荐使用继承的原因。

基于装饰器模式的设计方案

我们讲到“组合优于继承”,可以“使用组合来替代继承”。针对刚刚的继承结构过于复杂的问题,我们可以通过将继承关系改为组合关系来解决。下面的代码展示了Java IO的这种设计思路。不过,我对代码做了简化,只抽象出了必要的代码结构,如果你感兴趣的话,可以直接去查看JDK源码。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
//注意是抽象类
public abstract class InputStream {
//...
public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}

public int read(byte b[], int off, int len) throws IOException {
//...
}

public long skip(long n) throws IOException {
//...
}

public int available() throws IOException {
return 0;
}

public void close() throws IOException {}

public synchronized void mark(int readlimit) {}

public synchronized void reset() throws IOException {
throw new IOException("mark/reset not supported");
}

public boolean markSupported() {
return false;
}
}

public class BufferedInputStream extends InputStream {
protected volatile InputStream in;

protected BufferedInputStream(InputStream in) {
this.in = in;
}

//...实现基于缓存的读数据接口...
}

public class DataInputStream extends InputStream {
protected volatile InputStream in;

protected DataInputStream(InputStream in) {
this.in = in;
}

//...实现读取基本类型数据的接口
}

看了上面的代码,你可能会问,那装饰器模式就是简单的“用组合替代继承”吗?当然不是。从Java IO的设计来看,装饰器模式相对于简单的组合关系,还有两个比较特殊的地方。

第一个比较特殊的地方是:装饰器类和原始类继承同样的父类,这样我们可以对原始类“嵌套”多个装饰器类。比如,下面这样一段代码,我们对FileInputStream嵌套了两个装饰器类:BufferedInputStream和DataInputStream,让它既支持缓存读取,又支持按照基本数据类型来读取数据。

1
2
3
4
InputStream in = new FileInputStream("/user/wangzheng/test.txt");
InputStream bin = new BufferedInputStream(in);
DataInputStream din = new DataInputStream(bin);
int data = din.readInt();

第二个比较特殊的地方是:装饰器类是对功能的增强,这也是装饰器模式应用场景的一个重要特点。实际上,符合“组合关系”这种代码结构的设计模式有很多,比如之前讲过的代理模式、桥接模式,还有现在的装饰器模式。尽管它们的代码结构很相似,但是每种设计模式的意图是不同的。就拿比较相似的代理模式和装饰器模式来说吧,代理模式中,代理类附加的是跟原始类无关的功能,而在装饰器模式中,装饰器类附加的是跟原始类相关的增强功能

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
34
35
36
37
38
39
// 代理模式的代码结构(下面的接口也可以替换成抽象类)
public interface IA {
void f();
}
public class A impelements IA {
public void f() { //... }
}
public class AProxy implements IA {
private IA a;
public AProxy(IA a) {
this.a = a;
}

public void f() {
// 新添加的代理逻辑
a.f();
// 新添加的代理逻辑
}
}

// 装饰器模式的代码结构(下面的接口也可以替换成抽象类)
public interface IA {
void f();
}
public class A implements IA {
public void f() { //... }
}
public class ADecorator implements IA {
private IA a;
public ADecorator(IA a) {
this.a = a;
}

public void f() {
// 功能增强代码
a.f();
// 功能增强代码
}
}

实际上,如果去查看JDK的源码,你会发现,BufferedInputStream、DataInputStream并非继承自InputStream,而是另外一个叫FilterInputStream的类。那这又是出于什么样的设计意图,才引入这样一个类呢?

我们再重新来看一下BufferedInputStream类的代码。InputStream是一个抽象类而非接口,而且它的大部分函数(比如read()、available())都有默认实现,按理来说,我们只需要在BufferedInputStream类中重新实现那些需要增加缓存功能的函数就可以了,其他函数继承InputStream的默认实现。但实际上,这样做是行不通的(抽象类的非抽象子类必须重新实现抽象类的所有方法)。

对于即便是不需要增加缓存功能的函数来说,BufferedInputStream还是必须把它重新实现一遍,简单包裹对InputStream对象的函数调用。具体的代码示例如下所示。如果不重新实现,那BufferedInputStream类就无法将最终读取数据的任务,委托给传递进来的InputStream对象来完成。这一部分稍微有点不好理解,你自己多思考一下。

1
2
3
4
5
6
7
8
9
10
11
12
public class BufferedInputStream extends InputStream {
protected volatile InputStream in;

protected BufferedInputStream(InputStream in) {
this.in = in;
}

// f()函数不需要增强,只是重新调用一下InputStream in对象的f()
public void f() {
in.f();
}
}

实际上,DataInputStream也存在跟BufferedInputStream同样的问题。为了避免代码重复,Java IO抽象出了一个装饰器父类FilterInputStream,代码实现如下所示。InputStream的所有的装饰器类(BufferedInputStream、DataInputStream)都继承自这个装饰器父类。这样,装饰器类只需要实现它需要增强的方法就可以了,其他方法继承装饰器父类的默认实现。

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
34
35
36
37
38
39
40
41
42
43
public class FilterInputStream extends InputStream {
protected volatile InputStream in;

protected FilterInputStream(InputStream in) {
this.in = in;
}

public int read() throws IOException {
return in.read();
}

public int read(byte b[]) throws IOException {
return read(b, 0, b.length);
}

public int read(byte b[], int off, int len) throws IOException {
return in.read(b, off, len);
}

public long skip(long n) throws IOException {
return in.skip(n);
}

public int available() throws IOException {
return in.available();
}

public void close() throws IOException {
in.close();
}

public synchronized void mark(int readlimit) {
in.mark(readlimit);
}

public synchronized void reset() throws IOException {
in.reset();
}

public boolean markSupported() {
return in.markSupported();
}
}

34. 结构型:适配器模式

适配器模式的原理与实现

适配器模式的英文翻译是Adapter Design Pattern。顾名思义,这个模式就是用来做适配的,它将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作。对于这个模式,有一个经常被拿来解释它的例子,就是USB转接头充当适配器,把两种不兼容的接口,通过转接变得可以一起工作。

原理很简单,我们再来看下它的代码实现。适配器模式有两种实现方式:类适配器和对象适配器。其中,类适配器使用继承关系来实现,对象适配器使用组合关系来实现。具体的代码实现如下所示。其中,ITarget表示要转化成的接口定义。Adaptee是一组不兼容ITarget接口定义的接口,Adaptor将Adaptee转化成一组符合ITarget接口定义的接口。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 类适配器: 基于继承
public interface ITarget {
void f1();
void f2();
void fc();
}

public class Adaptee {
public void fa() { //... }
public void fb() { //... }
public void fc() { //... }
}

public class Adaptor extends Adaptee implements ITarget {
public void f1() {
super.fa();
}

public void f2() {
//...重新实现f2()...
}

// 这里fc()不需要实现,直接继承自Adaptee,这是跟对象适配器最大的不同点
}

// 对象适配器:基于组合
public interface ITarget {
void f1();
void f2();
void fc();
}

public class Adaptee {
public void fa() { //... }
public void fb() { //... }
public void fc() { //... }
}

public class Adaptor implements ITarget {
private Adaptee adaptee;

public Adaptor(Adaptee adaptee) {
this.adaptee = adaptee;
}

public void f1() {
adaptee.fa(); //委托给Adaptee
}

public void f2() {
//...重新实现f2()...
}

public void fc() {
adaptee.fc();
}
}

针对这两种实现方式,在实际的开发中,到底该如何选择使用哪一种呢?判断的标准主要有两个,一个是Adaptee接口的个数,另一个是Adaptee和ITarget的契合程度。

  • 如果Adaptee接口并不多,那两种实现方式都可以。
  • 如果Adaptee接口很多,而且Adaptee和ITarget接口定义大部分都相同,那我们推荐使用类适配器,因为Adaptor复用父类Adaptee的接口,比起对象适配器的实现方式,Adaptor的代码量要少一些。
  • 如果Adaptee接口很多,而且Adaptee和ITarget接口定义大部分都不相同,那我们推荐使用对象适配器,因为组合结构相对于继承更加灵活。

适配器模式应用场景总结

原理和实现讲完了,都不复杂。我们再来看,到底什么时候会用到适配器模式呢?

一般来说,适配器模式可以看作一种“补偿模式”,用来补救设计上的缺陷。应用这种模式算是“无奈之举”。如果在设计初期,我们就能协调规避接口不兼容的问题,那这种模式就没有应用的机会了。

前面我们反复提到,适配器模式的应用场景是“接口不兼容”。那在实际的开发中,什么情况下才会出现接口不兼容呢?我建议你先自己思考一下这个问题,然后再来看我下面的总结 。

1.封装有缺陷的接口设计

假设我们依赖的外部系统在接口设计方面有缺陷(比如包含大量静态方法),引入之后会影响到我们自身代码的可测试性。为了隔离设计上的缺陷,我们希望对外部系统提供的接口进行二次封装,抽象出更好的接口设计,这个时候就可以使用适配器模式了。

具体我还是举个例子来解释一下,你直接看代码应该会更清晰。具体代码如下所示:

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
34
35
36
37
38
public class CD { //这个类来自外部sdk,我们无权修改它的代码
//...
public static void staticFunction1() { //... }

public void uglyNamingFunction2() { //... }

public void tooManyParamsFunction3(int paramA, int paramB, ...) { //... }

public void lowPerformanceFunction4() { //... }
}

// 使用适配器模式进行重构
public class ITarget {
void function1();
void function2();
void fucntion3(ParamsWrapperDefinition paramsWrapper);
void function4();
//...
}
// 注意:适配器类的命名不一定非得末尾带Adaptor
public class CDAdaptor extends CD implements ITarget {
//...
public void function1() {
super.staticFunction1();
}

public void function2() {
super.uglyNamingFucntion2();
}

public void function3(ParamsWrapperDefinition paramsWrapper) {
super.tooManyParamsFunction3(paramsWrapper.getParamA(), ...);
}

public void function4() {
//...reimplement it...
}
}

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class ASensitiveWordsFilter { // A敏感词过滤系统提供的接口
//text是原始文本,函数输出用***替换敏感词之后的文本
public String filterSexyWords(String text) {
// ...
}

public String filterPoliticalWords(String text) {
// ...
}
}

public class BSensitiveWordsFilter { // B敏感词过滤系统提供的接口
public String filter(String text) {
//...
}
}

public class CSensitiveWordsFilter { // C敏感词过滤系统提供的接口
public String filter(String text, String mask) {
//...
}
}

// 未使用适配器模式之前的代码:代码的可测试性、扩展性不好
public class RiskManagement {
private ASensitiveWordsFilter aFilter = new ASensitiveWordsFilter();
private BSensitiveWordsFilter bFilter = new BSensitiveWordsFilter();
private CSensitiveWordsFilter cFilter = new CSensitiveWordsFilter();

public String filterSensitiveWords(String text) {
String maskedText = aFilter.filterSexyWords(text);
maskedText = aFilter.filterPoliticalWords(maskedText);
maskedText = bFilter.filter(maskedText);
maskedText = cFilter.filter(maskedText, "***");
return maskedText;
}
}

// 使用适配器模式进行改造
public interface ISensitiveWordsFilter { // 统一接口定义
String filter(String text);
}

public class ASensitiveWordsFilterAdaptor implements ISensitiveWordsFilter {
private ASensitiveWordsFilter aFilter;
public String filter(String text) {
String maskedText = aFilter.filterSexyWords(text);
maskedText = aFilter.filterPoliticalWords(maskedText);
return maskedText;
}
}
//...省略BSensitiveWordsFilterAdaptor、CSensitiveWordsFilterAdaptor...

// 扩展性更好,更加符合开闭原则,如果添加一个新的敏感词过滤系统,
// 这个类完全不需要改动;而且基于接口而非实现编程,代码的可测试性更好。
public class RiskManagement {
private List<ISensitiveWordsFilter> filters = new ArrayList<>();

public void addSensitiveWordsFilter(ISensitiveWordsFilter filter) {
filters.add(filter);
}

public String filterSensitiveWords(String text) {
String maskedText = text;
for (ISensitiveWordsFilter filter : filters) {
maskedText = filter.filter(maskedText);
}
return maskedText;
}
}

3.替换依赖的外部系统

当我们把项目中依赖的一个外部系统替换为另一个外部系统的时候,利用适配器模式,可以减少对代码的改动。具体的代码示例如下所示:

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
// 外部系统A
public interface IA {
//...
void fa();
}
public class A implements IA {
//...
public void fa() { //... }
}
// 在我们的项目中,外部系统A的使用示例
public class Demo {
private IA a;
public Demo(IA a) {
this.a = a;
}
//...
}
Demo d = new Demo(new A());

// 将外部系统A替换成外部系统B
public class BAdaptor implemnts IA {
private B b;
public BAdaptor(B b) {
this.b= b;
}
public void fa() {
//...
b.fb();
}
}
// 借助BAdaptor,Demo的代码中,调用IA接口的地方都无需改动,
// 只需要将BAdaptor如下注入到Demo即可。
Demo d = new Demo(new BAdaptor(new B()));

4.兼容老版本接口

在做版本升级的时候,对于一些要废弃的接口,我们不直接将其删除,而是暂时保留,并且标注为deprecated,并将内部实现逻辑委托为新的接口实现。这样做的好处是,让使用它的项目有个过渡期,而不是强制进行代码修改。这也可以粗略地看作适配器模式的一个应用场景。同样,我还是通过一个例子,来进一步解释一下。

JDK1.0中包含一个遍历集合容器的类Enumeration。JDK2.0对这个类进行了重构,将它改名为Iterator类,并且对它的代码实现做了优化。但是考虑到如果将Enumeration直接从JDK2.0中删除,那使用JDK1.0的项目如果切换到JDK2.0,代码就会编译不通过。为了避免这种情况的发生,我们必须把项目中所有使用到Enumeration的地方,都修改为使用Iterator才行。

单独一个项目做Enumeration到Iterator的替换,勉强还能接受。但是,使用Java开发的项目太多了,一次JDK的升级,导致所有的项目不做代码修改就会编译报错,这显然是不合理的。这就是我们经常所说的不兼容升级。为了做到兼容使用低版本JDK的老代码,我们可以暂时保留Enumeration类,并将其实现替换为直接调用Itertor。代码示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Collections {
public static Emueration emumeration(final Collection c) {
return new Enumeration() {
Iterator i = c.iterator();

public boolean hasMoreElments() {
return i.hashNext();
}

public Object nextElement() {
return i.next():
}
}
}
}

5.适配不同格式的数据

前面我们讲到,适配器模式主要用于接口的适配,实际上,它还可以用在不同格式的数据之间的适配。比如,把从不同征信系统拉取的不同格式的征信数据,统一为相同的格式,以方便存储和使用。再比如,Java中的Arrays.asList()也可以看作一种数据适配器,将数组类型的数据转化为集合容器类型。

1
List<String> stooges = Arrays.asList("Larry", "Moe", "Curly");

剖析适配器模式在Java日志中的应用

Java中有很多日志框架,在项目开发中,我们常常用它们来打印日志信息。其中,比较常用的有log4j、logback,以及JDK提供的JUL(java.util.logging)和Apache的JCL(Jakarta Commons Logging)等。

大部分日志框架都提供了相似的功能,比如按照不同级别(debug、info、warn、erro……)打印日志等,但它们却并没有实现统一的接口。这主要可能是历史的原因,它不像JDBC那样,一开始就制定了数据库操作的接口规范。

如果我们只是开发一个自己用的项目,那用什么日志框架都可以,log4j、logback随便选一个就好。但是,如果我们开发的是一个集成到其他系统的组件、框架、类库等,那日志框架的选择就没那么随意了。

比如,项目中用到的某个组件使用log4j来打印日志,而我们项目本身使用的是logback。将组件引入到项目之后,我们的项目就相当于有了两套日志打印框架。每种日志框架都有自己特有的配置方式。所以,我们要针对每种日志框架编写不同的配置文件(比如,日志存储的文件地址、打印日志的格式)。如果引入多个组件,每个组件使用的日志框架都不一样,那日志本身的管理工作就变得非常复杂。所以,为了解决这个问题,我们需要统一日志打印框架。

如果你是做Java开发的,那Slf4j这个日志框架你肯定不陌生,它相当于JDBC规范,提供了一套打印日志的统一接口规范。不过,它只定义了接口,并没有提供具体的实现,需要配合其他日志框架(log4j、logback……)来使用。

不仅如此,Slf4j的出现晚于JUL、JCL、log4j等日志框架,所以,这些日志框架也不可能牺牲掉版本兼容性,将接口改造成符合Slf4j接口规范。Slf4j也事先考虑到了这个问题,所以,它不仅仅提供了统一的接口定义,还提供了针对不同日志框架的适配器。对不同日志框架的接口进行二次封装,适配成统一的Slf4j接口定义。具体的代码示例如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// slf4j统一的接口定义
package org.slf4j;
public interface Logger {
public boolean isTraceEnabled();
public void trace(String msg);
public void trace(String format, Object arg);
public void trace(String format, Object arg1, Object arg2);
public void trace(String format, Object[] argArray);
public void trace(String msg, Throwable t);

public boolean isDebugEnabled();
public void debug(String msg);
public void debug(String format, Object arg);
public void debug(String format, Object arg1, Object arg2)
public void debug(String format, Object[] argArray)
public void debug(String msg, Throwable t);

//...省略info、warn、error等一堆接口
}

// log4j日志框架的适配器
// Log4jLoggerAdapter实现了LocationAwareLogger接口,
// 其中LocationAwareLogger继承自Logger接口,
// 也就相当于Log4jLoggerAdapter实现了Logger接口。
package org.slf4j.impl;
public final class Log4jLoggerAdapter extends MarkerIgnoringBase
implements LocationAwareLogger, Serializable {
final transient org.apache.log4j.Logger logger; // log4j

public boolean isDebugEnabled() {
return logger.isDebugEnabled();
}

public void debug(String msg) {
logger.log(FQCN, Level.DEBUG, msg, null);
}

public void debug(String format, Object arg) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.format(format, arg);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}

public void debug(String format, Object arg1, Object arg2) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.format(format, arg1, arg2);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}

public void debug(String format, Object[] argArray) {
if (logger.isDebugEnabled()) {
FormattingTuple ft = MessageFormatter.arrayFormat(format, argArray);
logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
}
}

public void debug(String msg, Throwable t) {
logger.log(FQCN, Level.DEBUG, msg, t);
}
//...省略一堆接口的实现...
}

所以,在开发业务系统或者开发框架、组件的时候,我们统一使用Slf4j提供的接口来编写打印日志的代码,具体使用哪种日志框架实现(log4j、logback……),是可以动态地指定的(使用Java的SPI技术,这里我不多解释,你自行研究吧),只需要将相应的SDK导入到项目中即可。

不过,你可能会说,如果一些老的项目没有使用Slf4j,而是直接使用比如JCL来打印日志,那如果想要替换成其他日志框架,比如log4j,该怎么办呢?实际上,Slf4j不仅仅提供了从其他日志框架到Slf4j的适配器,还提供了反向适配器,也就是从Slf4j到其他日志框架的适配。我们可以先将JCL切换为Slf4j,然后再将Slf4j切换为log4j。经过两次适配器的转换,我们就能成功将log4j切换为了logback。

代理、桥接、装饰器、适配器4种设计模式的区别

代理、桥接、装饰器、适配器,这4种模式是比较常用的结构型设计模式。它们的代码结构非常相似。笼统来说,它们都可以称为Wrapper模式,也就是通过Wrapper类二次封装原始类

尽管代码结构相似,但这4种设计模式的用意完全不同,也就是说要解决的问题、应用场景不同,这也是它们的主要区别。这里我就简单说一下它们之间的区别。

  • 代理模式:代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同。
  • 桥接模式:桥接模式的目的是将接口部分和实现部分分离,从而让它们可以较为容易、也相对独立地加以改变。
  • 装饰器模式:装饰者模式在不改变原始类接口的情况下,对原始类功能进行增强,并且支持多个装饰器的嵌套使用。
  • 适配器模式:适配器模式是一种事后的补救策略。适配器提供跟原始类不同的接口,而代理模式、装饰器模式提供的都是跟原始类相同的接口

35. 结构型:门面模式

门面模式原理和实现都特别简单,应用场景也比较明确,主要在接口设计方面使用。

如果你平时的工作涉及接口开发,不知道你有没有遇到关于接口粒度的问题呢?

为了保证接口的可复用性(或者叫通用性),我们需要将接口尽量设计得细粒度一点,职责单一一点。但是,如果接口的粒度过小,在接口的使用者开发一个业务功能时,就会导致需要调用n多细粒度的接口才能完成。调用者肯定会抱怨接口不好用。

相反,如果接口粒度设计得太大,一个接口返回n多数据,要做n多事情,就会导致接口不够通用、可复用性不好。接口不可复用,那针对不同的调用者的业务需求,我们就需要开发不同的接口来满足,这就会导致系统的接口无限膨胀。

那如何来解决接口的可复用性(通用性)和易用性之间的矛盾呢?

门面模式的原理与实现

门面模式,也叫外观模式,英文全称是Facade Design Pattern。在GoF的《设计模式》一书中,门面模式是这样定义的:

Provide a unified interface to a set of interfaces in a subsystem. Facade Pattern defines a higher-level interface that makes the subsystem easier to use.

翻译成中文就是:门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用。

这个定义很简洁,我再进一步解释一下。

假设有一个系统A,提供了a、b、c、d四个接口。系统B完成某个业务功能,需要调用A系统的a、b、d接口。利用门面模式,我们提供一个包裹a、b、d接口调用的门面接口x,给系统B直接使用。

不知道你会不会有这样的疑问,让系统B直接调用a、b、d感觉没有太大问题呀,为什么还要提供一个包裹a、b、d的接口x呢?关于这个问题,我通过一个具体的例子来解释一下。

假设我们刚刚提到的系统A是一个后端服务器,系统B是App客户端。App客户端通过后端服务器提供的接口来获取数据。我们知道,App和服务器之间是通过移动网络通信的,网络通信耗时比较多,为了提高App的响应速度,我们要尽量减少App与服务器之间的网络通信次数。

假设,完成某个业务功能(比如显示某个页面信息)需要“依次”调用a、b、d三个接口,因自身业务的特点,不支持并发调用这三个接口。

如果我们现在发现App客户端的响应速度比较慢,排查之后发现,是因为过多的接口调用过多的网络通信。针对这种情况,我们就可以利用门面模式,让后端服务器提供一个包裹a、b、d三个接口调用的接口x。App客户端调用一次接口x,来获取到所有想要的数据,将网络通信的次数从3次减少到1次,也就提高了App的响应速度。

这里举的例子只是应用门面模式的其中一个意图,也就是解决性能问题。实际上,不同的应用场景下,使用门面模式的意图也不同。接下来,我们就来看一下门面模式的各种应用场景。

门面模式的应用场景举例

在GoF给出的定义中提到,“门面模式让子系统更加易用”,实际上,它除了解决易用性问题之外,还能解决其他很多方面的问题。关于这一点,我总结罗列了3个常用的应用场景,你可以参考一下,举一反三地借鉴到自己的项目中。

除此之外,我还要强调一下,门面模式定义中的“子系统(subsystem)”也可以有多种理解方式。它既可以是一个完整的系统,也可以是更细粒度的类或者模块。关于这一点,在下面的讲解中也会有体现。

1.解决易用性问题

门面模式可以用来封装系统的底层实现,隐藏系统的复杂性,提供一组更加简单易用、更高层的接口。比如,Linux系统调用函数就可以看作一种“门面”。它是Linux操作系统暴露给开发者的一组“特殊”的编程接口,它封装了底层更基础的Linux内核调用。再比如,Linux的Shell命令,实际上也可以看作一种门面模式的应用。它继续封装系统调用,提供更加友好、简单的命令,让我们可以直接通过执行命令来跟操作系统交互。

我们前面也多次讲过,设计原则、思想、模式很多都是相通的,是同一个道理不同角度的表述。实际上,从隐藏实现复杂性,提供更易用接口这个意图来看,门面模式有点类似之前讲到的迪米特法则(最少知识原则)和接口隔离原则:两个有交互的系统,只暴露有限的必要的接口。除此之外,门面模式还有点类似之前提到封装、抽象的设计思想,提供更抽象的接口,封装底层实现细节。

2.解决性能问题

关于利用门面模式解决性能问题这一点,刚刚我们已经讲过了。我们通过将多个接口调用替换为一个门面接口调用,减少网络通信成本,提高App客户端的响应速度。所以,关于这点,我就不再举例说明了。我们来讨论一下这样一个问题:从代码实现的角度来看,该如何组织门面接口和非门面接口?

如果门面接口不多,我们完全可以将它跟非门面接口放到一块,也不需要特殊标记,当作普通接口来用即可。如果门面接口很多,我们可以在已有的接口之上,再重新抽象出一层,专门放置门面接口,从类、包的命名上跟原来的接口层做区分。如果门面接口特别多,并且很多都是跨多个子系统的,我们可以将门面接口放到一个新的子系统中。

3.解决分布式事务问题

关于利用门面模式来解决分布式事务问题,我们通过一个例子来解释一下。

在一个金融系统中,有两个业务领域模型,用户和钱包。这两个业务领域模型都对外暴露了一系列接口,比如用户的增删改查接口、钱包的增删改查接口。假设有这样一个业务场景:在用户注册的时候,我们不仅会创建用户(在数据库User表中),还会给用户创建一个钱包(在数据库的Wallet表中)。

对于这样一个简单的业务需求,我们可以通过依次调用用户的创建接口和钱包的创建接口来完成。但是,用户注册需要支持事务,也就是说,创建用户和钱包的两个操作,要么都成功,要么都失败,不能一个成功、一个失败。

要支持两个接口调用在一个事务中执行,是比较难实现的,这涉及分布式事务问题。虽然我们可以通过引入分布式事务框架或者事后补偿的机制来解决,但代码实现都比较复杂。而最简单的解决方案是,利用数据库事务或者Spring框架提供的事务(如果是Java语言的话),在一个事务中,执行创建用户和创建钱包这两个SQL操作。这就要求两个SQL操作要在一个接口中完成,所以,我们可以借鉴门面模式的思想,再设计一个包裹这两个操作的新接口,让新接口在一个事务中执行两个SQL操作。

36. 结构型:组合模式

组合模式跟我们之前讲的面向对象设计中的“组合关系(通过组合来组装两个类)”,完全是两码事。这里讲的“组合模式”,主要是用来处理树形结构数据。这里的“数据”,你可以简单理解为一组对象集合,待会我们会详细讲解。

正因为其应用场景的特殊性,数据必须能表示成树形结构,这也导致了这种模式在实际的项目开发中并不那么常用。但是,一旦数据满足树形结构,应用这种模式就能发挥很大的作用,能让代码变得非常简洁。

话不多说,让我们正式开始今天的学习吧!

组合模式的原理与实现

在GoF的《设计模式》一书中,组合模式是这样定义的:

Compose objects into tree structure to represent part-whole hierarchies.Composite lets client treat individual objects and compositions of objects uniformly.

翻译成中文就是:将一组对象组织(Compose)成树形结构,以表示一种“部分-整体”的层次结构。组合让客户端(在很多设计模式书籍中,“客户端”代指代码的使用者。)可以统一单个对象和组合对象的处理逻辑。

接下来,对于组合模式,我举个例子来给你解释一下。

假设我们有这样一个需求:设计一个类来表示文件系统中的目录,能方便地实现下面这些功能:

  • 动态地添加、删除某个目录下的子目录或文件;
  • 统计指定目录下的文件个数;
  • 统计指定目录下的文件总大小。

我这里给出了这个类的骨架代码,如下所示。其中的核心逻辑并未实现,你可以试着自己去补充完整,再来看我的讲解。在下面的代码实现中,我们把文件和目录统一用FileSystemNode类来表示,并且通过isFile属性来区分。

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
34
35
36
37
38
39
public class FileSystemNode {
private String path;
private boolean isFile;
private List<FileSystemNode> subNodes = new ArrayList<>();

public FileSystemNode(String path, boolean isFile) {
this.path = path;
this.isFile = isFile;
}

public int countNumOfFiles() {
// TODO:...
}

public long countSizeOfFiles() {
// TODO:...
}

public String getPath() {
return path;
}

public void addSubNode(FileSystemNode fileOrDir) {
subNodes.add(fileOrDir);
}

public void removeSubNode(FileSystemNode fileOrDir) {
int size = subNodes.size();
int i = 0;
for (; i < size; ++i) {
if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) {
break;
}
}
if (i < size) {
subNodes.remove(i);
}
}
}

实际上,如果你看过我的《数据结构与算法之美》专栏,想要补全其中的countNumOfFiles()和countSizeOfFiles()这两个函数,并不是件难事,实际上这就是树上的递归遍历算法。对于文件,我们直接返回文件的个数(返回1)或大小。对于目录,我们遍历目录中每个子目录或者文件,递归计算它们的个数或大小,然后求和,就是这个目录下的文件个数和文件大小。

我把两个函数的代码实现贴在下面了,你可以对照着看一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public int countNumOfFiles() {
if (isFile) {
return 1;
}
int numOfFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
numOfFiles += fileOrDir.countNumOfFiles();
}
return numOfFiles;
}

public long countSizeOfFiles() {
if (isFile) {
File file = new File(path);
if (!file.exists()) return 0;
return file.length();
}
long sizeofFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
sizeofFiles += fileOrDir.countSizeOfFiles();
}
return sizeofFiles;
}

单纯从功能实现角度来说,上面的代码没有问题,已经实现了我们想要的功能。但是,如果我们开发的是一个大型系统,从扩展性(文件或目录可能会对应不同的操作)、业务建模(文件和目录从业务上是两个概念)、代码的可读性(文件和目录区分对待更加符合人们对业务的认知)的角度来说,我们最好对文件和目录进行区分设计,定义为File和Directory两个类。

按照这个设计思路,我们对代码进行重构。重构之后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public abstract class FileSystemNode {
protected String path;

public FileSystemNode(String path) {
this.path = path;
}

public abstract int countNumOfFiles();
public abstract long countSizeOfFiles();

public String getPath() {
return path;
}
}

public class File extends FileSystemNode {
public File(String path) {
super(path);
}

@Override
public int countNumOfFiles() {
return 1;
}

@Override
public long countSizeOfFiles() {
java.io.File file = new java.io.File(path);
if (!file.exists()) return 0;
return file.length();
}
}

public class Directory extends FileSystemNode {
private List<FileSystemNode> subNodes = new ArrayList<>();

public Directory(String path) {
super(path);
}

@Override
public int countNumOfFiles() {
int numOfFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
numOfFiles += fileOrDir.countNumOfFiles();
}
return numOfFiles;
}

@Override
public long countSizeOfFiles() {
long sizeofFiles = 0;
for (FileSystemNode fileOrDir : subNodes) {
sizeofFiles += fileOrDir.countSizeOfFiles();
}
return sizeofFiles;
}

public void addSubNode(FileSystemNode fileOrDir) {
subNodes.add(fileOrDir);
}

public void removeSubNode(FileSystemNode fileOrDir) {
int size = subNodes.size();
int i = 0;
for (; i < size; ++i) {
if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) {
break;
}
}
if (i < size) {
subNodes.remove(i);
}
}
}

文件和目录类都设计好了,我们来看,如何用它们来表示一个文件系统中的目录树结构。具体的代码示例如下所示:

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
34
35
36
37
38
39
public class Demo {
public static void main(String[] args) {
/**
* /
* /wz/
* /wz/a.txt
* /wz/b.txt
* /wz/movies/
* /wz/movies/c.avi
* /xzg/
* /xzg/docs/
* /xzg/docs/d.txt
*/
Directory fileSystemTree = new Directory("/");
Directory node_wz = new Directory("/wz/");
Directory node_xzg = new Directory("/xzg/");
fileSystemTree.addSubNode(node_wz);
fileSystemTree.addSubNode(node_xzg);

File node_wz_a = new File("/wz/a.txt");
File node_wz_b = new File("/wz/b.txt");
Directory node_wz_movies = new Directory("/wz/movies/");
node_wz.addSubNode(node_wz_a);
node_wz.addSubNode(node_wz_b);
node_wz.addSubNode(node_wz_movies);

File node_wz_movies_c = new File("/wz/movies/c.avi");
node_wz_movies.addSubNode(node_wz_movies_c);

Directory node_xzg_docs = new Directory("/xzg/docs/");
node_xzg.addSubNode(node_xzg_docs);

File node_xzg_docs_d = new File("/xzg/docs/d.txt");
node_xzg_docs.addSubNode(node_xzg_docs_d);

System.out.println("/ files num:" + fileSystemTree.countNumOfFiles());
System.out.println("/wz/ files num:" + node_wz.countNumOfFiles());
}
}

我们对照着这个例子,再重新看一下组合模式的定义:“将一组对象(文件和目录)组织成树形结构,以表示一种‘部分-整体’的层次结构(目录与子目录的嵌套结构)。组合模式让客户端可以统一单个对象(文件)和组合对象(目录)的处理逻辑(递归遍历)。”

实际上,刚才讲的这种组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务需求可以通过在树上的递归遍历算法来实现。

组合模式的应用场景举例

刚刚我们讲了文件系统的例子,对于组合模式,我这里再举一个例子。搞懂了这两个例子,你基本上就算掌握了组合模式。在实际的项目中,遇到类似的可以表示成树形结构的业务场景,你只要“照葫芦画瓢”去设计就可以了。

假设我们在开发一个OA系统(办公自动化系统)。公司的组织结构包含部门和员工两种数据类型。其中,部门又可以包含子部门和员工。在数据库中的表结构如下所示:

img

我们希望在内存中构建整个公司的人员架构图(部门、子部门、员工的隶属关系),并且提供接口计算出部门的薪资成本(隶属于这个部门的所有员工的薪资和)。

部门包含子部门和员工,这是一种嵌套结构,可以表示成树这种数据结构。计算每个部门的薪资开支这样一个需求,也可以通过在树上的遍历算法来实现。所以,从这个角度来看,这个应用场景可以使用组合模式来设计和实现。

这个例子的代码结构跟上一个例子的很相似,代码实现我直接贴在了下面,你可以对比着看一下。其中,HumanResource是部门类(Department)和员工类(Employee)抽象出来的父类,为的是能统一薪资的处理逻辑。Demo中的代码负责从数据库中读取数据并在内存中构建组织架构图。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public abstract class HumanResource {
protected long id;
protected double salary;

public HumanResource(long id) {
this.id = id;
}

public long getId() {
return id;
}

public abstract double calculateSalary();
}

public class Employee extends HumanResource {
public Employee(long id, double salary) {
super(id);
this.salary = salary;
}

@Override
public double calculateSalary() {
return salary;
}
}

public class Department extends HumanResource {
private List<HumanResource> subNodes = new ArrayList<>();

public Department(long id) {
super(id);
}

@Override
public double calculateSalary() {
double totalSalary = 0;
for (HumanResource hr : subNodes) {
totalSalary += hr.calculateSalary();
}
this.salary = totalSalary;
return totalSalary;
}

public void addSubNode(HumanResource hr) {
subNodes.add(hr);
}
}

// 构建组织架构的代码
public class Demo {
private static final long ORGANIZATION_ROOT_ID = 1001;
private DepartmentRepo departmentRepo; // 依赖注入
private EmployeeRepo employeeRepo; // 依赖注入

public void buildOrganization() {
Department rootDepartment = new Department(ORGANIZATION_ROOT_ID);
buildOrganization(rootDepartment);
}

private void buildOrganization(Department department) {
List<Long> subDepartmentIds = departmentRepo.getSubDepartmentIds(department.getId());
for (Long subDepartmentId : subDepartmentIds) {
Department subDepartment = new Department(subDepartmentId);
department.addSubNode(subDepartment);
buildOrganization(subDepartment);
}
List<Long> employeeIds = employeeRepo.getDepartmentEmployeeIds(department.getId());
for (Long employeeId : employeeIds) {
double salary = employeeRepo.getEmployeeSalary(employeeId);
department.addSubNode(new Employee(employeeId, salary));
}
}
}

我们再拿组合模式的定义跟这个例子对照一下:“将一组对象(员工和部门)组织成树形结构,以表示一种‘部分-整体’的层次结构(部门与子部门的嵌套结构)。组合模式让客户端可以统一单个对象(员工)和组合对象(部门)的处理逻辑(递归遍历)。”

37. 结构型:享元模式(1)

再来学习一个不那么常用的模式,享元模式(Flyweight Design Pattern)。这也是我们要学习的最后一个结构型模式。

跟其他所有的设计模式类似,享元模式的原理和实现也非常简单。今天,我会通过棋牌游戏和文本编辑器两个实际的例子来讲解。除此之外,我还会讲到它跟单例、缓存、对象池的区别和联系。在下一节课中,我会带你剖析一下享元模式在Java Integer、String中的应用。

享元模式原理与实现

所谓“享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。

具体来讲,当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,我们就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元。

这里我稍微解释一下,定义中的“不可变对象”指的是,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何set()等修改内部状态的方法。之所以要求享元是不可变对象,那是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码。

接下来,我们通过一个简单的例子解释一下享元模式。

假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,我们就能显示一个完整的棋盘给玩家。具体的代码如下所示。其中,ChessPiece类表示棋子,ChessBoard类表示一个棋局,里面保存了象棋中30个棋子的信息。

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
34
35
36
37
38
39
public class ChessPiece {//棋子
private int id;
private String text;
private Color color;
private int positionX;
private int positionY;

public ChessPiece(int id, String text, Color color, int positionX, int positionY) {
this.id = id;
this.text = text;
this.color = color;
this.positionX = positionX;
this.positionY = positionX;
}

public static enum Color {
RED, BLACK
}

// ...省略其他属性和getter/setter方法...
}

public class ChessBoard {//棋局
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();

public ChessBoard() {
init();
}

private void init() {
chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0));
chessPieces.put(2, new ChessPiece(2,"馬", ChessPiece.Color.BLACK, 0, 1));
//...省略摆放其他棋子的代码...
}

public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}

为了记录每个房间当前的棋局情况,我们需要给每个房间都创建一个ChessBoard棋局对象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多),那保存这么多棋局对象就会消耗大量的内存。有没有什么办法来节省内存呢?

这个时候,享元模式就可以派上用场了。像刚刚的实现方式,在内存中会有大量的相似对象。这些相似对象的id、text、color都是相同的,唯独positionX、positionY不同。实际上,我们可以将棋子的id、text、color属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用。这样,棋盘只需要记录每个棋子的位置信息就可以了。具体的代码实现如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// 享元类
public class ChessPieceUnit {
private int id;
private String text;
private Color color;

public ChessPieceUnit(int id, String text, Color color) {
this.id = id;
this.text = text;
this.color = color;
}

public static enum Color {
RED, BLACK
}

// ...省略其他属性和getter方法...
}

public class ChessPieceUnitFactory {
private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();

static {
pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
pieces.put(2, new ChessPieceUnit(2,"馬", ChessPieceUnit.Color.BLACK));
//...省略摆放其他棋子的代码...
}

public static ChessPieceUnit getChessPiece(int chessPieceId) {
return pieces.get(chessPieceId);
}
}

public class ChessPiece {
private ChessPieceUnit chessPieceUnit;
private int positionX;
private int positionY;

public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) {
this.chessPieceUnit = unit;
this.positionX = positionX;
this.positionY = positionY;
}
// 省略getter、setter方法
}

public class ChessBoard {
private Map<Integer, ChessPiece> chessPieces = new HashMap<>();

public ChessBoard() {
init();
}

private void init() {
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(1), 0,0));
chessPieces.put(1, new ChessPiece(
ChessPieceUnitFactory.getChessPiece(2), 1,0));
//...省略摆放其他棋子的代码...
}

public void move(int chessPieceId, int toPositionX, int toPositionY) {
//...省略...
}
}

在上面的代码实现中,我们利用工厂类来缓存ChessPieceUnit信息(也就是id、text、color)。通过工厂类获取到的ChessPieceUnit就是享元。所有的ChessBoard对象共享这30个ChessPieceUnit对象(因为象棋中只有30个棋子)。在使用享元模式之前,记录1万个棋局,我们要创建30万(30*1万)个棋子的ChessPieceUnit对象。利用享元模式,我们只需要创建30个享元对象供所有棋局共享使用即可,大大节省了内存。

那享元模式的原理讲完了,我们来总结一下它的代码结构。实际上,它的代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个Map来缓存已经创建过的享元对象,来达到复用的目的

享元模式在文本编辑器中的应用

弄懂了享元模式的原理和实现之后,我们再来看另外一个例子,也就是文章标题中给出的:如何利用享元模式来优化文本编辑器的内存占用?

你可以把这里提到的文本编辑器想象成Office的Word。不过,为了简化需求背景,我们假设这个文本编辑器只实现了文字编辑功能,不包含图片、表格等复杂的编辑功能。对于简化之后的文本编辑器,我们要在内存中表示一个文本文件,只需要记录文字和格式两部分信息就可以了,其中,格式又包括文字的字体、大小、颜色等信息。

尽管在实际的文档编写中,我们一般都是按照文本类型(标题、正文……)来设置文字的格式,标题是一种格式,正文是另一种格式等等。但是,从理论上讲,我们可以给文本文件中的每个文字都设置不同的格式。为了实现如此灵活的格式设置,并且代码实现又不过于太复杂,我们把每个文字都当作一个独立的对象来看待,并且在其中包含它的格式信息。具体的代码示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Character {//文字
private char c;

private Font font;
private int size;
private int colorRGB;

public Character(char c, Font font, int size, int colorRGB) {
this.c = c;
this.font = font;
this.size = size;
this.colorRGB = colorRGB;
}
}

public class Editor {
private List<Character> chars = new ArrayList<>();

public void appendCharacter(char c, Font font, int size, int colorRGB) {
Character character = new Character(c, font, size, colorRGB);
chars.add(character);
}
}

在文本编辑器中,我们每敲一个文字,都会调用Editor类中的appendCharacter()方法,创建一个新的Character对象,保存到chars数组中。如果一个文本文件中,有上万、十几万、几十万的文字,那我们就要在内存中存储这么多Character对象。那有没有办法可以节省一点内存呢?

实际上,在一个文本文件中,用到的字体格式不会太多,毕竟不大可能有人把每个文字都设置成不同的格式。所以,对于字体格式,我们可以将它设计成享元,让不同的文字共享使用。按照这个设计思路,我们对上面的代码进行重构。重构后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class CharacterStyle {
private Font font;
private int size;
private int colorRGB;

public CharacterStyle(Font font, int size, int colorRGB) {
this.font = font;
this.size = size;
this.colorRGB = colorRGB;
}

@Override
public boolean equals(Object o) {
CharacterStyle otherStyle = (CharacterStyle) o;
return font.equals(otherStyle.font)
&& size == otherStyle.size
&& colorRGB == otherStyle.colorRGB;
}
}

public class CharacterStyleFactory {
private static final List<CharacterStyle> styles = new ArrayList<>();

public static CharacterStyle getStyle(Font font, int size, int colorRGB) {
CharacterStyle newStyle = new CharacterStyle(font, size, colorRGB);
for (CharacterStyle style : styles) {
if (style.equals(newStyle)) {
return style;
}
}
styles.add(newStyle);
return newStyle;
}
}

public class Character {
private char c;
private CharacterStyle style;

public Character(char c, CharacterStyle style) {
this.c = c;
this.style = style;
}
}

public class Editor {
private List<Character> chars = new ArrayList<>();

public void appendCharacter(char c, Font font, int size, int colorRGB) {
Character character = new Character(c, CharacterStyleFactory.getStyle(font, size, colorRGB));
chars.add(character);
}
}

享元模式vs单例、缓存、对象池

在上面的讲解中,我们多次提到“共享”“缓存”“复用”这些字眼,那它跟单例、缓存、对象池这些概念有什么区别呢?我们来简单对比一下。

我们先来看享元模式跟单例的区别。

在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。实际上,享元模式有点类似于之前讲到的单例的变体:多例。

我们前面也多次提到,区别两种设计模式,不能光看代码实现,而是要看设计意图,也就是要解决的问题。尽管从代码实现上来看,享元模式和多例有很多相似之处,但从设计意图上来看,它们是完全不同的。应用享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数。

我们再来看享元模式跟缓存的区别。

在享元模式的实现中,我们通过工厂类来“缓存”已经创建好的对象。这里的“缓存”实际上是“存储”的意思,跟我们平时所说的“数据库缓存”“CPU缓存”“MemCache缓存”是两回事。我们平时所讲的缓存,主要是为了提高访问效率,而非复用。

最后我们来看享元模式跟对象池的区别。

对象池、连接池(比如数据库连接池)、线程池等也是为了复用,那它们跟享元模式有什么区别呢?

你可能对连接池、线程池比较熟悉,对对象池比较陌生,所以,这里我简单解释一下对象池。像C++这样的编程语言,内存的管理是由程序员负责的。为了避免频繁地进行对象创建和释放导致内存碎片,我们可以预先申请一片连续的内存空间,也就是这里说的对象池。每次创建对象时,我们从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉。

虽然对象池、连接池、线程池、享元模式都是为了复用,但是,如果我们再细致地抠一抠“复用”这个字眼的话,对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”实际上是不同的概念。

池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用。享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间

38. 结构型:享元模式(2)

享元模式在Java Integer中的应用

我们先来看下面这样一段代码。你可以先思考下,这段代码会输出什么样的结果。

1
2
3
4
5
6
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

如果不熟悉Java语言,你可能会觉得,i1和i2值都是56,i3和i4值都是129,i1跟i2值相等,i3跟i4值相等,所以输出结果应该是两个true。这样的分析是不对的,主要还是因为你对Java语法不熟悉。要正确地分析上面的代码,我们需要弄清楚下面两个问题:

  • 如何判定两个Java对象是否相等(也就代码中的“==”操作符的含义)?
  • 什么是自动装箱(Autoboxing)和自动拆箱(Unboxing)?

Java为基本数据类型提供了对应的包装器类型。具体如下所示:

img

所谓的自动装箱,就是自动将基本数据类型转换为包装器类型。所谓的自动拆箱,也就是自动将包装器类型转化为基本数据类型。具体的代码示例如下所示:

1
2
Integer i = 56; //自动装箱
int j = i; //自动拆箱

数值56是基本数据类型int,当赋值给包装器类型(Integer)变量的时候,触发自动装箱操作,创建一个Integer类型的对象,并且赋值给变量i。其底层相当于执行了下面这条语句:

1
Integer i = 59;//底层执行了:Integer i = Integer.valueOf(59);

反过来,当把包装器类型的变量i,赋值给基本数据类型变量j的时候,触发自动拆箱操作,将i中的数据取出,赋值给j。其底层相当于执行了下面这条语句:

1
int j = i; //底层执行了:int j = i.intValue();

弄清楚了自动装箱和自动拆箱,我们再来看,如何判定两个对象是否相等?不过,在此之前,我们先要搞清楚,Java对象在内存中是如何存储的。我们通过下面这个例子来说明一下。

1
User a = new User(123, 23); // id=123, age=23

针对这条语句,我画了一张内存存储结构图,如下所示。a存储的值是User对象的内存地址,在图中就表现为a指向User对象。

img

当我们通过“==”来判定两个对象是否相等的时候,实际上是在判断两个局部变量存储的地址是否相同,换句话说,是在判断两个局部变量是否指向相同的对象。

了解了Java的这几个语法之后,我们重新看一下开头的那段代码。

1
2
3
4
5
6
Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

前4行赋值语句都会触发自动装箱操作,也就是会创建Integer对象并且赋值给i1、i2、i3、i4这四个变量。根据刚刚的讲解,i1、i2尽管存储的数值相同,都是56,但是指向不同的Integer对象,所以通过“==”来判定是否相同的时候,会返回false。同理,i3==i4判定语句也会返回false。

不过,上面的分析还是不对,答案并非是两个false,而是一个true,一个false。看到这里,你可能会比较纳闷了。实际上,这正是因为Integer用到了享元模式来复用对象,才导致了这样的运行结果。当我们通过自动装箱,也就是调用valueOf()来创建Integer对象的时候,如果要创建的Integer对象的值在-128到127之间,会从IntegerCache类中直接返回,否则才调用new方法创建。看代码更加清晰一些,Integer类的valueOf()函数的具体代码如下所示:

1
2
3
4
5
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}

实际上,这里的IntegerCache相当于,我们上一节课中讲的生成享元对象的工厂类,只不过名字不叫xxxFactory而已。我们来看它的具体代码实现。这个类是Integer的内部类,你也可以自行查看JDK源码。

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
34
35
36
37
38
39
40
41
42
43
/**
* Cache to support the object identity semantics of autoboxing for values between
* -128 and 127 (inclusive) as required by JLS.
*
* The cache is initialized on first usage. The size of the cache
* may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
* During VM initialization, java.lang.Integer.IntegerCache.high property
* may be set and saved in the private system properties in the
* sun.misc.VM class.
*/
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];

static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;

cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);

// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}

private IntegerCache() {}
}

为什么IntegerCache只缓存-128到127之间的整型值呢?

在IntegerCache的代码实现中,当这个类被加载的时候,缓存的享元对象会被集中一次性创建好。毕竟整型值太多了,我们不可能在IntegerCache类中预先创建好所有的整型值,这样既占用太多内存,也使得加载IntegerCache类的时间过长。所以,我们只能选择缓存对于大部分应用来说最常用的整型值,也就是一个字节的大小(-128到127之间的数据)。

实际上,JDK也提供了方法来让我们可以自定义缓存的最大值,有下面两种方式。如果你通过分析应用的JVM内存占用情况,发现-128到255之间的数据占用的内存比较多,你就可以用如下方式,将缓存的最大值从127调整到255。不过,这里注意一下,JDK并没有提供设置最小值的方法。

1
2
3
4
//方法一:
-Djava.lang.Integer.IntegerCache.high=255
//方法二:
-XX:AutoBoxCacheMax=255

现在,让我们再回到最开始的问题,因为56处于-128和127之间,i1和i2会指向相同的享元对象,所以i1==i2返回true。而129大于127,并不会被缓存,每次都会创建一个全新的对象,也就是说,i3和i4指向不同的Integer对象,所以i3==i4返回false。

实际上,除了Integer类型之外,其他包装器类型,比如Long、Short、Byte等,也都利用了享元模式来缓存-128到127之间的数据。比如,Long类型对应的LongCache享元工厂类及valueOf()函数代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static class LongCache {
private LongCache(){}

static final Long cache[] = new Long[-(-128) + 127 + 1];

static {
for(int i = 0; i < cache.length; i++)
cache[i] = new Long(i - 128);
}
}

public static Long valueOf(long l) {
final int offset = 128;
if (l >= -128 && l <= 127) { // will cache
return LongCache.cache[(int)l + offset];
}
return new Long(l);
}

在我们平时的开发中,对于下面这样三种创建整型对象的方式,我们优先使用后两种。

1
2
3
Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);

第一种创建方式并不会使用到IntegerCache,而后面两种创建方法可以利用IntegerCache缓存,返回共享的对象,以达到节省内存的目的。举一个极端一点的例子,假设程序需要创建1万个-128到127之间的Integer对象。使用第一种创建方式,我们需要分配1万个Integer对象的内存空间;使用后两种创建方式,我们最多只需要分配256个Integer对象的内存空间。

享元模式在Java String中的应用

刚刚我们讲了享元模式在Java Integer类中的应用,现在,我们再来看下,享元模式在Java String类中的应用。同样,我们还是先来看一段代码,你觉得这段代码输出的结果是什么呢?

1
2
3
4
5
6
String s1 = "小争哥";
String s2 = "小争哥";
String s3 = new String("小争哥");

System.out.println(s1 == s2);
System.out.println(s1 == s3);

上面代码的运行结果是:一个true,一个false。跟Integer类的设计思路相似,String类利用享元模式来复用相同的字符串常量(也就是代码中的“小争哥”)。JVM会专门开辟一块存储区来存储字符串常量,这块存储区叫作“字符串常量池”。上面代码对应的内存存储结构如下所示:

img

不过,String类的享元模式的设计,跟Integer类稍微有些不同。Integer类中要共享的对象,是在类加载的时候,就集中一次性创建好的。但是,对于字符串来说,我们没法事先知道要共享哪些字符串常量,所以没办法事先创建好,只能在某个字符串常量第一次被用到的时候,存储到常量池中,当之后再用到的时候,直接引用常量池中已经存在的即可,就不需要再重新创建了

39. 行为型:观察者模式(1)

开始学习行为型设计模式。我们知道,创建型设计模式主要解决“对象的创建”问题,结构型设计模式主要解决“类或对象的组合或组装”问题,那行为型设计模式主要解决的就是“类或对象之间的交互”问题。

行为型设计模式比较多,有11个,几乎占了23种经典设计模式的一半。它们分别是:观察者模式、模板模式、策略模式、职责链模式、状态模式、迭代器模式、访问者模式、备忘录模式、命令模式、解释器模式、中介模式

今天,我们学习第一个行为型设计模式,也是在实际的开发中用得比较多的一种模式:观察者模式。根据应用场景的不同,观察者模式会对应不同的代码实现方式:有同步阻塞的实现方式,也有异步非阻塞的实现方式;有进程内的实现方式,也有跨进程的实现方式。今天我会重点讲解原理、实现、应用场景。下一节课,我会带你一块实现一个基于观察者模式的异步非阻塞的EventBus,加深你对这个模式的理解。

原理及应用场景剖析

观察者模式(Observer Design Pattern)也被称为发布订阅模式(Publish-Subscribe Design Pattern)。在GoF的《设计模式》一书中,它的定义是这样的:

Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.

翻译成中文就是:在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知

一般情况下,被依赖的对象叫作被观察者(Observable),依赖的对象叫作观察者(Observer)。不过,在实际的项目开发中,这两种对象的称呼是比较灵活的,有各种不同的叫法,比如:Subject-Observer、Publisher-Subscriber、Producer-Consumer、EventEmitter-EventListener、Dispatcher-Listener。不管怎么称呼,只要应用场景符合刚刚给出的定义,都可以看作观察者模式。

实际上,观察者模式是一个比较抽象的模式,根据不同的应用场景和需求,有完全不同的实现方式,待会我们会详细地讲到。现在,我们先来看其中最经典的一种实现方式。这也是在讲到这种模式的时候,很多书籍或资料给出的最常见的实现方式。具体的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
public interface Subject {
void registerObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(Message message);
}

public interface Observer {
void update(Message message);
}

public class ConcreteSubject implements Subject {
private List<Observer> observers = new ArrayList<Observer>();

@Override
public void registerObserver(Observer observer) {
observers.add(observer);
}

@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}

@Override
public void notifyObservers(Message message) {
for (Observer observer : observers) {
observer.update(message);
}
}

}

public class ConcreteObserverOne implements Observer {
@Override
public void update(Message message) {
//TODO: 获取消息通知,执行自己的逻辑...
System.out.println("ConcreteObserverOne is notified.");
}
}

public class ConcreteObserverTwo implements Observer {
@Override
public void update(Message message) {
//TODO: 获取消息通知,执行自己的逻辑...
System.out.println("ConcreteObserverTwo is notified.");
}
}

public class Demo {
public static void main(String[] args) {
ConcreteSubject subject = new ConcreteSubject();
subject.registerObserver(new ConcreteObserverOne());
subject.registerObserver(new ConcreteObserverTwo());
subject.notifyObservers(new Message());
}
}

实际上,上面的代码算是观察者模式的“模板代码”,只能反映大体的设计思路。在真实的软件开发中,并不需要照搬上面的模板代码。观察者模式的实现方法各式各样,函数、类的命名等会根据业务场景的不同有很大的差别,比如register函数还可以叫作attach,remove函数还可以叫作detach等等。不过,万变不离其宗,设计思路都是差不多的。

原理和代码实现都非常简单,也比较好理解,不需要我过多的解释。我们还是通过一个具体的例子来重点讲一下,什么情况下需要用到这种设计模式?或者说,这种设计模式能解决什么问题呢?

假设我们在开发一个P2P投资理财系统,用户注册成功之后,我们会给用户发放投资体验金。代码实现大致是下面这个样子的:

1
2
3
4
5
6
7
8
9
10
11
12
public class UserController {
private UserService userService; // 依赖注入
private PromotionService promotionService; // 依赖注入

public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
promotionService.issueNewUserExperienceCash(userId);
return userId;
}
}

虽然注册接口做了两件事情,注册和发放体验金,违反单一职责原则,但是,如果没有扩展和修改的需求,现在的代码实现是可以接受的。如果非得用观察者模式,就需要引入更多的类和更加复杂的代码结构,反倒是一种过度设计。

相反,如果需求频繁变动,比如,用户注册成功之后,不再发放体验金,而是改为发放优惠券,并且还要给用户发送一封“欢迎注册成功”的站内信。这种情况下,我们就需要频繁地修改register()函数中的代码,违反开闭原则。而且,如果注册成功之后需要执行的后续操作越来越多,那register()函数的逻辑会变得越来越复杂,也就影响到代码的可读性和可维护性。

这个时候,观察者模式就能派上用场了。利用观察者模式,我对上面的代码进行了重构。重构之后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
public interface RegObserver {
void handleRegSuccess(long userId);
}

public class RegPromotionObserver implements RegObserver {
private PromotionService promotionService; // 依赖注入

@Override
public void handleRegSuccess(long userId) {
promotionService.issueNewUserExperienceCash(userId);
}
}

public class RegNotificationObserver implements RegObserver {
private NotificationService notificationService;

@Override
public void handleRegSuccess(long userId) {
notificationService.sendInboxMessage(userId, "Welcome...");
}
}

public class UserController {
private UserService userService; // 依赖注入
private List<RegObserver> regObservers = new ArrayList<>();

// 一次性设置好,之后也不可能动态的修改
public void setRegObservers(List<RegObserver> observers) {
regObservers.addAll(observers);
}

public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);

for (RegObserver observer : regObservers) {
observer.handleRegSuccess(userId);
}

return userId;
}
}

当我们需要添加新的观察者的时候,比如,用户注册成功之后,推送用户注册信息给大数据征信系统,基于观察者模式的代码实现,UserController类的register()函数完全不需要修改,只需要再添加一个实现了RegObserver接口的类,并且通过setRegObservers()函数将它注册到UserController类中即可。

不过,你可能会说,当我们把发送体验金替换为发送优惠券的时候,需要修改RegPromotionObserver类中handleRegSuccess()函数的代码,这还是违反开闭原则呀?你说得没错,不过,相对于register()函数来说,handleRegSuccess()函数的逻辑要简单很多,修改更不容易出错,引入bug的风险更低。

前面我们已经学习了很多设计模式,不知道你有没有发现,实际上,设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦,具体到观察者模式,它是将观察者和被观察者代码解耦。借助设计模式,我们利用更好的代码结构,将一大坨代码拆分成职责更单一的小类,让其满足开闭原则、高内聚松耦合等特性,以此来控制和应对代码的复杂性,提高代码的可扩展性。

基于不同应用场景的不同实现方式

观察者模式的应用场景非常广泛,小到代码层面的解耦,大到架构层面的系统解耦,再或者一些产品的设计思路,都有这种模式的影子,比如,邮件订阅、RSS Feeds,本质上都是观察者模式。

不同的应用场景和需求下,这个模式也有截然不同的实现方式,开篇的时候我们也提到,有同步阻塞的实现方式,也有异步非阻塞的实现方式;有进程内的实现方式,也有跨进程的实现方式。

之前讲到的实现方式,从刚刚的分类方式上来看,它是一种同步阻塞的实现方式。观察者和被观察者代码在同一个线程内执行,被观察者一直阻塞,直到所有的观察者代码都执行完成之后,才执行后续的代码。对照上面讲到的用户注册的例子,register()函数依次调用执行每个观察者的handleRegSuccess()函数,等到都执行完成之后,才会返回结果给客户端。

如果注册接口是一个调用比较频繁的接口,对性能非常敏感,希望接口的响应时间尽可能短,那我们可以将同步阻塞的实现方式改为异步非阻塞的实现方式,以此来减少响应时间。具体来讲,当userService.register()函数执行完成之后,我们启动一个新的线程来执行观察者的handleRegSuccess()函数,这样userController.register()函数就不需要等到所有的handleRegSuccess()函数都执行完成之后才返回结果给客户端。userController.register()函数从执行3个SQL语句才返回,减少到只需要执行1个SQL语句就返回,响应时间粗略来讲减少为原来的1/3。

那如何实现一个异步非阻塞的观察者模式呢?简单一点的做法是,在每个handleRegSuccess()函数中,创建一个新的线程执行代码。不过,我们还有更加优雅的实现方式,那就是基于EventBus来实现。今天,我们就不展开讲解了。在下一讲中,我会用一节课的时间,借鉴Google Guava EventBus框架的设计思想,手把手带你开发一个支持异步非阻塞的EventBus框架。它可以复用在任何需要异步非阻塞观察者模式的应用场景中。

刚刚讲到的两个场景,不管是同步阻塞实现方式还是异步非阻塞实现方式,都是进程内的实现方式。如果用户注册成功之后,我们需要发送用户信息给大数据征信系统,而大数据征信系统是一个独立的系统,跟它之间的交互是跨不同进程的,那如何实现一个跨进程的观察者模式呢?

如果大数据征信系统提供了发送用户注册信息的RPC接口,我们仍然可以沿用之前的实现思路,在handleRegSuccess()函数中调用RPC接口来发送数据。但是,我们还有更加优雅、更加常用的一种实现方式,那就是基于消息队列(Message Queue,比如ActiveMQ)来实现。

当然,这种实现方式也有弊端,那就是需要引入一个新的系统(消息队列),增加了维护成本。不过,它的好处也非常明显。在原来的实现方式中,观察者需要注册到被观察者中,被观察者需要依次遍历观察者来发送消息。而基于消息队列的实现方式,被观察者和观察者解耦更加彻底,两部分的耦合更小。被观察者完全不感知观察者,同理,观察者也完全不感知被观察者。被观察者只管发送消息到消息队列,观察者只管从消息队列中读取消息来执行相应的逻辑。

40. 行为型:观察者模式(2)

上一节课中,我们学习了观察者模式的原理、实现、应用场景,重点介绍了不同应用场景下,几种不同的实现方式,包括:同步阻塞、异步非阻塞、进程内、进程间的实现方式。

同步阻塞是最经典的实现方式,主要是为了代码解耦;异步非阻塞除了能实现代码解耦之外,还能提高代码的执行效率;进程间的观察者模式解耦更加彻底,一般是基于消息队列来实现,用来实现不同进程间的被观察者和观察者之间的交互。

今天,我们聚焦于异步非阻塞的观察者模式,带你实现一个类似Google Guava EventBus的通用框架。

异步非阻塞观察者模式的简易实现

上一节课中,我们讲到,对于异步非阻塞观察者模式,如果只是实现一个简易版本,不考虑任何通用性、复用性,实际上是非常容易的。

我们有两种实现方式。其中一种是:在每个handleRegSuccess()函数中创建一个新的线程执行代码逻辑;另一种是:在UserController的register()函数中使用线程池来执行每个观察者的handleRegSuccess()函数。两种实现方式的具体代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// 第一种实现方式,其他类代码不变,就没有再重复罗列
public class RegPromotionObserver implements RegObserver {
private PromotionService promotionService; // 依赖注入

@Override
public void handleRegSuccess(Long userId) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
promotionService.issueNewUserExperienceCash(userId);
}
});
thread.start();
}
}

// 第二种实现方式,其他类代码不变,就没有再重复罗列
public class UserController {
private UserService userService; // 依赖注入
private List<RegObserver> regObservers = new ArrayList<>();
private Executor executor;

public UserController(Executor executor) {
this.executor = executor;
}

public void setRegObservers(List<RegObserver> observers) {
regObservers.addAll(observers);
}

public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);

for (RegObserver observer : regObservers) {
executor.execute(new Runnable() {
@Override
public void run() {
observer.handleRegSuccess(userId);
}
});
}

return userId;
}
}

对于第一种实现方式,频繁地创建和销毁线程比较耗时,并且并发线程数无法控制,创建过多的线程会导致堆栈溢出。第二种实现方式,尽管利用了线程池解决了第一种实现方式的问题,但线程池、异步执行逻辑都耦合在了register()函数中,增加了这部分业务代码的维护成本。

如果我们的需求更加极端一点,需要在同步阻塞和异步非阻塞之间灵活切换,那就要不停地修改UserController的代码。除此之外,如果在项目中,不止一个业务模块需要用到异步非阻塞观察者模式,那这样的代码实现也无法做到复用。

我们知道,框架的作用有:隐藏实现细节,降低开发难度,做到代码复用,解耦业务与非业务代码,让程序员聚焦业务开发。针对异步非阻塞观察者模式,我们也可以将它抽象成框架来达到这样的效果,而这个框架就是我们这节课要讲的EventBus。

EventBus框架功能需求介绍

EventBus翻译为“事件总线”,它提供了实现观察者模式的骨架代码。我们可以基于此框架,非常容易地在自己的业务场景中实现观察者模式,不需要从零开始开发。其中,Google Guava EventBus就是一个比较著名的EventBus框架,它不仅仅支持异步非阻塞模式,同时也支持同步阻塞模式

现在,我们就通过例子来看一下,Guava EventBus具有哪些功能。还是上节课那个用户注册的例子,我们用Guava EventBus重新实现一下,代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
public class UserController {
private UserService userService; // 依赖注入

private EventBus eventBus;
private static final int DEFAULT_EVENTBUS_THREAD_POOL_SIZE = 20;

public UserController() {
//eventBus = new EventBus(); // 同步阻塞模式
eventBus = new AsyncEventBus(Executors.newFixedThreadPool(DEFAULT_EVENTBUS_THREAD_POOL_SIZE)); // 异步非阻塞模式
}

public void setRegObservers(List<Object> observers) {
for (Object observer : observers) {
eventBus.register(observer);
}
}

public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);

eventBus.post(userId);

return userId;
}
}

public class RegPromotionObserver {
private PromotionService promotionService; // 依赖注入

@Subscribe
public void handleRegSuccess(Long userId) {
promotionService.issueNewUserExperienceCash(userId);
}
}

public class RegNotificationObserver {
private NotificationService notificationService;

@Subscribe
public void handleRegSuccess(Long userId) {
notificationService.sendInboxMessage(userId, "...");
}
}

利用EventBus框架实现的观察者模式,跟从零开始编写的观察者模式相比,从大的流程上来说,实现思路大致一样,都需要定义Observer,并且通过register()函数注册Observer,也都需要通过调用某个函数(比如,EventBus中的post()函数)来给Observer发送消息(在EventBus中消息被称作事件event)。

但在实现细节方面,它们又有些区别。基于EventBus,我们不需要定义Observer接口,任意类型的对象都可以注册到EventBus中,通过@Subscribe注解来标明类中哪个函数可以接收被观察者发送的消息。

接下来,我们详细地讲一下,Guava EventBus的几个主要的类和函数。

  • EventBus、AsyncEventBus

Guava EventBus对外暴露的所有可调用接口,都封装在EventBus类中。其中,EventBus实现了同步阻塞的观察者模式,AsyncEventBus继承自EventBus,提供了异步非阻塞的观察者模式。具体使用方式如下所示:

1
2
EventBus eventBus = new EventBus(); // 同步阻塞模式
EventBus eventBus = new AsyncEventBus(Executors.newFixedThreadPool(8));// 异步阻塞模式
  • register()函数

EventBus类提供了register()函数用来注册观察者。具体的函数定义如下所示。它可以接受任何类型(Object)的观察者。而在经典的观察者模式的实现中,register()函数必须接受实现了同一Observer接口的类对象。

1
public void register(Object object);
  • unregister()函数

相对于register()函数,unregister()函数用来从EventBus中删除某个观察者。我就不多解释了,具体的函数定义如下所示:

1
public void unregister(Object object);
  • post()函数

EventBus类提供了post()函数,用来给观察者发送消息。具体的函数定义如下所示:

1
public void post(Object event);

跟经典的观察者模式的不同之处在于,当我们调用post()函数发送消息的时候,并非把消息发送给所有的观察者,而是发送给可匹配的观察者。所谓可匹配指的是,能接收的消息类型是发送消息(post函数定义中的event)类型的父类。我举个例子来解释一下。

比如,AObserver能接收的消息类型是XMsg,BObserver能接收的消息类型是YMsg,CObserver能接收的消息类型是ZMsg。其中,XMsg是YMsg的父类。当我们如下发送消息的时候,相应能接收到消息的可匹配观察者如下所示:

1
2
3
4
5
6
XMsg xMsg = new XMsg();
YMsg yMsg = new YMsg();
ZMsg zMsg = new ZMsg();
post(xMsg); => AObserver接收到消息
post(yMsg); => AObserver、BObserver接收到消息
post(zMsg); => CObserver接收到消息

你可能会问,每个Observer能接收的消息类型是在哪里定义的呢?我们来看下Guava EventBus最特别的一个地方,那就是@Subscribe注解。

  • @Subscribe注解

EventBus通过@Subscribe注解来标明,某个函数能接收哪种类型的消息。具体的使用代码如下所示。在DObserver类中,我们通过@Subscribe注解了两个函数f1()、f2()。

1
2
3
4
5
6
7
8
9
public DObserver {
//...省略其他属性和方法...

@Subscribe
public void f1(PMsg event) { //... }

@Subscribe
public void f2(QMsg event) { //... }
}

当通过register()函数将DObserver 类对象注册到EventBus的时候,EventBus会根据@Subscribe注解找到f1()和f2(),并且将两个函数能接收的消息类型记录下来(PMsg->f1,QMsg->f2)。当我们通过post()函数发送消息(比如QMsg消息)的时候,EventBus会通过之前的记录(QMsg->f2),调用相应的函数(f2)。

手把手实现一个EventBus框架

Guava EventBus的功能我们已经讲清楚了,总体上来说,还是比较简单的。接下来,我们就重复造轮子,“山寨”一个EventBus出来。

我们重点来看,EventBus中两个核心函数register()和post()的实现原理。弄懂了它们,基本上就弄懂了整个EventBus框架。下面两张图是这两个函数的实现原理图。

img img

从图中我们可以看出,最关键的一个数据结构是Observer注册表,记录了消息类型和可接收消息函数的对应关系。当调用register()函数注册观察者的时候,EventBus通过解析@Subscribe注解,生成Observer注册表。当调用post()函数发送消息的时候,EventBus通过注册表找到相应的可接收消息的函数,然后通过Java的反射语法来动态地创建对象、执行函数。对于同步阻塞模式,EventBus在一个线程内依次执行相应的函数。对于异步非阻塞模式,EventBus通过一个线程池来执行相应的函数。

弄懂了原理,实现起来就简单多了。整个小框架的代码实现包括5个类:EventBus、AsyncEventBus、Subscribe、ObserverAction、ObserverRegistry。接下来,我们依次来看下这5个类。

1.Subscribe

Subscribe是一个注解,用于标明观察者中的哪个函数可以接收消息。

1
2
3
4
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Beta
public @interface Subscribe {}

2.ObserverAction

ObserverAction类用来表示@Subscribe注解的方法,其中,target表示观察者类,method表示方法。它主要用在ObserverRegistry观察者注册表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ObserverAction {
private Object target;
private Method method;

public ObserverAction(Object target, Method method) {
this.target = Preconditions.checkNotNull(target);
this.method = method;
this.method.setAccessible(true);
}

public void execute(Object event) { // event是method方法的参数
try {
method.invoke(target, event);
} catch (InvocationTargetException | IllegalAccessException e) {
e.printStackTrace();
}
}
}

3.ObserverRegistry

ObserverRegistry类就是前面讲到的Observer注册表,是最复杂的一个类,框架中几乎所有的核心逻辑都在这个类中。这个类大量使用了Java的反射语法,不过代码整体来说都不难理解,其中,一个比较有技巧的地方是CopyOnWriteArraySet的使用。

CopyOnWriteArraySet,顾名思义,在写入数据的时候,会创建一个新的set,并且将原始数据clone到新的set中,在新的set中写入数据完成之后,再用新的set替换老的set。这样就能保证在写入数据的时候,不影响数据的读取操作,以此来解决读写并发问题。除此之外,CopyOnWriteSet还通过加锁的方式,避免了并发写冲突。具体的作用你可以去查看一下CopyOnWriteSet类的源码,一目了然。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
public class ObserverRegistry {
private ConcurrentMap<Class<?>, CopyOnWriteArraySet<ObserverAction>> registry = new ConcurrentHashMap<>();

public void register(Object observer) {
Map<Class<?>, Collection<ObserverAction>> observerActions = findAllObserverActions(observer);
for (Map.Entry<Class<?>, Collection<ObserverAction>> entry : observerActions.entrySet()) {
Class<?> eventType = entry.getKey();
Collection<ObserverAction> eventActions = entry.getValue();
CopyOnWriteArraySet<ObserverAction> registeredEventActions = registry.get(eventType);
if (registeredEventActions == null) {
registry.putIfAbsent(eventType, new CopyOnWriteArraySet<>());
registeredEventActions = registry.get(eventType);
}
registeredEventActions.addAll(eventActions);
}
}

public List<ObserverAction> getMatchedObserverActions(Object event) {
List<ObserverAction> matchedObservers = new ArrayList<>();
Class<?> postedEventType = event.getClass();
for (Map.Entry<Class<?>, CopyOnWriteArraySet<ObserverAction>> entry : registry.entrySet()) {
Class<?> eventType = entry.getKey();
Collection<ObserverAction> eventActions = entry.getValue();
if (postedEventType.isAssignableFrom(eventType)) {
matchedObservers.addAll(eventActions);
}
}
return matchedObservers;
}

private Map<Class<?>, Collection<ObserverAction>> findAllObserverActions(Object observer) {
Map<Class<?>, Collection<ObserverAction>> observerActions = new HashMap<>();
Class<?> clazz = observer.getClass();
for (Method method : getAnnotatedMethods(clazz)) {
Class<?>[] parameterTypes = method.getParameterTypes();
Class<?> eventType = parameterTypes[0];
if (!observerActions.containsKey(eventType)) {
observerActions.put(eventType, new ArrayList<>());
}
observerActions.get(eventType).add(new ObserverAction(observer, method));
}
return observerActions;
}

private List<Method> getAnnotatedMethods(Class<?> clazz) {
List<Method> annotatedMethods = new ArrayList<>();
for (Method method : clazz.getDeclaredMethods()) {
if (method.isAnnotationPresent(Subscribe.class)) {
Class<?>[] parameterTypes = method.getParameterTypes();
Preconditions.checkArgument(parameterTypes.length == 1,
"Method %s has @Subscribe annotation but has %s parameters."
+ "Subscriber methods must have exactly 1 parameter.",
method, parameterTypes.length);
annotatedMethods.add(method);
}
}
return annotatedMethods;
}
}

4.EventBus

EventBus实现的是阻塞同步的观察者模式。看代码你可能会有些疑问,这明明就用到了线程池Executor啊。实际上,MoreExecutors.directExecutor()是Google Guava提供的工具类,看似是多线程,实际上是单线程。之所以要这么实现,主要还是为了跟AsyncEventBus统一代码逻辑,做到代码复用。

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
public class EventBus {
private Executor executor;
private ObserverRegistry registry = new ObserverRegistry();

public EventBus() {
this(MoreExecutors.directExecutor());
}

protected EventBus(Executor executor) {
this.executor = executor;
}

public void register(Object object) {
registry.register(object);
}

public void post(Object event) {
List<ObserverAction> observerActions = registry.getMatchedObserverActions(event);
for (ObserverAction observerAction : observerActions) {
executor.execute(new Runnable() {
@Override
public void run() {
observerAction.execute(event);
}
});
}
}
}

5.AsyncEventBus

有了EventBus,AsyncEventBus的实现就非常简单了。为了实现异步非阻塞的观察者模式,它就不能再继续使用MoreExecutors.directExecutor()了,而是需要在构造函数中,由调用者注入线程池。

1
2
3
4
5
public class AsyncEventBus extends EventBus {
public AsyncEventBus(Executor executor) {
super(executor);
}
}

至此,我们用了不到200行代码,就实现了一个还算凑活能用的EventBus,从功能上来讲,它跟Google Guava EventBus几乎一样。不过,如果去查看Google Guava EventBus的源码,你会发现,在实现细节方面,相比我们现在的实现,它其实做了很多优化,比如优化了在注册表中查找消息可匹配函数的算法。如果有时间的话,建议你去读一下它的源码。

41. 行为型:模板模式(1)

模板模式主要是用来解决复用和扩展两个问题。我们今天会结合Java Servlet、JUnit TestCase、Java InputStream、Java AbstractList四个例子来具体讲解这两个作用。

模板模式的原理与实现

模板模式,全称是模板方法设计模式,英文是Template Method Design Pattern。在GoF的《设计模式》一书中,它是这么定义的:

Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithm’s structure.

翻译成中文就是:模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。

这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。

原理很简单,代码实现就更加简单,我写了一个示例代码,如下所示。templateMethod()函数定义为final,是为了避免子类重写它。method1()和method2()定义为abstract,是为了强迫子类去实现。不过,这些都不是必须的,在实际的项目开发中,模板模式的代码实现比较灵活,待会儿讲到应用场景的时候,我们会有具体的体现。

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
34
35
36
37
38
39
public abstract class AbstractClass {
public final void templateMethod() {
//...
method1();
//...
method2();
//...
}

protected abstract void method1();
protected abstract void method2();
}

public class ConcreteClass1 extends AbstractClass {
@Override
protected void method1() {
//...
}

@Override
protected void method2() {
//...
}
}

public class ConcreteClass2 extends AbstractClass {
@Override
protected void method1() {
//...
}

@Override
protected void method2() {
//...
}
}

AbstractClass demo = ConcreteClass1();
demo.templateMethod();

模板模式作用一:复用

开篇的时候,我们讲到模板模式有两大作用:复用和扩展。我们先来看它的第一个作用:复用。

模板模式把一个算法中不变的流程抽象到父类的模板方法templateMethod()中,将可变的部分method1()、method2()留给子类ContreteClass1和ContreteClass2来实现。所有的子类都可以复用父类中模板方法定义的流程代码。我们通过两个小例子来更直观地体会一下。

1.Java InputStream

Java IO类库中,有很多类的设计用到了模板模式,比如InputStream、OutputStream、Reader、Writer。我们拿InputStream来举例说明一下。

我把InputStream部分相关代码贴在了下面。在代码中,read()函数是一个模板方法,定义了读取数据的整个流程,并且暴露了一个可以由子类来定制的抽象方法。不过这个方法也被命名为了read(),只是参数跟模板方法不同。

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
34
35
36
37
38
39
40
41
42
43
public abstract class InputStream implements Closeable {
//...省略其他代码...

public int read(byte b[], int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
} else if (off < 0 || len < 0 || len > b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}

int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;

int i = 1;
try {
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}

public abstract int read() throws IOException;
}

public class ByteArrayInputStream extends InputStream {
//...省略其他代码...

@Override
public synchronized int read() {
return (pos < count) ? (buf[pos++] & 0xff) : -1;
}
}

2.Java AbstractList

在Java AbstractList类中,addAll()函数可以看作模板方法,add()是子类需要重写的方法,尽管没有声明为abstract的,但函数实现直接抛出了UnsupportedOperationException异常。前提是,如果子类不重写是不能使用的。

1
2
3
4
5
6
7
8
9
10
11
12
13
public boolean addAll(int index, Collection<? extends E> c) {
rangeCheckForAdd(index);
boolean modified = false;
for (E e : c) {
add(index++, e);
modified = true;
}
return modified;
}

public void add(int index, E element) {
throw new UnsupportedOperationException();
}

模板模式作用二:扩展

模板模式的第二大作用的是扩展。这里所说的扩展,并不是指代码的扩展性,而是指框架的扩展性,有点类似我们之前讲到的控制反转。基于这个作用,模板模式常用在框架的开发中,让框架用户可以在不修改框架源码的情况下,定制化框架的功能。我们通过Junit TestCase、Java Servlet两个例子来解释一下。

1.Java Servlet

对于Java Web项目开发来说,常用的开发框架是SpringMVC。利用它,我们只需要关注业务代码的编写,底层的原理几乎不会涉及。但是,如果我们抛开这些高级框架来开发Web项目,必然会用到Servlet。实际上,使用比较底层的Servlet来开发Web项目也不难。我们只需要定义一个继承HttpServlet的类,并且重写其中的doGet()或doPost()方法,来分别处理get和post请求。具体的代码示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doPost(req, resp);
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().write("Hello World.");
}
}

除此之外,我们还需要在配置文件web.xml中做如下配置。Tomcat、Jetty等Servlet容器在启动的时候,会自动加载这个配置文件中的URL和Servlet之间的映射关系。

1
2
3
4
5
6
7
8
9
<servlet>
<servlet-name>HelloServlet</servlet-name>
<servlet-class>com.xzg.cd.HelloServlet</servlet-class>
</servlet>

<servlet-mapping>
<servlet-name>HelloServlet</servlet-name>
<url-pattern>/hello</url-pattern>
</servlet-mapping>

当我们在浏览器中输入网址(比如,http://127.0.0.1:8080/hello )的时候,Servlet容器会接收到相应的请求,并且根据URL和Servlet之间的映射关系,找到相应的Servlet(HelloServlet),然后执行它的service()方法。service()方法定义在父类HttpServlet中,它会调用doGet()或doPost()方法,然后输出数据(“Hello world”)到网页。

我们现在来看,HttpServlet的service()函数长什么样子。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException
{
HttpServletRequest request;
HttpServletResponse response;
if (!(req instanceof HttpServletRequest &&
res instanceof HttpServletResponse)) {
throw new ServletException("non-HTTP request or response");
}
request = (HttpServletRequest) req;
response = (HttpServletResponse) res;
service(request, response);
}

protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException
{
String method = req.getMethod();
if (method.equals(METHOD_GET)) {
long lastModified = getLastModified(req);
if (lastModified == -1) {
// servlet doesn't support if-modified-since, no reason
// to go through further expensive logic
doGet(req, resp);
} else {
long ifModifiedSince = req.getDateHeader(HEADER_IFMODSINCE);
if (ifModifiedSince < lastModified) {
// If the servlet mod time is later, call doGet()
// Round down to the nearest second for a proper compare
// A ifModifiedSince of -1 will always be less
maybeSetLastModified(resp, lastModified);
doGet(req, resp);
} else {
resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
}
}
} else if (method.equals(METHOD_HEAD)) {
long lastModified = getLastModified(req);
maybeSetLastModified(resp, lastModified);
doHead(req, resp);
} else if (method.equals(METHOD_POST)) {
doPost(req, resp);
} else if (method.equals(METHOD_PUT)) {
doPut(req, resp);
} else if (method.equals(METHOD_DELETE)) {
doDelete(req, resp);
} else if (method.equals(METHOD_OPTIONS)) {
doOptions(req,resp);
} else if (method.equals(METHOD_TRACE)) {
doTrace(req,resp);
} else {
String errMsg = lStrings.getString("http.method_not_implemented");
Object[] errArgs = new Object[1];
errArgs[0] = method;
errMsg = MessageFormat.format(errMsg, errArgs);
resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, errMsg);
}
}

从上面的代码中我们可以看出,HttpServlet的service()方法就是一个模板方法,它实现了整个HTTP请求的执行流程,doGet()、doPost()是模板中可以由子类来定制的部分。实际上,这就相当于Servlet框架提供了一个扩展点(doGet()、doPost()方法),让框架用户在不用修改Servlet框架源码的情况下,将业务代码通过扩展点镶嵌到框架中执行。

2.JUnit TestCase

跟Java Servlet类似,JUnit框架也通过模板模式提供了一些功能扩展点(setUp()、tearDown()等),让框架用户可以在这些扩展点上扩展功能。

在使用JUnit测试框架来编写单元测试的时候,我们编写的测试类都要继承框架提供的TestCase类。在TestCase类中,runBare()函数是模板方法,它定义了执行测试用例的整体流程:先执行setUp()做些准备工作,然后执行runTest()运行真正的测试代码,最后执行tearDown()做扫尾工作

TestCase类的具体代码如下所示。尽管setUp()、tearDown()并不是抽象函数,还提供了默认的实现,不强制子类去重新实现,但这部分也是可以在子类中定制的,所以也符合模板模式的定义。

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
public abstract class TestCase extends Assert implements Test {
public void runBare() throws Throwable {
Throwable exception = null;
setUp();
try {
runTest();
} catch (Throwable running) {
exception = running;
} finally {
try {
tearDown();
} catch (Throwable tearingDown) {
if (exception == null) exception = tearingDown;
}
}
if (exception != null) throw exception;
}

/**
* Sets up the fixture, for example, open a network connection.
* This method is called before a test is executed.
*/
protected void setUp() throws Exception {
}

/**
* Tears down the fixture, for example, close a network connection.
* This method is called after a test is executed.
*/
protected void tearDown() throws Exception {
}
}

42. 行为型:模板模式(2)

复用和扩展是模板模式的两大作用,实际上,还有另外一个技术概念,也能起到跟模板模式相同的作用,那就是回调(Callback)。今天我们今天就来看一下,回调的原理、实现和应用,以及它跟模板模式的区别和联系。

回调的原理解析

相对于普通的函数调用来说,回调是一种双向调用关系。A类事先注册某个函数F到B类,A类在调用B类的P函数的时候,B类反过来调用A类注册给它的F函数。这里的F函数就是“回调函数”。A调用B,B反过来又调用A,这种调用机制就叫作“回调”。

A类如何将回调函数传递给B类呢?不同的编程语言,有不同的实现方法。C语言可以使用函数指针,Java则需要使用包裹了回调函数的类对象,我们简称为回调对象。这里我用Java语言举例说明一下。代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public interface ICallback {
void methodToCallback();
}

public class BClass {
public void process(ICallback callback) {
//...
callback.methodToCallback();
//...
}
}

public class AClass {
public static void main(String[] args) {
BClass b = new BClass();
b.process(new ICallback() { //回调对象
@Override
public void methodToCallback() {
System.out.println("Call back me.");
}
});
}
}

上面就是Java语言中回调的典型代码实现。从代码实现中,我们可以看出,回调跟模板模式一样,也具有复用和扩展的功能。除了回调函数之外,BClass类的process()函数中的逻辑都可以复用。如果ICallback、BClass类是框架代码,AClass是使用框架的客户端代码,我们可以通过ICallback定制process()函数,也就是说,框架因此具有了扩展的能力。

实际上,回调不仅可以应用在代码设计上,在更高层次的架构设计上也比较常用。比如,通过三方支付系统来实现支付功能,用户在发起支付请求之后,一般不会一直阻塞到支付结果返回,而是注册回调接口(类似回调函数,一般是一个回调用的URL)给三方支付系统,等三方支付系统执行完成之后,将结果通过回调接口返回给用户。

回调可以分为同步回调和异步回调(或者延迟回调)。同步回调指在函数返回之前执行回调函数;异步回调指的是在函数返回之后执行回调函数。上面的代码实际上是同步回调的实现方式,在process()函数返回之前,执行完回调函数methodToCallback()。而上面支付的例子是异步回调的实现方式,发起支付之后不需要等待回调接口被调用就直接返回。从应用场景上来看,同步回调看起来更像模板模式,异步回调看起来更像观察者模式。

应用举例一:JdbcTemplate

Spring提供了很多Template类,比如,JdbcTemplate、RedisTemplate、RestTemplate。尽管都叫作xxxTemplate,但它们并非基于模板模式来实现的,而是基于回调来实现的,确切地说应该是同步回调。而同步回调从应用场景上很像模板模式,所以,在命名上,这些类使用Template(模板)这个单词作为后缀。

这些Template类的设计思路都很相近,所以,我们只拿其中的JdbcTemplate来举例分析一下。对于其他Template类,你可以阅读源码自行分析。

在前面的章节中,我们也多次提到,Java提供了JDBC类库来封装不同类型的数据库操作。不过,直接使用JDBC来编写操作数据库的代码,还是有点复杂的。比如,下面这段是使用JDBC来查询用户信息的代码。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class JdbcDemo {
public User queryUser(long id) {
Connection conn = null;
Statement stmt = null;
try {
//1.加载驱动
Class.forName("com.mysql.jdbc.Driver");
conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/demo", "xzg", "xzg");

//2.创建statement类对象,用来执行SQL语句
stmt = conn.createStatement();

//3.ResultSet类,用来存放获取的结果集
String sql = "select * from user where id=" + id;
ResultSet resultSet = stmt.executeQuery(sql);

String eid = null, ename = null, price = null;

while (resultSet.next()) {
User user = new User();
user.setId(resultSet.getLong("id"));
user.setName(resultSet.getString("name"));
user.setTelephone(resultSet.getString("telephone"));
return user;
}
} catch (ClassNotFoundException e) {
// TODO: log...
} catch (SQLException e) {
// TODO: log...
} finally {
if (conn != null)
try {
conn.close();
} catch (SQLException e) {
// TODO: log...
}
if (stmt != null)
try {
stmt.close();
} catch (SQLException e) {
// TODO: log...
}
}
return null;
}

}

queryUser()函数包含很多流程性质的代码,跟业务无关,比如,加载驱动、创建数据库连接、创建statement、关闭连接、关闭statement、处理异常。针对不同的SQL执行请求,这些流程性质的代码是相同的、可以复用的,我们不需要每次都重新敲一遍。

针对这个问题,Spring提供了JdbcTemplate,对JDBC进一步封装,来简化数据库编程。使用JdbcTemplate查询用户信息,我们只需要编写跟这个业务有关的代码,其中包括,查询用户的SQL语句、查询结果与User对象之间的映射关系。其他流程性质的代码都封装在了JdbcTemplate类中,不需要我们每次都重新编写。我用JdbcTemplate重写了上面的例子,代码简单了很多,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class JdbcTemplateDemo {
private JdbcTemplate jdbcTemplate;

public User queryUser(long id) {
String sql = "select * from user where id="+id;
return jdbcTemplate.query(sql, new UserRowMapper()).get(0);
}

class UserRowMapper implements RowMapper<User> {
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
user.setTelephone(rs.getString("telephone"));
return user;
}
}
}

那JdbcTemplate底层具体是如何实现的呢?我们来看一下它的源码。因为JdbcTemplate代码比较多,我只摘抄了部分相关代码,贴到了下面。其中,JdbcTemplate通过回调的机制,将不变的执行流程抽离出来,放到模板方法execute()中,将可变的部分设计成回调StatementCallback,由用户来定制。query()函数是对execute()函数的二次封装,让接口用起来更加方便。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper) throws DataAccessException {
return query(sql, new RowMapperResultSetExtractor<T>(rowMapper));
}

@Override
public <T> T query(final String sql, final ResultSetExtractor<T> rse) throws DataAccessException {
Assert.notNull(sql, "SQL must not be null");
Assert.notNull(rse, "ResultSetExtractor must not be null");
if (logger.isDebugEnabled()) {
logger.debug("Executing SQL query [" + sql + "]");
}

class QueryStatementCallback implements StatementCallback<T>, SqlProvider {
@Override
public T doInStatement(Statement stmt) throws SQLException {
ResultSet rs = null;
try {
rs = stmt.executeQuery(sql);
ResultSet rsToUse = rs;
if (nativeJdbcExtractor != null) {
rsToUse = nativeJdbcExtractor.getNativeResultSet(rs);
}
return rse.extractData(rsToUse);
}
finally {
JdbcUtils.closeResultSet(rs);
}
}
@Override
public String getSql() {
return sql;
}
}

return execute(new QueryStatementCallback());
}

@Override
public <T> T execute(StatementCallback<T> action) throws DataAccessException {
Assert.notNull(action, "Callback object must not be null");

Connection con = DataSourceUtils.getConnection(getDataSource());
Statement stmt = null;
try {
Connection conToUse = con;
if (this.nativeJdbcExtractor != null &&
this.nativeJdbcExtractor.isNativeConnectionNecessaryForNativeStatements()) {
conToUse = this.nativeJdbcExtractor.getNativeConnection(con);
}
stmt = conToUse.createStatement();
applyStatementSettings(stmt);
Statement stmtToUse = stmt;
if (this.nativeJdbcExtractor != null) {
stmtToUse = this.nativeJdbcExtractor.getNativeStatement(stmt);
}
T result = action.doInStatement(stmtToUse);
handleWarnings(stmt);
return result;
}
catch (SQLException ex) {
// Release Connection early, to avoid potential connection pool deadlock
// in the case when the exception translator hasn't been initialized yet.
JdbcUtils.closeStatement(stmt);
stmt = null;
DataSourceUtils.releaseConnection(con, getDataSource());
con = null;
throw getExceptionTranslator().translate("StatementCallback", getSql(action), ex);
}
finally {
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, getDataSource());
}
}

应用举例二:setClickListener()

在客户端开发中,我们经常给控件注册事件监听器,比如下面这段代码,就是在Android应用开发中,给Button控件的点击事件注册监听器。

1
2
3
4
5
6
7
Button button = (Button)findViewById(R.id.button);
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
System.out.println("I am clicked.");
}
});

从代码结构上来看,事件监听器很像回调,即传递一个包含回调函数(onClick())的对象给另一个函数。从应用场景上来看,它又很像观察者模式,即事先注册观察者(OnClickListener),当用户点击按钮的时候,发送点击事件给观察者,并且执行相应的onClick()函数。

我们前面讲到,回调分为同步回调和异步回调。这里的回调算是异步回调,我们往setOnClickListener()函数中注册好回调函数之后,并不需要等待回调函数执行。这也印证了我们前面讲的,异步回调比较像观察者模式。

应用举例三:addShutdownHook()

Hook可以翻译成“钩子”,那它跟Callback有什么区别呢?

网上有人认为Hook就是Callback,两者说的是一回事儿,只是表达不同而已。而有人觉得Hook是Callback的一种应用。Callback更侧重语法机制的描述,Hook更加侧重应用场景的描述。我个人比较认可后面一种说法。不过,这个也不重要,我们只需要见了代码能认识,遇到场景会用就可以了。

Hook比较经典的应用场景是Tomcat和JVM的shutdown hook。接下来,我们拿JVM来举例说明一下。JVM提供了Runtime.addShutdownHook(Thread hook)方法,可以注册一个JVM关闭的Hook。当应用程序关闭的时候,JVM会自动调用Hook代码。代码示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ShutdownHookDemo {

private static class ShutdownHook extends Thread {
public void run() {
System.out.println("I am called during shutting down.");
}
}

public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new ShutdownHook());
}

}

我们再来看addShutdownHook()的代码实现,如下所示。这里我只给出了部分相关代码。

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
public class Runtime {
public void addShutdownHook(Thread hook) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission("shutdownHooks"));
}
ApplicationShutdownHooks.add(hook);
}
}

class ApplicationShutdownHooks {
/* The set of registered hooks */
private static IdentityHashMap<Thread, Thread> hooks;
static {
hooks = new IdentityHashMap<>();
} catch (IllegalStateException e) {
hooks = null;
}
}

static synchronized void add(Thread hook) {
if(hooks == null)
throw new IllegalStateException("Shutdown in progress");

if (hook.isAlive())
throw new IllegalArgumentException("Hook already running");

if (hooks.containsKey(hook))
throw new IllegalArgumentException("Hook previously registered");

hooks.put(hook, hook);
}

static void runHooks() {
Collection<Thread> threads;
synchronized(ApplicationShutdownHooks.class) {
threads = hooks.keySet();
hooks = null;
}

for (Thread hook : threads) {
hook.start();
}
for (Thread hook : threads) {
while (true) {
try {
hook.join();
break;
} catch (InterruptedException ignored) {
}
}
}
}
}

从代码中我们可以发现,有关Hook的逻辑都被封装到ApplicationShutdownHooks类中了。当应用程序关闭的时候,JVM会调用这个类的runHooks()方法,创建多个线程,并发地执行多个Hook。我们在注册完Hook之后,并不需要等待Hook执行完成,所以,这也算是一种异步回调。

模板模式 VS 回调

回调的原理、实现和应用到此就都讲完了。接下来,我们从应用场景和代码实现两个角度,来对比一下模板模式和回调。

从应用场景上来看,同步回调跟模板模式几乎一致。它们都是在一个大的算法骨架中,自由替换其中的某个步骤,起到代码复用和扩展的目的。而异步回调跟模板模式有较大差别,更像是观察者模式。

从代码实现上来看,回调和模板模式完全不同。回调基于组合关系来实现,把一个对象传递给另一个对象,是一种对象之间的关系;模板模式基于继承关系来实现,子类重写父类的抽象方法,是一种类之间的关系。

前面我们也讲到,组合优于继承。实际上,这里也不例外。在代码实现上,回调相对于模板模式会更加灵活,主要体现在下面几点。

  • 像Java这种只支持单继承的语言,基于模板模式编写的子类,已经继承了一个父类,不再具有继承的能力。
  • 回调可以使用匿名类来创建回调对象,可以不用事先定义类;而模板模式针对不同的实现都要定义不同的子类。
  • 如果某个类中定义了多个模板方法,每个方法都有对应的抽象方法,那即便我们只用到其中的一个模板方法,子类也必须实现所有的抽象方法。而回调就更加灵活,我们只需要往用到的模板方法中注入回调对象即可。

43. 行为型:策略模式(1)

策略模式的原理与实现

策略模式,英文全称是Strategy Design Pattern。在GoF的《设计模式》一书中,它是这样定义的:

Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.

翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。

我们知道,工厂模式是解耦对象的创建和使用,观察者模式是解耦观察者和被观察者。策略模式跟两者类似,也能起到解耦的作用,不过,它解耦的是策略的定义、创建、使用这三部分。接下来,我就详细讲讲一个完整的策略模式应该包含的这三个部分。

1.策略的定义

策略类的定义比较简单,包含一个策略接口和一组实现这个接口的策略类。因为所有的策略类都实现相同的接口,所以,客户端代码基于接口而非实现编程,可以灵活地替换不同的策略。示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public interface Strategy {
void algorithmInterface();
}

public class ConcreteStrategyA implements Strategy {
@Override
public void algorithmInterface() {
//具体的算法...
}
}

public class ConcreteStrategyB implements Strategy {
@Override
public void algorithmInterface() {
//具体的算法...
}
}

2.策略的创建

因为策略模式会包含一组策略,在使用它们的时候,一般会通过类型(type)来判断创建哪个策略来使用。为了封装创建逻辑,我们需要对客户端代码屏蔽创建细节。我们可以把根据type创建策略的逻辑抽离出来,放到工厂类中。示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StrategyFactory {
private static final Map<String, Strategy> strategies = new HashMap<>();

static {
strategies.put("A", new ConcreteStrategyA());
strategies.put("B", new ConcreteStrategyB());
}

public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
return strategies.get(type);
}
}

一般来讲,如果策略类是无状态的,不包含成员变量,只是纯粹的算法实现,这样的策略对象是可以被共享使用的,不需要在每次调用getStrategy()的时候,都创建一个新的策略对象。针对这种情况,我们可以使用上面这种工厂类的实现方式,事先创建好每个策略对象,缓存到工厂类中,用的时候直接返回。

相反,如果策略类是有状态的,根据业务场景的需要,我们希望每次从工厂方法中,获得的都是新创建的策略对象,而不是缓存好可共享的策略对象,那我们就需要按照如下方式来实现策略工厂类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StrategyFactory {
public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}

if (type.equals("A")) {
return new ConcreteStrategyA();
} else if (type.equals("B")) {
return new ConcreteStrategyB();
}

return null;
}
}

3.策略的使用

刚刚讲了策略的定义和创建,现在,我们再来看一下,策略的使用。

我们知道,策略模式包含一组可选策略,客户端代码一般如何确定使用哪个策略呢?最常见的是运行时动态确定使用哪种策略,这也是策略模式最典型的应用场景。

这里的“运行时动态”指的是,我们事先并不知道会使用哪个策略,而是在程序运行期间,根据配置、用户输入、计算结果等这些不确定因素,动态决定使用哪种策略。接下来,我们通过一个例子来解释一下。

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
34
35
36
37
// 策略接口:EvictionStrategy
// 策略类:LruEvictionStrategy、FifoEvictionStrategy、LfuEvictionStrategy...
// 策略工厂:EvictionStrategyFactory

public class UserCache {
private Map<String, User> cacheData = new HashMap<>();
private EvictionStrategy eviction;

public UserCache(EvictionStrategy eviction) {
this.eviction = eviction;
}

//...
}

// 运行时动态确定,根据配置文件的配置决定使用哪种策略
public class Application {
public static void main(String[] args) throws Exception {
EvictionStrategy evictionStrategy = null;
Properties props = new Properties();
props.load(new FileInputStream("./config.properties"));
String type = props.getProperty("eviction_type");
evictionStrategy = EvictionStrategyFactory.getEvictionStrategy(type);
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}

// 非运行时动态确定,在代码中指定使用哪种策略
public class Application {
public static void main(String[] args) {
//...
EvictionStrategy evictionStrategy = new LruEvictionStrategy();
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}

从上面的代码中,我们也可以看出,“非运行时动态确定”,也就是第二个Application中的使用方式,并不能发挥策略模式的优势。在这种应用场景下,策略模式实际上退化成了“面向对象的多态特性”或“基于接口而非实现编程原则”。

如何利用策略模式避免分支判断?

实际上,能够移除分支判断逻辑的模式不仅仅有策略模式,后面我们要讲的状态模式也可以。对于使用哪种模式,具体还要看应用场景来定。 策略模式适用于根据不同类型的动态,决定使用哪种策略这样一种应用场景。

我们先通过一个例子来看下,if-else或switch-case分支判断逻辑是如何产生的。具体的代码如下所示。在这个例子中,我们没有使用策略模式,而是将策略的定义、创建、使用直接耦合在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OrderService {
public double discount(Order order) {
double discount = 0.0;
OrderType type = order.getType();
if (type.equals(OrderType.NORMAL)) { // 普通订单
//...省略折扣计算算法代码
} else if (type.equals(OrderType.GROUPON)) { // 团购订单
//...省略折扣计算算法代码
} else if (type.equals(OrderType.PROMOTION)) { // 促销订单
//...省略折扣计算算法代码
}
return discount;
}
}

如何来移除掉分支判断逻辑呢?那策略模式就派上用场了。我们使用策略模式对上面的代码重构,将不同类型订单的打折策略设计成策略类,并由工厂类来负责创建策略对象。具体的代码如下所示:

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
// 策略的定义
public interface DiscountStrategy {
double calDiscount(Order order);
}
// 省略NormalDiscountStrategy、GrouponDiscountStrategy、PromotionDiscountStrategy类代码...

// 策略的创建
public class DiscountStrategyFactory {
private static final Map<OrderType, DiscountStrategy> strategies = new HashMap<>();

static {
strategies.put(OrderType.NORMAL, new NormalDiscountStrategy());
strategies.put(OrderType.GROUPON, new GrouponDiscountStrategy());
strategies.put(OrderType.PROMOTION, new PromotionDiscountStrategy());
}

public static DiscountStrategy getDiscountStrategy(OrderType type) {
return strategies.get(type);
}
}

// 策略的使用
public class OrderService {
public double discount(Order order) {
OrderType type = order.getType();
DiscountStrategy discountStrategy = DiscountStrategyFactory.getDiscountStrategy(type);
return discountStrategy.calDiscount(order);
}
}

重构之后的代码就没有了if-else分支判断语句了。实际上,这得益于策略工厂类。在工厂类中,我们用Map来缓存策略,根据type直接从Map中获取对应的策略,从而避免if-else分支判断逻辑。等后面讲到使用状态模式来避免分支判断逻辑的时候,你会发现,它们使用的是同样的套路。本质上都是借助“查表法”,根据type查表(代码中的strategies就是表)替代根据type分支判断。

但是,如果业务场景需要每次都创建不同的策略对象,我们就要用另外一种工厂类的实现方式了。具体的代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class DiscountStrategyFactory {
public static DiscountStrategy getDiscountStrategy(OrderType type) {
if (type == null) {
throw new IllegalArgumentException("Type should not be null.");
}
if (type.equals(OrderType.NORMAL)) {
return new NormalDiscountStrategy();
} else if (type.equals(OrderType.GROUPON)) {
return new GrouponDiscountStrategy();
} else if (type.equals(OrderType.PROMOTION)) {
return new PromotionDiscountStrategy();
}
return null;
}
}

这种实现方式相当于把原来的if-else分支逻辑,从OrderService类中转移到了工厂类中,实际上并没有真正将它移除。关于这个问题如何解决,我今天先暂时卖个关子。你可以在留言区说说你的想法,我在下一节课中再讲解。

44. 行为型:策略模式(2)

问题与解决思路

假设有这样一个需求,希望写一个小程序,实现对一个文件进行排序的功能。文件中只包含整型数,并且,相邻的数字通过逗号来区隔。如果由你来编写这样一个小程序,你会如何来实现呢?你可以把它当作面试题,先自己思考一下,再来看我下面的讲解。

你可能会说,这不是很简单嘛,只需要将文件中的内容读取出来,并且通过逗号分割成一个一个的数字,放到内存数组中,然后编写某种排序算法(比如快排),或者直接使用编程语言提供的排序函数,对数组进行排序,最后再将数组中的数据写入文件就可以了。

但是,如果文件很大呢?比如有10GB大小,因为内存有限(比如只有8GB大小),我们没办法一次性加载文件中的所有数据到内存中,这个时候,我们就要利用外部排序算法(具体怎么做,可以参看我的另一个专栏《数据结构与算法之美》中的“排序”相关章节)了。

如果文件更大,比如有100GB大小,我们为了利用CPU多核的优势,可以在外部排序的基础之上进行优化,加入多线程并发排序的功能,这就有点类似“单机版”的MapReduce。

如果文件非常大,比如有1TB大小,即便是单机多线程排序,这也算很慢了。这个时候,我们可以使用真正的MapReduce框架,利用多机的处理能力,提高排序的效率。

代码实现与分析

解决思路讲完了,不难理解。接下来,我们看一下,如何将解决思路翻译成代码实现。

我先用最简单直接的方式将它实现出来。具体代码我贴在下面了,你可以先看一下。因为我们是在讲设计模式,不是讲算法,所以,在下面的代码实现中,我只给出了跟设计模式相关的骨架代码,并没有给出每种排序算法的具体代码实现。感兴趣的话,你可以自行实现一下。

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
34
35
36
37
38
39
40
41
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;

public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
if (fileSize < 6 * GB) { // [0, 6GB)
quickSort(filePath);
} else if (fileSize < 10 * GB) { // [6GB, 10GB)
externalSort(filePath);
} else if (fileSize < 100 * GB) { // [10GB, 100GB)
concurrentExternalSort(filePath);
} else { // [100GB, ~)
mapreduceSort(filePath);
}
}

private void quickSort(String filePath) {
// 快速排序
}

private void externalSort(String filePath) {
// 外部排序
}

private void concurrentExternalSort(String filePath) {
// 多线程外部排序
}

private void mapreduceSort(String filePath) {
// 利用MapReduce多机排序
}
}

public class SortingTool {
public static void main(String[] args) {
Sorter sorter = new Sorter();
sorter.sortFile(args[0]);
}
}

在“编码规范”那一部分我们讲过,函数的行数不能过多,最好不要超过一屏的大小。所以,为了避免sortFile()函数过长,我们把每种排序算法从sortFile()函数中抽离出来,拆分成4个独立的排序函数。

如果只是开发一个简单的工具,那上面的代码实现就足够了。毕竟,代码不多,后续修改、扩展的需求也不多,怎么写都不会导致代码不可维护。但是,如果我们是在开发一个大型项目,排序文件只是其中的一个功能模块,那我们就要在代码设计、代码质量上下点儿功夫了。只有每个小的功能模块都写好,整个项目的代码才能不差。

在刚刚的代码中,我们并没有给出每种排序算法的代码实现。实际上,如果自己实现一下的话,你会发现,每种排序算法的实现逻辑都比较复杂,代码行数都比较多。所有排序算法的代码实现都堆在Sorter一个类中,这就会导致这个类的代码很多。而在“编码规范”那一部分中,我们也讲到,一个类的代码太多也会影响到可读性、可维护性。除此之外,所有的排序算法都设计成Sorter的私有函数,也会影响代码的可复用性。

代码优化与重构

只要掌握了我们之前讲过的设计原则和思想,针对上面的问题,即便我们想不到该用什么设计模式来重构,也应该能知道该如何解决,那就是将Sorter类中的某些代码拆分出来,独立成职责更加单一的小类。实际上,拆分是应对类或者函数代码过多、应对代码复杂性的一个常用手段。按照这个解决思路,我们对代码进行重构。重构之后的代码如下所示:

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
public interface ISortAlg {
void sort(String filePath);
}

public class QuickSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}

public class ExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}

public class ConcurrentExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}

public class MapReduceSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}

public class Sorter {
private static final long GB = 1000 * 1000 * 1000;

public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize < 6 * GB) { // [0, 6GB)
sortAlg = new QuickSort();
} else if (fileSize < 10 * GB) { // [6GB, 10GB)
sortAlg = new ExternalSort();
} else if (fileSize < 100 * GB) { // [10GB, 100GB)
sortAlg = new ConcurrentExternalSort();
} else { // [100GB, ~)
sortAlg = new MapReduceSort();
}
sortAlg.sort(filePath);
}
}

经过拆分之后,每个类的代码都不会太多,每个类的逻辑都不会太复杂,代码的可读性、可维护性提高了。除此之外,我们将排序算法设计成独立的类,跟具体的业务逻辑(代码中的if-else那部分逻辑)解耦,也让排序算法能够复用。这一步实际上就是策略模式的第一步,也就是将策略的定义分离出来。

实际上,上面的代码还可以继续优化。每种排序类都是无状态的,我们没必要在每次使用的时候,都重新创建一个新的对象。所以,我们可以使用工厂模式对对象的创建进行封装。按照这个思路,我们对代码进行重构。重构之后的代码如下所示:

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
34
35
36
37
38
public class SortAlgFactory {
private static final Map<String, ISortAlg> algs = new HashMap<>();

static {
algs.put("QuickSort", new QuickSort());
algs.put("ExternalSort", new ExternalSort());
algs.put("ConcurrentExternalSort", new ConcurrentExternalSort());
algs.put("MapReduceSort", new MapReduceSort());
}

public static ISortAlg getSortAlg(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException("type should not be empty.");
}
return algs.get(type);
}
}

public class Sorter {
private static final long GB = 1000 * 1000 * 1000;

public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize < 6 * GB) { // [0, 6GB)
sortAlg = SortAlgFactory.getSortAlg("QuickSort");
} else if (fileSize < 10 * GB) { // [6GB, 10GB)
sortAlg = SortAlgFactory.getSortAlg("ExternalSort");
} else if (fileSize < 100 * GB) { // [10GB, 100GB)
sortAlg = SortAlgFactory.getSortAlg("ConcurrentExternalSort");
} else { // [100GB, ~)
sortAlg = SortAlgFactory.getSortAlg("MapReduceSort");
}
sortAlg.sort(filePath);
}
}

经过上面两次重构之后,现在的代码实际上已经符合策略模式的代码结构了。我们通过策略模式将策略的定义、创建、使用解耦,让每一部分都不至于太复杂。不过,Sorter类中的sortFile()函数还是有一堆if-else逻辑。这里的if-else逻辑分支不多、也不复杂,这样写完全没问题。但如果你特别想将if-else分支判断移除掉,那也是有办法的。我直接给出代码,你一看就能明白。实际上,这也是基于查表法来解决的,其中的“algs”就是“表”。

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
34
35
36
37
38
39
40
41
42
43
44
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
private static final List<AlgRange> algs = new ArrayList<>();
static {
algs.add(new AlgRange(0, 6*GB, SortAlgFactory.getSortAlg("QuickSort")));
algs.add(new AlgRange(6*GB, 10*GB, SortAlgFactory.getSortAlg("ExternalSort")));
algs.add(new AlgRange(10*GB, 100*GB, SortAlgFactory.getSortAlg("ConcurrentExternalSort")));
algs.add(new AlgRange(100*GB, Long.MAX_VALUE, SortAlgFactory.getSortAlg("MapReduceSort")));
}

public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg = null;
for (AlgRange algRange : algs) {
if (algRange.inRange(fileSize)) {
sortAlg = algRange.getAlg();
break;
}
}
sortAlg.sort(filePath);
}

private static class AlgRange {
private long start;
private long end;
private ISortAlg alg;

public AlgRange(long start, long end, ISortAlg alg) {
this.start = start;
this.end = end;
this.alg = alg;
}

public ISortAlg getAlg() {
return alg;
}

public boolean inRange(long size) {
return size >= start && size < end;
}
}
}

现在的代码实现就更加优美了。我们把可变的部分隔离到了策略工厂类和Sorter类中的静态代码段中。当要添加一个新的排序算法时,我们只需要修改策略工厂类和Sort类中的静态代码段,其他代码都不需要修改,这样就将代码改动最小化、集中化了。

你可能会说,即便这样,当我们添加新的排序算法的时候,还是需要修改代码,并不完全符合开闭原则。有什么办法让我们完全满足开闭原则呢?

对于Java语言来说,我们可以通过反射来避免对策略工厂类的修改。具体是这么做的:我们通过一个配置文件或者自定义的annotation来标注都有哪些策略类;策略工厂类读取配置文件或者搜索被annotation标注的策略类,然后通过反射动态地加载这些策略类、创建策略对象;当我们新添加一个策略的时候,只需要将这个新添加的策略类添加到配置文件或者用annotation标注即可。还记得上一节课的课堂讨论题吗?我们也可以用这种方法来解决。

对于Sorter来说,我们可以通过同样的方法来避免修改。我们通过将文件大小区间和算法之间的对应关系放到配置文件中。当添加新的排序算法时,我们只需要改动配置文件即可,不需要改动代码。

99. 总结

img

一、创建型设计模式

创建型设计模式包括:单例模式、工厂模式、建造者模式、原型模式。它主要解决对象的创建问题,封装复杂的创建过程,解耦对象的创建代码和使用代码。

1.单例模式

单例模式用来创建全局唯一的对象。一个类只允许创建一个对象(或者叫实例),那这个类就是一个单例类,这种设计模式就叫作单例模式。单例有几种经典的实现方式,它们分别是:饿汉式、懒汉式、双重检测、静态内部类、枚举。

尽管单例是一个很常用的设计模式,在实际的开发中,我们也确实经常用到它,但是,有些人认为单例是一种反模式(anti-pattern),并不推荐使用,主要的理由有以下几点:

  • 单例对OOP特性的支持不友好
  • 单例会隐藏类之间的依赖关系
  • 单例对代码的扩展性不友好
  • 单例对代码的可测试性不友好
  • 单例不支持有参数的构造函数

那有什么替代单例的解决方案呢?如果要完全解决这些问题,我们可能要从根上寻找其他方式来实现全局唯一类。比如,通过工厂模式、IOC容器来保证全局唯一性。

有人把单例当作反模式,主张杜绝在项目中使用。我个人觉得这有点极端。模式本身没有对错,关键看你怎么用。如果单例类并没有后续扩展的需求,并且不依赖外部系统,那设计成单例类就没有太大问题。对于一些全局类,我们在其他地方new的话,还要在类之间传来传去,不如直接做成单例类,使用起来简洁方便。

除此之外,我们还讲到了进程唯一单例、线程唯一单例、集群唯一单例、多例等扩展知识点,这一部分在实际的开发中并不会被用到,但是可以扩展你的思路、锻炼你的逻辑思维。这里我就不带你回顾了,你可以自己回忆一下。

2.工厂模式

工厂模式包括简单工厂、工厂方法、抽象工厂这3种细分模式。其中,简单工厂和工厂方法比较常用,抽象工厂的应用场景比较特殊,所以很少用到,不是我们学习的重点。

工厂模式用来创建不同但是相关类型的对象(继承同一父类或者接口的一组子类),由给定的参数来决定创建哪种类型的对象。实际上,如果创建对象的逻辑并不复杂,那我们直接通过new来创建对象就可以了,不需要使用工厂模式。当创建逻辑比较复杂,是一个“大工程”的时候,我们就考虑使用工厂模式,封装对象的创建过程,将对象的创建和使用相分离。

当每个对象的创建逻辑都比较简单的时候,我推荐使用简单工厂模式,将多个对象的创建逻辑放到一个工厂类中。当每个对象的创建逻辑都比较复杂的时候,为了避免设计一个过于庞大的工厂类,我们推荐使用工厂方法模式,将创建逻辑拆分得更细,每个对象的创建逻辑独立到各自的工厂类中。

详细点说,工厂模式的作用有下面4个,这也是判断要不要使用工厂模式最本质的参考标准。

  • 封装变化:创建逻辑有可能变化,封装成工厂类之后,创建逻辑的变更对调用者透明。
  • 代码复用:创建代码抽离到独立的工厂类之后可以复用。
  • 隔离复杂性:封装复杂的创建逻辑,调用者无需了解如何创建对象。
  • 控制复杂度:将创建代码抽离出来,让原本的函数或类职责更单一,代码更简洁。

除此之外,我们还讲了工厂模式一个非常经典的应用场景:依赖注入框架,比如Spring IOC、Google Guice,它用来集中创建、组装、管理对象,跟具体业务代码解耦,让程序员聚焦在业务代码的开发上。DI框架已经成为了我们平时开发的必备框架,在专栏中,我还带你实现了一个简单的DI框架,你可以再回过头去看看。

3.建造者模式

建造者模式用来创建复杂对象,可以通过设置不同的可选参数,“定制化”地创建不同的对象。建造者模式的原理和实现比较简单,重点是掌握应用场景,避免过度使用。

如果一个类中有很多属性,为了避免构造函数的参数列表过长,影响代码的可读性和易用性,我们可以通过构造函数配合set()方法来解决。但是,如果存在下面情况中的任意一种,我们就要考虑使用建造者模式了。

  • 我们把类的必填属性放到构造函数中,强制创建对象的时候就设置。如果必填的属性有很多,把这些必填属性都放到构造函数中设置,那构造函数就又会出现参数列表很长的问题。如果我们把必填属性通过set()方法设置,那校验这些必填属性是否已经填写的逻辑就无处安放了。
  • 如果类的属性之间有一定的依赖关系或者约束条件,我们继续使用构造函数配合set()方法的设计思路,那这些依赖关系或约束条件的校验逻辑就无处安放了。
  • 如果我们希望创建不可变对象,也就是说,对象在创建好之后,就不能再修改内部的属性值,要实现这个功能,我们就不能在类中暴露set()方法。构造函数配合set()方法来设置属性值的方式就不适用了。

4.原型模式

如果对象的创建成本比较大,而同一个类的不同对象之间差别不大(大部分字段都相同),在这种情况下,我们可以利用对已有对象(原型)进行复制(或者叫拷贝)的方式,来创建新对象,以达到节省创建时间的目的。这种基于原型来创建对象的方式就叫作原型模式。

原型模式有两种实现方法,深拷贝和浅拷贝。浅拷贝只会复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的引用对象……而深拷贝得到的是一份完完全全独立的对象。所以,深拷贝比起浅拷贝来说,更加耗时,更加耗内存空间。

如果要拷贝的对象是不可变对象,浅拷贝共享不可变对象是没问题的,但对于可变对象来说,浅拷贝得到的对象和原始对象会共享部分数据,就有可能出现数据被修改的风险,也就变得复杂多了。操作非常耗时的情况下,我们比较推荐使用浅拷贝,否则,没有充分的理由,不要为了一点点的性能提升而使用浅拷贝。

二、结构型设计模式

结构型模式主要总结了一些类或对象组合在一起的经典结构,这些经典的结构可以解决特定应用场景的问题。结构型模式包括:代理模式、桥接模式、装饰器模式、适配器模式、门面模式、组合模式、享元模式。

1.代理模式

代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同。一般情况下,我们让代理类和原始类实现同样的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的。在这种情况下,我们可以通过让代理类继承原始类的方法来实现代理模式。

静态代理需要针对每个类都创建一个代理类,并且每个代理类中的代码都有点像模板式的“重复”代码,增加了维护成本和开发成本。对于静态代理存在的问题,我们可以通过动态代理来解决。我们不事先为每个原始类编写代理类,而是在运行的时候动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。

代理模式常用在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。我们将这些附加功能与业务功能解耦,放到代理类统一处理,让程序员只需要关注业务方面的开发。除此之外,代理模式还可以用在RPC、缓存等应用场景中。

2.桥接模式

桥接模式的代码实现非常简单,但是理解起来稍微有点难度,并且应用场景也比较局限,所以,相对来说,桥接模式在实际的项目中并没有那么常用,你只需要简单了解,见到能认识就可以了,并不是我们学习的重点。

桥接模式有两种理解方式。第一种理解方式是“将抽象和实现解耦,让它们能独立开发”。这种理解方式比较特别,应用场景也不多。另一种理解方式更加简单,等同于“组合优于继承”设计原则,这种理解方式更加通用,应用场景比较多。不管是哪种理解方式,它们的代码结构都是相同的,都是一种类之间的组合关系。

对于第一种理解方式,弄懂定义中“抽象”和“实现”两个概念,是理解它的关键。定义中的“抽象”,指的并非“抽象类”或“接口”,而是被抽象出来的一套“类库”,它只包含骨架代码,真正的业务逻辑需要委派给定义中的“实现”来完成。而定义中的“实现”,也并非“接口的实现类”,而是的一套独立的“类库”。“抽象”和“实现”独立开发,通过对象之间的组合关系组装在一起。

3.装饰器模式

装饰器模式主要解决继承关系过于复杂的问题,通过组合来替代继承,给原始类添加增强功能。这也是判断是否该用装饰器模式的一个重要的依据。除此之外,装饰器模式还有一个特点,那就是可以对原始类嵌套使用多个装饰器。为了满足这样的需求,在设计的时候,装饰器类需要跟原始类继承相同的抽象类或者接口。

4.适配器模式

代理模式、装饰器模式提供的都是跟原始类相同的接口,而适配器提供跟原始类不同的接口。适配器模式是用来做适配的,它将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作。适配器模式有两种实现方式:类适配器和对象适配器。其中,类适配器使用继承关系来实现,对象适配器使用组合关系来实现。

适配器模式是一种事后的补救策略,用来补救设计上的缺陷。应用这种模式算是“无奈之举”。如果在设计初期,我们就能规避接口不兼容的问题,那这种模式就无用武之地了。在实际的开发中,什么情况下才会出现接口不兼容呢?我总结下了下面这5种场景:

  • 封装有缺陷的接口设计
  • 统一多个类的接口设计
  • 替换依赖的外部系统
  • 兼容老版本接口
  • 适配不同格式的数据

5.门面模式

门面模式原理、实现都非常简单,应用场景比较明确。它通过封装细粒度的接口,提供组合各个细粒度接口的高层次接口,来提高接口的易用性,或者解决性能、分布式事务等问题。

6.组合模式

组合模式跟我们之前讲的面向对象设计中的“组合关系(通过组合来组装两个类)”,完全是两码事。这里讲的“组合模式”,主要是用来处理树形结构数据。正因为其应用场景的特殊性,数据必须能表示成树形结构,这也导致了这种模式在实际的项目开发中并不那么常用。但是,一旦数据满足树形结构,应用这种模式就能发挥很大的作用,能让代码变得非常简洁。

组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务需求可以通过在树上的递归遍历算法来实现。组合模式,将一组对象组织成树形结构,将单个对象和组合对象都看作树中的节点,以统一处理逻辑,并且它利用树形结构的特点,递归地处理每个子树,依次简化代码实现。

7.享元模式

所谓“享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象。

具体来讲,当一个系统中存在大量重复对象的时候,我们就可以利用享元模式,将对象设计成享元,在内存中只保留一份实例,供多处代码引用,这样可以减少内存中对象的数量,以起到节省内存的目的。实际上,不仅仅相同对象可以设计成享元,对于相似对象,我们也可以将这些对象中相同的部分(字段),提取出来设计成享元,让这些大量相似对象引用这些享元。

三、行为型设计模式

我们知道,创建型设计模式主要解决“对象的创建”问题,结构型设计模式主要解决“类或对象的组合”问题,那行为型设计模式主要解决的就是“类或对象之间的交互”问题。行为型模式比较多,有11种,它们分别是:观察者模式、模板模式、策略模式、职责链模式、迭代器模式、状态模式、访问者模式、备忘录模式、命令模式、解释器模式、中介模式。

1.观察者模式

观察者模式将观察者和被观察者代码解耦。观察者模式的应用场景非常广泛,小到代码层面的解耦,大到架构层面的系统解耦,再或者一些产品的设计思路,都有这种模式的影子,比如,邮件订阅、RSS Feeds,本质上都是观察者模式。

不同的应用场景和需求下,这个模式也有截然不同的实现方式:有同步阻塞的实现方式,也有异步非阻塞的实现方式;有进程内的实现方式,也有跨进程的实现方式。同步阻塞是最经典的实现方式,主要是为了代码解耦;异步非阻塞除了能实现代码解耦之外,还能提高代码的执行效率;进程间的观察者模式解耦更加彻底,一般是基于消息队列来实现,用来实现不同进程间的被观察者和观察者之间的交互。

框架的作用有隐藏实现细节,降低开发难度,实现代码复用,解耦业务与非业务代码,让程序员聚焦业务开发。针对异步非阻塞观察者模式,我们也可以将它抽象成EventBus框架来达到这样的效果。EventBus翻译为“事件总线”,它提供了实现观察者模式的骨架代码。我们可以基于此框架非常容易地在自己的业务场景中实现观察者模式,不需要从零开始开发。

2.模板模式

模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。

模板模式有两大作用:复用和扩展。其中复用指的是,所有的子类可以复用父类中提供的模板方法的代码。扩展指的是,框架通过模板模式提供功能扩展点,让框架用户可以在不修改框架源码的情况下,基于扩展点定制化框架的功能。

除此之外,我们还讲到回调。它跟模板模式具有相同的作用:代码复用和扩展。在一些框架、类库、组件等的设计中经常会用到,比如JdbcTemplate就是用了回调。

相对于普通的函数调用,回调是一种双向调用关系。A类事先注册某个函数F到B类,A类在调用B类的P函数的时候,B类反过来调用A类注册给它的F函数。这里的F函数就是“回调函数”。A调用B,B反过来又调用A,这种调用机制就叫作“回调”。

回调可以细分为同步回调和异步回调。从应用场景上来看,同步回调看起来更像模板模式,异步回调看起来更像观察者模式。回调跟模板模式的区别,更多的是在代码实现上,而非应用场景上。回调基于组合关系来实现,模板模式基于继承关系来实现。回调比模板模式更加灵活。

3.策略模式

策略模式定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。策略模式用来解耦策略的定义、创建、使用。实际上,一个完整的策略模式就是由这三个部分组成的。

策略类的定义比较简单,包含一个策略接口和一组实现这个接口的策略类。策略的创建由工厂类来完成,封装策略创建的细节。策略模式包含一组策略可选,客户端代码选择使用哪个策略,有两种确定方法:编译时静态确定和运行时动态确定。其中,“运行时动态确定”才是策略模式最典型的应用场景。

在实际的项目开发中,策略模式也比较常用。最常见的应用场景是,利用它来避免冗长的if-else或switch分支判断。不过,它的作用还不止如此。它也可以像模板模式那样,提供框架的扩展点等等。实际上,策略模式主要的作用还是解耦策略的定义、创建和使用,控制代码的复杂度,让每个部分都不至于过于复杂、代码量过多。除此之外,对于复杂代码来说,策略模式还能让其满足开闭原则,添加新策略的时候,最小化、集中化代码改动,减少引入bug的风险。

4.职责链模式

在职责链模式中,多个处理器依次处理同一个请求。一个请求先经过A处理器处理,然后再把请求传递给B处理器,B处理器处理完后再传递给C处理器,以此类推,形成一个链条。链条上的每个处理器各自承担各自的处理职责,所以叫作职责链模式。

在GoF的定义中,一旦某个处理器能处理这个请求,就不会继续将请求传递给后续的处理器了。当然,在实际的开发中,也存在对这个模式的变体,那就是请求不会中途终止传递,而是会被所有的处理器都处理一遍。

职责链模式常用在框架开发中,用来实现过滤器、拦截器功能,让框架的使用者在不需要修改框架源码的情况下,添加新的过滤、拦截功能。这也体现了之前讲到的对扩展开放、对修改关闭的设计原则。

5.迭代器模式

迭代器模式也叫游标模式,它用来遍历集合对象。这里说的“集合对象”,我们也可以叫“容器”“聚合对象”,实际上就是包含一组对象的对象,比如,数组、链表、树、图、跳表。迭代器模式主要作用是解耦容器代码和遍历代码。大部分编程语言都提供了现成的迭代器可以使用,我们不需要从零开始开发。

遍历集合一般有三种方式:for循环、foreach循环、迭代器遍历。后两种本质上属于一种,都可以看作迭代器遍历。相对于for循环遍历,利用迭代器来遍历有3个优势:

  • 迭代器模式封装集合内部的复杂数据结构,开发者不需要了解如何遍历,直接使用容器提供的迭代器即可;
  • 迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一;
  • 迭代器模式让添加新的遍历算法更加容易,更符合开闭原则。除此之外,因为迭代器都实现自相同的接口,在开发中,基于接口而非实现编程,替换迭代器也变得更加容易。

在通过迭代器来遍历集合元素的同时,增加或者删除集合中的元素,有可能会导致某个元素被重复遍历或遍历不到。针对这个问题,有两种比较干脆利索的解决方案,来避免出现这种不可预期的运行结果。一种是遍历的时候不允许增删元素,另一种是增删元素之后让遍历报错。第一种解决方案比较难实现,因为很难确定迭代器使用结束的时间点。第二种解决方案更加合理,Java语言就是采用的这种解决方案。增删元素之后,我们选择fail-fast解决方式,让遍历操作直接抛出运行时异常。

6.状态模式

状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。状态机又叫有限状态机,它由3个部分组成:状态、事件、动作。其中,事件也称为转移条件。事件触发状态的转移及动作的执行。不过,动作不是必须的,也可能只转移状态,不执行任何动作。

针对状态机,我们总结了三种实现方式。

第一种实现方式叫分支逻辑法。利用if-else或者switch-case分支逻辑,参照状态转移图,将每一个状态转移原模原样地直译成代码。对于简单的状态机来说,这种实现方式最简单、最直接,是首选。

第二种实现方式叫查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维数组来表示状态转移图,能极大地提高代码的可读性和可维护性。

第三种实现方式就是利用状态模式。对于状态并不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,我们首选这种实现方式。

7.访问者模式

访问者模式允许一个或者多个操作应用到一组对象上,设计意图是解耦操作和对象本身,保持类职责单一、满足开闭原则以及应对代码的复杂性。

对于访问者模式,学习的主要难点在代码实现。而代码实现比较复杂的主要原因是,函数重载在大部分面向对象编程语言中是静态绑定的。也就是说,调用类的哪个重载函数,是在编译期间,由参数的声明类型决定的,而非运行时,根据参数的实际类型决定的。除此之外,我们还讲到Double Disptach。如果某种语言支持Double Dispatch,那就不需要访问者模式了。

正是因为代码实现难理解,所以,在项目中应用这种模式,会导致代码的可读性比较差。如果你的同事不了解这种设计模式,可能就会读不懂、维护不了你写的代码。所以,除非不得已,不要使用这种模式。

8.备忘录模式

备忘录模式也叫快照模式,具体来说,就是在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。这个模式的定义表达了两部分内容:一部分是,存储副本以便后期恢复;另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。

备忘录模式的应用场景也比较明确和有限,主要用来防丢失、撤销、恢复等。它跟平时我们常说的“备份”很相似。两者的主要区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。

对于大对象的备份来说,备份占用的存储空间会比较大,备份和恢复的耗时会比较长。针对这个问题,不同的业务场景有不同的处理方式。比如,只备份必要的恢复信息,结合最新的数据来恢复;再比如,全量备份和增量备份相结合,低频全量备份,高频增量备份,两者结合来做恢复。

9.命令模式

命令模式在平时工作中并不常用,你稍微了解一下就可以。

落实到编码实现,命令模式用到最核心的实现手段,就是将函数封装成对象。我们知道,在大部分编程语言中,函数是没法作为参数传递给其他函数的,也没法赋值给变量。借助命令模式,我们将函数封装成对象,这样就可以实现把函数像对象一样使用。

命令模式的主要作用和应用场景,是用来控制命令的执行,比如,异步、延迟、排队执行命令、撤销重做命令、存储命令、给命令记录日志等,这才是命令模式能发挥独一无二作用的地方。

10.解释器模式

解释器模式为某个语言定义它的语法(或者叫文法)表示,并定义一个解释器用来处理这个语法。实际上,这里的“语言”不仅仅指我们平时说的中、英、日、法等各种语言。从广义上来讲,只要是能承载信息的载体,我们都可以称之为“语言”,比如,古代的结绳记事、盲文、哑语、摩斯密码等。

要想了解“语言”要表达的信息,我们就必须定义相应的语法规则。这样,书写者就可以根据语法规则来书写“句子”(专业点的叫法应该是“表达式”),阅读者根据语法规则来阅读“句子”,这样才能做到信息的正确传递。而我们要讲的解释器模式,其实就是用来实现根据语法规则解读“句子”的解释器。

解释器模式的代码实现比较灵活,没有固定的模板。我们前面说过,应用设计模式主要是应对代码的复杂性,解释器模式也不例外。它的代码实现的核心思想,就是将语法解析的工作拆分到各个小类中,以此来避免大而全的解析类。一般的做法是,将语法规则拆分一些小的独立的单元,然后对每个单元进行解析,最终合并为对整个语法规则的解析。

11.中介模式

中介模式的设计思想跟中间层很像,通过引入中介这个中间层,将一组对象之间的交互关系(或者说依赖关系)从多对多(网状关系)转换为一对多(星状关系)。原来一个对象要跟n个对象交互,现在只需要跟一个中介对象交互,从而最小化对象之间的交互关系,降低了代码的复杂度,提高了代码的可读性和可维护性。

观察者模式和中介模式都是为了实现参与者之间的解耦,简化交互关系。两者的不同在于应用场景上。在观察者模式的应用场景中,参与者之间的交互比较有条理,一般都是单向的,一个参与者只有一个身份,要么是观察者,要么是被观察者。而在中介模式的应用场景中,参与者之间的交互关系错综复杂,既可以是消息的发送者、也可以同时是消息的接收者。

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022 ZHU
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信