24.4 备忘录模式的扩展

24.4.1 clone方式的备忘录

大家还记得在第13章中讲的原型模式吗?我们可以通过复制的方式产生一个对象的内部状态,这是一个很好的办法,发起人角色只要实现Cloneable就成,比较简单,我们来看类图,如图24-5所示。

image-20210930000232486

图24-5 Clone方式的备忘录

从类图上看,发起人角色融合了发起人角色和备忘录角色,具有双重功效,如代码清单24-12所示。

代码清单24-12 融合备忘录的发起人角色

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 Originator implements Cloneable{
//内部状态
private String state = "";
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
//创建一个备忘录
public Originator createMemento(){
return this.clone();

}
//恢复一个备忘录
public void restoreMemento(Originator _originator){
this.setState(_originator.getState());
}
//克隆当前对象
@Override
protected Originator clone(){
try {
return (Originator)super.clone();
}
catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return null;
}
}

增加了clone方法,产生了一个备份对象,需要使用的时候再还原,我们再来看管理员角色,如代码清单24-13所示。

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

1
2
3
4
5
6
7
8
9
10
public class Caretaker {
//发起人对象
private Originator originator;
public Originator getOriginator() {
return originator;
}
public void setOriginator(Originator originator) {
this.originator = originator;
}
}

没什么太大变化,只是备忘录角色转换成了发起人角色,还是一个简单的JavaBean。我们来想想这种模式是不是还可以简化?要管理员角色干什么?就是为了管理备忘录角色,现在连备忘录角色都被合并了,还留着它干吗?我们想办法把它也精简掉,如代码清单24-14 所示。

代码清单24-14 发起人自主备份和恢复

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 Originator implements Cloneable{
private Originator backup;
//内部状态
private String state = "";
public String getState() {
return state;
}

public void setState(String state) {
this.state = state;
}
//创建一个备忘录
public void createMemento(){
this.backup = this.clone();
}
//恢复一个备忘录
public void restoreMemento(){
//在进行恢复前应该进行断言,防止空指针
this.setState(this.backup.getState());
}
//克隆当前对象
@Override
protected Originator clone(){
try {
return (Originator)super.clone();
}
catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return null;
}
}

可能你要发问了,这和备忘录模式的定义不相符,它定义是“在该对象之外保存这个状态”,而你却把这个状态保存在了发起人内部。是的,设计模式定义的诞生比Java的出世略早,它没有想到Java程序是这么有活力,有远见,而且在面向对象的设计中,即使把一个类封装在另一个类中也是可以做到的,何况一个小小的对象复制,这是它的设计模式完全没有预见到的,我们把它弥补回来。

再来看看Client是如何调用的,如代码清单24-15所示。

代码清单24-15 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Client {
public static void main(String[] args) {
//定义发起人
Originator originator = new Originator();
//建立初始状态
originator.setState("初始状态...");
System.out.println("初始状态是:"+originator.getState());
//建立备份
originator.createMemento();
//修改状态
originator.setState("修改后的状态...");
System.out.println("修改后状态是:"+originator.getState());
//恢复原有状态
originator.restoreMemento();
System.out.println("恢复后状态是:"+originator.getState());

}
}

运行结果如下所示:

1
2
3
初始状态是:初始状态... 
修改后状态是:修改后的状态...
恢复后状态是:初始状态...

运行结果是我们所希望的,程序精简了很多,而且高层模块的依赖也减少了,这正是我们期望的效果。现在我们来考虑一下原型模式深拷贝和浅拷贝的问题,在复杂的场景下它会让你的程序逻辑异常混乱,出现错误也很难跟踪。因此Clone方式的备忘录模式适用于较简单的场景。


注意 使用Clone方式的备忘录模式,可以使用在比较简单的场景或者比较单一的场景中,尽量不要与其他的对象产生严重的耦合关系。


24.4.2 多状态的备忘录模式

读者应该看到我们以上讲解都是单状态的情况,在实际的开发中一个对象不可能只有一个状态,一个JavaBean有多个属性非常常见,这都是它的状态,如果照搬我们以上讲解的备忘录模式,是不是就要写一堆的状态备份、还原语句?这不是一个好办法,这种类似的非智力劳动越多,犯错误的几率越大,那我们有什么办法来处理多个状态的备份问题呢?

下面我们来讲解一个对象全状态备份方案,它有多种处理方式,比如使用Clone的方式就可以解决,使用数据技术也可以解决(DTO回写到临时表中)等,我们要讲的方案就对备忘录模式继续扩展一下,实现一个JavaBean对象的所有状态的备份和还原,如图24-6所示。

image-20210930000744163

图24-6 多状态的备忘录模式

还是比较简单的类图,增加了一个BeanUtils类,其中backupProp是把发起人的所有属性值转换到HashMap中,方便备忘录角色存储;restoreProp方法则是把HashMap中的值返回到发起人角色中。可能各位要说了,为什么要使用HashMap,直接使用Originator对象的拷贝不是一个很好的方法吗?可以这样做,你就破坏了发起人的通用性,你在做恢复动作的时候需要对该对象进行多次赋值操作,也容易产生错误。我们先来看发起人角色,如代码清单24- 16所示。

代码清单24-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
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Originator {
//内部状态
private String state1 = "";
private String state2 = "";
private String state3 = "";
public String getState1() {
return state1;
}
public void setState1(String state1) {
this.state1 = state1;
}
public String getState2() {
return state2;
}
public void setState2(String state2) {
this.state2 = state2;
}
public String getState3() {
return state3;
}
public void setState3(String state3) {
this.state3 = state3;
}
//创建一个备忘录
public Memento createMemento(){
return new Memento(BeanUtils.backupProp(this));
}
//恢复一个备忘录
public void restoreMemento(Memento _memento){
BeanUtils.restoreProp(this, _memento.getStateMap());
}
//增加一个toString方法
@Override
public String toString(){
return "state1=" +state1+"\nstat2="+state2+"\nstate3="+state3;
}
}

覆写toString方法是为了方便打印,可以让展示的结果更清晰。我们再来看BeanUtils工具类,如代码清单24-17所示。

代码清单24-17 BeanUtils工具类

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 BeanUtils {
//把bean的所有属性及数值放入到Hashmap中
public static HashMap<String,Object> backupProp(Object bean){
HashMap<String,Object> result = new HashMap<String,Object>();
try {
//获得Bean描述
BeanInfo beanInfo=Introspector.getBeanInfo(bean.getClass());
//获得属性描述
PropertyDescriptor[] descriptors=beanInfo.getPropertyDescriptors();
//遍历所有属性
for(PropertyDescriptor des:descriptors){
//属性名称
String fieldName = des.getName();
//读取属性的方法
Method getter = des.getReadMethod();
//读取属性值
Object fieldValue=getter.invoke(bean,new Object[]{});
if(!fieldName.equalsIgnoreCase("class")){
result.put(fieldName, fieldValue);
}
}
}
catch (Exception e) {
//异常处理
}
return result;
}
//把HashMap的值返回到bean中
public static void restoreProp(Object bean,HashMap<String,Object> propMap){
try {
//获得Bean描述
BeanInfo beanInfo = Introspector.getBeanInfo(bean.getClass());
//获得属性描述
PropertyDescriptor[] descriptors = beanInfo.getPropertyDescriptors();
//遍历所有属性
for(PropertyDescriptor des:descriptors){
//属性名称
String fieldName = des.getName();
//如果有这个属性
if(propMap.containsKey(fieldName)){
//写属性的方法
Method setter = des.getWriteMethod();
setter.invoke(bean, new Object[]{propMap.get(fieldName)});
}
}
}
catch (Exception e) {
//异常处理
System.out.println("shit");
e.printStackTrace();
}
}
}

该类大家在项目中会经常用到,可以作为参考使用。类似的功能有很多工具已经提供, 比如Spring、Apache工具集commons等,大家也可以直接使用。我们再来看备忘录角色,如代码清单24-18所示。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Memento {
//接受HashMap作为状态
private HashMap<String,Object> stateMap;
//接受一个对象,建立一个备份
public Memento(HashMap<String,Object> map){
this.stateMap = map;
}
public HashMap<String,Object> getStateMap() {
return stateMap;
}
public void setStateMap(HashMap<String,Object> stateMap) {
this.stateMap = stateMap;
}
}

我们再编写一个场景类,看看我们的成果是否正确,如代码清单24-19所示。

代码清单24-19 场景类

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 Client {
public static void main(String[] args) {
//定义出发起人
Originator ori = new Originator();
//定义出备忘录管理员
Caretaker caretaker = new Caretaker();
//初始化
ori.setState1("中国");
ori.setState2("强盛");
ori.setState3("繁荣");
System.out.println("===初始化状态===\n"+ori);
//创建一个备忘录
caretaker.setMemento(ori.createMemento());
//修改状态值
ori.setState1("软件");
ori.setState2("架构");
ori.setState3("优秀");
System.out.println("\n===修改后状态===\n"+ori);
//恢复一个备忘录
ori.restoreMemento(caretaker.getMemento());
System.out.println("\n===恢复后状态===\n"+ori);
}
}

运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
===初始化状态=== 
state1=中国
stat2=强盛
state3=繁荣
===修改后状态===
state1=软件
stat2=架构
state3=优秀
===恢复后状态===
state1=中国
stat2=强盛
state3=繁荣

通过这种方式的改造,不管有多少状态都没有问题,直接把原有的对象所有属性都备份了一遍,想恢复当时的点数据?那太容易了!


注意 如果要设计一个在运行期决定备份状态的框架,则建议采用AOP框架来实现,避 免采用动态代理无谓地增加程序逻辑复杂性。


24.4.3 多备份的备忘录

不知道你有没有做过系统级别的维护?比如Backup Administrator(备份管理员),每天负责查看系统的备份情况,所有的备份都是由自动化脚本产生的。有一天,突然有一个重要的系统说我数据库有点问题,请把上一个月末的数据拉出来恢复,那怎么办?对备份管理员来说,这很好办,直接根据时间戳找到这个备份,还原回去就成了,但是对于我们刚刚学习的备忘录模式却行不通,为什么呢?它对于一个确定的发起人,永远只有一份备份,在这种情况下,单一的备份就不能满足要求了,我们需要设计一套多备份的架构。

我们先来说一个名词,检查点(Check Point),也就是你在备份的时候做的戳记,系统级的备份一般是时间戳,那我们程序的检查点该怎么设计呢?一般是一个有意义的字符串。

我们只要把通用代码中的Caretaker管理员稍做修改就可以了,如代码清单24-20所示。

代码清单24-20 备忘录管理员

1
2
3
4
5
6
7
8
9
10
public class Caretaker {
//容纳备忘录的容器
private HashMap<String,Memento> memMap = new HashMap<String,Memento>();
public Memento getMemento(String idx) {
return memMap.get(idx);
}
public void setMemento(String idx,Memento memento) {
this.memMap.put(idx, memento);
}
}

把容纳备忘录的容器修改为Map类型就可以了,场景类也稍做改动,如代码清单24-21所示。

代码清单24-21 场景类

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

注意 内存溢出问题,该备份一旦产生就装入内存,没有任何销毁的意向,这是非常危险的。因此,在系统设计时,要严格限定备忘录的创建,建议增加Map的上限,否则系统很容易产生内存溢出情况。


24.4.4 封装得更好一点

在系统管理上,一个备份的数据是完全、绝对不能修改的,它保证数据的洁净,避免数据污染而使备份失去意义。在我们的设计领域中,也存在着同样的问题,备份是不能被篡改的,也就是说需要缩小备份出的备忘录的阅读权限,保证只能是发起人可读就成了,那怎么才能做到这一点呢?使用内置类,如图24-7所示。

image-20210930094946625

图24-7 使用内置类的备忘录模式

这也是比较简单的,建立一个空接口IMemento——什么方法属性都没有的接口,然后在发起人Originator类中建立一个内置类(也叫做类中类)Memento实现IMemento接口,同时也实现自己的业务逻辑,如代码清单24-22所示。

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

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 Originator {
//内部状态
private String state = "";
public String getState() {
return state;
}
public void setState(String state) {
this.state = state;
}
//创建一个备忘录
public IMemento createMemento(){
return new Memento(this.state);
}
//恢复一个备忘录
public void restoreMemento(IMemento _memento){
this.setState(((Memento)_memento).getState());
}
//内置类
private class Memento implements IMemento{
//发起人的内部状态
private String state = "";
//构造函数传递参数
private Memento(String _state){
this.state = _state;
}
private String getState() {
return state;
}
private void setState(String state) {
this.state = state;
}
}
}

内置类Memento全部是private的访问权限,也就是说除了发起人外,别人休想访问到, 那如果要产生关联关系又应如何处理呢?通过接口!别忘记了我们还有一个空接口是公共的访问权限,如代码清单24-23所示。

代码清单24-23 备忘录的空接口

1
2
public interface IMemento {
}

我们再来看管理者,如代码清单24-24所示。

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

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

全部通过接口访问,这当然没有问题,如果你想访问它的属性那是肯定不行的。但是安全是相对的,没有绝对的安全,可以使用refelect反射修改Memento的数据。

在这里我们使用了一个新的设计方法:双接口设计,我们的一个类可以实现多个接口, 在系统设计时,如果考虑对象的安全问题,则可以提供两个接口,一个是业务的正常接口, 实现必要的业务逻辑,叫做宽接口;另外一个接口是一个空接口,什么方法都没有,其目的是提供给子系统外的模块访问,比如容器对象,这个叫做窄接口,由于窄接口中没有提供任何操纵数据的方法,因此相对来说比较安全。

24.3 备忘录模式的应用

由于备忘录模式有太多的变形和处理方式,每种方式都有它自己的优点和缺点,标准的备忘录模式很难在项目中遇到,基本上都有一些变换处理方式。因此,我们在使用备忘录模式时主要了解如何应用以及需要注意哪些事项就成了。

24.3.1 备忘录模式的使用场景

  • 需要保存和恢复数据的相关状态场景。
  • 提供一个可回滚(rollback)的操作;比如Word中的CTRL+Z组合键,IE浏览器中的后 退按钮,文件管理器上的backspace键等。
  • 需要监控的副本场景中。例如要监控一个对象的属性,但是监控又不应该作为系统的 主业务来调用,它只是边缘应用,即使出现监控不准、错误报警也影响不大,因此一般的做 法是备份一个主线程中的对象,然后由分析程序来分析。
  • 数据库连接的事务管理就是用的备忘录模式,想想看,如果你要实现一个JDBC驱 动,你怎么来实现事务?还不是用备忘录模式嘛!

24.3.2 备忘录模式的注意事项

  • 备忘录的生命期

备忘录创建出来就要在“最近”的代码中使用,要主动管理它的生命周期,建立就要使用,不使用就要立刻删除其引用,等待垃圾回收器对它的回收处理。

  • 备忘录的性能

不要在频繁建立备份的场景中使用备忘录模式(比如一个for循环中),原因有二:一是控制不了备忘录建立的对象数量;二是大对象的建立是要消耗资源的,系统的性能需要考虑。因此,如果出现这样的代码,设计师就应该好好想想怎么修改架构了。

23.5 最佳实践

门面模式是一个很好的封装方法,一个子系统比较复杂时,比如算法或者业务比较复杂,就可以封装出一个或多个门面出来,项目的结构简单,而且扩展性非常好。还有,对于一个较大项目,为了避免人员带来的风险,也可以使用门面模式,技术水平比较差的成员, 尽量安排独立的模块,然后把他写的程序封装到一个门面里,尽量让其他项目成员不用看到这些人的代码,看也看不懂,我也遇到过一个“高人”写的代码,private方法、构造函数、常量基本都不用,你要一个public方法,好,一个类里就一个public方法,所有代码都在里面, 然后你就看吧,一大坨程序,看着就能把人逼疯。使用门面模式后,对门面进行单元测试, 约束项目成员的代码质量,对项目整体质量的提升也是一个比较好的帮助。

23.4 门面模式的注意事项

23.4.1 一个子系统可以有多个门面

一般情况下,一个子系统只要有一个门面足够了,在什么情况下一个子系统有多个门面呢?以下列举了几个。

  • 门面已经庞大到不能忍受的程度

比如一个纯洁的门面对象已经超过了200行的代码,虽然都是非常简单的委托操作,也建议拆分成多个门面,否则会给以后的维护和扩展带来不必要的麻烦。那怎么拆分呢?按照功能拆分是一个非常好的原则,比如一个数据库操作的门面可以拆分为查询门面、删除门面、更新门面等。

  • 子系统可以提供不同访问路径

我们以门面模式的通用源代码为例。ClassA、ClassB、ClassC是一个子系统的中3个对象,现在有两个不同的高层模块来访问该子系统,模块一可以完整的访问所有业务逻辑,也就是通用代码中的Facade类,它是子系统的信任模块;而模块二属于受限访问对象,只能访问methodB方法,那该如何处理呢?在这种情况下,就需要建立两个门面以供不同的高层模块来访问,在原有的通用源码上增加一个新的门面即可,如代码清单23-10所示。

代码清单23-10 新增门面

1
2
3
4
5
6
7
8
public class Facade2 {
//引用原有的门面
private Facade facade = new Facade();
//对外提供唯一的访问子系统的方法
public void methodB(){
this.facade.methodB();
}
}

增加的门面非常简单,委托给了已经存在的门面对象Facade进行处理,为什么要使用委托而不再编写一个委托到子系统的方法呢?那是因为在面向对象的编程中,尽量保持相同的代码只编写一遍,避免以后到处修改相似代码的悲剧。

23.4.2 门面不参与子系统内的业务逻辑

我们这节的标题是什么意思呢?我们举一个例子来说明,还是以通用源代码为例。我们把门面上的methodC上的逻辑修改一下,它必须先调用ClassA的doSomethingA方法,然后再调用ClassC的doSomethingC方法,如代码清单23-11所示。

代码清单23-11 修改门面

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.a.doSomethingA();
this.c.doSomethingC();
}
}

还是非常简单,只是在methodC方法中增加了doSomethingA()方法的调用,可以这样做吗?我相信大部分读者都说可以这样做,而且已经在实际系统开发中这样使用了,我今天告诉各位,这样设计是非常不靠谱的,为什么呢?因为你已经让门面对象参与了业务逻辑,门面对象只是提供一个访问子系统的一个路径而已,它不应该也不能参与具体的业务逻辑,否则就会产生一个倒依赖的问题:子系统必须依赖门面才能被访问,这是设计上一个严重错误,不仅违反了单一职责原则,同时也破坏了系统的封装性。

说了这么多,那对于这种情况该怎么处理呢?建立一个封装类,封装完毕后提供给门面对象。我们先建立一个封装类,如代码清单23-12所示。

代码清单23-12 封装类

1
2
3
4
5
6
7
8
9
10
public class Context {
//委托处理
private ClassA a = new ClassA();
private ClassC c = new ClassC();
//复杂的计算
public void complexMethod(){
this.a.doSomethingA();
this.c.doSomethingC();
}
}

该封装类的作用就是产生一个业务规则complexMethod,并且它的生存环境是在子系统内,仅仅依赖两个相关的对象,门面对象通过对它的访问完成一个复杂的业务逻辑,如代码清单23-13所示。

代码清单23-13 门面类

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

通过这样一次封装后,门面对象又不参与业务逻辑了,在门面模式中,门面角色应该是稳定,它不应该经常变化,一个系统一旦投入运行它就不应该被改变,它是一个系统对外的接口,你变来变去还怎么保证其他模块的稳定运行呢?但是,业务逻辑是会经常变化的,我们已经把它的变化封装在子系统内部,无论你如何变化,对外界的访问者来说,都还是同一个门面,同样的方法——这才是架构师最希望看到的结构。

23.3 门面模式的应用

23.3.1 门面模式的优点

门面模式有如下优点。

  • 减少系统的相互依赖

想想看,如果我们不使用门面模式,外界访问直接深入到子系统内部,相互之间是一种强耦合关系,你死我就死,你活我才能活,这样的强依赖是系统设计所不能接受的,门面模式的出现就很好地解决了该问题,所有的依赖都是对门面对象的依赖,与子系统无关。

  • 提高了灵活性

依赖减少了,灵活性自然提高了。不管子系统内部如何变化,只要不影响到门面对象, 任你自由活动。

  • 提高安全性

想让你访问子系统的哪些业务就开通哪些逻辑,不在门面上开通的方法,你休想访问到。

23.3.2 门面模式的缺点

门面模式最大的缺点就是不符合开闭原则,对修改关闭,对扩展开放,看看我们那个门面对象吧,它可是重中之重,一旦在系统投产后发现有一个小错误,你怎么解决?完全遵从开闭原则,根本没办法解决。继承?覆写?都顶不上用,唯一能做的一件事就是修改门面角色的代码,这个风险相当大,这就需要大家在设计的时候慎之又慎,多思考几遍才会有好收获。

23.3.3 门面模式的使用场景

  • 为一个复杂的模块或子系统提供一个供外界访问的接口
  • 子系统相对独立——外界对子系统的访问只要黑箱操作即可

比如利息的计算问题,没有深厚的业务知识和扎实的技术水平是不可能开发出该子系统的,但是对于使用该系统的开发人员来说,他需要做的就是输入金额以及存期,其他的都不用关心,返回的结果就是利息,这时候,门面模式是非使用不可了。

  • 预防低水平人员带来的风险扩散

比如一个低水平的技术人员参与项目开发,为降低个人代码质量对整体项目的影响风险,一般的做法是“画地为牢”,只能在指定的子系统中开发,然后再提供门面接口进行访问操作。

22.5 最佳实践

观察者模式在实际项目和生活中非常常见,我们举几个经常发生的例子来说明。

  • 文件系统

比如,在一个目录下新建立一个文件,这个动作会同时通知目录管理器增加该目录,并通知磁盘管理器减少1KB的空间,也就说“文件”是一个被观察者,“目录管理器”和“磁盘管理器”则是观察者。

  • 猫鼠游戏

夜里猫叫一声,家里的老鼠撒腿就跑,同时也吵醒了熟睡的主人,这个场景中,“猫”就是被观察者,老鼠和人则是观察者。

  • ATM取钱

比如你到ATM机器上取钱,多次输错密码,卡就会被ATM吞掉,吞卡动作发生的时候,会触发哪些事件呢?第一,摄像头连续快拍,第二,通知监控系统,吞卡发生;第三, 初始化ATM机屏幕,返回最初状态。一般前两个动作都是通过观察者模式来完成的,后一个动作是异常来完成。

  • 广播收音机

电台在广播,你可以打开一个收音机,或者两个收音机来收听,电台就是被观察者,收音机就是观察者。

22.4 观察者模式的扩展

22.4.1 Java世界中的观察者模式

细心的你可能已经发现,HanFeiZi这个实现类中应该抽象出一个父类,父类完全作为被观察者的职责,每一个被观察者只实现自己的逻辑方法就可以了,如此则非常符合单一职责原则。是的,确实是应该这样。幸运的是,Java从一开始诞生就提供了一个可扩展的父类, 即java.util.Observable,这个类就是为那些“暴露狂”准备的,他们老是喜欢把自己的状态变更让别人去欣赏,去触发,这正符合了我们现在的要求,要把韩非子的所有活动都暴露出去, 并且想暴露给谁就暴露给谁。我们打开Java的帮助文件看看,查找一下Observable是不是已经有这个类了?JDK中提供了:java.util.Observable实现类和java.util.Observer接口,也就是说我们上面写的那个例子中的Observable接口可以改换成java.util.Observale实现类了,如图22-6 所示。

image-20210929202341629

图22-6 Java中的观察者类图
是不是又简单了很多?那就对了!然后我们看一下我们程序的变更,先看HanFeiZi的实现类,如代码清单22-20所示。

代码清单22-20 优化后的被观察者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class HanFeiZi extends Observable,IHanFeiZi{
//韩非子要吃饭了
public void haveBreakfast(){
System.out.println("韩非子:开始吃饭了...");
//通知所有的观察者
super.setChanged();
super.notifyObservers("韩非子在吃饭");
}
//韩非子开始娱乐了
public void haveFun(){
System.out.println("韩非子:开始娱乐了...");
super.setChanged();
this.notifyObservers("韩非子在娱乐");
}
}

改变得不多,引入了一个java.util.Observable对象,删除了增加、删除观察者的方法,简单了很多,那我们再来看观察者的实现类,如代码清单22-21所示。

代码清单22-21 优化后的观察者

1
2
3
4
5
6
7
8
9
10
11
12
public class LiSi implements Observer{
//首先李斯是个观察者,一旦韩非子有活动,他就知道,他就要向老板汇报
public void update(Observable observable,Object obj){
System.out.println("李斯:观察到韩非子活动,开始向老板汇报了...");
this.reportToQinShiHuang(obj.toString());
System.out.println("李斯:汇报完毕...\n");
}
//汇报给秦始皇
private void reportToQinShiHuang(String reportContext){
System.out.println("李斯:报告,秦老板!韩非子有活动了--->"+reportContext);
}
}

只改变了粗体部分,应java.util.Observer接口要求update传递过来两个变量,Observable 这个变量我们没用到(接口中定义必须实现的),就不处理了。其他两个观察者实现类也是相同的改动,不再赘述。

场景类没有改动,运行结果也完全相同,大家看看我们使用了Java提供的观察者模式后是不是简单了很多,所以在Java的世界里横行时,多看看API,有帮助很大,很多东西Java已经帮你设计了一个良好的框架。

22.4.2 项目中真实的观察者模式

为什么要说“真实”呢?因为我们刚刚讲的那些是太标准的模式了,在系统设计中会对观察者模式进行改造或改装,主要在以下3个方面。

  • 观察者和被观察者之间的消息沟通

被观察者状态改变会触发观察者的一个行为,同时会传递一个消息给观察者,这是正确的,在实际中一般的做法是:观察者中的update方法接受两个参数,一个是被观察者,一个是DTO(Data Transfer Object,据传输对象),DTO一般是一个纯洁的JavaBean,由被观察者生成,由观察者消费。

当然,如果考虑到远程传输,一般消息是以XML格式传递。

  • 观察者响应方式

我们这样来想一个问题,观察者是一个比较复杂的逻辑,它要接受被观察者传递过来的信息,同时还要对他们进行逻辑处理,在一个观察者多个被观察者的情况下,性能就需要提到日程上来考虑了,为什么呢?如果观察者来不及响应,被观察者的执行时间是不是也会被拉长?那现在的问题就是:观察者如何快速响应?有两个办法:一是采用多线程技术,甭管是被观察者启动线程还是观察者启动线程,都可以明显地提高系统性能,这也就是大家通常所说的异步架构;二是缓存技术,甭管你谁来,我已经准备了足够的资源给你了,我保证快速响应,这当然也是一种比较好方案,代价就是开发难度很大,而且压力测试要做的足够充分,这种方案也就是大家说的同步架构。

  • 被观察者尽量自己做主

这是什么意思呢?被观察者的状态改变是否一定要通知观察者呢?不一定吧,在设计的时候要灵活考虑,否则会加重观察者的处理逻辑,一般是这样做的,对被观察者的业务逻辑doSomething方法实现重载,如增加一个doSomething(boolean isNotifyObs)方法,决定是否通知观察者,而不是在消息到达观察者时才判断是否要消费。

22.4.3 订阅发布模型

观察者模式也叫做发布/订阅模型(Publish/Subscribe),如果你做过EJB(Enterprise JavaBean)的开发,这个你绝对不会陌生。EJB2是个折腾死人不偿命的玩意儿,写个Bean要实现,还要继承,再加上那一堆的配置文件,小项目还凑合,你要知道用EJB开发的基本上都不是小项目,到最后是每个项目成员都在骂EJB这个忽悠人的东西;但是EJB3是个非常优秀的框架,还是算比较轻量级,写个Bean只要加个Annotaion就成了,配置文件减少了,而且也引入了依赖注入的概念,虽然只是EJB2的翻版,但是毕竟还是前进了一步。在EJB中有3 个类型的Bean: Session Bean、Entity Bean和MessageDriven Bean,我们这里来说一下MessageDriven Bean(一般简称为MDB),消息驱动Bean,消息的发布者(Provider)发布一个消息,也就是一个消息驱动Bean,通过EJB容器(一般是Message Queue消息队列)通知订阅者做出回应,从原理上看很简单,就是观察者模式的升级版,或者说是观察则模式的BOSS版。

22.3 观察者模式的应用

22.3.1 观察者模式的优点

  • 观察者和被观察者之间是抽象耦合

如此设计,则不管是增加观察者还是被观察者都非常容易扩展,而且在Java中都已经实现的抽象层级的定义,在系统扩展方面更是得心应手。

  • 建立一套触发机制

根据单一职责原则,每个类的职责是单一的,那么怎么把各个单一的职责串联成真实世界的复杂的逻辑关系呢?比如,我们去打猎,打死了一只母鹿,母鹿有三个幼崽,因失去了母鹿而饿死,尸体又被两只秃鹰争抢,因分配不均,秃鹰开始斗殴,然后羸弱的秃鹰死掉, 生存下来的秃鹰,则因此扩大了地盘……这就是一个触发机制,形成了一个触发链。观察者模式可以完美地实现这里的链条形式。

22.3.2 观察者模式的缺点

观察者模式需要考虑一下开发效率和运行效率问题,一个被观察者,多个观察者,开发和调试就会比较复杂,而且在Java中消息的通知默认是顺序执行,一个观察者卡壳,会影响整体的执行效率。在这种情况下,一般考虑采用异步的方式。

多级触发时的效率更是让人担忧,大家在设计时注意考虑。

22.3.3 观察者模式的使用场景

  • 关联行为场景。需要注意的是,关联行为是可拆分的,而不是“组合”关系。
  • 事件多级触发场景。
  • 跨系统的消息交换场景,如消息队列的处理机制。

22.3.4 观察者模式的注意事项

使用观察者模式也有以下两个重点问题要解决。

  • 广播链的问题

如果你做过数据库的触发器,你就应该知道有一个触发器链的问题,比如表A上写了一个触发器,内容是一个字段更新后更新表B的一条数据,而表B上也有个触发器,要更新表C,表C也有触发器……完蛋了,这个数据库基本上就毁掉了!我们的观察者模式也是一样的问题,一个观察者可以有双重身份,既是观察者,也是被观察者,这没什么问题呀,但是链一旦建立,这个逻辑就比较复杂,可维护性非常差,根据经验建议,在一个观察者模式中最多出现一个对象既是观察者也是被观察者,也就是说消息最多转发一次(传递两次),这还是比较好控制的。


注意 它和责任链模式的最大区别就是观察者广播链在传播的过程中消息是随时更改的,它是由相邻的两个节点协商的消息结构;而责任链模式在消息传递过程中基本上保持消息不可变,如果要改变,也只是在原有的消息上进行修正。


  • 异步处理问题

这个EJB是一个非常好的例子,被观察者发生动作了,观察者要做出回应,如果观察者比较多,而且处理时间比较长怎么办?那就用异步呗,异步处理就要考虑线程安全和队列的问题,这个大家有时间看看Message Queue,就会有更深的了解。

21.5 最佳实践

组合模式在项目中到处都有,比如现在的页面结构一般都是上下结构,上面放系统的Logo,下边分为两部分:左边是导航菜单,右边是展示区,左边的导航菜单一般都是树形的结构,比较清晰,有非常多的JavaScript源码实现了类似的树形菜单,大家可以到网上搜索一下。

还有,大家常用的XML结构也是一个树形结构,根节点、元素节点、值元素这些都与我们的组合模式相匹配,之所以本章节不以XML为例子讲解,是因为很少有人还直接读写XML文件,一般都是用JDOM或者DOM4J了。

还有一个非常重要的例子:我们自己本身也是一个树状结构的一个树枝或树叶。根据我能够找到我的父母,根据父亲又能找到爷爷奶奶,根据母亲能够找到外公外婆等,很典型的树形结构,而且还很规范(这个要是不规范那肯定乱套了)。

21.4 组合模式的扩展

21.4.1 真实的组合模式

什么是真实的组合模式?就是你在实际项目中使用的组合模式,而不是仅仅依照书本上学习到的模式,它是“实践出真知”。在我们的例子中,经过精简后,确实是类、接口减少了很多,而且程序也简单很多,但是大家可能还是很迷茫,这个Client程序并没有改变多少呀,非常正确,树的组装是跑不了的,你要知道在项目中使用关系型数据库来存储这些信息,你可以从数据库中直接提取出哪些人要分配到树枝,哪些人要分配到树叶,树枝与树枝、树叶的关系等,这些都是由相关的业务人员维护到数据库中的,通常这里是把数据存放到一张单独的表中,表结构如图21-7所示。

image-20210929172935715

图21-7 关系数据库中存储的树形结构

这张数据表定义了一个树形结构,我们要做的就是从数据库中把它读取出来,然后展现到前台上,用for循环加上递归就可以完成这个读取。用了数据库后,数据和逻辑已经在表中定义好了,我们直接读取放到树上就可以了,这个还是比较容易做的,大家不妨自己考虑一下。

这才是组合模式的真实引用,它依靠了关系数据库的非对象存储性能,非常方便地保存了一个树形结构。大家可以在项目中考虑采用,想想看现在还有哪个项目不使用关系型数据库呢?

21.4.2 透明的组合模式

组合模式有两种不同的实现:透明模式和安全模式,我们上面讲的就是安全模式,那透明模式是什么样子呢?透明模式的通用类图,如图21-8所示。

image-20210929173044473

图21-8 透明模式的通用类图

我们与图21-6所示的安全模式类图对比一下就非常清楚了,透明模式是把用来组合使用的方法放到抽象类中,比如add()、remove()以及getChildren等方法(顺便说一下,getChildren 一般返回的结果为Iterable的实现类,很多,大家可以看JDK的帮助),不管叶子对象还是树枝对象都有相同的结构,通过判断是getChildren的返回值确认是叶子节点还是树枝节点,如果处理不当,这个会在运行期出现问题,不是很建议的方式;安全模式就不同了,它是把树枝节点和树叶节点彻底分开,树枝节点单独拥有用来组合的方法,这种方法比较安全,我们的例子使用了安全模式。

由于透明模式的使用者还是比较多,我们也把它的通用源代码共享出来,首先看抽象构件,如代码清单21-22所示。

代码清单21-22 抽象构件

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class Component {
//个体和整体都具有的共享
public void doSomething(){
//编写业务逻辑
}
//增加一个叶子构件或树枝构件
public abstract void add(Component component);
//删除一个叶子构件或树枝构件
public abstract void remove(Component component);
//获得分支下的所有叶子构件和树枝构件
public abstract ArrayList<Component> getChildren();
}

抽象构件定义了树枝节点和树叶节点都必须具有的方法和属性,这样树枝节点的实现就不需要任何变化,如代码清单21-19所示。

树叶节点继承了Component抽象类,不想让它改变有点难,它必须实现三个抽象方法, 怎么办?好办,给个空方法,如代码清单21-23所示。

代码清单21-23 树叶节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Leaf extends Component {
@Deprecated
public void add(Component component) throws UnsupportedOperationException{
//空实现,直接抛弃一个"不支持请求"异常
throw new UnsupportedOperationException();
}
@Deprecated
public void remove(Component component)throws UnsupportedOperationException{
//空实现
throw new UnsupportedOperationException();
}
@Deprecated
public ArrayList<Component> getChildren()throws UnsupportedOperationException{
//空实现
throw new UnsupportedOperationException();
}
}

为什么要加个Deprecated注解呢?就是在编译器期告诉调用者,你可以调我这个方法, 但是可能出现错误哦,我已经告诉你“该方法已经失效”了,你还使用那在运行期也会抛出UnsupportedOperationException异常。

在透明模式下,遍历整个树形结构是比较容易的,不用进行强制类型转换,如代码清单21-24所示。

代码清单21-24 树结构遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Client {
//通过递归遍历树
public static void display(Component root){
for(Component c:root.getChildren()){
if(c instanceof Leaf){
//叶子节点
c.doSomething();
}
else{
//树枝节点
display(c);
}
}
}
}

仅仅在遍历时不再进行牵制的类型转化了,其他的组装则没有任何变化。透明模式的好处就是它基本遵循了依赖倒转原则,方便系统进行扩展。

21.4.3 组合模式的遍历

我们在上面也还提到了一个问题,就是树的遍历问题,从上到下遍历没有问题,但是我要是从下往上遍历呢?比如组织机构这棵树,我从中抽取一个用户,要找到它的上级有哪些,下级有哪些,怎么处理?想想,再想想!想出来了吧,我们对下答案,类图如图21-9所示。

image-20210929173339475

图21-9 增加父查询的类图
看类图中,在Corp类中增加了两个方法,setParent是设置父节点是谁,getParent是查找父节点是谁,我们来看一下程序的改变,如代码清单21-25所示。

代码清单21-25 抽象构件

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 Corp {
//公司每个人都有名称
private String name = "";
//公司每个人都职位
private String position = "";
//公司每个人都有薪水
private int salary =0;
//父节点是谁
private Corp parent = null;
public Corp(String _name,String _position,int _salary){
this.name = _name;
this.position = _position;
this.salary = _salary;
}
//获得员工信息
public String getInfo(){
String info = "";
info = "姓名:" + this.name;
info = info + "\t职位:"+ this.position;
info = info + "\t薪水:" + this.salary;
return info;
}
//设置父节点
protected void setParent(Corp _parent){
this.parent = _parent;
}

//得到父节点
public Corp getParent(){
return this.parent;
}
}

就增加了粗体部分,然后我们再来看看树枝节点的改变,如代码清单21-26所示。

代码清单21-26 树枝构件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Branch extends Corp {
//领导下边有哪些下级领导和小兵
ArrayList<Corp> subordinateList = new ArrayList<Corp>();
//构造函数是必需的
public Branch(String _name,String _position,int _salary){
super(_name,_position,_salary);
}
//增加一个下属,可能是小头目,也可能是个小兵
public void addSubordinate(Corp corp) {
corp.setParent(this);
//设置父节点
this.subordinateList.add(corp);
}
//我有哪些下属
public ArrayList<Corp> getSubordinate() {
return this.subordinateList;
}
}

增加了粗体部分。看懂程序了吗?甭管是树枝节点还是树叶节点,在每个节点都增加了一个属性:父节点对象,这样在树枝节点增加子节点或叶子节点是设置父节点,然后你看整棵树除了根节点外每个节点都有一个父节点,剩下的事情还不好处理吗?每个节点上都有父节点了,你要往上找,那就找呗!大家自己考虑一下,写个find方法,然后一步一步往上找,非常简单的方法,这里就不再赘述。

有了这个parent属性,什么后序遍历(从下往上找)、中序遍历(从中间某个环节往上或往下遍历)都解决了,这个就不多说了。

再提一个问题,树叶节点和树枝节点是有顺序的,你不能乱排,怎么办?比如我们上面的例子,研发一组下边有3个成员,这3个成员要进行排序(在机关里这叫做排位,同样是同事也有个先后升迁顺序),你怎么处理?问我呀,问你呢,好好想想,以后用得着的!