23.1 我要投递信件

我们都写过纸质信件吧,比如给女朋友写情书什么的。写信的过程大家应该都还记得 ——先写信的内容,然后写信封,再把信放到信封中,封好,投递到信箱中进行邮递,这个过程还是比较简单的,虽然简单,但是这4个步骤都不可或缺!我们先把这个过程通过程序实现出来,如图23-1所示。

image-20210929203017422

图23-1 写信过程类图

这一个过程还是比较简单的,我们看程序的实现,先看接口,如代码清单23-1所示。

代码清单23-1 写信过程接口

1
2
3
4
5
6
7
8
9
10
public interface ILetterProcess {
//首先要写信的内容
public void writeContext(String context);
//其次写信封
public void fillEnvelope(String address);
//把信放到信封里
public void letterInotoEnvelope();
//然后邮递
public void sendLetter();
}

在接口中定义了完成的一个写信过程,这个过程需要实现,其实现类如代码清单23-2所示。

代码清单23-2 写信过程的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class LetterProcessImpl implements ILetterProcess {
//写信
public void writeContext(String context) {
System.out.println("填写信的内容..." + context);
}
//在信封上填写必要的信息
public void fillEnvelope(String address) {
System.out.println("填写收件人地址及姓名..." + address);
}
//把信放到信封中,并封好
public void letterInotoEnvelope() {
System.out.println("把信放到信封中...");
}
//塞到邮箱中,邮递
public void sendLetter() {
System.out.println("邮递信件...");
}
}

在这种环境下,最累的是写信人,为了发送一封信要有4个步骤,而且这4个步骤还不能颠倒,我们先看看这个过程如何通过程序表现出来,有人开始用这个过程写信了,如代码清单23-3所示。

代码清单23-3 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Client {
public static void main(String[] args) {
//创建一个处理信件的过程
ILetterProcess letterProcess = new LetterProcessImpl();
//开始写信
letterProcess.writeContext("Hello,It's me,do you know who I am? I'm your old lover. I'd like to...");
//开始写信封
letterProcess.fillEnvelope("Happy Road No. 666,God Province,Heaven");
//把信放到信封里,并封装好
letterProcess.letterInotoEnvelope();
//跑到邮局把信塞到邮箱,投递
letterProcess.sendLetter();
}
}

运行结果如下所示:

1
2
3
4
填写信的内容...Hello,It's me,do you know who I am? I'm your old lover.I'd like to...
填写收件人地址及姓名...Happy Road No.666,God Province,Heaven
把信放到信封中...
邮递信件...

我们回过头来看看这个过程,它与高内聚的要求相差甚远,更不要说迪米特法则、接口隔离原则了。你想想,你要知道这4个步骤,而且还要知道它们的顺序,一旦出错,信就不可能邮寄出去,这在面向对象的编程中是极度地不适合,它根本就没有完成一个类所具有的单一职责。

还有,如果信件多了就非常麻烦,每封信都要这样运转一遍,非得累死,更别说要发个广告信了,那怎么办呢?还好,现在邮局开发了一个新业务,你只要把信件的必要信息告诉我,我给你发,我来完成这4个过程,只要把信件交给我就成了,其他就不要管了。非常好的方案!我们来看类图,如图23-2所示。

image-20210929211319927

图23-2 增加现代化邮局的类图

这还是比较简单的类图,增加了一个ModenPostOffice类,负责对一个比较复杂的信件处理过程的封装,然后高层模块只要和它有交互就成了,如代码清单23-4所示。

代码清单23-4 现代化邮局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ModenPostOffice {
private ILetterProcess letterProcess = new LetterProcessImpl();
//写信,封装,投递,一体化
public void sendLetter(String context,String address){
//帮你写信
letterProcess.writeContext(context);
//写好信封
letterProcess.fillEnvelope(address);
//把信放到信封中
letterProcess.letterInotoEnvelope();
//邮递信件
letterProcess.sendLetter();
}
}

这个类是什么意思呢,就是说现在有一个Hell Road PostOffice(地狱路邮局)提供了一种新型服务,客户只要把信的内容以及收信地址给他们,他们就会把信写好,封好,并发送出去。这种服务推出后大受欢迎,这多简单,客户减少了很多工作,谁不乐意呀。那我们看看客户是怎么调用的,如代码清单23-5所示。

代码清单23-5 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//现代化的邮局,有这项服务,邮局名称叫Hell
Road ModenPostOffice hellRoadPostOffice = new ModenPostOffice();
//你只要把信的内容和收信人地址给他,他会帮你完成一系列的工作
//定义一个地址
String address = "Happy Road No. 666,God Province,Heaven";
//信的内容
String context = "Hello,It's me,do you know who I am? I'm your old lover. I'd like to....";
//你给我发送吧
hellRoadPostOffice.sendLetter(context, address);
}
}

运行结果是相同的。我们看看场景类是不是简化了很多,只要与ModenPostOffice交互就 成了,其他的什么都不用管,写信封啦、写地址啦……都不用关心,只要把需要的信息提交 过去就成了,邮局保证会按照我们指定的地址把指定的内容发送出去,这种方式不仅简单, 而且扩展性还非常好,比如一个非常时期,寄往God Province(上帝省)的邮件都必须进行 安全检查,那我们就很好处理了,如图23-3所示。

image-20210929220723841

图23-3 扩展后的系统类图

增加了一个Police类,负责对信件进行检查,如代码清单23-6所示。

代码清单23-6 信件检查类

1
2
3
4
5
6
public class Police {
//检查信件,检查完毕后警察在信封上盖个戳:此信无病毒
public void checkLetter(ILetterProcess letterProcess){
System.out.println(letterProcess+" 信件已经检查过了...");
}
}

我们再来看一下封装类ModenPostOffice的变更,它封装了这部分的变化,如代码清单23-7所示。

代码清单23-7 扩展后的现代化邮局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class ModenPostOffice {
private ILetterProcess letterProcess = new LetterProcessImpl();
private Police letterPolice = new Police();
//写信,封装,投递,一体化了
public void sendLetter(String context,String address){
//帮你写信
letterProcess.writeContext(context);
//写好信封
letterProcess.fillEnvelope(address);
//警察要检查信件了
letterPolice.checkLetter(letterProcess);
//把信放到信封中
letterProcess.letterInotoEnvelope();
//邮递信件
letterProcess.sendLetter();
}
}

只是增加了一个letterPolice变量的声明以及一个方法的调用,那这个写信的过程就变成这样:先写信、写信封,然后警察开始检查,之后才把信放到信封,最后发送出去,那这个变更对客户来说是透明的,他根本就看不到有人在检查他的邮件,他也不用了解,反正现代化的邮件系统都帮他做了,这也是他乐意的地方。

场景类还是完全相同,但是运行结果稍有不同,如下所示:

1
2
3
4
5
6
填写信的内容...Hello,It's me,do you know who I am?I'm your old lover.I'd like to... 
填写收件人地址及姓名...Happy Road No.666,God Province,Heaven
com.cbf4life.common3.LetterProcessImpl@15ff48b
信件已经检查过了...
把信放到信封中...
邮递信件...

高层模块没有任何改动,但是信件却已经被检查过了。这正是我们设计所需要的模式, 不改变子系统对外暴露的接口、方法,只改变内部的处理逻辑,其他兄弟模块的调用产生了不同的结果,确实是一个非常棒的设计。这就是门面模式。

23.2 门面模式的定义

门面模式(Facade Pattern)也叫做外观模式,是一种比较常用的封装模式,其定义如下:

Provide a unified interface to a set of interfaces in a subsystem.Facade defines a higher-level interface that makes the subsystem easier to use.(要求一个子系统的外部与其内部的通信必须通过一个统一的对象进行。门面模式提供一个高层次的接口,使得子系统更易于使用。)

门面模式注重“统一的对象”,也就是提供一个访问子系统的接口,除了这个接口不允许有任何访问子系统的行为发生,其通用类图,如图23-4所示。

image-20210929221159108

图23-4 扩展后的系统类图

是的,类图就这么简单,但是它代表的意义可是异常复杂,Subsystem Classes是子系统所有类的简称,它可能代表一个类,也可能代表几十个对象的集合。甭管多少对象,我们把这些对象全部圈入子系统的范畴,其结构如图23-5所示。

image-20210929221239108

图23-5 门面模式示意图

再简单地说,门面对象是外界访问子系统内部的唯一通道,不管子系统内部是多么杂乱无章,只要有门面对象在,就可以做到“金玉其外,败絮其中”。我们先明确一下门面模式的角色。

  • Facade门面角色

客户端可以调用这个角色的方法。此角色知晓子系统的所有功能和责任。一般情况下, 本角色会将所有从客户端发来的请求委派到相应的子系统去,也就说该角色没有实际的业务逻辑,只是一个委托类。

  • subsystem子系统角色

可以同时有一个或者多个子系统。每一个子系统都不是一个单独的类,而是一个类的集合。子系统并不知道门面的存在。对于子系统而言,门面仅仅是另外一个客户端而已。

我们来看一下门面模式的通用源码,先来看子系统源代码。由于子系统是类的集合,因此要描述该集合很花费精力,每一个子系统都不相同,我们使用3个相互无关的类来代表, 如代码清单23-8所示。

代码清单23-8 子系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ClassA {
public void doSomethingA(){
//业务逻辑
}
}
public class ClassB {
public void doSomethingB(){
//业务逻辑
}
}
public class ClassC {
public void doSomethingC(){
//业务逻辑
}
}

我们认为这3个类属于近邻,处理相关的业务,因此应该被认为是一个子系统的不同逻辑处理模块,对于此子系统的访问需要通过门面进行,如代码清单23-9所示。

代码清单23-9 门面对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Facade {
//被委托的对象
private ClassA a = new ClassA();
private ClassB b = new ClassB();
private ClassC c = new ClassC();
//提供给外部访问的方法
public void methodA(){
this.a.doSomethingA();
}
public void methodB(){

this.b.doSomethingB();
}
public void methodC(){
this.c.doSomethingC();
}
}

24.1 如此追女孩子,你还不乐

大家有没有看过尼古拉斯·凯奇主演的《Next》(中文译名为《预见未来》)?尼古拉斯 ·凯奇饰演一个可以预视并且扭转未来的人,其中有一个情节很是让人心动——男女主角见面的那段情节:Cris Johnson(尼古拉斯·凯奇饰演)坐在咖啡吧台前,看着离自己近在咫尺的Callie Ferris(朱莉安·摩尔饰演),计划着怎么认识这个命中注定的女人,看Cris Johnson 如何利用自己的特异功能:

  • Cris Johnson端着一杯咖啡走过去,说“你好,可以认识你吗?”被拒绝,恢复到坐在咖 啡吧台前的状态。
  • 走过去询问是否可以搭车,被拒绝,恢复原状。
  • 帮助解决困境,被拒绝,恢复原状。
  • 采用嬉皮士的方式解决困境,被拒绝,恢复原状。
  • 帮助解决困境,被打伤,装可怜,Callie Ferris怜惜,于是乎相识了。

看看这是一件多么幸福的事情,追求一个女生可以多次反复地实验,直到找到好的方法和途径为止,这估计是大多数男生都希望获得的特异功能。想想看,看到一个心仪的女生, 我们若反复尝试,总会有一个方法打动她的,多美好的一件事。现在我们还得回到现实生活,我们来分析一下类似事情的经过:

  • 复制一个当前状态,保留下来,这个状态就是等会儿搭讪女孩子失败后要恢复的状 态,你不恢复原始状态,这不就露馅儿了吗?
  • 每次试探性尝试失败后,都必须恢复到这个原始状态。
  • N次试探总有一次成功吧,成功以后即可走成功路线。

想想看,我们这里的场景中最重要的是哪一块?对的,是原始状态的保留和恢复这块, 如何保留一个原始,如何恢复一个原始状态才是最重要的,那想想看,我们应该怎么实现呢?很简单呀,我们可以定义一个中间变量,保留这个原始状态。我们先看看类图,如图24-1所示。

image-20210929234433197

图24-1 男孩状态类图

太简单的类图了,我们来解释一下图中的状态state是什么意思,在某一时间点的所有位置信息、心理信息、环境信息都属于状态,我们这里用了一个标识性的名词state代表所有状态,比如在追女孩子前心情是期待、心理是焦躁不安等。每一次去认识女孩子都是会发生状态变化的,我们使用changeState方法来代替,由于程序比较简单,就没有编写接口,我们来看实现,如代码清单24-1所示。

代码清单24-1 男孩状态类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Boy {
//男孩的状态
private String state = "";
//认识女孩子后状态肯定改变,比如心情、手中的花等
public void changeState(){
this.state = "心情可能很不好";
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}

程序是很简单,主要的业务逻辑是在场景类中,我们来看场景类是如何进行状态的保留、恢复的,如代码清单24-2所示。

代码清单24-2 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Client {
public static void main(String[] args) {
//声明出主角
Boy boy = new Boy();
//初始化当前状态
boy.setState("心情很棒!");
System.out.println("=====男孩现在的状态======");
System.out.println(boy.getState());
//需要记录下当前状态呀
Boy backup = new Boy();
backup.setState(boy.getState());
//男孩去追女孩,状态改变
boy.changeState();
System.out.println("\n=====男孩追女孩子后的状态======");
System.out.println(boy.getState());
//追女孩失败,恢复原状
boy.setState(backup.getState());
System.out.println("\n=====男孩恢复后的状态======");
System.out.println(boy.getState());
}
}

程序运行结果如下所示:

1
2
3
4
5
6
=====男孩现在的状态====== 
心情很棒!
=====男孩追女孩子后的状态======
心情可能很不好
=====男孩恢复后的状态======
心情很棒!

程序运行正确,输出结果也是我们期望的,但是结果正确并不表示程序是最优的,我们来看看场景类Client,它代表的是高层模块,或者说是非“近亲”模块的调用者,注意看backup 变量的使用,它对于高层模块完全是多余的,为什么一个状态的保存和恢复要让高层模块来负责呢?这应该是Boy类的职责,而不应该让高层模块来完成,也就是破坏了Boy类的封装,或者说Boy类没有封装好,它应该是把backup的定义容纳进来,而不应该让高层模块来定义。

问题我们已经知道了,就是Boy类封装不够,那我们应该如何修改呢?如果在Boy类中再增加一个方法或者其他的内部类来保存这个状态,则对单一职责原则是一种破坏,想想看单一职责原则是怎么说的?一个类的职责应该是单一的,Boy类本身的职责是追求女孩子, 而保留和恢复原始状态则应该由另外一个类来承担,那我们把这个类取名就叫做备忘录,这和大家经常在桌面上贴的那个便签是一个概念,分析到这里我们的思路已经非常清楚了,我们来修改一下类图,如图24-2所示。

image-20210929234747033

图24-2 完善后的男孩状态类图
改动很小,增加了一个新的类Memento,负责状态的保存和备份;同时,在Boy类中增加了创建一份备忘录createMemento和恢复一个备忘录resotreMemento,我们先来看Boy类的变化,如代码清单24-3所示。

代码清单24-3 改进后的男孩状态类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Boy {
//男孩的状态
private String state = "";
//认识女孩子后状态肯定改变,比如心情、手中的花等
public void changeState(){
this.state = "心情可能很不好";
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
//保留一个备份
public Memento createMemento(){
return new Memento(this.state);
}
//恢复一个备份
public void restoreMemento(Memento _memento){
this.setState(_memento.getState());
}
}

注意看,确实只增加了两个方法创建备份和恢复备份,至于在什么时候创建备份和恢复备份则是由高层模块决定的。我们再来看备忘录模块,如代码清单24-4所示。

代码清单24-4 备忘录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Memento {
//男孩的状态
private String state = "";
//通过构造函数传递状态信息
public Memento(String _state){
this.state = _state;
}
public String getState() {
return state;
}
public void setState(String state) {

this.state = state;
}
}

这就是一个简单的JavaBean,保留男孩当时的状态信息。我们再来看场景类,稍做修改,如代码清单24-5所示。

代码清单24-5 改进后的场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Client {
public static void main(String[] args) {
//声明出主角
Boy boy = new Boy();
//初始化当前状态
boy.setState("心情很棒!");
System.out.println("=====男孩现在的状态======");
System.out.println(boy.getState());
//需要记录下当前状态呀
Memento mem = boy.createMemento();
//男孩去追女孩,状态改变
boy.changeState();
System.out.println("\n=====男孩追女孩子后的状态======");
System.out.println(boy.getState());
//追女孩失败,恢复原状
boy.restoreMemento(mem);
System.out.println("\n=====男孩恢复后的状态======");
System.out.println(boy.getState());
}
}

运行结果保持相同,虽然程序中不再重复定义Boy类的对象了,但是我们还是要关心备忘录,这对迪米特法则是一个亵渎,它告诉我们只和朋友类通信,那这个备忘录对象是我们必须要通信的朋友类吗?对高层模块来说,它最希望要做的就是创建一个备份点,然后在需要的时候再恢复到这个备份点就成了,它不用关心到底有没有备忘录这个类。那根据这一指导思想,我们就需要把备忘录类再包装一下,怎么包装呢?建立一个管理类,就是管理这个备忘录,如图24-3所示。

image-20210929235000932

图24-3 完整的男孩追女生类图

又增加了一个JavaBean,Boy类和Memento没有任何改变,不再赘述。我们来看增加的备忘录管理类,如代码清单24-6所示。

代码清单24-6 备忘录管理者

1
2
3
4
5
6
7
8
9
10
public class Caretaker {
//备忘录对象
private Memento memento;
public Memento getMemento() {
return memento;
}
public void setMemento(Memento memento) {
this.memento = memento;
}
}

这个太简单了,非常纯粹的一个JavaBean,甭管它多简单,只要有用就成,我们来看场景类如何调用,如代码清单24-7所示。

代码清单24-7 进一步改进后的场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Client {
public static void main(String[] args) {
//声明出主角
Boy boy = new Boy();
//声明出备忘录的管理者
Caretaker caretaker = new Caretaker();
//初始化当前状态
boy.setState("心情很棒!");
System.out.println("=====男孩现在的状态======");
System.out.println(boy.getState());
//需要记录下当前状态呀
caretaker.setMemento(boy.createMemento());
//男孩去追女孩,状态改变
boy.changeState();
System.out.println("\n=====男孩追女孩子后的状态======");
System.out.println(boy.getState());
//追女孩失败,恢复原状
boy.restoreMemento(caretaker.getMemento());
System.out.println("\n=====男孩恢复后的状态======");
System.out.println(boy.getState());
}
}

注意看黑体部分,就修改了这么多,看看程序的逻辑是不是清晰了很多,需要备份的时候就创建一个备份,然后丢给备忘录管理者进行管理,要取的时候再从管理者手中拿到这个备份。这个备份者就类似于一个备份的仓库管理员,创建一个丢进去,需要的时候再拿出来。这就是备忘录模式。

24.2 备忘录模式的定义

备忘录模式(Memento Pattern)提供了一种弥补真实世界缺陷的方法,让“后悔药”在程序的世界中真实可行,其定义如下:

Without violating encapsulation,capture and externalize an object’s internal state so that the object can be restored to this state later.(在不破坏封装性的前提下,捕获一个对象的内部状 态,并在该对象之外保存这个状态。这样以后就可将该对象恢复到原先保存的状态。)

通俗地说,备忘录模式就是一个对象的备份模式,提供了一种程序数据的备份方法,其通用类图如图24-4所示。

image-20210929235311072

图24-4 备忘录模式的通用类图

我们来看看类图中的三个角色。

  • Originator发起人角色

记录当前时刻的内部状态,负责定义哪些属于备份范围的状态,负责创建和恢复备忘录数据。

  • Memento备忘录角色

负责存储Originator发起人对象的内部状态,在需要的时候提供发起人需要的内部状态。

  • Caretaker备忘录管理员角色

对备忘录进行管理、保存和提供备忘录。

备忘录模式的通用代码也非常简单,我们先看发起人角色,如代码清单24-8所示。

代码清单24-8 发起人角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Originator {
//内部状态
private String state = "";
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
//创建一个备忘录
public Memento createMemento(){
return new Memento(this.state);
}
//恢复一个备忘录
public void restoreMemento(Memento _memento){
this.setState(_memento.getState());
}
}

我相信你心里此刻有很多疑问,比如状态是多个怎么办?需要有多份备份怎么办?如果你很着急的话,请看24.4节,但我建议你还是跟随我一步一步地走,我们再来看备忘录角色,如代码清单24-9所示。

代码清单24-9 备忘录角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Memento {
//发起人的内部状态
private String state = "";
//构造函数传递参数
public Memento(String _state){
this.state = _state;
}
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
}

这是一个简单的JavaBean,备忘录管理者也是一个简单的JavaBean,如代码清单24-10所示。

代码清单24-10 备忘录管理员角色

1
2
3
4
5
6
7
8
9
10
public class Caretaker {
//备忘录对象
private Memento memento;
public Memento getMemento() {
return memento;
}
public void setMemento(Memento memento) {
this.memento = memento;
}
}

这3个主要角色都很简单,我们来看场景类如何调用,如代码清单24-11所示。

代码清单24-11 场景类

1
2
3
4
5
6
7
8
9
10
11
12
public class Client {
public static void main(String[] args) {
//定义出发起人
Originator originator = new Originator();
//定义出备忘录管理员
Caretaker caretaker = new Caretaker();
//创建一个备忘录
caretaker.setMemento(originator.createMemento());
//恢复一个备忘录
originator.restoreMemento(caretaker.getMemento());
}
}

备忘录模式就是这么简单,真正使用备忘录模式的时候可比这复杂得多。

25.2 访问者模式的定义

访问者模式(Visitor Pattern)是一个相对简单的模式,其定义如下:

Represent an operation to be performed on the elements of an object structure.Visitor lets you define a new operation without changing the classes of the elements on which it operates.(封装一些作用于某种数据结构中的各元素的操作,它可以在不改变数据结构的前提下定义作用于这些元素的新的操作。)

访问者模式的通用类图如图25-5所示。

image-20210930102720082

图25-5 访问者模式的通用类图

看了这个通用类图,大家可能要犯迷糊了,这里怎么有一个ObjectStruture类呢?你刚刚举的例子怎么就没有呢?真没有吗?我们不是定义了一个List了吗?它中间的元素是我们一个一个手动增加上去的,这就是一个ObjectStruture,我们来看这几个角色的职责。

  • Visitor——抽象访问者

抽象类或者接口,声明访问者可以访问哪些元素,具体到程序中就是visit方法的参数定义哪些对象是可以被访问的。

  • ConcreteVisitor——具体访问者

它影响访问者访问到一个类后该怎么干,要做什么事情。

  • Element——抽象元素

接口或者抽象类,声明接受哪一类访问者访问,程序上是通过accept方法中的参数来定义的。

  • ConcreteElement——具体元素

实现accept方法,通常是visitor.visit(this),基本上都形成了一种模式了。

  • ObjectStruture——结构对象

元素产生者,一般容纳在多个不同类、不同接口的容器,如List、Set、Map等,在项目中,一般很少抽象出这个角色。

大家可以这样理解访问者模式,我作为一个访客(Visitor)到朋友家(Visited Class)去拜访,朋友之间聊聊天,喝喝酒,再相互吹捧吹捧,炫耀炫耀,这都正常。聊天的时候,朋友告诉我,他今年加官晋爵了,工资也涨了30%,准备再买套房子,那我就在心里盘算 (Visitor-self-method)“你这么有钱,我去年要借10万你都不借”,我根据朋友的信息,执行了自己的一个方法。

我们来看看访问者模式的通用源码,先看抽象元素,如代码清单25-11所示。

代码清单25-11 抽象元素

1
2
3
4
5
6
public abstract class Element {
//定义业务逻辑
public abstract void doSomething();
//允许谁来访问
public abstract void accept(IVisitor visitor);
}

抽象元素有两类方法:一是本身的业务逻辑,也就是元素作为一个业务处理单元必须完成的职责;另外一个是允许哪一个访问者来访问。我们来看具体元素,如代码清单25-12所示。

代码清单25-12 具体元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ConcreteElement1 extends Element{
//完善业务逻辑
public void doSomething(){
//业务处理
}
//允许那个访问者访问
public void accept(IVisitor visitor){
visitor.visit(this);
}
}
public class ConcreteElement2 extends Element{
//完善业务逻辑
public void doSomething(){
//业务处理
}
//允许那个访问者访问
public void accept(IVisitor visitor){
visitor.visit(this);
}
}

它定义了两个具体元素,我们再来看抽象访问者,一般是有几个具体元素就有几个访问方法,如代码清单25-13所示。

代码清单25-13 抽象访问者

1
2
3
4
5
public interface IVisitor {
//可以访问哪些对象
public void visit(ConcreteElement1 el1);
public void visit(ConcreteElement2 el2);
}

具体访问者如代码清单25-14所示。

代码清单25-14 具体访问者

1
2
3
4
5
6
7
8
9
10
public class Visitor implements IVisitor {
//访问el1元素
public void visit(ConcreteElement1 el1) {
el1.doSomething();
}
//访问el2元素
public void visit(ConcreteElement2 el2) {
el2.doSomething();
}
}

结构对象是产生出不同的元素对象,我们使用工厂方法模式来模拟,如代码清单25-15 所示。

代码清单25-15 结构对象

1
2
3
4
5
6
7
8
9
10
11
12
public class ObjectStruture {
//对象生成器,这里通过一个工厂方法模式模拟
public static Element createElement(){
Random rand = new Random();
if(rand.nextInt(100) > 50){
return new ConcreteElement1();
}
else{
return new ConcreteElement2();
}
}
}

进入了访问者角色后,我们对所有的具体元素的访问就非常简单了,我们通过一个场景类模拟这种情况,如代码清单25-16所示。

代码清单25-16 场景类

1
2
3
4
5
6
7
8
9
10
public class Client {
public static void main(String[] args) {
for(int i=0;i<10;i++){
//获得元素对象
Element el = ObjectStruture.createElement();
//接受访问者访问
el.accept(new Visitor());
}
}
}

通过增加访问者,只要是具体元素就非常容易访问,对元素的遍历就更加容易了,甭管它是什么对象,只要它在一个容器中,都可以通过访问者来访问,任务集中化。这就是访问者模式。

26.2 状态模式的定义

上面的例子中多次提到状态,本节讲的就是状态模式,什么是状态模式呢?其定义如下:

Allow an object to alter its behavior when its internal state changes.The object will appear to change its class.(当一个对象内在状态改变时允许其改变行为,这个对象看起来像改变了其类。)

状态模式的核心是封装,状态的变更引起了行为的变更,从外部看起来就好像这个对象对应的类发生了改变一样。状态模式的通用类图如图26-5所示。

image-20210930110452937

图26-5 状态模式通用类图

我们先来看看状态模式中的3个角色。

  • State——抽象状态角色

接口或抽象类,负责对象状态定义,并且封装环境角色以实现状态切换。

  • ConcreteState——具体状态角色

每一个具体状态必须完成两个职责:本状态的行为管理以及趋向状态处理,通俗地说, 就是本状态下要做的事情,以及本状态如何过渡到其他状态。

  • Context——环境角色

定义客户端需要的接口,并且负责具体状态的切换。

状态模式相对来说比较复杂,它提供了一种对物质运动的另一个观察视角,通过状态变更促使行为的变化,就类似水的状态变更一样,一碗水的初始状态是液态,通过加热转变为气态,状态的改变同时也引起体积的扩大,然后就产生了一个新的行为:鸣笛或顶起壶盖, 瓦特就是这么发明蒸汽机的。我们再来看看状态模式的通用源代码,首先来看抽象环境角色,如代码清单26-14所示。

代码清单26-14 抽象环境角色

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class State {
//定义一个环境角色,提供子类访问
protected Context context;
//设置环境角色
public void setContext(Context _context){
this.context = _context;
}
//行为1
public abstract void handle1();
//行为2
public abstract void handle2();
}

抽象环境中声明一个环境角色,提供各个状态类自行访问,并且提供所有状态的抽象行为,由各个实现类实现。具体环境角色如代码清单26-15所示。

代码清单26-15 环境角色

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 ConcreteState1 extends State {

@Override
public void handle1() {
//本状态下必须处理的逻辑
}
@Override
public void handle2() {
//设置当前状态为stat2
super.context.setCurrentState(Context.STATE2);
//过渡到state2状态,由Context实现
super.context.handle2();
}
}
public class ConcreteState2 extends State {
@Override
public void handle1() {
//设置当前状态为state1
super.context.setCurrentState(Context.STATE1);
//过渡到state1状态,由Context实现
super.context.handle1();
}
@Override
public void handle2() {
//本状态下必须处理的逻辑
}
}

具体环境角色有两个职责:处理本状态必须完成的任务,决定是否可以过渡到其他状态。我们再来看环境角色,如代码清单26-16所示。

代码清单26-16 具体环境角色

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 Context {
//定义状态
public final static State STATE1 = new ConcreteState1();
public final static State STATE2 = new ConcreteState2();
//当前状态
private State CurrentState;
//获得当前状态
public State getCurrentState() {
return CurrentState;
}
//设置当前状态
public void setCurrentState(State currentState) {
this.CurrentState = currentState;
//切换状态
this.CurrentState.setContext(this);
}
//行为委托
public void handle1(){
this.CurrentState.handle1();
}
public void handle2(){
this.CurrentState.handle2();
}
}

环境角色有两个不成文的约束:

  • 把状态对象声明为静态常量,有几个状态对象就声明几个静态常量。
  • 环境角色具有状态抽象角色定义的所有行为,具体执行使用委托方式。

我们再来看场景类如何执行,如代码清单26-17所示。

代码清单26-17 具体环境角色

1
2
3
4
5
6
7
8
9
10
11
public class Client {
public static void main(String[] args) {
//定义环境角色
Context context = new Context();
//初始化状态
context.setCurrentState(new ConcreteState1());
//行为执行
context.handle1();
context.handle2();
}
}

看到没?我们已经隐藏了状态的变化过程,它的切换引起了行为的变化。对外来说,我们只看到行为的发生改变,而不用知道是状态变化引起的。

第27章 解释器模式

27.1 四则运算你会吗

在银行、证券类项目中,经常会有一些模型运算,通过对现有数据的统计、分析而预测不可知或未来可能发生的商业行为。模型运算大部分是针对海量数据的,例如建立一个模型公式,分析一个城市的消费倾向,进而影响银行的营销和业务扩张方向。一般的模型运算都有一个或多个运算公式,通常是加、减、乘、除四则运算,偶尔也有指数、开方等复杂运算。具体到一个金融业务中,模型公式是非常复杂的,虽然只有加、减、乘、除四则运算, 但是公式有可能有十多个参数,而且上百个业务品各有不同的取参路径,同时相关表的数据量都在百万级。呵呵,复杂了吧,不复杂那就不叫金融业务,我们来讲讲运算的核心——模型公式及其如何实现。

业务需求:输入一个模型公式(加、减运算),然后输入模型中的参数,运算出结果。
设计要求

  • 公式可以运行时编辑,并且符合正常算术书写方式,例如a+b-c。
  • 高扩展性,未来增加指数、开方、极限、求导等运算符号时较少改动。
  • 效率可以不用考虑,晚间批量运算。

需求不复杂,若仅仅对数字采用四则运算,每个程序员都可以写出来。但是增加了增加模型公式就复杂了。先解释一下为什么需要公式,而不采用直接计算的方法,例如有如下3 个公式:

  • 业务种类1的公式:a+b+c-d。
  • 业务种类2的公式:a+b+e-d。
  • 业务种类3的公式:a-f。

其中,a、b、c、d、e、f参数的值都可以取得,如果使用直接计算数值的方法需要为每个品种写一个算法,目前仅仅是3个业务种类,那上百个品种呢?歇菜了吧!建立公式,然后通过公式运算才是王道。

我们以实现加、减算法(由于篇幅所限,乘、除法的运算读者可以自行扩展)的公式为例,讲解如何解析一个固定语法逻辑。由于使用语法解析的场景比较少,而且一些商业公司 (如SAS、SPSS等统计分析软件)都支持类似的规则运算,亲自编写语法解析的工作已经非常少,以下例程采用逐步分析方法,带领大家了解这一实现过程。

想想公式中有什么?仅有两类元素:运算元素和运算符号,运算元素就是指a、b、c等符号,需要具体赋值的对象,也叫做终结符号,为什么叫终结符号呢?因为这些元素除了需要赋值外,不需要做任何处理,所有运算元素都对应一个具体的业务参数,这是语法中最小的单元逻辑,不可再拆分;运算符号就是加减符号,需要我们编写算法进行处理,每个运算符号都要对应处理单元,否则公式无法运行,运算符号也叫做非终结符号。两类元素的共同点是都要被解析,不同点是所有的运算元素具有相同的功能,可以用一个类表示,而运算符号则是需要分别进行解释,加法需要加法解析器,减法需要减法解析器。分析到这里,我们就可以先画一个简单的类图,如图27-1所示。

image-20210930111518583

图27-1 初步分析加减法类图

这是一个很简单的类图,VarExpression用来解析运算元素,各个公式能运算元素的数量是不同的,但每个运算元素都对应一个VarExpression对象。SybmolExpression负责解析符号,由两个子类AddExpression(负责加法运算)和SubExpression(负责减法运算)来实现。 解析的工作完成了,我们还需要把安排运行的先后顺序(加减法不用考虑,但是乘除法呢? 注意扩展性),并且还要返回结果,因此我们需要增加一个封装类来进行封装处理,由于我们只做运算,暂时还不与业务有关联,定义为Calculator类。分析到这里,思路就比较清晰了,优化后加减法类图如图27-2所示。

Calculator的作用是封装,根据迪米特法则,Client只与直接的朋友Calculator交流,与其 他类没关系。整个类图的结构比较清晰,下面填充类图中的方法,完整类图如图27-3所示。

类图已经完成,下面来看代码实现。Expression抽象类如代码清单27-1所示。

代码清单27-1 抽象表达式类

1
2
3
4
public abstract class Expression {
//解析公式和数值,其中var中的key值是公式中的参数,value值是具体的数字
public abstract int interpreter(HashMap<String,Integer> var);
}

image-20210930111633860

图27-2 优化后加减法类图

image-20210930111653467

图27-3 完整加减法类图

抽象类非常简单,仅一个方法interpreter负责对传递进来的参数和值进行解析和匹配,其中输入参数为HashMap类型,key值为模型中的参数,如a、b、c等,value为运算时取得的具体数字。

变量解析器如代码清单27-2所示。

代码清单27-2 变量解析器

1
2
3
4
5
6
7
8
9
10
public class VarExpression extends Expression {
private String key;
public VarExpression(String _key){
this.key = _key;
}
//从map中取之
public int interpreter(HashMap<String, Integer> var) {
return var.get(this.key);
}
}

抽象运算符号解析器如代码清单27-3所示。

代码清单27-3 抽象运算符号解析器

1
2
3
4
5
6
7
8
9
public abstract class SymbolExpression extends Expression {
protected Expression left;
protected Expression right;
//所有的解析公式都应只关心自己左右两个表达式的结果
public SymbolExpression(Expression _left,Expression _right){
this.left = _left;
this.right = _right;
}
}

这个解析过程还是比较有意思的,每个运算符号都只和自己左右两个数字有关系,但左右两个数字有可能也是一个解析的结果,无论何种类型,都是Expression的实现类,于是在对运算符解析的子类中增加了一个构造函数,传递左右两个表达式。具体的加、减法解析器如代码清单27-4、代码清单27-5所示。

代码清单27-4 加法解析器

1
2
3
4
5
6
7
8
9
public class AddExpression extends SymbolExpression {
public AddExpression(Expression _left,Expression _right){
super(_left,_right);
}
//把左右两个表达式运算的结果加起来
public int interpreter(HashMap<String, Integer> var) {
return super.left.interpreter(var) + super.right.interpreter(var);
}
}

代码清单27-5 减法解析器

1
2
3
4
5
6
7
8
9
public class SubExpression extends SymbolExpression {
public SubExpression(Expression _left,Expression _right){
super(_left,_right);
}
//左右两个表达式相减
public int interpreter(HashMap<String, Integer> var) {
return super.left.interpreter(var) - super.right.interpreter(var);
}
}

解析器的开发工作已经完成了,但是需求还没有完全实现。我们还需要对解析器进行封装,封装类Calculator如代码清单27-6所示。

代码清单27-6 解析器封装类

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 Calculator {
//定义表达式
private Expression expression;
//构造函数传参,并解析
public Calculator(String expStr){
//定义一个栈,安排运算的先后顺序
Stack<Expression> stack = new Stack<Expression>();
//表达式拆分为字符数组
char[] charArray = expStr.toCharArray();
//运算
Expression left = null;
Expression right = null;
for(int i=0;i<charArray.length;i++){
switch(charArray[i]) {
case '+'://加法
//加法结果放到栈中
left = stack.pop();
right=new VarExpression(String.valueOf(charArray[++i]));
stack.push(new AddExpression(left,right));
break;
case '-': left = stack.pop();
right=new VarExpression(String.valueOf(charArray[++i]));
stack.push(new SubExpression(left,right));
break;
default://公式中的变量
stack.push(new VarExpression(String.valueOf(charArray[i])));
}
}
//把运算结果抛出来
this.expression = stack.pop();
}
//开始运算
public int run(HashMap<String,Integer> var){
return this.expression.interpreter(var);
}
}

方法比较长,我们来分析一下,Calculator构造函数接收一个表达式,然后把表达式转化为char数组,并判断运算符号,如果是“+”则进行加法运算,把左边的数(left变量)和右边的数(right变量)加起来就可以了,那左边的数为什么是在栈中呢?例如这个公式:a+b-c, 根据for循环,首先被压入栈中的应该是有a元素生成的VarExpression对象,然后判断到加号时,把a元素的对象VarExpression从栈中弹出,与右边的数组b进行相加,b又是怎么得来的呢?当前的数组游标下移一个单元格即可,同时为了防止该元素再次被遍历,则通过++i的方式跳过下一个遍历——于是一个加法的运行结束。减法也采用相同的运行原理。

为了满足业务要求,我们设置了一个Client类来模拟用户情况,用户要求可以扩展,可以修改公式,那就通过接收键盘事件来处理,Client类如代码清单27-7所示。

代码清单27-7 客户模拟类

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 Client {
//运行四则运算
public static void main(String[] args) throws IOException{
String expStr = getExpStr();
//赋值
HashMap<String,Integer> var = getValue(expStr);
Calculator cal = new Calculator(expStr);
System.out.println("运算结果为:"+expStr +"="+cal.run(var));
}
//获得表达式
public static String getExpStr() throws IOException{
System.out.print("请输入表达式:");
return (new BufferedReader(new InputStreamReader(System.in))).readLine();
}
//获得值映射
public static HashMap<String,Integer> getValue(String exprStr) throws IOException{
HashMap<String,Integer> map = new HashMap<String,Integer>();
//解析有几个参数要传递
for(char ch:exprStr.toCharArray()){
if(ch != '+' && ch != '-'){
//解决重复参数的问题
if(!map.containsKey(String.valueOf(ch))){
String in = (new BufferedReader(new InputStreamReader (System.in))).readLine();
map.put(String.valueOf(ch),Integer.valueOf(in));
}
}
}
return map;
}
}

其中,getExpStr是从键盘事件中获得的表达式,getValue方法是从键盘事件中获得表达式中的元素映射值,运行过程如下。

  • 首先,要求输入公式。
1
请输入表达式:a+b-c
  • 其次,要求输入公式中的参数。
1
2
3
请输入a的值:100
请输入b的值:20
请输入c的值:40
  • 最后,运行出结果。
1
运算结果为:a+b-c=80

看,要求输入一个公式,然后输入参数,运行结果出来了!那我们是不是可以修改公式?当然可以,我们只要输入公式,然后输入相应的值就可以了,公式是在运行时定义的, 而不是在运行前就制定好的,是不是类似于初中学过的“代数”这门课?先公式,然后赋值, 运算出结果。

需求已经开发完毕,公式可以自由定义,只要符合规则(有变量有运算符合)就可以运算出结果;若需要扩展也非常容易,只要增加SymbolExpression的子类就可以了,这就是解释器模式。

27.2 解释器模式的定义

解释器模式(Interpreter Pattern)是一种按照规定语法进行解析的方案,在现在项目中使用较少,其定义如下:

Given a language, define a representation for its grammar along with an interpreter that uses the representation to interpret sentences in the language.(给定一门语言,定义它的文法的一种表示,并定义一个解释器,该解释器使用该表示来解释语言中的句子。)

解释器模式的通用类图如图27-4所示。

image-20210930112248422

图27-4 解释器模式通用类图
  • AbstractExpression——抽象解释器

具体的解释任务由各个实现类完成,具体的解释器分别由TerminalExpression和Non-terminalExpression完成。

  • TerminalExpression——终结符表达式

实现与文法中的元素相关联的解释操作,通常一个解释器模式中只有一个终结符表达式,但有多个实例,对应不同的终结符。具体到我们例子就是VarExpression类,表达式中的每个终结符都在栈中产生了一个VarExpression对象。

  • NonterminalExpression——非终结符表达式

文法中的每条规则对应于一个非终结表达式,具体到我们的例子就是加减法规则分别对应到AddExpression和SubExpression两个类。非终结符表达式根据逻辑的复杂程度而增加,原则上每个文法规则都对应一个非终结符表达式。

  • Context——环境角色

具体到我们的例子中是采用HashMap代替。

解释器是一个比较少用的模式,以下为其通用源码,可以作为参考。抽象表达式通常只有一个方法,如代码清单27-8所示。

代码清单27-8 抽象表达式

1
2
3
4
public abstract class Expression {
//每个表达式必须有一个解析任务
public abstract Object interpreter(Context ctx);
}

抽象表达式是生成语法集合(也叫做语法树)的关键,每个语法集合完成指定语法解析任务,它是通过递归调用的方式,最终由最小的语法单元进行解析完成。终结符表达式如代码清单27-9所示。

代码清单27-9 终结符表达式

1
2
3
4
5
6
public class TerminalExpression extends Expression {
//通常终结符表达式只有一个,但是有多个对象
public Object interpreter(Context ctx) {
return null;
}
}

通常,终结符表达式比较简单,主要是处理场景元素和数据的转换。

非终结符表达式如代码清单27-10所示。

代码清单27-10 非终结符表达式

1
2
3
4
5
6
public class TerminalExpression extends Expression {
//通常终结符表达式只有一个,但是有多个对象
public Object interpreter(Context ctx) {
return null;
}
}

每个非终结符表达式都代表了一个文法规则,并且每个文法规则都只关心自己周边的文法规则的结果(注意是结果),因此这就产生了每个非终结符表达式调用自己周边的非终结符表达式,然后最终、最小的文法规则就是终结符表达式,终结符表达式的概念就是如此, 不能够再参与比自己更小的文法运算了。

客户类如代码清单27-11所示。

代码清单27-11 客户类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Client {
public static void main(String[] args) {
Context ctx = new Context();
//通常定一个语法容器,容纳一个具体的表达式,通常为ListArray、LinkedList、Stack等类型
Stack&Expression> stack = null;
for(;;){
//进行语法判断,并产生递归调用
}
//产生一个完整的语法树,由各个具体的语法分析进行解析
Expression exp = stack.pop();
//具体元素进入场景
exp.interpreter(ctx);
}
}

通常Client是一个封装类,封装的结果就是传递进来一个规范语法文件,解析器分析后产生结果并返回,避免了调用者与语法解析器的耦合关系。

28.2 享元模式的定义

享元模式(Flyweight Pattern)是池技术的重要实现方式,其定义如下:Use sharing to support large numbers of fine-grained objects efficiently.(使用共享对象可有效地支持大量的细粒度的对象。)

享元模式的定义为我们提出了两个要求:细粒度的对象和共享对象。我们知道分配太多的对象到应用程序中将有损程序的性能,同时还容易造成内存溢出,那怎么避免呢?就是享元模式提到的共享技术。我们先来了解一下对象的内部状态和外部状态。

要求细粒度对象,那么不可避免地使得对象数量多且性质相近,那我们就将这些对象的信息分为两个部分:内部状态(intrinsic)与外部状态(extrinsic)。

  • 内部状态

内部状态是对象可共享出来的信息,存储在享元对象内部并且不会随环境改变而改变, 如我们例子中的id、postAddress等,它们可以作为一个对象的动态附加信息,不必直接储存在具体某个对象中,属于可以共享的部分。

  • 外部状态

外部状态是对象得以依赖的一个标记,是随环境改变而改变的、不可以共享的状态,如我们例子中的考试科目+考试地点复合字符串,它是一批对象的统一标识,是唯一的一个索引值。

有了对象的两个状态,我们就可以来看享元模式的通用类图,如图28-3所示。

图28-3 享元模式的通用类图

类图也很简单,我们先来看我们享元模式角色名称。

  • Flyweight——抽象享元角色

它简单地说就是一个产品的抽象类,同时定义出对象的外部状态和内部状态的接口或实现。

  • ConcreteFlyweight——具体享元角色

具体的一个产品类,实现抽象角色定义的业务。该角色中需要注意的是内部状态处理应该与环境无关,不应该出现一个操作改变了内部状态,同时修改了外部状态,这是绝对不允许的。

  • unsharedConcreteFlyweight——不可共享的享元角色

不存在外部状态或者安全要求(如线程安全)不能够使用共享技术的对象,该对象一般不会出现在享元工厂中。

  • FlyweightFactory——享元工厂

职责非常简单,就是构造一个池容器,同时提供从池中获得对象的方法。

享元模式的目的在于运用共享技术,使得一些细粒度的对象可以共享,我们的设计确实也应该这样,多使用细粒度的对象,便于重用或重构。我来看享元模式的通用代码,先看抽象享元角色,如代码清单28-7所示。

代码清单28-7 抽象享元角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public abstract class Flyweight {
//内部状态
private String intrinsic;
//外部状态
protected final String Extrinsic;
//要求享元角色必须接受外部状态
public Flyweight(String _Extrinsic){
this.Extrinsic = _Extrinsic;
}
//定义业务操作
public abstract void operate();
//内部状态的getter/setter
public String getIntrinsic() {
return intrinsic;
}
public void setIntrinsic(String intrinsic) {
this.intrinsic = intrinsic;
}
}

抽象享元角色一般为抽象类,在实际项目中,一般是一个实现类,它是描述一类事物的方法。在抽象角色中,一般需要把外部状态和内部状态(当然了,可以没有内部状态,只有行为也是可以的)定义出来,避免子类的随意扩展。我们再来看具体的享元角色,如代码清单28-8所示。

代码清单28-8 具体享元角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ConcreteFlyweight1 extends Flyweight{
//接受外部状态
public ConcreteFlyweight1(String _Extrinsic){

super(_Extrinsic);
}
//根据外部状态进行逻辑处理
public void operate(){
//业务逻辑
}
}
public class ConcreteFlyweight2 extends Flyweight{
//接受外部状态
public ConcreteFlyweight2(String _Extrinsic){
super(_Extrinsic);
}
//根据外部状态进行逻辑处理
public void operate(){
//业务逻辑
}
}

这很简单,实现自己的业务逻辑,然后接收外部状态,以便内部业务逻辑对外部状态的依赖。注意,我们在抽象享元中对外部状态加上了final关键字,防止意外产生,什么意外? 获得了一个外部状态,然后无意修改了一下,池就混乱了!


注意 在程序开发中,确认只需要一次赋值的属性则设置为final类型,避免无意修改导致逻辑混乱,特别是Session级的常量或变量。


我们继续看享元工厂,如代码清单28-9所示。

代码清单28-9 享元工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class FlyweightFactory {
//定义一个池容器
private static HashMap<String,Flyweight> pool= new HashMap<String,Flyweight>();
//享元工厂
public static Flyweight getFlyweight(String Extrinsic){
//需要返回的对象
Flyweight flyweight = null;
//在池中没有该对象
if(pool.containsKey(Extrinsic)){
flyweight = pool.get(Extrinsic);
}
else{
//根据外部状态创建享元对象
flyweight = new ConcreteFlyweight1(Extrinsic);
//放置到池中
pool.put(Extrinsic, flyweight);
}
return flyweight;

}
}

28.1 内存溢出,司空见惯

下午,我正在开会中,老大推门进来。

“三儿,出来一下。”

我刚出会议室门口,老大就发话了。

“郎当(姓朗,顺口就叫郎当)的那个报考系统又crash了一台机器,两天已经宕了4次 了,你这边还有紧急的事情没有?……没有,那赶快过去顶一下,就运行三天的程序,两天 宕了4次,还怎么玩?!”

我马上收拾东西,冲到马路上拦了出租车,同时打电话给郎当。

“三哥,厂商人员已经定位出了,OutOfMemory内存溢出,没查到有内存泄漏的情况,现 在还在跟踪……是突然暴涨的,都是在繁忙期出现问题的……”

内存溢出对Java应用来说实在是太平常了,有以下两种可能。

  • 内存泄漏

无意识的代码缺陷,导致内存泄漏,JVM不能获得连续的内存空间。

  • 对象太多

代码写得很烂,产生的对象太多,内存被耗尽。现在的情况是没有内存泄漏,那只有一种原因——代码太差把内存耗尽。

到现场后,郎当给我介绍了一下系统情况。该系统是一个报考系统,其中有一个模块负责社会人员报名,该模块对全国的考试人员只开放3天,并且限制报考人员数量。第一天9点开始报考,系统慢得像蜗牛,基本上都不能访问,后来设置了HTTP Server的并发数量,稍有缓解,40分钟后宕了一台机器,10分钟后,又挂了一台,下午3点又挂了一台,看样子晚上要让郎当去寺庙烧烧香了。

该系统一共有8台应用服务器,基本上CPU繁忙程度都在60%以上,HTTP的最大并发是2000,平均分配到每台应用服务器上没有太大的压力,于是怀疑是代码问题,然后详细了解了一下业务和数据流逻辑,基本的业务操作过程清楚了,先登录(没有账号的,则要先注册),登录后,需要填写以下信息:

  • 考试科目,选择框。
  • 考试地点,选择框,根据科目不同,列表不同。
  • 准考证邮寄地址,输入框。

还有其他一堆信息,我们以这三者作为代表来讲解。信息填写完毕后,点击确认,报名就结束了。简单程序的业务逻辑也确实是这样,为什么出现Crash情况呢?那肯定是和压力有关系!

我们先把这个过程的静态类图画出来,如图28-1所示。

image-20210930113627220

图28-1 报考系统类图

很简单的工厂方法模式,表现层通过工厂方法模式创建对象,然后传递给业务层和持久层,最终保存到数据库中,为什么要使用工厂方法模式而不用直接new一个对象呢?因为是在框架下编程,必须有一个对象工厂(ObjectFactory,Spring也有对象工厂)。我们先来看报考信息,如代码清单28-1所示。

代码清单28-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
public class SignInfo {
//报名人员的ID
private String id;
//考试地点
private String location;
//考试科目
private String subject;
//邮寄地址
private String postAddress;
public String getId() {
return id;
}
public void setId(String id) {

this.id = id;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
public String getSubject() {
return subject;
}
public void setSubject(String subject) {
this.subject = subject;
}
public String getPostAddress() {
return postAddress;
}
public void setPostAddress(String postAddress) {
this.postAddress = postAddress;
}
}

它是一个很简单的POJO对象(Plain Ordinary Java Object,简单Java对象)。我们再来看工厂类,如代码清单28-2所示。

代码清单28-2 报考信息工厂

1
2
3
4
5
6
public class SignInfoFactory {
//报名信息的对象工厂
public static SignInfo getSignInfo(){
return new SignInfo();
}
}

工厂类就这么简单?非也,这是我们的教学代码,真实的ObjectFactory要复杂得多,主要是注入了部分Handler的管理。表现层是如何创建对象的,如代码清单28-3所示。

代码清单28-3 场景类

1
2
3
4
5
6
7
public class Client {
public static void main(String[] args) {
//从工厂中获得一个对象
SignInfo signInfo = SignInfoFactory.getSignInfo();
//进行其他业务处理
}
}

就这么简单,但是简单为什么会出现问题呢?而且这样写也没有问题呀,很标准的工厂方法模式,应该不会有大问题,然后又看了看系统厂商提供的分析报告,报告中指出:内存突然由800MB飙升到1.4GB,新的对象申请不到内存空间,于是出现OutOfMemory,同时报告中还列出宕机时刻内存中的对象,其中SignInfo类的对象就有400MB,疯子,绝对是疯子!报告都没有看嘛!

问题找到了,我拉郎当过来谈话,“厂商不是分析出原因了嘛,人家已经指出SignInfo类的对象占用了400MB多的内存,这是怎么回事?”

“三哥,这是很正常的,这么大的访问量,产生出这么多的SignInfo对象也是应该的,内 存中有这么多对象并不表示这些对象正在被使用呀,估计很大一部分还没有被回收而已,垃 圾回收器什么时候回收内存中的对象这是不确定的。你看,并发200多个,这可是并发数 量……”

我想了想,也确实是这么回事。既然已经定位是内存中对象太多,那就应该想到使用一种共享的技术减少对象数量,那怎么共享呢?

大家知道,对象池(Object Pool)的实现有很多开源工具,比如Apache的commons-pool 就是一个非常不错的池工具,我们暂时还用不到这种重量级的工具,我们自己来设计一个共享对象池,需要实现如下两个功能。

  • 容器定义

我们要定义一个池容器,在这个容器中容纳哪些对象。

  • 提供客户端访问的接口

我们要提供一个接口供客户端访问,池中有可用对象时,可以直接从池中获得,否则建立一个新的对象,并放置到池中。

设计思路有了,那我们池中对象的标准是什么呢?你想想看,如果你把所有的对象都放到池中,那还有什么意义?内存早就给你撑爆了!这么多对象,必然有一些相同的属性值,如几十万SignInfo对象中,考试科目就4个,考试地点也就是30多个,其他的属性则是每个对象都不相同的,我们把对象的相同属性提取出来,不同的属性在系统内进行赋值处理,是不是就可以建立一个池了?话无须多说,我们以类图来表示,如图28-2所示。

image-20210930113940991

图28-2 增加对象池的类图
做一个很小的改动,增加了一个子类,实现带缓冲池的对象建立,同时在工厂类上增加了一个容器对象HashMap,保存池中的所有对象。我们先来看产品子类,如代码清单28-4所示。

代码清单28-4 带对象池的报考信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SignInfo4Pool extends SignInfo {
//定义一个对象池提取的KEY值
private String key;
//构造函数获得相同标志
public SignInfo4Pool(String _key){
this.key = _key;
}
public String getKey() {
return key;
}
public void setKey(String key) {
this.key = key;
}
}

很简单,就是增加了一个key值,为什么要增加key值?为什么要使用子类,而不在SignInfo类上做修改?好,我来给你解释为什么要这样做,我们刚刚已经分析了所有的SignInfo对象都有一些共同的属性:考试科目和考试地点,我们把这些共性提取出来作为所有对象的外部状态,在这个对象池中一个具体的外部状态只有一个对象。按照这个设计,我们定义key值的标准为:考试科目+考试地点的复合字符串作为唯一的池对象标准,也就是说在对象池中,一个key值唯一对应一个对象。


注意 在对象池中,对象一旦产生,必然有一个唯一的、可访问的状态标志该对象,而且池中的对象声明周期是由池容器决定,而不是由使用者决定的。


你可能马上就要提出了,为什么不建立一个新的类,包含subject和location两个属性作为外部状态呢?嗯,这是一个办法,但不是最好的办法,有两个原因:

  • 修改的工作量太大,增加的这个类由谁来创建呢?同时,SignInfo类是否也要修改 呢?你不可能让两段相同的POJO程序同时出现在同一模块中吧!
  • 性能问题,我们会在扩展模块中讲解。

说了这么多,我们还是继续来看程序,工厂类如代码清单28-5所示。

代码清单28-5 带对象池的工厂类

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 class SignInfoFactory {
//池容器
private static HashMap<String,SignInfo> pool = new HashMap<String,SignInfo>();
//报名信息的对象工厂
@Deprecated
public static SignInfo(){
return new SignInfo();
}
//从池中获得对象
public static SignInfo getSignInfo(String key){
//设置返回对象
SignInfo result = null;
//池中没有该对象,则建立,并放入池中
if(!pool.containsKey(key)){
System.out.println(key + "----建立对象,并放置到池中");
result = new SignInfo4Pool(key);
pool.put(key, result);
}
else{
result = pool.get(key);
System.out.println(key +"---直接从池中取得");
}
return result;
}
}

方法都很简单,不多解释。读者需要注意一点的是@Deprecated注解,不要有删除投产中代码的念头,如果方法或类确实不再使用了,增加该注解,表示该方法或类已经过时,尽量不要再使用了,我们应该保持历史原貌,同时也有助于版本向下兼容,特别是在产品级研发中。

我们再来看看客户端是如何调用的,如代码清单28-6所示。

代码清单28-6 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
public static void main(String[] args) {
//初始化对象池
for(int i=0;i<4;i++){
String subject = "科目" + i;
//初始化地址
for(int j=0;j<30;j++){
String key = subject + "考试地点"+j;
SignInfoFactory.getSignInfo(key);
}
}
SignInfo signInfo = SignInfoFactory.getSignInfo("科目1考试地点1");

}
}

运行结果如下所示:

1
2
3
4
5
6
科目3考试地点25----建立对象,并放置到池中 
科目3考试地点26----建立对象,并放置到池中
科目3考试地点27----建立对象,并放置到池中
科目3考试地点28----建立对象,并放置到池中
科目3考试地点29----建立对象,并放置到池中
科目1考试地点1---直接从池中取得

前面还有很多的对象创建提示语句,不再复制。通过这样的改造后,我们想想内存中有多少个SignInfo对象?是的,最多120个对象,相比之前几万个SignInfo对象优化了非常多。细心的读者可能注意到了SignInfo4Pool类基本上没有跑出我们的视线范围,仅仅在工厂方法中使用到了,尽量缩小变更引起的风险,想想看我们的改动是不是很小,只要在展示层中拼一个字符串,然后传递到工厂方法中就可以了。

通过这样的改造后,第三天系统运行得非常稳定,CPU占用率也下降了,而且以后再也没有出现类似问题,这就是享元模式的功劳。

image-20210930114537654