30.2 抽象工厂模式VS建造者模式

抽象工厂模式实现对产品家族的创建,一个产品家族是这样的一系列产品:具有不同分类维度的产品组合,采用抽象工厂模式则是不需要关心构建过程,只关心什么产品由什么工厂生产即可。而建造者模式则是要求按照指定的蓝图建造产品,它的主要目的是通过组装零配件而产生一个新产品,两者的区别还是比较明显的,但是还有读者对这两个模式产生混淆,我们通过一个例子说明两者的差别。

现代化的汽车工厂能够批量生产汽车(不考虑手工打造的豪华车)。不同的工厂生产不同的汽车,宝马工厂生产宝马牌子的车,奔驰工厂生产奔驰牌子的车。车不仅具有不同品牌,还有不同的用途分类,如商务车Van,运动型车SUV等,我们按照两种设计模式分别实现车辆的生产过程。

30.2.1 按抽象工厂模式生产车辆

按照抽象工厂模式,首先需要定义一个抽象的产品接口即汽车接口,然后宝马和奔驰分别实现该接口,由于它们只具有了一个品牌属性,还没有定义一个具体的型号,属于对象的抽象层次,每个具体车型由其子类实现,如R系列的奔驰车是商务车,X系列的宝马车属于SUV,我们来看类图,如图30-3所示。

image-20210930155636695

图30-3 车辆生产的工厂类图

在类图中,产品类很简单,我们从两个维度看产品:品牌和车型,每个品牌下都有两个车型,如宝马SUV,宝马商务车等,同时我们又建造了两个工厂,一个专门生产宝马车的宝马工厂BMWFactory,一个是生产奔驰车的奔驰车生产工厂BenzFactory。当然,汽车工厂也有两个不同的维度,可以建立这样两个工厂:一个专门生产SUV车辆的生产工厂,生产宝马SUV和奔驰SUV,另外一个工厂专门生成商务车,分别是宝马商务车和奔驰商务车,这样设计在技术上是完全可行的,但是在业务上是不可行的,为什么?这是因为你看到过有一个工厂既能生产奔驰SUV也能生产宝马SUV吗?这是不可能的,因为业务受限,除非是国内的山寨工厂。我们先来看产品类,汽车接口如代码清单30-12所示。

代码清单30-12 汽车接口

1
2
3
4
5
6
public interface ICar {
//汽车的生产商,也就是牌子
public String getBand();
//汽车的型号
public String getModel();
}

在产品接口中我们定义了车辆有两个可以查询的属性:品牌和型号,奔驰车和宝马车是两个不同品牌的产品,但不够具体,只是知道它们的品牌而已,还不能够实例化,因此还是一个抽象类,如代码清单30-13所示。

代码清单30-13 抽象宝马车

1
2
3
4
5
6
7
8
9
public abstract class AbsBMW implements ICar {
private final static String BMW_BAND = "宝马汽车";
//宝马车
public String getBand() {
return BMW_BAND;
}
//型号由具体的实现类实现
public abstract String getModel();
}

抽象产品类中实现了产品的类型定义,车辆的型号没有实现,两实现类分别实现商务车和运动型车,分别如代码清单30-14、代码清单30-15所示。

代码清单30-14 宝马商务车

1
2
3
4
5
6
public class BMWVan extends AbsBMW {
private final static String SEVENT_SEARIES = "7系列车型商务车";
public String getModel() {
return SEVENT_SEARIES;
}
}

代码清单30-15 宝马SUV

1
2
3
4
5
6
public class BMWSuv extends AbsBMW {
private final static String X_SEARIES = "X系列车型SUV";
public String getModel() {
return X_SEARIES;
}
}

奔驰车与宝马车类似,都已经有清晰品牌定义,但是型号还没有确认,也是一个抽象的产品类,如代码清单30-16所示。

代码清单30-16 抽象奔驰车

1
2
3
4
5
6
7
8
public abstract class AbsBenz implements ICar {
private final static String BENZ_BAND = "奔驰汽车";
public String getBand() {
return BENZ_BAND;
}
//具体型号由实现类完成
public abstract String getModel();
}

由于分类的标准是相同的,因此奔驰车也应该有商务车和运动车两个类型,分别如代码清单30-17和代码清单30-18所示。

代码清单30-17 奔驰商务车

1
2
3
4
5
6
public class BenzVan extends AbsBenz {
private final static String R_SERIES = "R系列商务车";
public String getModel() {
return R_SERIES;
}
}

代码清单30-18 奔驰SUV

1
2
3
4
5
6
public class BenzSuv extends AbsBenz {
private final static String G_SERIES = "G系列SUV";
public String getModel() {
return G_SERIES;
}
}

所有的产品类都已经实现了,剩下的工作就是要定义工厂类进行生产,由于产品类型多样,也导致了必须有多个工厂类来生产不同产品,首先就需要定义一个抽象工厂,声明每个工厂必须完成的职责,如代码清单30-19所示。

代码清单30-19 抽象工厂

1
2
3
4
5
6
public interface CarFactory {
//生产SUV
public ICar createSuv();
//生产商务车
public ICar createVan();
}

抽象工厂定义了每个工厂必须生产两个类型车:SUV(运动车)和VAN(商务车),否则一个工厂就不能被实例化,我们来看宝马车工厂,如代码清单30-20所示。

代码清单30-20 宝马车工厂

1
2
3
4
5
6
7
8
9
10
public class BMWFactory implements CarFactory {
//生产SUV
public ICar createSuv() {
return new BMWSuv();
}
//生产商务车
public ICar createVan(){
return new BMWVan();
}
}

很简单,你要我生产宝马商务车,没问题,直接产生一个宝马商务车对象,返回给调用者,这对调用者来说根本不需要关心到底是怎么生产的,它只要找到一个宝马工厂,即可生产出自己需要的产品(汽车)。奔驰车工厂与此类似,如代码清单30-21所示。

代码清单30-21 奔驰车工厂

1
2
3
4
5
6
7
8
9
10
public class BenzFactory implements CarFactory {
//生产SUV
public ICar createSuv() {
return new BenzSuv();
}
//生产商务车
public ICar createVan(){
return new BenzVan();
}
}

产品和工厂都具备了,剩下的工作就是建立一个场景类模拟调用者调用,如代码清单30-22所示。

代码清单30-22 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
//要求生产一辆奔驰SUV
System.out.println("===要求生产一辆奔驰SUV===");
//首先找到生产奔驰车的工厂
System.out.println("A、找到奔驰车工厂");
CarFactory carFactory= new BenzFactory();
//开始生产奔驰SUV
System.out.println("B、开始生产奔驰SUV");
ICar benzSuv = carFactory.createSuv();
//生产完毕,展示一下车辆信息
System.out.println("C、生产出的汽车如下:");
System.out.println("汽车品牌:"+benzSuv.getBand());
System.out.println("汽车型号:" + benzSuv.getModel());
}
}

运行结果如下所示:

1
2
3
4
5
6
===要求生产一辆奔驰SUV=== 
A、找到奔驰车工厂
B、开始生产奔驰SUV
C、生产出的汽车如下:
汽车品牌:奔驰汽车
汽车型号:G系列SUV

对外界调用者来说,只要更换一个具备相同结构的对象,即可发生非常大的改变,如我们原本使用BenzFactory生产汽车,但是过了一段时间后,我们的系统需要生产宝马汽车,这对系统来说不需要很大的改动,只要把工厂类使用BMWFactory代替即可,立刻可以生产出宝马车,注意这里生产的是一辆完整的车,对于一个产品,只要给出产品代码(车类型)即可生产,抽象工厂模式把一辆车认为是一个完整的、不可拆分的对象。它注重完整性,一个产品一旦找到一个工厂生产,那就是固定的型号,不会出现一个宝马工厂生产奔驰车的情况。那现在的问题是我们就想要一辆混合的车型,如奔驰的引擎,宝马的车轮,那该怎么处理呢?使用我们的建造者模式!

30.2.2 按建造者模式生产车辆

按照建造者模式设计一个生产车辆需要把车辆进行拆分,拆分成引擎和车轮两部分,然后由建造者进行建造,想要什么车,你只要有设计图纸就成,马上可以制造一辆车出来。它注重的是对零件的装配、组合、封装,它从一个细微构件装配角度看待一个对象。我们来看生产车辆的类图,如图30-4所示。

注意看我们类图中的蓝图类Blueprint,它负责对产品建造过程定义。既然要生产产品, 那必然要对产品进行一个描述,在类图中我们定义了一个接口来描述汽车,如代码清单30- 23所示。

代码清单30-23 车辆产品描述

1
2
3
4
5
6
public interface ICar {
//汽车车轮
public String getWheel();
//汽车引擎
public String getEngine();
}

image-20210930160246938

图30-4 建造者模式建造车辆

我们定义一辆车必须有车轮和引擎,具体的产品如代码清单30-24所示。

代码清单30-24 具体车辆

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Car implements ICar {
//汽车引擎
private String engine;
//汽车车轮
private String wheel;
//一次性传递汽车需要的信息
public Car(String _engine,String _wheel){
this.engine = _engine;
this.wheel = _wheel;
}
public String getEngine() {
return engine;
}
public String getWheel() {
return wheel;
}
public String toString(){
return "车的轮子是:" + wheel + "\n车的引擎是:" + engine;
}
}

一个简单的JavaBean定义产品的属性,明确对产品的描述。我们继续来思考,因为我们的产品是比较抽象的,它没有指定引擎的型号,也没有指定车轮的牌子,那么这样的组合方式有很多,完全要靠建造者来建造,建造者说要生产一辆奔驰SUV那就得用奔驰的引擎和奔驰的车轮,该建造者对于一个具体的产品来说是绝对的权威,我们来描述一下建造者,如代码清单30-25所示。

代码清单30-25 抽象建造者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public abstract class CarBuilder {
//待建造的汽车
private ICar car;
//设计蓝图
private Blueprint bp;
public Car buildCar(){
//按照顺序生产一辆车
return new Car(buildEngine(),buildWheel());
}
//接收一份设计蓝图
public void receiveBlueprint(Blueprint _bp){
this.bp = _bp;
}
//查看蓝图,只有真正的建造者才可以查看蓝图
protected Blueprint getBlueprint(){
return bp;
}
//建造车轮
protected abstract String buildWheel();
//建造引擎
protected abstract String buildEngine();
}

看到Blueprint类了,它中文的意思是“蓝图”,你要建造一辆车必须有一个设计样稿或者蓝图吧,否则怎么生产?怎么装配?该类就是一个可参考的生产样本,如代码清单30-26所示。

代码清单30-26 生产蓝图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Blueprint {
//车轮的要求
private String wheel;
//引擎的要求
private String engine;
public String getWheel() {
return wheel;
}
public void setWheel(String wheel) {
this.wheel = wheel;
}
public String getEngine() {
return engine;
}
public void setEngine(String engine) {
this.engine = engine;
}
}

这和一个具体的产品Car类是一样的?错,不一样!它是一个蓝图,是一个可以参考的模板,有一个蓝图可以设计出非常多的产品,如有一个R系统的奔驰商务车设计蓝图,我们就可以生产出一系列的奔驰车。它指导我们的产品生产,而不是一个具体的产品。我们来看宝马车建造车间,如代码清单30-27所示。

代码清单30-27 宝马车建造车间

1
2
3
4
5
6
7
8
public class BMWBuilder extends CarBuilder {
public String buildEngine() {
return super.getBlueprint().getEngine();
}
public String buildWheel() {
return super.getBlueprint().getWheel();
}
}

这是非常简单的类。只要获得一个蓝图,然后按照蓝图制造引擎和车轮即可,剩下的事情就交给抽象的建造者进行装配。奔驰车间与此类似,如代码清单30-28所示。

代码清单30-28 奔驰车建造车间

1
2
3
4
5
6
7
8
public class BenzBuilder extends CarBuilder {
public String buildEngine() {
return super.getBlueprint().getEngine();
}
public String buildWheel() {
return super.getBlueprint().getWheel();
}
}

两个建造车间都已经完成,那现在的问题就变成了怎么让车间运作,谁来编写蓝图?谁来协调生产车间?谁来对外提供最终产品?于是导演类出场了,它不仅仅有每个车间需要的设计蓝图,还具有指导不同车间装配顺序的职责,如代码清单30-29所示。

代码清单30-29 导演类

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 Director {
//声明对建造者的引用
private CarBuilder benzBuilder = new BenzBuilder();
private CarBuilder bmwBuilder = new BMWBuilder();
//生产奔驰SUV
public ICar createBenzSuv(){
//制造出汽车
return createCar(benzBuilder, "benz的引擎", "benz的轮胎");
}
//生产出一辆宝马商务车
public ICar createBMWVan(){
return createCar(benzBuilder, "BMW的引擎", "BMW的轮胎");
}
//生产出一个混合车型
public ICar createComplexCar(){
return createCar(bmwBuilder, "BMW的引擎", "benz的轮胎");
}
//生产车辆
private ICar createCar(CarBuilder _carBuilder,String engine,String wheel){
//导演怀揣蓝图
Blueprint bp = new Blueprint();
bp.setEngine(engine);
bp.setWheel(wheel);
System.out.println("获得生产蓝图");
_carBuilder.receiveBlueprint(bp);
return _carBuilder.buildCar();
}
}

这里有一个私有方法createCar,其作用是减少导演类中的方法对蓝图的依赖,全部由该方法来完成。我们编写一个场景类,如代码清单30-30所示。

代码清单30-30 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//定义出导演类
Director director =new Director();
//给我一辆奔驰车SUV
System.out.println("===制造一辆奔驰SUV===");

ICar benzSuv = director.createBenzSuv();
System.out.println(benzSuv);
//给我一辆宝马商务车
System.out.println("\n===制造一辆宝马商务车===");
ICar bmwVan = director.createBMWVan();
System.out.println(bmwVan);
//给我一辆混合车型
System.out.println("\n===制造一辆混合车===");
ICar complexCar = director.createComplexCar();
System.out.println(complexCar);
}
}

场景类只要找到导演类(也就是车间主任了)说给我制造一辆这样的宝马车,车间主任马上通晓你的意图,设计了一个蓝图,然后命令建造车间拼命加班加点建造,最终返回给你一件最新出品的产品,运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
===制造一辆奔驰SUV=== 
获得生产蓝图
车的轮子是:benz的轮胎
车的引擎是:benz的引擎
===制造一辆宝马商务车===
获得生产蓝图
车的轮子是:BMW的轮胎
车的引擎是:BMW的引擎
===制造一辆混合车===
获得生产蓝图
车的轮子是:benz的轮胎
车的引擎是:BMW的引擎

注意最后一个运行结果片段,我们可以立刻生产出一辆混合车型,只要有设计蓝图,这非常容易实现。反观我们的抽象工厂模式,它是不可能实现该功能的,因为它更关注的是整体,而不关注到底用的是奔驰引擎还是宝马引擎,而我们的建造者模式却可以很容易地实现该设计,市场信息变更了,我们就可以立刻跟进,生产出客户需要的产品。

30.2.3 最佳实践

注意看上面的描述,我们在抽象工厂模式中使用“工厂”来描述构建者,而在建造者模式中使用“车间”来描述构建者,其实我们已经在说它们两者的区别了,抽象工厂模式就好比是一个一个的工厂,宝马车工厂生产宝马SUV和宝马VAN,奔驰车工厂生产奔驰车SUV和奔驰VAN,它是从一个更高层次去看对象的构建,具体到工厂内部还有很多的车间,如制造引擎的车间、装配引擎的车间等,但这些都是隐藏在工厂内部的细节,对外不公布。也就是对领导者来说,他只要关心一个工厂到底是生产什么产品的,不用关心具体怎么生产。而建造者模式就不同了,它是由车间组成,不同的车间完成不同的创建和装配任务,一个完整的汽车生产过程需要引擎制造车间、引擎装配车间的配合才能完成,它们配合的基础就是设计蓝图,而这个蓝图是掌握在车间主任(导演类)手中,它给建造车间什么蓝图就能生产什么产品,建造者模式更关心建造过程。虽然从外界看来一个车间还是生产车辆,但是这个车间的转型是非常快的,只要重新设计一个蓝图,即可产生不同的产品,这有赖于建造者模式的功劳。

相对来说,抽象工厂模式比建造者模式的尺度要大,它关注产品整体,而建造者模式关注构建过程,因此建造者模式可以很容易地构建出一个崭新的产品,只要导演类能够提供具体的工艺流程。也正因为如此,两者的应用场景截然不同,如果希望屏蔽对象的创建过程, 只提供一个封装良好的对象,则可以选择抽象工厂方法模式。而建造者模式可以用在构件的装配方面,如通过装配不同的组件或者相同组件的不同顺序,可以产生出一个新的对象,它可以产生一个非常灵活的架构,方便地扩展和维护系统。

32.1 命令模式VS策略模式

命令模式和策略模式的类图确实很相似,只是命令模式多了一个接收者(Receiver)角色。它们虽然同为行为类模式,但是两者的区别还是很明显的。策略模式的意图是封装算法,它认为“算法”已经是一个完整的、不可拆分的原子业务(注意这里是原子业务,而不是原子对象),即其意图是让这些算法独立,并且可以相互替换,让行为的变化独立于拥有行为的客户;而命令模式则是对动作的解耦,把一个动作的执行分为执行对象(接收者角色)、执行行为(命令角色),让两者相互独立而不相互影响。

我们从一个相同的业务需求出发,按照命令模式和策略模式分别设计出一套实现,来看看它们的侧重点有什么不同。zip和gzip文件格式相信大家都很熟悉,它们是两种不同的压缩格式,我们今天就来对一个目录或文件实现两种不同的压缩方式:zip压缩和gzip压缩(这里的压缩指的是压缩和解压缩两种对应的操作行为,下同)。实现这两种压缩格式有什么意义呢?有意义!一是zip格式(.zip后缀)是Windows操作系统常用的压缩格式,gzip格式(.gz 后缀)是*nix系统常用的压缩格式;二是JDK提供了对zip和gzip文件的操作包,非常容易实现文件的压缩和解压缩操作。

下面我们来实现不同格式的压缩和解压缩功能。

32.1.1 策略模式实现压缩算法

使用策略模式实现压缩算法非常简单,也是非常标准的,类图如图32-1所示。

在类图中,我们的侧重点是zip压缩算法和gzip压缩算法可以互相替换,一个文件或者目录可以使用zip压缩,也可以使用gzip压缩,选择哪种压缩算法是由高层模块(实际操作者) 决定的。我们来看一下代码实现。先看抽象的压缩算法,如代码清单32-1所示。

image-20210930163955571

图32-1 策略模式实现压缩算法的类图

代码清单32-1 抽象压缩算法

1
2
3
4
5
6
public interface Algorithm {
//压缩算法
public boolean compress(String source,String to);
//解压缩算法
public boolean uncompress(String source,String to);
}

每一个算法要实现两个功能:压缩和解压缩,传递进来一个绝对路径source,compress 把它压缩到to目录下,uncompress则进行反向操作——解压缩,这两个方法一定要成对地实现,为什么呢?用gzip解压缩算法能解开zip格式的压缩文件吗?我们分别来看两种不同格式的压缩算法,zip、gzip压缩算法分别如代码清单32-2、代码清单32-3所示。

代码清单32-2 zip压缩算法

1
2
3
4
5
6
7
8
9
10
11
12
public class Zip implements Algorithm {
//zip格式的压缩算法
public boolean compress(String source, String to) {
System.out.println(source + " --> " +to + " ZIP压缩成功!");
return true;
}
//zip格式的解压缩算法
public boolean uncompress(String source,String to){
System.out.println(source + " --> " +to + " ZIP解压缩成功!");
return true;
}
}

代码清单32-3 gzip压缩算法

1
2
3
4
5
6
7
8
9
10
11
12
public class Gzip implements Algorithm {
//gzip的压缩算法
public boolean compress(String source, String to) {
System.out.println(source + " --> " +to + " GZIP压缩成功!");
return true;
}
//gzip解压缩算法
public boolean uncompress(String source,String to){
System.out.println(source + " --> " +to + " GZIP解压缩成功!");
return true;
}
}

这两种压缩算法实现起来都很简单,Java对此都提供了相关的API操作,这里就不再提供详细的编写代码,读者可以参考JDK自己进行实现,或者上网搜索一下,网上有太多类似的源代码。

两个具体的算法实现了同一个接口,完全遵循依赖倒转原则。我们再来看环境角色,如代码清单32-4所示。

代码清单32-4 环境角色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Context {
//指向抽象算法
private Algorithm al;
//构造函数传递具体的算法
public Context(Algorithm _al){
this.al = _al;
}
//执行压缩算法
public boolean compress(String source,String to){
return al.compress(source, to);
}
//执行解压缩算法
public boolean uncompress(String source,String to){
return al.uncompress(source, to);
}
}

也是非常简单,指定一个算法,执行该算法,一个标准的策略模式就编写完毕了。请读者注意,这里虽然有两个算法Zip和Gzip,但是对调用者来说,这两个算法没有本质上的区别,只是“形式”上不同,什么意思呢?从调用者来看,使用哪一个算法都无所谓,两者完全可以互换,甚至用一个算法替代另外一个算法。我们继续看调用者是如何调用的,如代码清单32-5所示。

代码清单32-5 场景类

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) {
//定义环境角色
Context context;
//对文件执行zip压缩算法
System.out.println("========执行算法========");
context = new Context(new Zip());
/*
*算法替换
* context = new Context(new Gzip());
*
*/
//执行压缩算法
context.compress("c:\\windows","d:\\windows.zip");
//执行解压缩算法
context.uncompress("c:\\windows.zip","d:\\windows");
}
}

运行结果如下所示:

1
2
3
========执行算法======== 
c:\windows --> d:\windows.zip ZIP 压缩成功!
c:\windows.zip --> d:\windows ZIP 解压缩成功!

要使用gzip算法吗?在客户端(Client)上把注释删掉就可以了,其他的模块根本不受任何影响,策略模式关心的是算法是否可以相互替换。策略模式虽然简单,但是在项目组使用得非常多,可以说随手拈来就是一个策略模式。

32.1.2 命令模式实现压缩算法

命令模式的主旨是封装命令,使请求者与实现者解耦。例如,到饭店点菜,客人(请求者)通过服务员(调用者)向厨师(接收者)发送了订单(行为的请求),该例子就是通过封装命令来使请求者和接收者解耦。我们继续来看压缩和解压缩的例子,怎么使用命令模式来完成该需求呢?我们先画出类图,如图32-2所示。

类图看着复杂,但是还是一个典型的命令模式,通过定义具体命令完成文件的压缩、解压缩任务,注意我们这里对文件的每一个操作都是封装好的命令,对于给定的请求,命令不同,处理的结果当然也不同,这就是命令模式要强调的。我们先来看抽象命令,如代码清单32-6所示。

代码清单32-6 抽象压缩命令

1
2
3
4
5
6
7
public abstract class AbstractCmd {
//对接收者的引用
protected IReceiver zip = new ZipReceiver();
protected IReceiver gzip = new GzipReceiver();
//抽象方法,命令的具体单元
public abstract boolean execute(String source,String to);
}

image-20210930164353477

图32-2 命令模式实现压缩算法的类图

抽象命令定义了两个接收者的引用:zip接收者和gzip接收者,大家可以想象一下这两个“受气包”,它们完全是受众,人家让它干啥它就干啥,具体使用哪个接收者是命令决定的。具体命令有4个:zip压缩、zip解压缩、gzip压缩、gzip解压缩,分别如代码清单32-7、 32-8、32-9、32-10所示。

代码清单32-7 zip压缩命令

1
2
3
4
5
public class ZipCompressCmd extends AbstractCmd {
public boolean execute(String source,String to) {
return super.zip.compress(source, to);
}
}

代码清单32-8 zip解压缩命令

1
2
3
4
5
public class ZipUncompressCmd extends AbstractCmd {
public boolean execute(String source,String to) {
return super.zip.uncompress(source, to);
}
}

代码清单32-9 gzip压缩命令

1
2
3
4
5
public class GzipCompressCmd extends AbstractCmd {
public boolean execute(String source,String to) {
return super.gzip.compress(source, to);
}
}

代码清单32-10 gzip解压缩命令

1
2
3
4
5
public class GzipUncompressCmd extends AbstractCmd {
public boolean execute(String source,String to) {
return super.gzip.uncompress(source, to);
}
}

它们非常简单,都只有一个方法,坚决地执行命令,使用了委托的方式,由接收者来实现。我们再来看抽象接收者,如代码清单32-11所示。

代码清单32-11 抽象接收者

1
2
3
4
5
6
public interface IReceiver {
//压缩
public boolean compress(String source,String to);
//解压缩
public boolean uncompress(String source,String to);
}

抽象接收者与策略模式的抽象策略完全相同,具体的实现也完全相同,只是类名做了改动,我们先来看zip压缩的实现,如代码清单32-12所示。
代码清单32-12 zip接收者

1
2
3
4
5
6
7
8
9
10
11
12
public class ZipReceiver implements IReceiver {
//zip格式的压缩算法
public boolean compress(String source, String to) {
System.out.println(source + " --> " +to + " ZIP压缩成功!");
return true;
}
//zip格式的解压缩算法
public boolean uncompress(String source,String to){
System.out.println(source + " --> " +to + " ZIP解压缩成功!");
return true;
}
}

这就是一个具体动作执行者,它在策略模式中是一个具体的算法,关心的是是否可以被替换;而在命令模式中,它则是一个具体、真实的命令执行者。我们再来看gzip接收者,如代码清单32-13所示。

代码清单32-13 gzip接收者

1
2
3
4
5
6
7
8
9
10
11
12
public class GzipReceiver implements IReceiver {
//gzip的压缩算法
public boolean compress(String source, String to) {
System.out.println(source + " --> " +to + " GZIP压缩成功!");
return true;
}
//gzip解压缩算法
public boolean uncompress(String source,String to){
System.out.println(source + " --> " +to + " GZIP解压缩成功!");
return true;
}
}

大家可以这样思考这个问题,接收者就是厨房的厨师,具体要哪个厨师做这道菜则是餐馆的规章制度已经明确的,你让专做粤菜的师傅做一个剁椒鱼头,能做出好菜吗?在命令模式中,就是在抽象命令中定义了接收者的引用,然后在具体的实现类中确定要让哪个接收者进行处理。这就好比是客人点菜:我要一个剁椒鱼头,这就是一个命令,然后服务员 (Inovker)接收到这个命令后,就开始执行,把这个命令指定给具体的执行者执行。

当然了,接收者这部分还可以这样设计,即按照职责设计接收者,比如压缩接收者、解压缩接收者,但接口需要稍稍改动,如代码清单32-14所示。

代码清单32-14 依照职责设计的接收者接口

1
2
3
4
5
6
public interface IReceiver {
//执行zip命令
public boolean zipExec(String source,String to);
//执行gzip命令
public boolean gzipExec(String source,String to);
}

接收者接口只是定义了每个接收者都必须完成zip和gzip相关的两个逻辑,有多少个职责就有多少个实现类。我们这里只有两个职责:压缩和解压缩,分别如代码清单32-15、32-16 所示。

代码清单32-15 压缩接收者

1
2
3
4
5
6
7
8
9
10
11
12
public class CompressReceiver implements IReceiver {
//执行gzip压缩命令
public boolean gzipExec(String source, String to) {
System.out.println(source + " --> " +to + " GZIP压缩成功!");
return true;
}
//执行zip压缩命令
public boolean zipExec(String source, String to) {
System.out.println(source + " --> " +to + " ZIP压缩成功!");
return true;
}
}

代码清单32-16 解压缩接收者

1
2
3
4
5
6
7
8
9
10
11
12
public class UncompressReceiver implements IReceiver {
//执行gzip解压缩命令
public boolean gzipExec(String source, String to) {
System.out.println(source + " --> " +to + " GZIP解压缩成功!");
return true;
}
//执行zip解压缩命令
public boolean zipExec(String source, String to) {
System.out.println(source + " --> " +to + " ZIP解压缩成功!");
return true;
}
}

剩下的工作就是对抽象命令、具体命令稍作修改,这里不再赘述。为什么要在这里增加一个分支描述呢?这是为了与策略模式对比,在命令模式中,我们可以把接收者设计得与策略模式的算法相同,也可以不相同。我们按照职责设计的接口就不适用于策略模式,不可能封装一个叫做压缩的算法类,然后在类中提供两种不同格式的压缩功能,这违背了策略模式的意图——封装算法,为什么呢?如果要增加一个rar压缩算法,该怎么办呢?修改抽象算法?这是绝对不允许的!那为什么命令模式就是允许的呢?因为命令模式着重于请求者和接收者解耦,你管我接收者怎么变化,只要不影响请求者就成,这才是命令模式的意图。

命令、接收者都具备了,我们再来封装一个命令的调用者,如代码清单32-17所示。

代码清单32-17 调用者

1
2
3
4
5
6
7
8
9
10
11
public class Invoker {
//抽象命令的引用
private AbstractCmd cmd;
public Invoker(AbstractCmd _cmd){
this.cmd = _cmd;
}
//执行命令
public boolean execute(String source,String to){
return cmd.execute(source, to);
}
}

调用者非常简单,只负责把命令向后传递,当然这里也可以进行一定的拦截处理,我们暂时用不到就不做处理了。我们来看场景类是如何描述这个场景的,如代码清单32-18所示。

代码清单32-18 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
//定义一个命令,压缩一个文件
AbstractCmd cmd = new ZipCompressCmd();

/*
* 想换一个?执行解压命令
* AbstractCmd cmd = new ZipUncompressCmd();
*/
//定义调用者
Invoker invoker = new Invoker(cmd);
//我命令你对这个文件进行压缩
System.out.println("========执行压缩命令========");
invoker.execute("c:\\windows", "d:\\windows.zip");
}
}

想新增一个命令?当然没有问题,只要重新定义一个命令就成,命令改变了,高层模块只要调用它就成。请注意,这里的程序还有点欠缺,没有与文件的后缀名绑定,不应该出现使用zip压缩命令产生一个.gzip后缀的文件名,读者在实际应用中可以考虑与文件后缀名之间建立关联。

通过以上例子,我们看到命令模式也实现了文件的压缩、解压缩的功能,它的实现是关注了命令的封装,是请求者与执行者彻底分开,看看我们的程序,执行者根本就不用了解命令的具体执行者,它只要封装一个命令——“给我用zip格式压缩这个文件”就可以了,具体由谁来执行,则由调用者负责,如此设计后,就可以保证请求者和执行者之间可以相互独立, 各自发展而不相互影响。

同时,由于是一个命令模式,接收者的处理可以进行排队处理,在排队处理的过程中, 可以进行撤销处理,比如客人点了一个菜,厨师还没来得及做,那要撤回很简单,撤回也是命令,这是策略模式所不能实现的。

32.1.3 小结

策略模式和命令模式相似,特别是命令模式退化时,比如无接收者(接收者非常简单或者接收者是一个Java的基础操作,无需专门编写一个接收者),在这种情况下,命令模式和策略模式的类图完全一样,代码实现也比较类似,但是两者还是有区别的。

  • 关注点不同

策略模式关注的是算法替换的问题,一个新的算法投产,旧算法退休,或者提供多种算法由调用者自己选择使用,算法的自由更替是它实现的要点。换句话说,策略模式关注的是算法的完整性、封装性,只有具备了这两个条件才能保证其可以自由切换。

命令模式则关注的是解耦问题,如何让请求者和执行者解耦是它需要首先解决的,解耦的要求就是把请求的内容封装为一个一个的命令,由接收者执行。由于封装成了命令,就同时可以对命令进行多种处理,例如撤销、记录等。

  • 角色功能不同

在我们的例子中,策略模式中的抽象算法和具体算法与命令模式的接收者非常相似,但是它们的职责不同。策略模式中的具体算法是负责一个完整算法逻辑,它是不可再拆分的原子业务单元,一旦变更就是对算法整体的变更。

而命令模式则不同,它关注命令的实现,也就是功能的实现。例如我们在分支中也提到接收者的变更问题,它只影响到命令族的变更,对请求者没有任何影响,从这方面来说,接收者对命令负责,而与请求者无关。命令模式中的接收者只要符合六大设计原则,完全不用关心它是否完成了一个具体逻辑,它的影响范围也仅仅是抽象命令和具体命令,对它的修改不会扩散到模式外的模块。

当然,如果在命令模式中需要指定接收者,则需要考虑接收者的变化和封装,例如一个老顾客每次吃饭都点同一个厨师的饭菜,那就必须考虑接收者的抽象化问题。

  • 使用场景不同

策略模式适用于算法要求变换的场景,而命令模式适用于解耦两个有紧耦合关系的对象场合或者多命令多撤销的场景。

32.3 观察者模式VS责任链模式

为什么要把观察者模式和责任链模式放在一起对比呢?看起来这两个模式没有太多的相似性,真没有吗?回答是有。我们在观察者模式中也提到了触发链(也叫做观察者链)的问题,一个具体的角色既可以是观察者,也可以是被观察者,这样就形成了一个观察者链。这与责任链模式非常类似,它们都实现了事务的链条化处理,比如说在上课的时候你睡着了, 打鼾声音太大,盖过了老师讲课声音,老师火了,捅到了校长这里,校长也处理不了,然后告状给你父母,于是你的魔鬼日子来临了,这是责任链模式,老师、校长、父母都是链中的一个具体角色,事件(你睡觉)在链中传递,最终由一个具体的节点来处理,并将结果反馈给调用者(你挨揍了)。那什么是触发链?你还是在课堂上睡觉,还是打鼾声音太大,老师火了,但是老师掏出个扩音器来讲课,于是你睡不着了,同时其他同学的耳朵遭殃了,这就是触发链,其中老师既是观察者(相对你)也是被观察者(相对其他同学),事件从“你睡觉”到老师这里转化为“扩音器放大声音”,这也是一个链条结构,但是链结构中传递的事件改变了。

我们还是以一个具体的例子来说明两者的区别,DNS协议相信大家都听说过,只要在“网络设置”中设置一个DNS服务器地址就可以把我们需要的域名翻译成IP地址。DNS协议还是比较简单的,传递过去一个域名以及记录标志(比如是要A记录还是要MX记录),DNS就开始查找自己的记录树,找到后把IP地址反馈给请求者。我们可以在Windows 操作系统中了解一下DNS解析过程,在DOS窗口下输入nslookup命令后,结果如图32-6所示。

image-20210930172207752

图32-6 DNS服务器解析域名
我们的意图就是要DNS服务器192.168.10.1解析出www.xxx.com.cn的IP地址,DNS服务器是如何工作的呢?图32-6中的192.168.10.1这个DNS Server存储着全球的域名和IP之间的对应关系吗?不可能,目前全球的域名数量是1.7亿个,如此庞大的数字,每个DNS服务器都存储一份,还怎么快速响应?DNS解析的响应时间一般都是毫秒级别的,如此高的性能要求还怎么让DNS服务器遍地开花呢?而且域名变更非常频繁,数据读写的量也非常大,不可能每个DNS服务器都保留这1.7亿数据,那么是怎么设计的呢?DNS协议还是很聪明的,它规定了每个区域的DNS服务器(Local DNS)只保留自己区域的域名解析,对于不能解析的域名, 则提交上级域名解析器解析,最终由一台位于美国洛杉矶的顶级域名服务器进行解析,返回结果。很明显这是一个事务的链结构处理,我们使用两种模式来实现该解析过程。

32.3.1 责任链模式实现DNS解析过程

本小节我们用责任链模式来实现DNS解析过程。首先我们定义一下业务场景,这里有三个DNS服务器:上海DNS服务器(区域服务器)、中国顶级DNS服务器(父服务器)、全球顶级DNS服务器,其示意图如图32-7所示。

image-20210930172314258

图32-7 DNS解析示意图
假设有请求者发出请求,由上海DNS进行解析,如果能够解析,则返回结果,若不能解析,则提交给父服务器(中国顶级DNS)进行解析,若还不能解析,则提交到全球顶级DNS 进行解析,若还不能解析呢?那就返回该域名无法解析。确实,这与责任链模式非常相似, 我们把这一过程抽象一下,类图如图32-8所示。

image-20210930172345216

图32-8 责任链模式实现DNS解析的类图
我们来解释一下类图,Recorder是一个BO对象,它记录DNS服务器解析后的结果,包括域名、IP地址、属主(即由谁解析的),除此之外还有getter/setter方法。DnsServer抽象类中的resolve方法是一个基本方法,每个DNS服务器都必须拥有该方法,它对DNS进行解析,如何解析呢?具体是由echo方法来实现的,每个DNS服务器独自实现。类图还是比较简单的, 我们首先看一下解析记录Recorder类,如代码清单32-31所示。

代码清单32-31 解析记录

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
public class Recorder {
//域名
private String domain;
//IP地址
private String ip;
//属主
private String owner;
public String getDomain() {
return domain;
}
public void setDomain(String domain) {
this.domain = domain;
}
public String getIp() {
return ip;
}
public void setIp(String ip) {
this.ip = ip;
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
this.owner = owner;
}
//输出记录信息
@Override
public String toString(){
String str= "域名:" + this.domain;
str = str + "\nIP地址:" + this.ip;
str = str + "\n解析者:" + this.owner;
return str;
}
}

为什么要覆写toString方法呢?是为了打印展示的需要,可以直接把Recorder的信息打印出来。我们再来看抽象域名服务器,如代码清单32-32所示。

代码清单32-32 抽象域名服务器

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 DnsServer {
//上级DNS是谁
private DnsServer upperServer;

//解析域名
public final Recorder resolve(String domain){
Recorder recorder=null;
if(isLocal(domain)){
//是本服务器能解析的域名
recorder = echo(domain);
}
else{
//本服务器不能解析
//提交上级DNS进行解析
recorder = upperServer.resolve(domain);
}
return recorder;
}
//指向上级DNS
public void setUpperServer(DnsServer _upperServer){
this.upperServer = _upperServer;
}
//每个DNS都有一个数据处理区(ZONE),检查域名是否在本区中
protected abstract boolean isLocal(String domain);
//每个DNS服务器都必须实现解析任务
protected Recorder echo(String domain){
Recorder recorder = new Recorder();
//获得IP地址
recorder.setIp(genIpAddress());
recorder.setDomain(domain);
return recorder;
}
//随机产生一个IP地址,工具类
private String genIpAddress(){
Random rand = new Random();
String address = rand.nextInt(255) + "." + rand.nextInt(255) + "."+ rand.nextInt(255) + "."+ rand.nextInt(255);
return address;
}
}

在该类中有一个方法——genIpAddress方法——没有在类图中展现出来,它用于实现随机生成IP地址,这是我们为模拟DNS解析场景而建立的一个虚拟方法,在实际的应用中是不可能出现的。抽象DNS服务器编写完成,我们再来看具体的DNS服务器,先看上海的DNS服务器,如代码清单32-33所示。

代码清单32-33 上海DNS服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SHDnsServer extends DnsServer {
@Override
protected Recorder echo(String domain) {
Recorder recorder= super.echo(domain);
recorder.setOwner("上海DNS服务器");
return recorder;
}
//定义上海的DNS服务器能处理的级别
@Override
protected boolean isLocal(String domain) {
return domain.endsWith(".sh.cn");

}
}

为什么要覆写echo方法?各具体的DNS服务器实现自己的解析过程,属于个性化处理, 它代表的是每个DNS服务器的不同处理逻辑。还要注意一下,我们在这里做了一个简化处理,所有以”.sh.cn”结尾的域名都由上海DNS服务器解析。其他的中国顶级DNS和全球顶级DNS实现过程类似,如代码清单32-34、32-35所示。

代码清单32-34 中国顶级DNS服务器

1
2
3
4
5
6
7
8
9
10
11
12
public class ChinaTopDnsServer extends DnsServer {
@Override
protected Recorder echo(String domain) {
Recorder recorder = super.echo(domain);
recorder.setOwner("中国顶级DNS服务器");
return recorder;
}
@Override
protected boolean isLocal(String domain) {
return domain.endsWith(".cn");
}
}

代码清单32-35 全球顶级DNS服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
public class TopDnsServer extends DnsServer {
@Override
protected Recorder echo(String domain) {
Recorder recorder = super.echo(domain);
recorder.setOwner("全球顶级DNS服务器");
return recorder;
}
@Override
protected boolean isLocal(String domain) {
//所有的域名最终的解析地点
return true;
}
}

所有的DNS服务器都准备好了,下面我们写一个客户端来模拟一下IP地址是怎么解析的,如代码清单32-36所示。

代码清单32-36 场景类

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 Client {
public static void main(String[] args) throws Exception {

//上海域名服务器
DnsServer sh = new SHDnsServer();
//中国顶级域名服务器
DnsServer china = new ChinaTopDnsServer();
//全球顶级域名服务器
DnsServer top = new TopDnsServer();
//定义查询路径
china.setUpperServer(top);
sh.setUpperServer(china);
//解析域名
System.out.println("=====域名解析模拟器=====");
while(true){
System.out.print("\n请输入域名(输入N退出):");
String domain = (new BufferedReader(new InputStreamReader (System.in))).readLine();
if(domain.equalsIgnoreCase("n")){
return;
}
Recorder recorder = sh.resolve(domain);
System.out.println("----DNS服务器解析结果----");
System.out.println(recorder);
}
}
}

我们来模拟一下,运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=====域名解析模拟器===== 
请输入域名(输入N退出):www.xxx.sh.cn
----DNS服务器解析结果----
域名:www. xxx.sh.cn
IP地址:69.224.162.154
解析者:上海DNS服务器
请输入域名(输入N退出):www. xxx.com.cn
----DNS服务器解析结果----
域名:www. xxx.com.cn
IP地址:51.28.66.140
解析者:中国顶级DNS服务器
请输入域名(输入N退出):www. xxx.com
----DNS服务器解析结果----
域名:www. xxx.com
IP地址:73.247.80.117
解析者:全球顶级DNS服务器
请输入域名(输入N退出):n

请注意看运行结果,以”.sh.cn”结尾的域名确实由上海DNS服务器解析了,以”.cn”结尾的域名由中国顶级DNS服务器解析了,其他域名都由全球顶级DNS服务器解析。这个模拟过程看起来很完整,它完全就是责任链模式的一个具体应用,把一个请求放置到链中的首节点, 然后由链中的某个节点进行解析并将结果反馈给调用者。但是,我可以负责任地告诉你:这个解析过程是有缺陷的,什么缺陷?后面会说明。

32.3.2 触发链模式实现DNS解析过程

上面说到使用责任链模式模拟DNS解析过程是有缺陷的,究竟有什么缺陷?大家是不是觉得这个解析过程很完美了,没什么问题了?那说明你对DNS协议了解得还不太深入。我们来做一个实验,在dos窗口下输入nslookup命令,然后输入多个域名,注意观察返回值有哪些数据是相同的。可以看出,解析者都相同,都是由同一个DNS服务器解析的,准确地说都是由本机配置的DNS服务器做的解析。这与我们上面的模拟过程是不相同的,看看我们模拟的过程,对请求者来说,”.sh.cn”是由区域DNS解析的,”.com”却是由全球顶级DNS解析的,与真实的过程不相同,这是怎么回事呢?

肯定地说,采用责任链模式模拟DNS解析过程是不完美的,或者说是有缺陷的,怎么来修复这个缺陷呢?我们先来看看真实的DNS解析过程,如图32-9所示。

image-20210930172848826

图32-9 真实的DNS解析示意图
解析一个域名的完整路径如图32-9中的标号①~⑥所示,首先由请求者发送一个请求, 然后由上海DNS服务器尝试解析,若不能解析再通过路径②转发给中国顶级DNS进行解析, 解析后的结果通过路径⑤返回给上海DNS服务器,然后由上海DNS服务器通过路径⑥返回给请求者。同样,若中国顶级DNS不能解析,则通过路径③转由全球顶级DNS进行解析,通过路径④把结果返回给中国顶级DNS,然后再通过路径⑤返回给上海DNS。注意看标号⑥,不管一个域名最终由谁解析,最终反馈到请求者的还是第一个节点,也就是说首节点负责对请求者应答,其他节点都不与请求者交互,而只与自己的左右节点交互。实际上我们的DNS服务器确实是如此处理的,例如本机请求查询一个www.abcdefg.com的域名,上海DNS服务器解析不到这个域名,于是提交到中国顶级DNS服务器,如果中国顶级DNS服务器有该域名的记录,则找到该记录,反馈到上海DNS服务器,上海DNS服务器做两件事务处理:一是响应请求者,二是存储该记录,以备其他请求者再次查询,这类似于数据缓存。

整个场景我们已经清晰,想想看,我们把请求者看成是被观察者,它的行为或属性变更通知了观察者——上海DNS,上海DNS又作为被观察者出现了自己不能处理的行为(行为改变),通知了中国顶级DNS,依次类推,这是不是一个非常标准的触发链?而且还必须是同步的触发,异步触发已经在该场景中失去了意义(读者可以想想为什么)。

分析了这么多,我们用触发链来模拟DNS的解析过程,如图32-10所示。

image-20210930172945418

图32-10 触发链模式实现DNS解析的类图

与责任链模式很相似,仅仅多了一个Observable父类和Observer接口,但是在实现上这两种模式有非常大的差异。我们先来解释一下抽象DnsServer的作用。

  • 标示声明

表示所有的DNS服务器都具备双重身份:既是观察者也是被观察者,这很重要,它声明所有的服务器都具有相同的身份标志,具有该标志后就可以在链中随意移动,而无需固定在链中的某个位置(这也是链的一个重要特性)。

  • 业务抽象

方法setUpperServer的作用是设置父DNS,也就是设置自己的观察者,update方法不仅仅是一个事件的处理者,也同时是事件的触发者。

我们来看代码,首先是最简单的,Recorder类与责任链模式中的记录相同,这里不再赘述。那我们就先看看该模式的核心抽象DnsServer,如代码清单32-37所示。

代码清单32-37 抽象DNS服务器

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 abstract class DnsServer extends Observable implements Observer {
//处理请求,也就是接收到事件后的处理
public void update(Observable arg0, Object arg1) {
Recorder recorder = (Recorder)arg1;
//如果本机能解析
if(isLocal(recorder)){
recorder.setIp(genIpAddress());
}
else{
//本机不能解析,则提交到上级DNS
responsFromUpperServer(recorder);
}
//签名
sign(recorder);
}
//作为被观察者,允许增加观察者,这里上级DNS一般只有一个
public void setUpperServer(DnsServer dnsServer){
//先清空,然后再增加
super.deleteObservers();
super.addObserver(dnsServer);
}
//向父DNS请求解析,也就是通知观察者
private void responsFromUpperServer(Recorder recorder){
super.setChanged();
super.notifyObservers(recorder);
}
//每个DNS服务器签上自己的名字
protected abstract void sign(Recorder recorder);
//每个DNS服务器都必须定义自己的处理级别
protected abstract boolean isLocal(Recorder recorder);
//随机产生一个IP地址,工具类
private String genIpAddress(){
Random rand = new Random();
String address = rand.nextInt(255) + "." + rand.nextInt(255) + "."+ rand.nextInt(255) + "."+ rand.nextInt(255);
return address;
}
}

注意看一下responseFromUpperServer方法,它只允许设置一个观察者,因为一般的DNS 服务器都只有一个上级DNS服务器。sign方法是签名,这个记录是由谁解析出来的,就由各个实现类独自来实现。三个DnsServer的实现类都比较简单,如代码清单32-38、32-39、32- 40所示。

代码清单32-38 上海DNS服务器

1
2
3
4
5
6
7
8
9
10
11
public class SHDnsServer extends DnsServer {
@Override
protected void sign(Recorder recorder) {
recorder.setOwner("上海DNS服务器");
}
//定义上海的DNS服务器能处理的级别
@Override
protected boolean isLocal(Recorder recorder) {
return recorder.getDomain().endsWith(".sh.cn");
}
}

代码清单32-39 中国顶级DNS服务器

1
2
3
4
5
6
7
8
9
10
public class ChinaTopDnsServer extends DnsServer {
@Override
protected void sign(Recorder recorder) {
recorder.setOwner("中国顶级DNS服务器");
}
@Override
protected boolean isLocal(Recorder recorder) {
return recorder.getDomain().endsWith(".cn");
}
}

代码清单32-40 全球顶级DNS服务器

1
2
3
4
5
6
7
8
9
10
11
public class TopDnsServer extends DnsServer {
@Override
protected void sign(Recorder recorder) {
recorder.setOwner("全球顶级DNS服务器");
}
@Override
protected boolean isLocal(Recorder recorder) {
//所有的域名最终的解析地点
return true;
}
}

我们再建立一个场景类模拟一下DNS解析过程,如代码清单32-41所示。

代码清单32-41 场景类

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 Client {
public static void main(String[] args) throws Exception {
//上海域名服务器
DnsServer sh = new SHDnsServer();
//中国顶级域名服务器
DnsServer china = new ChinaTopDnsServer();
//全球顶级域名服务器
DnsServer top = new TopDnsServer();
//定义查询路径
china.setUpperServer(top);

sh.setUpperServer(china);
//解析域名
System.out.println("=====域名解析模拟器=====");
while(true){
System.out.print("\n请输入域名(输入N退出):");
String domain = (new BufferedReader(new InputStreamReader (System.in))).readLine();
if(domain.equalsIgnoreCase("n")){
return;
}
Recorder recorder = new Recorder();
recorder.setDomain(domain);
sh.update(null,recorder);
System.out.println("----DNS服务器解析结果----");
System.out.println(recorder);
}
}
}

与责任链模式中的场景类很相似。读者请注意sh.update(null,recorder)这句代码,这是我们虚拟了观察者触发动作,完整的做法是把场景类作为一个被观察者,然后设置观察者为上海DNS服务器,再进行测试,其结果完全相同,我们这里为减少代码量采用了简化处理,有兴趣的读者可以扩充实现。

我们来看看运行结果如何,结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
=====域名解析模拟器===== 
请输入域名(输入N退出):www.xxx.sh.cn
----DNS服务器解析结果----
域名:www.xxx.sh.cn
IP地址:197.15.34.227
解析者:上海DNS服务器
请输入域名(输入N退出):www.xxx.com.cn
----DNS服务器解析结果----
域名:www.xxx.com.cn
IP地址:201.177.148.99
解析者:上海DNS服务器
请输入域名(输入N退出):www.xxx.com
----DNS服务器解析结果----
域名:www.xxx.com
IP地址:251.41.14.230
解析者:上海DNS服务器
请输入域名(输入N退出):n

可以看出,所有的解析结果都是由上海DNS服务器返回的,这才是真正的DNS解析过程。如何知道它是由上海DNS服务器解析的还是由别的DNS服务器解析的呢?很好办,把代码拷贝过去,然后调试跟踪一下就可以了。或者仔细看看代码,理解一下代码逻辑也可以非常清楚地知道它是如何解析的。

再仔细看一下我们的代码逻辑,上下两个节点之间的关系很微妙,很有意思。

  • 下级节点对上级节点顶礼膜拜

比如我们输入的这个域名www.xxx.com,上海域名服务器只知道它是由父节点(中国顶级DNS服务器)解析的,而不知道父节点把该请求转发给了更上层节点(全球顶级DNS服务器),也就是说下级节点关注的是上级节点的响应,只要是上级反馈的结果就认为是上级的。www.xxx.com这个域名最终是由最高节点(全球顶级DNS服务器)解析的,它把解析结果传递给第二个节点(中国顶级DNS服务器)时的签名为“全球顶级DNS服务器”,而第二个节点把请求传递给首节点(上海DNS服务器)时的签名被修改为“中国顶级DNS服务器”。所有从上级节点反馈的响应都认为是上级节点处理的结果,而不追究到底是不是真的是上级节点处理的。

  • 上级节点对下级节点绝对信任

上级节点只对下级节点负责,它不关心下级节点的请求从何而来,只要是下级发送的请求就认为是下级的。还是以www.xxx.com域名为例,当最高节点(全球顶级DNS服务器)获得解析请求时,它认为这个请求是谁的?当然是第二个节点(中国顶级DNS服务器)的,否则它也不会把结果反馈给它,但是这个请求的源头却是首节点(上海DNS服务器)的。

32.3.3 小结

通过对DNS解析过程的实现,我们发现触发链和责任链虽然都是链结构,但是还是有区别的。

  • 链中的消息对象不同

从首节点开始到最终的尾节点,两个链中传递的消息对象是不同的。责任链模式基本上不改变消息对象的结构,虽然每个节点都可以参与消费(一般是不参与消费),类似于“雁过拔毛”,但是它的结构不会改变,比如从首节点传递进来一个String对象或者Person对象, 不会到链尾的时候成了int对象或者Human对象,这在责任链模式中是不可能的,但是在触发链模式中是允许的,链中传递的对象可以自由变化,只要上下级节点对传递对象了解即可, 它不要求链中的消息对象不变化,它只要求链中相邻两个节点的消息对象固定。

  • 上下节点的关系不同

在责任链模式中,上下节点没有关系,都是接收同样的对象,所有传递的对象都是从链首传递过来,上一节点是什么没有关系,只要按照自己的逻辑处理就成。而触发链模式就不同了,它的上下级关系很亲密,下级对上级顶礼膜拜,上级对下级绝对信任,链中的任意两个相邻节点都是一个牢固的独立团体。

  • 消息的分销渠道不同

在责任链模式中,一个消息从链首传递进来后,就开始沿着链条向链尾运动,方向是单一的、固定的;而触发链模式则不同,由于它采用的是观察者模式,所以有非常大的灵活性,一个消息传递到链首后,具体怎么传递是不固定的,可以以广播方式传递,也可以以跳跃方式传递,这取决于处理消息的逻辑。

34.1 搬移UNIX的命令

在操作系统的世界里,有两大阵营一直在PK着:*nix(包括UNIX和Linux)和Windows。从目前的统计数据来看,*nix在应用服务器领域占据相对优势,不过Windows也不甘示弱,国内某些小型银行已经在使用PC Server(安装Windows操作系统的服务器)集群来进行银行业务运算,而且稳定性、性能各方面的效果不错;而在个人桌面方面,Windows 是占绝对优势的,大家应该基本上都在用这个操作系统,它的诸多优点这里就不多说了,我们今天就来解决一个习惯问题。如果你负责过UNIX系统维护,你自己的笔记本又是Windows 操作系统的话,我想你肯定有这样的经验,如图34-1所示。

image-20210930214930410

图34-1 时常犯的错误

是不是经常把UNIX上的命令敲到Windows系统了?为了避免这种情况发生,可以把UNIX上的命令移植到Windows上,也就是Windows下的shell工具,有很多类似的工具,比如cygwin、GUN Bash等,这些都是非常完美的工具,我们今天的任务就是自己写一个这样的工具。怎么写呢?我们学了这么多的模式,当然要融会贯通了,可以使用命令模式、责任链模式、模板方法模式设计一个方便扩展、稳定的工具。

我们先说说UNIX下的命令,一条命令分为命令名、选项和操作数,例如命令”ls-l/usr”, 其中,ls是命令名,l是选项,/usr是操作数,后两项都是可选项,根据实际情况而定。UNIX 命令一定遵守以下几个规则:

  • 命令名为小写字母。
  • 命令名、选项、操作数之间以空格分隔,空格数量不受限制。
  • 选项之间可以组合使用,也可以单独拆分使用。
  • 选项以横杠(-)开头。

在UNIX世界中,我们最常用的就是ls这个命令,它用于显示目录或文件信息,下面我们先来看看这个命令。常用的有以下几条组合命令:

  • ls:简单列出一个目录下的文件。
  • ls-l:详细列出目录下的文件。
  • ls-a:列出目录下包含的隐藏文件,主要是点号(.)开头的文件。
  • ls-s:列出文件的大小。

除此之外,还有一些非常常用的组合命令,如”ls-la”、”ls-ls”等。ls命令名确定了,但是其后连接的选项和操作数是不确定的。操作数我们不用关心它,每个命令必然有一个操作数,若没有则是当前的目录。问题的关键是选项,用哪个选项以及什么时候使用都是由用户决定的,也就是从设计上考虑。设计者需要完全解析所有的参数,需要很多个类来处理如此多的选项,客户输入一个参数,立刻返回一个结果。针对一个ls命令族,要求如下:

  • 每一个ls命令都有操作数,默认操作数为当前目录。
  • 选项不可重复,例如对于”ls-l-l-s”,解析出的选项应该只有两个:l选项和s选项。
  • 每个选项返回不同的结果,也就是说每个选项应该由不同的业务逻辑来处理。
  • 为提高扩展性,ls命令族内的运算应该是对外封闭的,减少外界访问ls命令族内部细 节的可能性。

针对一个命令族的分析结果,我们可以使用什么模式?责任链模式!对,只要把一个参数传递到链首,就可以立刻获得一个结果,中间是如何传递的以及由哪个逻辑解析都不需要外界(高层)模块关心,该模块的类图如图34-2所示。

image-20210930215126398

图34-2 命令族的解析类图
类图还是比较清晰的,UNIX的命令有上百个,我们定义一个CommandName抽象类,所有的命令都继承于该类,它就是责任链模式的handler类,负责链表控制;每个命令族都有一个独立的抽象类,因为每个命令族都有其独特的个性,比如ls命令和df命令,其后可加的参数是不一样的,这就可以在抽象类AbstractLS中定义,而且它还有标示作用,标示其下的实现类都是实现ls命令的,只是命令的选项不同;Context负责建立一条命令的链表,比如ls命令族、df命令族等,它组装出一个处理一个命令族的责任链,并返回首节点供高层模块调用,这是非常典型的责任链模式。

分析完毕一个具体的命令族,已经确定可以采用责任链模式,我们继续往下分析。 UNIX命令非常多,敲一个命令返回一个结果,每个具体的命令可以由相关的命令族(也就是责任链)来解析,但是如此多的命令还是需要有一个派发的角色,输入一个命令,不管后台谁来解析,返回一个结果就成,这就要用到命令模式。命令模式负责协调各个命令正确地传递到各个责任链的首节点,这就是它的任务,其类图如图34-3所示。

image-20210930215233203

图34-3 命令传递类图

是不是典型的命令模式类图?其中Chain是一个标示符,表示的就是我们上面分析的责任链,每一个具体的命令负责调用责任链的首节点,获得返回值,结束命令的执行。两个核心模块都分析完毕了,就可以把类图融合在一起,完整的类图如图34-4所示。

这个类图还是比较简单的,我们来看一下各个类的职责。

  • ClassUtils

ClassUtils是工具类,其主要职责是根据一个接口、父类查找到所有的子类。在不考虑效 率的应用中,使用该类可以带来非常好的扩展性。

  • CommandVO

CommandVO是命令的值对象,它把一个命令解析为命令名、选项、操作数,例如”ls- l/usr”命令分别解析为getCommandName、getParam、getData三个方法的返回值。

  • CommandEnum

CommandEnum是枚举类型,是主要的命令配置文件。为什么需要枚举类型?这是JDK 1.5提供的一个非常好的功能,我们在程序中再讲解如何使用它。

所有的分析都已经完成了,我们来看看程序。程序不复杂,看看类图,应该先写命令的解释,这是项目的核心。我们先来看CommandName抽象类,如代码清单34-1所示。

image-20210930215409230

图34-4 完整类图

代码清单34-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
public abstract class CommandName {
private CommandName nextOperator;
public final String handleMessage(CommandVO vo){
//处理结果
String result = "";
//判断是否是自己处理的参数
if(vo.getParam().size() == 0 || vo.getParam().contains (this.getOperateParam())){
result = this.echo(vo);
}
else{
if(this.nextOperator !=null){
result = this.nextOperator.handleMessage(vo);

}
else{
result = "命令无法执行";
}
}
return result;
}
//设置剩余参数由谁来处理
public void setNext(CommandName _operator){
this.nextOperator = _operator;
}
//每个处理者都要处理一个后缀参数
protected abstract String getOperateParam();
//每个处理者都必须实现处理任务
protected abstract String echo(CommandVO vo);
}

很简单,就是责任链模式中的handler,也就是中控程序,控制一个链应该如何建立。我们再来看3个ls命令族,先看AbstractLS抽象类,如代码清单34-2所示。

代码清单34-2 抽象ls命令

1
2
3
4
5
6
7
8
public abstract class AbstractLS extends CommandName{
//默认参数
public final static String DEFAULT_PARAM = "";
//参数a
public final static String A_PARAM ="a";
//参数l
public final static String L_PARAM = "l";
}

很惊讶,是吗?怎么是个空的抽象类?是的,确实是一个空类,只定义了3个参数名称,它有两个职责:

  • 标记ls命令族。
  • 个性化处理。

因为现在还没有思考清楚ls有什么个性(可以把命令的选项也认为是其个性化数据), 所以先写个空类放在这里,以后想清楚了再填写上去,留下一些可扩展的类也许会给未来带来不可估量的优点。

我们再来看ls不带任何参数的命令处理,如代码清单34-3所示。

代码清单34-3 ls命令

1
2
3
4
5
6
7
8
9
10
public class LS extends AbstractLS {
//最简单的ls命令
protected String echo(CommandVO vo) {
return FileManager.ls(vo.formatData());
}
//参数为空
protected String getOperateParam() {
return super.DEFAULT_PARAM;
}
}

太简单了,首先定义了自己能处理什么样的参数,即只能处理不带参数的ls命令,getOperateParam返回一个长度为零的字符串,就是说该类作为链上的一个节点,只处理没有参数的ls命令。echo方法是执行ls命令,通过调用操作系统相关的命令返回结果。我们再来看ls -l命令,如代码清单34-4所示。

代码清单34-4 ls-l命令

1
2
3
4
5
6
7
8
9
public class LS_L extends AbstractLS {
protected String echo(CommandVO vo) {
return FileManager.ls_l(vo.formatData());
}
//l选项
protected String getOperateParam() {
return super.L_PARAM;
}
}

该类只处理选项为”l”的命令,也非常简单。ls-a命令的处理与此类似,如代码清单34-5 所示。

代码清单34-5 ls-a命令

1
2
3
4
5
6
7
8
9
public class LS_A extends AbstractLS {
//ls -a命令
protected String echo(CommandVO vo) {
return FileManager.ls_a(vo.formatData());
}
protected String getOperateParam() {
return super.A_PARAM;
}
}

这3个实现类都关联到了FileManager,这个类有什么用呢?它是负责与操作系统交互的。要把UNIX的命令迁移到Windows上运行,就需要调用Windows的低层函数,实现起来较复杂,而且和我们本章要讲的内容没有太大关系,所以这里采用示例性代码代替,如代码清单34-6所示。

代码清单34-6 文件管理类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class FileManager {
//ls命令
public static String ls(String path){
return "file1\nfile2\nfile3\nfile4";
}
//ls-l命令
public static String ls_l(String path){
String str = "drw-rw-rw root system 1024 2009-8-20 10:23 file1\n";
str = str + "drw-rw-rw root system 1024 2009-8-20 10:23 file2\n";
str = str + "drw-rw-rw root system 1024 2009-8-20 10:23 file3";
return str;
}
//ls -a命令
public static String ls_a(String path){
String str = ".\n..\nfile1\nfile2\nfile3";
return str;
}
}

以上都是比较简单的方法,大家有兴趣可以自己实现一下,以下提供3种思路:

  • 通过java.io.File类自己封装出类似UNIX的返回格式。
  • 通过java.lang.Runtime类的exec方法执行dos的dir命令,产生类似的ls结果。
  • 通过JNI(Java Native Interface)来调用与操作系统有关的动态链接库,当然前提是需 要自己写一个动态链接库文件。

3个具体的命令都已经解析完毕,我们再来看看如何建立一条处理链,由于建链的任务 已经移植到抽象命令类,我们就先来看抽象类Command,如代码清单34-7所示。

代码清单34-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
public abstract class Command {
public abstract String execute(CommandVO vo);
//建立链表
protected final List<? extends CommandName> buildChain(Class<? extends CommandName> abstractClass){
//取出所有的命令名下的子类
List<Class> classes = ClassUtils.getSonClass(abstractClass);
//存放命令的实例,并建立链表关系
List<CommandName> commandNameList = new ArrayList<CommandName>();
for(Class c:classes){
CommandName commandName =null;
try {
//产生实例
commandName = (CommandName)Class.forName (c.getName()) .newInstance();
}
catch (Exception e){
// TODO
异常处理 }
//建立链表
if(commandNameList.size()>0){
commandNameList.get(commandNameList.size()-1).setNext (commandName);
}
commandNameList.add(commandName);
}
return commandNameList;
}
}

Command抽象类有两个作用:一是定义命令的执行方法,二是负责命令族(责任链)的 建立。其中buildChain方法负责建立一个责任链,它通过接收一个抽象的命令族类就可以建 立一条命令解析链,如传递AbstarctLS类就可以建立一条解析ls命令族的责任链,请读者注意 如下这句代码:

1
commandName = (CommandName)Class.forName(c.getName()).newInstance();

在一个遍历中,类中的每个元素都是一个类名,然后根据类名产生一个实例,它会抛出异常,例如类文件不存在、初始化失败等,读者在设计时要实现该部分的异常。我们再来想一下,每个实现类的类名是如何取得的呢?看下面这句代码:

1
List<Class> classes = ClassUtils.getSonClass(abstractClass);

根据一个父类取得所有子类,是一个非常好的工具类,其实现如代码清单34-8所示。

代码清单34-8 根据父类获得子类

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
public class ClassUtils {
//根据父类查找到所有的子类,默认情况是子类和父类都在同一个包名下
public static List<Class> getSonClass(Class fatherClass){
//定义一个返回值
List<Class> returnClassList = new ArrayList<Class>();
//获得包名称
String packageName = fatherClass.getPackage().getName();
//获得包中的所有类
List<Class> packClasses = getClasses(packageName);
//判断是否是子类
for(Class c:packClasses){
if(fatherClass.isAssignableFrom(c) && !fatherClass.equals(c)){
returnClassList.add(c);
}
}
return returnClassList;
}
//从一个包中查找出所有的类,在jar包中不能查找
private static List<Class> getClasses(String packageName) {
ClassLoader classLoader = Thread.currentThread() .getContextClassLoader();
String path = packageName.replace('.', '/');
Enumeration<URL> resources = null;
try {
resources = classLoader.getResources(path);
}
catch (IOException e) {
// TODO
Auto-generated catch block e.printStackTrace();
}
List<File> dirs = new ArrayList<File>();
while (resources.hasMoreElements()) {
URL resource = resources.nextElement();
dirs.add(new File(resource.getFile()));
}
ArrayList<Class> classes = new ArrayList<Class>();
for (File directory : dirs) {
classes.addAll(findClasses(directory, packageName));
}
return classes;
}
private static List<Class> findClasses(File directory, String packageName) {
List<Class> classes = new ArrayList<Class>();
if (!directory.exists()) {
return classes;
}
File[] files = directory.listFiles();
for (File file : files) {
if (file.isDirectory()) {
assert !file.getName().contains(".");
classes.addAll(findClasses(file, packageName + "." + file.getName()));
}
else if (file.getName().endsWith(".class")) {
try {
classes.add(Class.forName(packageName + '.' + file.getName() .substring(0, file.getName().length() - 6)));
}
catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
return classes;
}
}

这个类请大家谨慎使用,在核心的应用中尽量不要使用该工具,它会严重影响性能。

再来看LSCommand类的实现,如代码清单34-9所示。

代码清单34-9 具体的ls命令

1
2
3
4
5
6
7
public class LSCommand extends Command{
public String execute(CommandVO vo){
//返回链表的首节点
CommandName firstNode = super.buildChain(AbstractLS.class).get(0);
return firstNode.handleMessage(vo);
}
}

很简单的方法,先建立一个命令族的责任链,然后找到首节点调用。在该类中我们使用CommandVO类,它是一个封装对象,其代码如代码清单34-10所示。

代码清单34-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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public class CommandVO {
//定义参数名与参数的分隔符号,一般是空格
public final static String DIVIDE_FLAG =" ";
//定义参数前的符号,Unix一般是-,如ls
-la public final static String PREFIX="-";
//命令名,如ls、du
private String commandName = "";
//参数列表
private ArrayList<String> paramList = new ArrayList<String>();
//操作数列表
private ArrayList<String> dataList = new ArrayList<String>();
//通过构造函数传递进来命令
public CommandVO(String commandStr){
//常规判断
if(commandStr != null && commandStr.length() !=0){
//根据分隔符号拆分出执行符号
String[] complexStr = commandStr.split(CommandVO.DIVIDE_FLAG);
//第一个参数是执行符号
this.commandName = complexStr[0];
//把参数放到List中
for(int i=1;i<complexStr.length;i++){
String str = complexStr[i];
//包含前缀符号,认为是参数
if(str.indexOf(CommandVO.PREFIX)==0){
this.paramList.add(str.replace (CommandVO.PREFIX, "").trim());
}
else{
this.dataList.add(str.trim());
}
}
}
else{
//传递的命令错误
System.out.println("命令解析失败,必须传递一个命令才能执行!");
}
}
//得到命令名
public String getCommandName(){
return this.commandName;
}
//获得参数
public ArrayList<String> getParam(){
//为了方便处理空参数
if(this.paramList.size() ==0){
this.paramList.add("");
}
return new ArrayList(new HashSet(this.paramList));
}
//获得操作数
public ArrayList<String> getData(){
return this.dataList;
}
}

CommandVO解析一个命令,规定一个命令必须有3项:命令名、选项、操作数。如果没 有呢?那就以长度为零的字符串代替,通过这样的一个约定可以大大降低命令解析的开发工 作。注意getParam参数中的返回值:

1
new ArrayList(new HashSet(this.paramList));

为什么要这么处理?HashSet具有值唯一的优点,这样处理就是为了避免出现两个相同的参数,比如对于”ls-l-l-s”这样的命令,通过getParam返回的参数是几个呢?回答是两个:l 选项和s选项。

我们再来看Invoker类,它是负责命令分发的类,如代码清单34-11所示。

代码清单34-11 命令分发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Invoker {
//执行命令
public String exec(String _commandStr){
//定义返回值
String result = "";
//首先解析命令
CommandVO vo = new CommandVO(_commandStr);
//检查是否支持该命令
if(CommandEnum.getNames().contains(vo.getCommandName())){
//产生命令对象
String className = CommandEnum.valueOf (vo.getCommandName()) .getValue();

Command command;
try {
command = (Command)Class.forName(className).newInstance();
result = command.execute(vo);
}
catch(Exception e){
// TODO
异常处理 }
}
else{
result = "无法执行命令,请检查命令格式";
}
return result;
}
}

实现也是比较简单的,从CommandEnum中获得命令与命令类的配置信息,然后建立一个命令实例,调用其execute方法,完成命令的执行操作。CommandEnum类是一个枚举类型,如代码清单34-12所示。

代码清单34-12 命令配置对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public enum CommandEnum {
ls("com.cbf4life.common.command.LSCommand");
private String value = "";
//定义构造函数,目的是Data(value)类型的相匹配
private CommandEnum(String value){
this.value = value;
}
public String getValue(){
return this.value;
}
//返回所有的enum对象
public static List<String> getNames(){
CommandEnum[] commandEnum = CommandEnum.values();
List<String> names = new ArrayList<String>();
for(CommandEnum c:commandEnum){
names.add(c.name());
}
return names;
}
}

为什么要用枚举类型?用一个接口来管理也是很容易实现的。注意CommandEnum中的构造函数CommandEnum(String value)和getValue类,没有新建一个Enum对象,但是可以直接使用CommandEnum.ls.getValue方法获得值,这就是Enum类型的独特地方。再看下面:

1
ls("com.cbf4life.common.command.LSCommand");

是不是很特别?是的,枚举的基本功能就是定义默认可选值,但是Java中的枚举功能又增强了很多,可以添加方法和属性,基本上就是一个特殊的类。若要详细了解Enum,读者可以翻阅一下相关语法书。

现在剩下的工作就是写一个Client类,然后看看运行情况如何,如代码清单34-13所示。

代码清单34-13 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) throws IOException {
Invoker invoker = new Invoker();
while(true){
//UNIX下的默认提示符号
System.out.print("#");
//捕获输出
String input = (new BufferedReader(new InputStreamReader (System.in))).readLine();
//输入quit或exit则退出
if(input.equals("quit") || input.equals("exit")){
return;
}
System.out.println(invoker.exec(input));
}
}
}

Client也很简单,通过一个while循环允许使用者持续输入,然后打印出返回值,运行结 果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ls 
file1
file2
file3
file4
#ls -l
drw-rw-rw root system 1024 2009-8-20 10:23 file1
drw-rw-rw root system 1024 2009-8-20 10:23 file2
drw-rw-rw root system 1024 2009-8-20 10:23 file3
#ls -a
.
.
.
file1
file2
file3
#quit

我们已经实现了在Windows下操作UNIX命令的功能,但是仅仅一个ls命令族是不够的, 我们要扩展,把一百多个命令都扩展出来,怎么扩展呢?现在增加一个df命令族,显示磁盘的大小,只要增加类图就成,如图34-5所示。

image-20211001143359455

图34-5 扩展df命令后的类图

仅仅增加了粗框的部分,也就是增加DFCommand、AbstractDF以及实现类就可以完成扩展功能。先看AbstractDF代码,如代码清单34-14所示。
代码清单34-14 df命令的抽象类

1
2
3
4
5
6
7
8
public abstract class AbstractDF extends CommandName {
//默认参数
public final static String DEFAULT_PARAM = "";
//参数k
public final static String K_PARAM = "k";
//参数g
public final static String G_PARAM = "g";
}

与前面一样的功能,定义选项名称。接下来是三个实现类,都非常简单,如代码清单34-15所示。

代码清单34-15 df命令的具体实现类

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 DF extends AbstractDF{
//定义一下自己能处理什么参数
protected String getOperateParam() {
return super.DEFAULT_PARAM;
}
//命令处理
protected String echo(CommandVO vo) {
return DiskManager.df();
}
}
public class DF_K extends AbstractDF{
//定义一下自己能处理什么参数
protected String getOperateParam() {
return super.K_PARAM;
}
//命令处理
protected String echo(CommandVO vo) {
return DiskManager.df_k();
}
}
public class DF_G extends AbstractDF{
//定义一下自己能处理什么参数
protected String getOperateParam() {
return super.G_PARAM;
}
//命令处理
protected String echo(CommandVO vo) {
return DiskManager.df_g();
}
}

每个选项的实现类都定义了自己能解析什么命令,然后通过echo方法返回执行结果。在三个实现类中都与DiskManager类有关联关系,该类负责与操作系统有关的功能,是必须要实现的,其示例代码如代码清单34-16所示。

代码清单34-16 磁盘管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DiskManager {
//默认的计算大小
public static String df(){
return "/\t10485760\n/usr\t104857600\n/home\t1048576000\n";
}
//按照kb来计算
public static String df_k(){
return "/\t10240\n/usr\t102400\n/home\tt10240000\n";
}
//按照gb计算
public static String df_g(){
return "/\t10\n/usr\t100\n/home\tt10000\n";
}
}

以上为示例代码,若要实际计算磁盘大小,可以使用JNI的方式或者执行操作系统的命令的方式获得,特别是JDK 1.6提供了获得一个root目录大小的方法。

然后再增加一个DFCommand命令,负责执行命令,如代码清单34-17所示。

代码清单34-17 可执行的df命令

1
2
3
4
5
public class DFCommand extends Command {
public String execute(CommandVO vo) {
return super.buildChain(AbstractDF.class).get(0).handleMessage(vo);
}
}

最后一步,修改一下CommandEnum配置,增加一个枚举项,如代码清单34-18所示。

代码清单34-18 增加后的枚举项

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum CommandEnum {
ls("com.cbf4life.common.command.LSCommand"), df("com.cbf4life.common.command.DFCommand");
private String value = "";
//定义构造函数,目的是Data(value)类型的相匹配
private CommandEnum(String value){
this.value = value;
}
public String getValue(){
return this.value;
}
//返回所有的enum对象
public static List<String> getNames(){
CommandEnum[] commandEnum = CommandEnum.values();
List<String> names = new ArrayList<String>();
for(CommandEnum c:commandEnum){
names.add(c.name());
}
return names;

}
}

运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ls 
file1
file2
file3
file4
#df / 10485760
/usr 104857600
/home 1048576000
#df -k
/ 10240
/usr 102400
/home t10240000
#df -g
/ 10
/usr 100
/home t10000
#

仅仅增加类就完成了变更,这才是我们要的结果:对修改关闭,对扩展开放。

33.2 门面模式VS中介者模式

门面模式为复杂的子系统提供一个统一的访问界面,它定义的是一个高层接口,该接口使得子系统更加容易使用,避免外部模块深入到子系统内部而产生与子系统内部细节耦合的问题。中介者模式使用一个中介对象来封装一系列同事对象的交互行为,它使各对象之间不再显式地引用,从而使其耦合松散,建立一个可扩展的应用架构。

33.2.1 中介者模式实现工资计算

大家工作会得到工资,那么工资与哪些因素有关呢?这里假设工资与职位、税收有关, 职位提升工资就会增加,同时税收也增加,职位下降了工资也同步降低,当然税收也降低。 而如果税收比率增加了呢?工资自然就减少了!这三者之间两两都有关系,很适合中介者模式的场景,类图如图33-4所示。

image-20210930203153669

图33-4 工资、职位、税收的示意类图

类图中的方法比较简单,我们主要分析的是三者之间的关系,通过类图可以发现三者之间已经没有耦合,原本在需求分析时我们发现三者有直接的交互,采用中介者模式后,三个对象之间已经相互独立了,全部委托中介者完成。我们在类图中还定义了一个抽象同事类, 它是一个标志性接口,其子类都是同事类,都可以被中介者接收,如代码清单33-11所示。

代码清单33-11 抽象同事类

1
2
3
4
5
6
7
public abstract class AbsColleague {
//每个同事类都对中介者非常了解
protected AbsMediator mediator;
public AbsColleague(AbsMediator _mediator){
this.mediator = _mediator;
}
}

在抽象同事类中定义了每个同事类对中介者都非常了解,如此才能把请求委托给中介者完成。三个同事类都具有相同的设计,即定义一个业务接口以及每个对象必须实现的职责, 同时既然是同事类就都继承AbsColleague。抽象同事类只是一个标志性父类,并没有限制子类的业务逻辑,因此每一个同事类并没有违背单一职责原则。首先来看职位接口,如代码清单33-12所示。

代码清单33-12 职位接口

1
2
3
4
5
6
public interface IPosition {
//升职
public void promote();
//降职
public void demote();
}

职位会有升有降,职位变化如代码清单33-13所示。

代码清单33-13 职位

1
2
3
4
5
6
7
8
9
10
11
public class Position extends AbsColleague implements IPosition {
public Position(AbsMediator _mediator){
super(_mediator);
}
public void demote() {
super.mediator.down(this);
}
public void promote() {
super.mediator.up(this);
}
}

每一个职位的升降动作都委托给中介者执行,具体一个职位升降影响到谁这里没有定义,完全由中介者完成,简单而且扩展性非常好。下面我们来看工资接口,如代码清单33- 14所示。

代码清单33-14 工资接口

1
2
3
4
5
6
public interface ISalary {
//加薪
public void increaseSalary();
//降薪
public void decreaseSalary();
}

工资也会有升有降,如代码清单33-15所示。

代码清单33-15 工资

1
2
3
4
5
6
7
8
9
10
11
public class Salary extends AbsColleague implements ISalary {
public Salary(AbsMediator _mediator){
super(_mediator);
}
public void decreaseSalary() {
super.mediator.down(this);
}
public void increaseSalary() {
super.mediator.up(this);
}
}

交税是公民的义务,税收接口如代码清单33-16所示。

代码清单33-16 税收接口

1
2
3
4
5
6
public interface ITax {
//税收上升
public void raise();
//税收下降
public void drop();
}

税收的变化对我们的工资当然有影响,如代码清单33-17所示。

代码清单33-17 税收

1
2
3
4
5
6
7
8
9
10
11
12
public class Tax extends AbsColleague implements ITax {
//注入中介者
public Tax(AbsMediator _mediator){
super(_mediator);
}
public void drop() {
super.mediator.down(this);
}
public void raise() {
super.mediator.up(this);
}
}

以上同事类的业务都委托给了中介者,其本类已经没有任何的逻辑了,非常简单,现在的问题是中介者类非常复杂,因为它要处理三者之间的关系。我们首先来看抽象中介者,如代码清单33-18所示。

代码清单33-18 抽象中介者

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 abstract class AbsMediator {
//工资
protected final ISalary salary;
//职位
protected final IPosition position;
//税收
protected final ITax tax;
public AbsMediator(){
salary = new Salary(this);
position = new Position(this);
tax = new Tax(this);
}
//工资增加了
public abstract void up(ISalary _salary);
//职位提升了
public abstract void up(IPosition _position);
//税收增加了
public abstract void up(ITax _tax);
//工资降低了
public abstract void down(ISalary _salary);
//职位降低了
public abstract void down(IPosition _position);
//税收降低了
public abstract void down(ITax _tax);
}

在抽象中介者中我们定义了6个方法,分别处理职位升降、工资升降以及税收升降的业务逻辑,采用Java多态机制来实现,我们来看实现类,如代码清单33-19所示。

代码清单33-19 中介者

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 Mediator extends AbsMediator{
//工资增加了
public void up(ISalary _salary) {
upSalary();
upTax();
}
//职位提升了
public void up(IPosition position) {
upPosition();
upSalary();
upTax();

}
//税收增加了
public void up(ITax tax) {
upTax();
downSalary();
}
/**工资、职位、税收降低的处理方法相同,不再赘述 *///工资增加
private void upSalary(){
System.out.println("工资翻倍,乐翻天");
}
private void upTax(){
System.out.println("税收上升,为国家做贡献");
}
private void upPosition(){
System.out.println("职位上升一级,狂喜");
}
private void downSalary(){
System.out.println("经济不景气,降低工资");
}
private void downTax(){
System.out.println("税收减低,国家收入减少");
}
private void downPostion(){
System.out.println("官降三级,比自杀还痛苦");
}
}

该类的方法较多,但是还是非常简单的,它的12个方法分为两大类型:一类是每个业务的独立流程,比如增加工资,仅仅实现单独增加工资的职能,而不关心职位、税收是如何变化的,该类型的方法是private私有类型,只能提供本类内访问;另一类是实现抽象中介者定义的方法,完成具体的每一个逻辑,比如职位上升,同时也引起了工资增加、税收增加。我们编写一个场景类,看看运行结果,如代码清单33-20所示。

代码清单33-20 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//定义中介者
Mediator mediator = new Mediator();
//定义各个同事类
IPosition position = new Position(mediator);
ISalary salary = new Salary(mediator);
ITax tax = new Tax(mediator);
//职位提升了
System.out.println("===职位提升===");
position.promote();
}
}

运行结果如下所示:

1
2
3
4
===职位提升=== 
职位上升一级,狂喜
工资翻倍,乐翻天
税收上升,为国家做贡献

我们回过头来分析一下设计,在接收到需求后我们发现职位、工资、税收之间有着紧密的耦合关系,如果不采用中介者模式,则每个对象都要与其他两个对象进行通信,这势必会增加系统的复杂性,同时也使系统处于僵化状态,很难实现拥抱变化的理想。通过增加一个中介者,每个同事类的职位、工资、税收都只与中介者通信,中介者封装了各个同事类之间的逻辑关系,方便系统的扩展和维护。

33.2.2 门面模式实现工资计算

工资计算是一件非常复杂的事情,简单来说,它是对基本工资、月奖金、岗位津贴、绩效、考勤、税收、福利等因素综合运算后的一个数字。即使设计一个HR(人力资源)系统,员工工资计算也是非常复杂的模块,但是对于外界,比如高管层,最希望看到的结果是张三拿了多少钱,李四拿了多少钱,而不是看中间的计算过程,怎么计算那是人事部门的事情。换句话说,对外界的访问者来说,它只要传递进去一个人员名称和月份即可获得工资数,而不用关心其中的计算有多么复杂,这就用得上门面模式了。

门面模式对子系统起封装作用,它可以提供一个统一的对外服务接口,如图33-5所示。

image-20210930203813115

图33-5 HR系统的类图

该类图主要实现了工资计算,通过HRFacade门面可以查询用户的工资以及出勤天数等,而不用关心这个工资或者出勤天数是怎么计算出来的,从而屏蔽了外系统对工资计算模块的内部细节依赖。我们先看子系统内部的各个实现,考勤情况如代码清单33-21所示。

代码清单33-21 考勤情况

1
2
3
4
5
6
public class Attendance {
//得到出勤天数
public int getWorkDays(){
return (new Random()).nextInt(30);
}
}

非常简单,只用一个方法获得一个员工的出勤天数。我们再来看奖金计算,如代码清单33-22所示。

代码清单33-22 奖金计算

1
2
3
4
5
6
7
8
9
10
11
12
public class Bonus {
//考勤情况
private Attendance atte = new Attendance();
//奖金
public int getBonus(){
//获得出勤情况
int workDays = atte.getWorkDays();
//奖金计算模型
int bonus = workDays * 1800 / 30;
return bonus;
}
}

我们在这里实现了一个示意方法,实际的奖金计算是非常复杂的,与考勤、绩效、基本工资、岗位都有关系,单单一个奖金计算就可以设计出一个门面。我们再来看基本工资,这个基本上是按照职位而定的,比较固定,如代码清单33-23所示。

代码清单33-23 基本工资

1
2
3
4
5
6
public class BasicSalary {
//获得一个人的基本工资
public int getBasicSalary(){
return 2000;
}
}

我们定义了员工的基本工资都为2000元,没有任何浮动的余地。再来看绩效,如代码清单33-24所示。

代码清单33-24 绩效

1
2
3
4
5
6
7
8
9
10
public class Performance {
//基本工资
private BasicSalary salary = new BasicSalary();
//绩效奖励
public int getPerformanceValue(){
//随机绩效
int perf = (new Random()).nextInt(100);
return salary.getBasicSalary() * perf /100;
}
}

绩效按照一个非常简单的算法,即基本工资乘以一个随机的百分比。我们再来看税收,如代码清单33-25所示。

代码清单33-25 税收

1
2
3
4
5
6
7
public class Tax {
//收取多少税金
public int getTax(){
//交纳一个随机数量的税金
return (new Random()).nextInt(300);
}
}

一个计算员工薪酬的所有子元素都已经具备了,剩下的就是编写组合逻辑类,总工资的计算如代码清单33-26所示。

代码清单33-26 总工资计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SalaryProvider {
//基本工资
private BasicSalary basicSalary = new BasicSalary();
//奖金
private Bonus bonus = new Bonus();
//绩效
private Performance perf = new Performance();
//税收
private Tax tax = new Tax();
//获得用户的总收入
public int totalSalary(){
return basicSalary.getBasicSalary() + bonus.getBonus() + perf.getPerformanceValue() - tax.getTax();
}
}

这里只是对前面的元素值做了一个加减法计算,这是对实际HR系统的简化处理,如果把这个类暴露给外系统,那么被修改的风险是非常大的,因为它的方法totalSalary是一个具体的业务逻辑。我们采用门面模式的目的是要求门面是无逻辑的,与业务无关,只是一个子系统的访问入口。门面模式只是一个技术层次上的实现,全部业务还是在子系统内实现。我们来看HR门面,如代码清单33-27所示。

代码清单33-27 HR门面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HRFacade {
//总工资情况
private SalaryProvider salaryProvider = new SalaryProvider();
//考勤情况
private Attendance attendance = new Attendance();
//查询一个人的总收入
public int querySalary(String name,Date date){
return salaryProvider.totalSalary();
}
//查询一个员工一个月工作了多少天
public int queryWorkDays(String name){
return attendance.getWorkDays();
}
}

所有的行为都是委托行为,由具体的子系统实现,门面只是提供了一个统一访问的基础而已,不做任何的校验、判断、异常等处理。我们编写一个场景类查看运行结果,如代码清单33-28所示。

代码清单33-28 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//定义门面
HRFacade facade = new HRFacade();
System.out.println("===外系统查询总收入===");
int salary = facade.querySalary("张三",new Date(System. currentTimeMillis()));
System.out.println( "张三 11月 总收入为:" +salary);
//再查询出勤天数
System.out.println("\n===外系统查询出勤天数===");
int workDays = facade.queryWorkDays("李四");
System.out.println("李四 本月出勤:" +workDays);
}
}

运行结果如下所示:

1
2
3
4
===外系统查询总收入=== 
张三 11月 总收入为:4133
===外系统查询出勤天数===
李四 本月出勤:22

在该例中,我们使用了门面模式对薪水计算子系统进行封装,避免子系统内部复杂逻辑外泄,确保子系统的业务逻辑的单纯性,即使业务流程需要变更,影响的也是子系统内部功能,比如奖金需要与基本工资挂钩,这样的修改对外系统来说是透明的,只需要子系统内部变更即可。

33.2.3 最佳实践

门面模式和中介者模式之间的区别还是比较明显的,门面模式是以封装和隔离为主要任务,而中介者模式则是以调和同事类之间的关系为主,因为要调和,所以具有了部分的业务逻辑控制。两者的主要区别如下:

  • 功能区别

门面模式只是增加了一个门面,它对子系统来说没有增加任何的功能,子系统若脱离门 面模式是完全可以独立运行的。而中介者模式则增加了业务功能,它把各个同事类中的原有 耦合关系移植到了中介者,同事类不可能脱离中介者而独立存在,除非是想增加系统的复杂 性和降低扩展性。

  • 知晓状态不同

对门面模式来说,子系统不知道有门面存在,而对中介者来说,每个同事类都知道中介 者存在,因为要依靠中介者调和同事之间的关系,它们对中介者非常了解。

  • 封装程度不同

门面模式是一种简单的封装,所有的请求处理都委托给子系统完成,而中介者模式则需要有一个中心,由中心协调同事类完成,并且中心本身也完成部分业务,它属于更进一步的业务功能封装。

33.3 包装模式群PK

我们讲了这么多的设计模式,大家有没有发觉在很多的模式中有些角色是不干活的?它们只是充当黔首作用,你有问题,找我,但我不处理,我让其他人处理。最典型的就是代理模式了,代理角色接收请求然后传递到被代理角色处理。门面模式也是一样,门面角色的任务就是把请求转发到子系统。类似这种结构的模式还有很多,我们先给这种类型的模式定义一个名字,叫做包装模式(wrapping pattern)。注意,包装模式是一组模式而不是一个。包装模式包括哪些设计模式呢?包装模式包括:装饰模式、适配器模式、门面模式、代理模式、桥梁模式。下面我们通过一组例子来说明这五个包装模式的区别。

33.3.1 代理模式

现在很多明星都有经纪人,一般有什么事他们都会说:“你找我的经纪人谈好了”,下面我们就看看这一过程怎么模拟。假设有一个追星族想找明星签字,我们看看采用代理模式怎么实现。代理模式是包装模式中的最一般的实现,类图如图33-6所示。

image-20210930204514221

图33-6 追星族找明星签字
类图很简单,就是一个简单的代理模式,我们来看明星的定义,明星接口如代码清单33-29所示。

代码清单33-29 明星接口

1
2
3
4
public interface IStar {
//明星都会签名
public void sign();
}

明星只有一个行为:签字。我们来看明星的实现,如代码清单33-30所示。

代码清单33-30 明星

1
2
3
4
5
public class Singer implements IStar {
public void sign() {
System.out.println("明星签字:我是XXX大明星");
}
}

经纪人与明星应该有相同的行为,比如说签名,虽然经纪人不签名,但是他把你要签名的笔记本、衣服、CD等传递过去让真正的明星签字,经纪人如代码清单33-31所示。

代码清单33-31 经纪人

1
2
3
4
5
6
7
8
9
10
11
12
public class Agent implements IStar {
//定义是谁的经纪人
private IStar star;
//构造函数传递明星
public Agent(IStar _star){
this.star = _star;
}
//经纪人是不会签字的,签字了歌迷也不认
public void sign() {
star.sign();
}
}

应该非常明确地指出一个经纪人是谁的代理,因此要在构造函数中接收一个明星对象, 确定是要做这个明星的代理。我们再来看看追星族是怎么找明星签字的,如代码清单33-32所示。

代码清单33-32 追星族

1
2
3
4
5
6
7
8
9
10
11
public class Idolater {
public static void main(String[] args) {
//崇拜的明星是谁
IStar star = new Singer();
//找到明星的经纪人
IStar agent = new Agent(star);
System.out.println("追星族:我是你的崇拜者,请签名!");
//签字
agent.sign();
}
}

很简单,找到明星的代理,然后明星就签字了。运行结果如下所示:

1
2
追星族:我是你的崇拜者,请签名! 
明星签字:我是XXX大明星

看看我们的程序逻辑,我们是找明星的经纪人签字,真实签字的是明星,经纪人只是把这个请求传递给明星处理而已,这是普通的代理模式的典型应用。

33.3.2 装饰模式

明星也都是一步一步地奋斗出来的,谁都不是一步就成为大明星的。甚至一些演员通过粉饰自己给观众一个好的印象,现在我们就来看怎么粉饰一个演员,如图33-7所示。

image-20210930204858856

图33-7 演技修饰

下面我们就来看看这些过程如何实现,先看明星接口,如代码清单33-33所示。

代码清单33-33 明星接口

1
2
3
4
public interface IStar {
//演戏
public void act();
}

我们来看看我们的主角,如代码清单33-34所示。

代码清单33-34 假明星

1
2
3
4
5
public class FreakStar implements IStar {
public void act() {
System.out.println("演中:演技很拙劣");
}
}

我们看看这个明星是怎么粉饰的,先定义一个抽象装饰类,如代码清单33-35所示。

代码清单33-35 抽象装饰类

1
2
3
4
5
6
7
8
9
10
public abstract class Decorator implements IStar {
//粉饰的是谁
private IStar star;
public Decorator(IStar _star){
this.star = _star;
}
public void act() {
this.star.act();
}
}

前后两次修饰,开演前毫无忌惮地吹嘘,如代码清单33-36所示。

代码清单33-36 吹大话

1
2
3
4
5
6
7
8
9
public class HotAir extends Decorator {
public HotAir(IStar _star){
super(_star);
}
public void act(){
System.out.println("演前:夸夸其谈,没有自己不能演的角色");
super.act();
}
}

大家发现这个明星演技不好的时候,他拼命找借口,说是那天天气不好、心情不好等, 如代码清单33-37所示。

代码清单33-37 抵赖

1
2
3
4
5
6
7
8
9
public class Deny extends Decorator {
public Deny(IStar _star){
super(_star);
}
public void act(){
super.act();
System.out.println("演后:百般抵赖,死不承认");
}
}

我们建立一个场景把这种情况展示一下,如代码清单33-38所示。

代码清单33-38 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Client {
public static void main(String[] args) {
//定义出所谓的明星
IStar freakStar = new FreakStar();
//看看他是怎么粉饰自己的
//演前吹嘘自己无所不能
freakStar = new HotAir(freakStar);
//演完后,死不承认自己演的不好
freakStar = new Deny(freakStar);
System.out.println("====看看一些虚假明星的形象====");
freakStar.act();
}
}

运行结果如下所示:

1
2
3
4
====看看一些虚假明星的形象==== 
演前:夸夸其谈,没有自己不能演的角色
演中:演技很拙劣
演后:百般抵赖,死不承认

33.3.3 适配器模式

我们知道在演艺圈中还存在一种情况:替身,替身也是演员,只是普通的演员而已,在一段戏中,前十五分钟是明星本人,后十五分钟也是明星本人,就中间的五分钟是替身,那这个场景该怎么描述呢?注意中间那五分钟,这个时候一个普通演员被导演认为是明星演员,我们来看类图,如图33-8所示。

image-20210930213500367

图33-8 替身演员类图

导演找了一个普通演员作为明星的替身,不过观众看到的还是明星的身份。我们来看代码,首先看明星接口,如代码清单33-39所示。

代码清单33-39 明星接口

1
2
3
4
public interface IStar {
//明星都要演戏
public void act(String context);
}

再来看一个具体的电影明星,他的主要职责就是演戏,如代码清单33-40所示。

代码清单33-40 电影明星

1
2
3
4
5
public class FilmStar implements IStar {
public void act(String context) {
System.out.println("明星演戏:" + context);
}
}

我们再来看普通演员,明星就那么多,但是普通演员非常多,我们看其接口,如代码清单33-41所示。

代码清单33-41 普通演员接口

1
2
3
4
public interface IActor {
//普通演员演戏
public void playact(String contet);
}

普通演员也是演员,是要演戏的,我们来看一个普通演员的实现,如代码清单33-42所示。

代码清单33-42 普通演员

1
2
3
4
5
6
public class UnknownActor implements IActor {
//普通演员演戏
public void playact(String context) {
System.out.println("普通演员:"+context);
}
}

我们来看替身该怎么编写,如代码清单33-43所示。

代码清单33-43 替身演员

1
2
3
4
5
6
7
8
9
10
public class Standin implements IStar {
private IActor actor;
//替身是谁
public Standin(IActor _actor){
this.actor = _actor;
}
public void act(String context) {
actor.playact(context);
}
}

这是一个通用的替身,哪个普通演员能担任哪个明星的替身是由导演决定的,导演想让谁当就让谁当,我们来看导演,如代码清单33-44所示。

代码清单33-44 导演类

1
2
3
4
5
6
7
8
9
10
11
12
13
public class direcotr {
public static void main(String[] args) {
System.out.println("=======演戏过程模拟==========");
//定义一个大明星
IStar star = new FilmStar();
star.act("前十五分钟,明星本人演戏");
//导演把一个普通演员当做明星演员来用
IActor actor = new UnknownActor();
IStar standin= new Standin(actor);
standin.act("中间五分钟,替身在演戏");
star.act("后十五分钟,明星本人演戏");
}
}

运行结果如下所示:

1
2
3
4
=======演戏过程模拟========== 
明星演戏:前十五分钟,明星本人演戏
普通演员:中间五分钟,替身在演戏
明星演戏:后十五分钟,明星本人演戏

33.3.4 桥梁模式

我们继续说明星圈的事情,现在明星类型太多了,比如电影明星、电视明星、歌星、体育明星、网络明星等,每个类型的明星都有明确的职责,电影明星的主要工作就是演电影, 电视明星的主要工作就是演电视剧或者主持电视节目。再看看现在的明星,单一发展的基本没有,主持人出专辑、体育明星演电影、歌星拍戏等太平常了,我们就用程序来表现一下多元化情形,如图33-9所示。

image-20210930213931585

图33-9 各类明星描述

图33-9中定义了一个抽象明星AbsStar,然后产生出各个具体类型的明星,比如电影明星FilmStar、歌星Singer,当然还可以继续扩展下去。这里还定义了一个抽象的行为AbsAction,描述明星所具有的活动,比如演电影、唱歌等,在这种设计下,明星可以扩展,明星的活动也可以扩展,非常灵活。我们先来看明星的活动,抽象活动如代码清单33- 45所示。

代码清单33-45 抽象活动

1
2
3
4
public abstract class AbsAction {
//每个活动都有描述
public abstract void desc();
}

很简单,只有一个活动的描述,由子类来实现。我们来看演电影和唱歌两个活动,分别如代码清单33-46、33-47所示。

代码清单33-46 演电影

1
2
3
4
5
public class ActFilm extends AbsAction {
public void desc() {
System.out.println("演出精彩绝伦的电影");
}
}

代码清单33-47 唱歌

1
2
3
4
5
public class Sing extends AbsAction {
public void desc() {
System.out.println("唱出优美的歌曲");
}
}

各种精彩的活动都有了,我们再来看抽象明星,它是所有明星的代表,如代码清单33- 48所示。

代码清单33-48 抽象明星

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class AbsStar {
//一个明星参加哪些活动
protected final AbsAction action;
//通过构造函数传递具体活动
public AbsStar(AbstAction _action){
this.action = _action;
}
//每个明星都有自己的主要工作
public void doJob(){
action.desc();
}
}

明星都有自己的主要活动(或者是主要工作),我们在抽象明星中只是定义明星有活动,具体有什么活动由各个子类实现。我们再来看电影明星,如代码清单33-49所示。

代码清单33-49 电影明星

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class FilmStar extends AbsStar {
//默认的电影明星的主要工作是拍电影
public FilmStar(){
super(new ActFilm());
}
//也可以重新设置一个新职业
public FilmStar(AbsAction _action){
super(_action);
}
//细化电影明星的职责
public void doJob(){
System.out.println("\n======影星的工作=====");
super.doJob();
}
}

电影明星的本职工作就应该是演电影,因此就有了一个无参构造函数来定义电影明星的默认工作,如果明星要客串一下去唱歌也可以,有参构造解决了该问题。歌星的实现与此相同,如代码清单33-50所示。

代码清单33-50 歌星

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singer extends AbsStar {
//歌星的默认活动是唱歌
public Singer(){
super(new Sing());
}
//也可以重新设置一个新职业
public Singer(AbsAction _action){
super(_action);
}
//细化歌星的职责
public void doJob(){
System.out.println("\n======歌星的工作=====");
super.doJob();
}
}

我们使用电影明星和歌星来作为代表,这两类明星也是我们经常听到或看到的,下面建立一个场景类来模拟一下明星的事迹,如代码清单33-51所示。

代码清单33-51 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Client {
public static void main(String[] args) {
//声明一个电影明星
AbsStar zhangSan = new FilmStar();
//声明一个歌星
AbsStar liSi = new Singer();
//展示一下各个明星的主要工作
zhangSan.doJob();
liSi.doJob();
//当然,也有部分明星不务正业,比如歌星演戏
liSi = new Singer(new ActFilm());
liSi.doJob();
}
}

运行结果如下所示:

1
2
3
4
5
6
======影星的工作===== 
演出精彩绝伦的电影
======歌星的工作=====
唱出优美的歌曲
======歌星的工作=====
演出精彩绝伦的电影

好了,各类明星都有自己的本职工作,但是偶尔客串一个其他类型的活动也是允许的, 如此设计后,明星就可以不用固定在自己的本职工作上,而是向其他方向发展,比如影视歌三栖明星。

门面模式我们在其他章节已经讲解得比较多了,本小节就不再赘述。

33.3.5 最佳实践

5个包装模式是大家在系统设计中经常会用到的模式,它们具有相似的特征:都是通过 委托的方式对一个对象或一系列对象(例如门面模式)施行包装,有了包装,设计的系统才 更加灵活、稳定,并且极具扩展性。从实现的角度来看,它们都是代理的一种具体表现形 式,我们来看看它们在使用场景上有什么区别。

代理模式主要用在不希望展示一个对象内部细节的场景中,比如一个远程服务不需要把远程连接的所有细节都暴露给外部模块,通过增加一个代理类,可以很轻松地实现被代理类的功能封装。此外,代理模式还可以用在一个对象的访问需要限制的场景中,比如AOP。

装饰模式是一种特殊的代理模式,它倡导的是在不改变接口的前提下为对象增强功能, 或者动态添加额外职责。就扩展性而言,它比子类更加灵活,例如在一个已经运行的项目中,可以很轻松地通过增加装饰类来扩展系统的功能。

适配器模式的主要意图是接口转换,把一个对象的接口转换成系统希望的另外一个接口,这里的系统指的不仅仅是一个应用,也可能是某个环境,比如通过接口转换可以屏蔽外界接口,以免外界接口深入系统内部,从而提高系统的稳定性和可靠性。

桥梁模式是在抽象层产生耦合,解决的是自行扩展的问题,它可以使两个有耦合关系的对象互不影响地扩展,比如对于使用笔画图这样的需求,可以采用桥梁模式设计成用什么笔 (铅笔、毛笔)画什么图(圆形、方形)的方案,至于以后需求的变更,如增加笔的类型, 增加图形等,对该设计来说是小菜一碟。

门面模式是一个粗粒度的封装,它提供一个方便访问子系统的接口,不具有任何的业务逻辑,仅仅是一个访问复杂系统的快速通道,没有它,子系统照样运行,有了它,只是更方便访问而已。

36.1 事件触发器的开发

大家都应该做过桌面程序的开发吧,比如编写一个EXE文件,或者使用Java Swing编写一个应用程序,或者是用Delphi、C编写C/S结构的应用系统,即使这些都没有做过,那也总编写过B/S结构的页面吧?回忆一下开发过程,大家是不是经常使用文本框和按钮这两个控件?比如设计一个按钮,那总要编写鼠标点击处理,你是不是这样开发:在按钮的onClick函数中编写自己的逻辑代码,然后鼠标点击测试,该代码就会运行。大家有没有想过为什么我们点击了按钮就会触发我们自己编写的代码呢?浏览器怎么知道操作者按了按钮要触发该事件呢?鼠标点击动作、按钮、自己编写的代码之间是如何关联起来呢?

我们今天的任务就是来模拟类似触发过程。我们这样分析:有一个产品(不管是Frame 还是Button或者是Radio),它有多个触发事件,它产生的时候触发一个创建事件,修改的时候触发修改事件,删除的时候触发删除事件,这就类似于我们的文本框,初始化(也就是创建)的时候要触发一个onLoad或onCreate事件,修改的时候触发onChange事件,双击(类似于删除)的时候又触发onDbClick事件,我们今天的目标就是来思考怎么实现这样一个架构。

设计都是先易后难,我们先从最简单的部分入手。首先需要一个产品,并且该产品要有创建、修改、销毁的动作,很明显这就是一个工厂方法模式。同时产品也可以通过克隆方式产生,这与我们在GUI设计中经常使用的复制粘贴操作相类似,要不界面上那么多的文本框,不使用复制粘贴,不累死人才怪呢,那这非常明显就是原型模式。好,分析到这里,我们先把这部分的类图建立起来,如图36-1所示。

很熟悉的类图,与工厂方法模式的通用类图非常相似,但不完全是。有什么差别呢?注意看产品类的私有属性canChanged和构造函数,它们有特殊的用途。在该类图中,我们使用了工厂方法模式创建产品,使用原型模式让对象可以被拷贝,仅仅这两个模式还不足以解决我们的问题,想想看,产品的产生是有一定的条件的,不是谁想产生就产生,否则怎么能够触发创建事件呢?因此需要限定产品的创建者,所以我们在类图中把产品和工厂的关系定位为组合关系,而不是简单的聚集或依赖关系。换句话说,产品只能由工厂类创建,而不能被其他对象通过new方式创建,因此我们在这里还用到一个单来源调用(Single Call)方法解决该问题。这是一个方法,不是一个设计模式,我马上给大家讲解它是如何工作的。

image-20211001202204935

图36-1 产品创建工厂
我们先来看产品类的源代码,它比较简单,如代码清单36-1所示。

代码清单36-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 Product implements Cloneable{
//产品名称
private String name;
//是否可以属性变更
private boolean canChanged = false;
//产生一个新的产品
public Product(ProductManager manager,String _name){
//允许建立产品
if(manager.isCreateProduct()){
canChanged =true;
this.name = _name;
}
}
public String getName() {
return name;
}
public void setName(String name) {

if(canChanged){
this.name = name;
}
}
//覆写clone方法
@Override
public Product clone(){
Product p =null;
try {
p =(Product)super.clone();
}
catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return p;
}
}

在产品类中,我们只定义产品的一个属性:产品名称(name),并实现了getter/setter方法,然后我们实现了它的clone方法,确保对象是可以被拷贝的。还有一个特殊的地方是我们的构造函数,它怎么会要求传递进来一个工厂对象ProductManager呢?保留你的好奇心,马上为你揭晓答案。我们继续看代码,工厂类如代码清单36-2所示。

代码清单36-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
public class ProductManager {
//是否可以创建一个产品
private boolean isPermittedCreate = false;
//建立一个产品
public Product createProduct(String name){
//首先修改权限,允许创建
isPermittedCreate = true;
Product p = new Product(this,name);
return p;
}
//废弃一个产品
public void abandonProduct(Product p){
//销毁一个产品,例如删除数据库记录
p = null;
}
//修改一个产品
public void editProduct(Product p,String name){
//修改后的产品
p.setName(name);
}
//获得是否可以创建一个产品
public boolean isCreateProduct(){
return isPermittedCreate;
}
//克隆一个产品
public Product clone(Product p){
//产生克隆事件
return p.clone();
}
}

仔细看看工厂类,产品的创建、修改、遗弃、克隆方法都很简单,但有一个方法可不简单——isCreateProduct方法,它的作用是告诉产品类“我是能创建产品的”,注意看我们的程序,在工厂类ProductManager中定义了一个私有变量isCreateProduct,该变量只有在工厂类的createProduct函数中才能设置为true,在创建产品的时候,产品类Product的构造函数要求传递工厂对象,然后判断是否能够创建产品,即使你想使用类似这样的方法:

1
Product p = new Product(new ProductManager(),"abc");

也是不可能创建出产品的,它在产品类中限制必须是当前有效工厂才能生产该产品,而且也只有有效的工厂才能修改产品,看看产品类的canChanged属性,只有它为true时,产品才可以修改,那怎么才能为true呢?在构造函数中判断是否可以为true。这就类似工厂要创建产品了,产品就问“你有权利创建我吗?”于是工厂类出示了两个证明材料证明自己可以创建产品:一是“我是你的工厂类”,二是“我的isCreateProduct返回true,我有权创建”,于是产品就被创建出来了。这种一个对象只能由固定的对象初始化的方法就叫做单来源调用(Single Call)——很简单,但非常有用的方法。


注意 采用单来源调用的两个对象一般是组合关系,两者有相同的生命期,它通常适用于有单例模式和工厂方法模式的场景中。


我们继续往下分析,一个产品新建要触发事件,那事件是什么?当然也是一个对象了, 需要把它设计出来,仅仅有事件还不行,还要考虑有人去处理这个事件,产生了一个事件不可能没有对象去处理吧?如果是这样那事件还有什么意义呢?既然要去处理,那就需要一个通知渠道了,于是观察者模式准备好了。好,我们把这段分析的类图也画出来,如图36-2所示。

image-20211001211253487

图36-2 观察者模式处理事件

在该类图中,观察者为EventDispatch类,它使用了单例模式,避免对象膨胀,但同时也带来了性能及线程安全隐患,这点需要大家在实际应用中注意(想想Spring中的Bean注入, 默认也是单例,在通常的应用中一般不需要修改,除非是较大并发的应用)。我们来看代码,先来看事件类型定义,它是一个枚举类型,如代码清单36-3所示。

代码清单36-3 事件类型定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum ProductEventType {
//新建一个产品
NEW_PRODUCT(1),
//删除一个产品
DEL_PRODUCT(2),
//修改一个产品
EDIT_PRODUCT(3),
//克隆一个产品
CLONE_PRODUCT(4);
private int value=0;
private ProductEventType(int _value){
this.value = _value;
}
public int getValue(){
return this.value;
}
}

这里定义了4个事件类型,分别是新建、修改、删除以及克隆,比较简单。我们再来看产品的事件,如代码清单36-4所示。

代码清单36-4 产品事件

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 ProductEvent extends Observable{
//事件起源
private Product source;
//事件的类型
private ProductEventType type;
//传入事件的源头,默认为新建类型
public ProductEvent(Product p) {
this(p,ProductEventType.NEW_PRODUCT);
}
//事件源头以及事件类型
public ProductEvent(Product p,ProductEventType _type){
this.source = p;
this.type = _type;
//事件触发
notifyEventDispatch();
}
//获得事件的始作俑者
public Product getSource(){
return source;
}
//获得事件的类型
public ProductEventType getEventType(){
return this.type;
}
//通知事件处理中心
private void notifyEventDispatch(){
super.addObserver(EventDispatch.getEventDispatch());
super.setChanged();
super.notifyObservers(source);
}
}

我们在产品事件类中增加了一个私有方法notfiyEventDispatch,该方法的作用是明确事件的观察者,并同时在初始化时通知观察者,它在有参构造中被调用。我们再来看事件的观察者,如代码清单36-5所示。

代码清单36-5 事件的观察者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class EventDispatch implements Observer{
//单例模式
private final static EventDispatch dispatch = new EventDispatch();
//不允许生成新的实例
private EventDispatch(){
}
//获得单例对象
public static EventDispatch getEventDispatch(){
return dispatch;
}
//事件触发
public void update(Observable o, Object arg) {
}
}

产品和事件都定义出来了,那我们想想怎么把这两者关联起来,产品和事件是两个独立的对象,两者都可以独立地扩展,用什么来适应它们的扩展呢?桥梁模式!两个不相关的类可以通过桥梁模式组合出稳定、健壮的结构,我们画出类图,如图36-3所示。

image-20211001211950545

图36-3 桥梁模式实现产品和事件的组合
看着不像桥梁模式?看看桥梁模式的通用类图,然后把抽象化角色和实现化角色去掉看看,是不是就是一样了?各位可能要说了,把抽象化角色和实现化角色去掉,那桥梁模式在抽象层次耦合的优点还怎么体现呢?因为我们采用的是单个产品对象,没有必要进行抽象化处理,读者若要按照该框架做扩展开发,该部分是肯定需要抽象出接口或抽象类的,好在也非常简单,只要抽取一下就可以了。这样考虑后,我们的ProductManager类就增加一个功能:组合产品类和事件类,产生有意义的产品事件,如代码清单36-6所示。

代码清单36-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
37
38
public class ProductManager {
//是否可以创建一个产品
private boolean isPermittedCreate = false;
//建立一个产品
public Product createProduct(String name){
//首先修改权限,允许创建
isPermittedCreate = true;
Product p = new Product(this,name);
//产生一个创建事件
new ProductEvent(p,ProductEventType.NEW_PRODUCT);
return p;
}
//废弃一个产品
public void abandonProduct(Product p){
//销毁一个产品,例如删除数据库记录
//产生删除事件
new ProductEvent(p,ProductEventType.DEL_PRODUCT);
p = null;
}
//修改一个产品
public void editProduct(Product p,String name){
//修改后的产品
p.setName(name);
//产生修改事件
new ProductEvent(p,ProductEventType.EDIT_PRODUCT);
}
//获得是否可以创建一个产品
public boolean isCreateProduct(){
return isPermittedCreate;
}
//克隆一个产品
public Product clone(Product p){
//产生克隆事件
new ProductEvent(p,ProductEventType.CLONE_PRODUCT);
return p.clone();
}

}

在每个方法中增加了事件的产生机制,在createProduct方法中增加了创建产品事件,在editProduct方法中增加了修改产品事件,在delProduct方法中增加了遗弃产品事件,在clone方法中增加克隆产品事件,而且每个事件都是通过组合产生的,产品和事件的扩展性非常优秀。

刚刚我们说完了产品和事件的关系处理,现在回到我们事件的观察者,它承担着非常重要的职责。我们知道它要处理事件,但是现在还没有想好怎么实现它处理事件的update方法,暂时保持为空。

我们继续分析,这么多的事件(现在只有1个产品类,如果产品类很多呢?比如30多个)不可能每个产品事件都写一个处理者吧,对于产品事件来说,它最希望的结果就是我通知了事件处理者(也就是观察者模式的观察者),其他具体怎么处理由观察者来解决,那现在问题是观察者怎么来处理这么多的事件呢?事件的处理者必然有N多个,如何才能通知相应的处理者来处理事件呢?一个事件也可能通知多个处理者来处理,并且一个处理者处理完毕还可能通知其他的处理者,这不可能让每个处理者独自完成这样“不可能完成的任务”,我们把问题的示意图画出来,如图36-4所示。

image-20211001212113884

图36-4 事件处理示意图

看到该示意图,你立刻就会想到中介者模式。是的,需要中介者模式上场了,我们把EventDispatch类(嘿嘿,为什么要定义成Dispatch呢?就是分发的意思)作为事件分发的中介者,事件的处理者都是具体的同事类,它们有着相似的行为,都是处理产品事件,但是又有不相同的逻辑,每个同事类对事件都有不同的处理行为。我们来看类图,如图36-5所示。

在类图中,EventDispatch类有3个职责。

  • 事件的观察者

作为观察者模式中的观察者角色,接收被观察期望完成的任务,在我们的框架中就是接收ProductEvent事件。

  • 事件分发者

作为中介者模式的中介者角色,它担当着非常重要的任务——分发事件,并同时协调各个同事类(也就是事件的处理者)处理事件。

  • 事件处理者的管理员角色

不是每一个事件的处理者都可以接收事件并进行处理,是需要获得分发者许可后才可以,也就是说只有事件分发者允许它处理,它才能处理。

image-20211001212239626

图36-5 采用中介者模式对事件进行分发
事件分发者担当了这么多的职责,那是不是与单一职责原则相违背了?确实如此,我们在整个系统的设计中确实需要这样一个角色担任这么多的功能,如果强制细分也可以完成, 但是会加大代码量,同时导致系统的结构复杂,读者可以考虑拆分这3个职责,然后再组合相关的功能,看看代码量是如何翻倍的。

注意 设计原则只是一个理论,而不是一个带有刻度的标尺,因此在系统设计中不应该把它视为不可逾越的屏障,而是应该把它看成是一个方向标,尽量遵守,而不是必须恪守。


既然事件分发者这么重要,我们就仔细研读一下它的代码,如代码清单36-7所示。

代码清单36-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
31
32
33
public class EventDispatch implements Observer{
//单例模式
private final static EventDispatch dispatch = new EventDispatch();
//事件消费者
private Vector<EventCustomer> customer = new Vector<EventCustomer>();
//不允许生成新的实例
private EventDispatch(){
}
//获得单例对象
public static EventDispatch getEventDispatch(){
return dispatch;
}
//事件触发
public void update(Observable o, Object arg) {
//事件的源头
Product product = (Product)arg;
//事件
ProductEvent event = (ProductEvent)o;
//处理者处理,这里是中介者模式的核心,可以是很复杂的业务逻辑
for(EventCustomer e:customer){
//处理能力是否匹配
for(EventCustomType t:e.getCustomType()){
if(t.getValue()== event.getEventType().getValue()){
e.exec(event);
}
}
}
}
//注册事件处理者
public void registerCustomer(EventCustomer _customer){
customer.add(_customer);
}
}

我们在这里使用Vector来存储所有的事件处理者,在update方法中使用了两个简单的for 循环来完成业务逻辑的判断,只要事件的处理者级别和事件的类型相匹配,就调用事件处理者的exec方法来处理事件,该逻辑是整个事件触发架构的关键点,但不是难点。请读者注意,在设计这样的框架前,一定要定义好消费者与生产者之间的搭配问题,一般的做法是通过xml文件类或者IoC容器配置规则,然后在框架启动时加载并驻留内存。

EventCustomer抽象类负责定义事件处理者必须具有的行为,首先是每一个事件的处理者 都必须定义自己能够处理的级别,也就是通过构造函数来定义自己的处理能力,当然处理能 力可以是多值的,也就是说一个处理者可以处理多个事件;然后各个事件的处理者只要实现 exec方法就可以了,完成自己对事件的消费处理即可。我们先来看抽象的事件处理者,如代 码清单36-8所示。

代码清单36-8 抽象的事件处理者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class EventCustomer {
//容纳每个消费者能够处理的级别
private Vector<EventCustomType> customType = new Vector<EventCustomType>();
//每个消费者都要声明自己处理哪一类别的事件
public EventCustomer(EventCustomType _type){
addCustomType(_type);
}
//每个消费者可以消费多个事件
public void addCustomType(EventCustomType _type){
customType.add(_type);
}
//得到自己的处理能力
public Vector<EventCustomType> getCustomType(){
return customType;
}
//每个事件都要对事件进行声明式消费
public abstract void exec(ProductEvent event);
}

很简单,我们定义了一个Vector变量来存储处理者的处理能力,然后通过构造函数约束子类必须定义一个自己的处理能力。在代码中,我们用到了事件处理类型枚举,如代码清单36-9所示。

代码清单36-9 事件处理枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public enum EventCustomType {
//新建立事件
NEW(1),
//删除事件
DEL(2),
//修改事件
EDIT(3),
//克隆事件
CLONE(4);
private int value=0;
private EventCustomType(int _value){
this.value = _value;
}
public int getValue(){
return value;
}
}

我们在系统中定义了3个事件处理者,分别是乞丐、平民和贵族。乞丐只能获得别人遗弃的物品,平民消费自己生产的东西,自给自足,而贵族则可以获得精修的产品或者是绿色产品(也就是我们这里的克隆产品,不用自己劳动获得的产品)。我们先看乞丐的源代码, 如代码清单36-10所示。

代码清单36-10 乞丐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Beggar extends EventCustomer {
//只能处理被人遗弃的东西
public Beggar(){
super(EventCustomType.DEL);
}
@Override
public void exec(ProductEvent event) {
//事件的源头
Product p = event.getSource();
//事件类型
ProductEventType type = event.getEventType();
System.out.println("乞丐处理事件:"+p.getName() +"销毁,事件类型="+type);
}
}

乞丐在无参构造中定义了自己只能处理删除的事件,然后在exec方法中定义了事件的处理逻辑,每个处理者都是只要完成这两个方法即可,我们再来看平民级别的事件处理者,如代码清单36-11所示。

代码清单36-11 平民

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Commoner extends EventCustomer {
//定义平民能够处理的事件的级别
public Commoner() {
super(EventCustomType.NEW);
}
@Override
public void exec(ProductEvent event) {
//事件的源头
Product p = event.getSource();
//事件类型
ProductEventType type = event.getEventType();
System.out.println("平民处理事件:"+p.getName() +"诞生记,事件类型="+type);
}
}

平民只处理新建立的事件,其他事件不做处理,我们再来看贵族级别的事件处理者,如代码清单36-12所示。

代码清单36-12 贵族

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Nobleman extends EventCustomer {
//定义贵族能够处理的事件的级别
public Nobleman() {
super(EventCustomType.EDIT);
super.addCustomType(EventCustomType.CLONE);
}
@Override
public void exec(ProductEvent event) {
//事件的源头
Product p = event.getSource();
//事件类型
ProductEventType type = event.getEventType();
if(type.getValue() == EventCustomType.CLONE.getValue()){
System.out.println("贵族处理事件:"+p.getName() +"克隆,事件类型="+type);
}
else{
System.out.println("贵族处理事件:"+p.getName() +"修改,事件类型="+type);
}
}
}

贵族稍有不同,它有两个处理能力,能够处理修改事件和克隆事件,同时在exec方法中对这两类事件分别进行处理。此时,读者可能会想到另外一个处理模式:责任链模式。建立一个链,然后两类事件分别在链上进行处理并反馈结果。读者可以参考一下Servlet的过滤器(Filter)的设计,在框架平台的开发中可以采用该模式,它具有非常好的扩展性和稳定性。

所有的角色都已出场,我们建立一个场景类把它们串联起来,如代码清单36-13所示。

代码清单36-13 场景类

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) {
//获得事件分发中心
EventDispatch dispatch = EventDispatch.getEventDispatch();
//接受乞丐对事件的处理
dispatch.registerCustomer(new Beggar());
//接受平民对事件的处理
dispatch.registerCustomer(new Commoner());
//接受贵族对事件的处理
dispatch.registerCustomer(new Nobleman());
//建立一个原子弹生产工厂
ProductManager factory = new ProductManager();
//制造一个产品
System.out.println("=====模拟创建产品事件========");
System.out.println("创建一个叫做小男孩的原子弹");
Product p = factory.createProduct("小男孩原子弹");
//修改一个产品
System.out.println("\n=====模拟修改产品事件========");
System.out.println("把小男孩原子弹修改为胖子号原子弹");
factory.editProduct(p, "胖子号原子弹");
//再克隆一个原子弹
System.out.println("\n=====模拟克隆产品事件========");
System.out.println("克隆胖子号原子弹");
factory.clone(p);
//遗弃一个产品
System.out.println("\n=====模拟销毁产品事件========");
System.out.println("遗弃胖子号原子弹");
factory.abandonProduct(p);
}
}

运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
=====模拟创建产品事件======== 
创建一个叫做小男孩的原子弹
平民处理事件:小男孩原子弹诞生记,事件类型=NEW_PRODUCT
=====模拟修改产品事件========
把小男孩原子弹修改为胖子号原子弹
贵族处理事件:胖子号原子弹修改,事件类型=EDIT_PRODUCT
=====模拟克隆产品事件======== 克隆胖子号原子弹
贵族处理事件:胖子号原子弹克隆,事件类型=CLONE_PRODUCT
=====模拟销毁产品事件========
遗弃胖子号原子弹
乞丐处理事件:胖子号原子弹销毁,事件类型=DEL_PRODUCT

我们的事件处理框架已经生效了,有行为,就产生事件,并有处理事件的处理者,并且这三者都相互解耦,可以独立地扩展下去。比如,想增加处理者,没有问题,建立一个类继承EventCustomer,然后注册到EventDispatch上,就可以进行处理事件了;想扩展产品,没问题?需要稍稍修改一下,首先抽取出产品和事件的抽象类,然后再进行扩展即可。

38.1 规格模式

38.1.1 规格模式的实现

不知道诸位有没有使用C#3.5做过开发,它有一个非常重要的新特性—— LINQ(Language INtegrated Query,语言集成查询),它提供了类似于SQL语法的遍历、筛选等功能,能完成对对象的查询,就像通过SQL语句查询数据库一样,例如这样的一个程序片段:

1
2
Dim DataList As String() = {"abc", "def", "ght"} 
Dim Result = From T As String In DataList Where T = "abc"

这句话的意思就是从一个数组中查找出值为abc的元素,返回结果为IEnumerable,枚举器类型。注意看第二句话,它使用了类似SQL的Select语法结构,from、where关键字都有了,而且还支持类似的Orderby、Groupby功能,很强大,有兴趣的读者可以查阅有关资料。 那在Java世界中是否也存在这样的辅助框架呢?有,JoSQL、Quaere都可以提供类似的LINQ 语言,读者可以到网上研究一下JavaDoc,同样非常简单,功能强大。

我们今天要讲的主题与LINQ有很大关系,它是实现LINQ的核心。想想SQL语句中什么是最复杂的,是where后面的查询条件,看看自己写的SQL语句基本上都是一长串的条件判断,中间一堆的and、or、not逻辑符。我们今天的任务就是要实现条件语句的解析,该部分实现了,基本上LINQ语法已经实现了一大半。

我们以一个案例来讲解该技术,在内存中有10个User对象,根据不同的条件查找出用户,比如姓名包含某个字符、年龄小于多少岁等条件,类似这样的SQL:

1
Select * From User where name like '%国庆%'

查找出姓名中包含“国庆”两个字的用户,这在关系型数据库中很容易实现,但是在对象群中怎么实现这样的查询呢?好,看似很简单,先设计一个用户类,然后提供一个用户查找工具类,类图非常容易,如图38-1所示。

很简单的类图,有一个用户类,同时提供了一个操作用户的辅助类,我们先来看User 类,如代码清单38-1所示。

image-20211001221545481

图38-1 简单用户查询类图

代码清单38-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
public class User {
//姓名
private String name;
//年龄
private int age;
public User(String _name,int _age){
this.name = _name;
this.age = _age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {

this.age = age;
}
//用户信息打印
@Override
public String toString(){
return "用户名:" + name+"\t年龄:" + age;
}
}

User就是一个简单BO业务对象,再来看用户操作接口,它定义一个用户操作类必须具 有的方法,如代码清单38-2所示。

代码清单38-2 用户操作对象接口

1
2
3
4
5
6
public interface IUserProvider {
//根据用户名查找用户
public ArrayList<User> findUserByNameEqual(String name);
//年龄大于指定年龄的用户
public ArrayList<User> findUserByAgeThan(int age);
}

在这里只定义了两个查询实现,分别是名字相同的用户和年龄大于指定年龄的用户,大家都知道,相似的查询条件还有很多,比如名字中包含指定字符、年龄小于指定年龄等,我们仅以实现这两个查询作为代表,如代码清单38-3所示。

代码清单38-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
public class UserProvider implements IUserProvider {
//用户列表
private ArrayList<User> userList;
//构造函数传递用户列表
public UserProvider(ArrayList<User> _userList){
this.userList = _userList;
}
//年龄大于指定年龄的用户
public ArrayList<User> findUserByAgeThan(int age) {
ArrayList<User> result = new ArrayList<User>();
for(User u:userList){
if(u.getAge()>age){
//符合条件的用户
result.add(u);
}
}
return result;
}
//姓名等于指定姓名的用户
public ArrayList<User> findUserByNameEqual(String name) {
ArrayList<User> result = new ArrayList<User>();
for(User u:userList){

if(u.getName().equals(name)){
//符合条件
result.add(u);
}
}
return result;
}
}

通过for循环遍历一个动态数组,判断用户是否符合条件,将符合条件的用户放置到另外一个数组中,比较简单。我们编写场景类来模拟该情景,如代码清单38-4所示。

代码清单38-4 场景类

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) {
//首先初始化一批用户
ArrayList<User> userList = new ArrayList<User>();
userList.add(new User("苏大",3));
userList.add(new User("牛二",8));
userList.add(new User("张三",10));
userList.add(new User("李四",15));
userList.add(new User("王五",18));
userList.add(new User("赵六",20));
userList.add(new User("马七",25));
userList.add(new User("杨八",30));
userList.add(new User("侯九",35));
userList.add(new User("布十",40));
//定义一个用户查询类
IUserProvider userProvider = new UserProvider(userList);
//打印出年龄大于20岁的用户
System.out.println("===年龄大于20岁的用户===");
for(User u:userProvider.findUserByAgeThan(20)){
System.out.println(u);
}
}
}

运行结果如下所示:

1
2
3
4
5
===年龄大于20岁的用户=== 
用户名:马七 年龄:25
用户名:杨八 年龄:30
用户名:侯九 年龄:35
用户名:布十 年龄:40

结果非常正确,但是这样的一个框架基本上是不能适应业务变化的,为什么呢?业务变化虽然无规则,但是可以预测,比如我们这个查询,今天要查找年龄大于20岁的用户,明天要查找年龄小于30岁的用户,后天要查找姓名中包含“国庆”两个字的用户,想想看IUserProvider接口是不是要一直修改下去?接口是契约,而且我们一直提倡面向接口编程, 但是在这里接口竟然都可以修改,是不是发现设计有很大问题了!

问题发现了,就要想办法解决。再回顾一下编写的代码,注意看findUserByAgeThan和findUserByNameEqual两个方法,两者的代码有什么不同呢?除了if后面的判断条件不同外, 就没有不同的地方了,我们一直在说封装变化,这两段程序就仅仅有这一个变化点,我们是不是可以把它封装起来呢?完全可以,把它们两者的共同点抽取出来,先修改一下接口,如代码清单38-5所示。

代码清单38-5 修正后的接口

1
2
3
4
public interface IUserProvider {
//根据条件查找用户
public ArrayList<User> findUser(boolean condition);
}

这个接口的设计想法非常好,但是参数condition很难实现,看看findUserByAgeThan、 findUserByNameEqual这两个方法,怎么才能把两者的不同点设置成一个布尔型呢?如果需要在IUserProvider对象外判断后传递进来,那我们的封装就没有任何意义了——目前为止,这个方案有问题了。

继续考虑,既然不能在封装外运算,那就把整个条件都进行封装,由IUserProvider自己实现运算。好方法!那我们就设计一个这样的类,我们叫它规格类,什么意思呢?它是对一批对象的说明性描述,它依照基准判断候选对象是否满足条件。

思考后,我们设计出类图,如图38-2所示。

image-20211001222008885

图38-2 加入规格后的设计类图
在该类图中建立了一个规格书接口,它的作用就是定制各种各样的规格,比如名字相等的规格UserByNameEqual、年龄大于基准年龄的规格UserByAgeThan等,然后在用户操作类中采用该规格进行判断。User类没有任何改变,如代码清单38-1所示,不再赘述。

规格书接口是对全体规格书的声明定义,如代码清单38-6所示。

代码清单38-6 规格书接口

1
2
3
4
public interface IUserSpecification {
//候选者是否满足要求
public boolean isSatisfiedBy(User user);
}

规格书接口只定义一个方法,判断候选用户是否满足条件。再来看姓名相同的规格书, 它实现了规格书接口,如代码清单38-7所示。

代码清单38-7 姓名相同的规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
public class UserByNameEqual implements IUserSpecification {
//基准姓名
private String name;

//构造函数传递基准姓名
public UserByNameEqual(String _name){
this.name = _name;
}
//检验用户是否满足条件
public boolean isSatisfiedBy(User user) {
return user.getName().equals(name);
}
}

代码很简单,通过构造函数传递进来基准用户名,然后判断候选用户是否匹配。大于基准年龄的规格书与此类似,如代码清单38-8所示。

代码清单38-8 大于基准年龄的规格书

1
2
3
4
5
6
7
8
9
10
11
12
public class UserByAgeThan implements IUserSpecification {
//基准年龄
private int age;
//构造函数传递基准年龄
public UserByAgeThan(int _age){
this.age = _age;
}
//检验用户是否满足条件
public boolean isSatisfiedBy(User user) {
return user.getAge() > age;
}
}

规格书都已经定义完毕,我们再来看用户操作类,先看用户操作的接口,如代码清单38-9所示。

代码清单38-9 用户操作接口

1
2
3
4
public interface IUserProvider {
//根据条件查找用户
public ArrayList<User> findUser(IUserSpecification userSpec);
}

只有一个方法——根据指定的规格书查找用户。再来看其实现类,如代码清单38-10所示。

代码清单38-10 用户操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class UserProvider implements IUserProvider {
//用户列表
private ArrayList<User> userList;
//传递用户列表
public UserProvider(ArrayList<User> _userList){
this.userList = _userList;
}
//根据指定的规格书查找用户
public ArrayList<User> findUser(IUserSpecification userSpec) {
ArrayList<User> result = new ArrayList<User>();
for(User u:userList){
if(userSpec.isSatisfiedBy(u)){
//符合指定规格
result.add(u);
}
}
return result;
}
}

程序改动很小,仅仅在if判断语句中根据规格书进行判断,我们持续地扩展规格书,有多少查询分类就可以扩展出多少个实现类,而IUserProvider则不需要任何改动,它的一个方法就覆盖了我们刚刚提出的N多查询路径。我们设计一个场景来看看效果如何,如代码清单38-11所示。

代码清单38-11 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Client {
public static void main(String[] args) {
//首先初始化一批用户
ArrayList<User> userList = new ArrayList<User>();
userList.add(new User("苏大",3));
userList.add(new User("牛二",8));
userList.add(new User("张三",10));
userList.add(new User("李四",15));
userList.add(new User("王五",18));
userList.add(new User("赵六",20));
userList.add(new User("马七",25));
userList.add(new User("杨八",30));
userList.add(new User("侯九",35));
userList.add(new User("布十",40));
//定义一个用户查询类
IUserProvider userProvider = new UserProvider(userList);
//打印出年龄大于20岁的用户
System.out.println("===年龄大于20岁的用户===");
//定义一个规格书
IUserSpecification userSpec = new UserByAgeThan(20);
for(User u:userProvider.findUser(userSpec)){
System.out.println(u);
}
}
}

在场景类中定义了一个规格书,然后把规格书提交给UserProvider就可以查找到自己需要的用户了,运行结果相同,不再赘述。

大家想想看,如果现在需求变更了,比如需要一个年龄小于基准年龄的用户,该怎么修改?增加一个小于基准年龄的规格书,实现IUserSpecification接口,然后在新的业务中调用即可,别的什么都不需要修改。再比如需要一个类似SQL中like语句的处理逻辑,这个也不难,如代码清单38-12所示。

代码清单38-12 Like规格书

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 UserByNameLike implements IUserSpecification {
//like的标记
private final static String LIKE_FLAG = "%";
//基准的like字符串
private String likeStr;
//构造函数传递基准姓名
public UserByNameLike(String _likeStr){
this.likeStr = _likeStr;
}
//检验用户是否满足条件
public boolean isSatisfiedBy(User user) {
boolean result = false;
String name = user.getName();
//替换掉%后的干净字符串
String str = likeStr.replace("%","");
//是以名字开头,如'国庆%'
if(likeStr.endsWith(LIKE_FLAG) && !likeStr.startsWith(LIKE_FLAG)){
result = name.startsWith(str);
}
else if(likeStr.startsWith(LIKE_FLAG) && !likeStr.endsWith(LIKE_FLAG)){
//类似 '%国庆'
result = name.endsWith(str);
}
else{
result = name.contains(str);
//类似于'%国庆%'
}
return result;
}
}

同时,场景类也要适当地改动,毕竟业务已经发生了变化,高层模块要适应这种变化, 如代码清单38-13所示。

代码清单38-13 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//首先初始化一批用户
ArrayList<User> userList = new ArrayList<User>();
userList.add(new User("苏国庆",23));
userList.add(new User("国庆牛",82));
userList.add(new User("张国庆三",10));
userList.add(new User("李四",10));
//定义一个用户查询类
IUserProvider userProvider = new UserProvider(userList);
//打印出名字包含"国庆"的人员
System.out.println("===名字包含国庆的人员===");
//定义一个规格书
IUserSpecification userSpec = new UserByNameLike("%国庆%");
for(User u:userProvider.findUser(userSpec)){
System.out.println(u);
}
}
}

运行结果如下所示:

1
2
3
4
===名字包含国庆的人员=== 
用户名:苏国庆 年龄:23
用户名:国庆牛 年龄:82
用户名:张国庆三 年龄:10

到目前为止,我们已经设计了一个可扩展的对象查询平台,但是我们还有遗留问题未解决,看看SQL语句,为什么where后面会很长?是因为有AND、OR、NOT这些逻辑操作符的存在,它们可以串联起多个判断语句,然后整体反馈出一个结果来。想想看,我们上面的平台能支持这种逻辑操作符吗?不能,你要说能,那也说得通,需要两次过滤才能实现,比如要找名字包含“国庆”并且年龄大于25岁的用户,代码该怎么修改?如代码清单38-14所示。

代码清单38-14 复合查询

1
2
3
4
5
6
7
8
9
10
11
public class Client {
public static void main(String[] args) {
//定义一个规格书
IUserSpecification userSpec1 = new UserByNameLike("%国庆%");
IUserSpecification userSpec2 = new UserByAgeThan(20);
userList = userProvider.findUser(userSpec1);
for(User u:userProvider.findUser(userSpec2)){
System.out.println(u);
}
}
}

能够实现,但是思考一下程序逻辑,它采用了两次过滤,也就是两次循环,如果对象数量少还好说,如果对象数量巨大,这个效率就太低了,这是其一;其二,组合方式非常多, 比如“与”、“或”、“非”可以自由组合,姓名中包含“国庆”但年龄小于25的用户,姓名中不包含国庆但年龄大于25岁的用户等,我们还能如此设计吗?太多的组合方式,产生组合爆炸, 这种设计就不妥了,应该有更优秀的方案。

我们换个方式思考该问题,不管是AND或者OR或者NOT操作,它们的返回结果都还是一个规格书,只是逻辑更复杂了而已,这3个操作符只是提供了对原有规格书的复合作用, 换句话说,规格书对象之间可以进行与或非操作,操作的结果不变,分析到这里,我们就可以开始修改接口了,如代码清单38-15所示。

代码清单38-15 带与或非的规格书接口

1
2
3
4
5
6
7
8
9
10
public interface IUserSpecification {
//候选者是否满足要求
public boolean isSatisfiedBy(User user);
//and操作
public IUserSpecification and(IUserSpecification spec);
//or操作
public IUserSpecification or(IUserSpecification spec);
//not操作
public IUserSpecification not();
}

在规格书接口中增加了与或非的操作,接口修改了,实现类当然也要修改。先全面思考一下业务,与或非是不可扩展的操作,规格书(也就是规格对象)之间的操作只有这三种方法,是不需要扩展也不用预留扩展空间的。如此,我们就可以把与或非的实现放到基类中, 那现在的问题变成了怎么在基类中实现与或非。注意看它们的返回值都需要返回规格书类型,很明显,我们在这里要用到递归调用了。可以这样理解,基类需要子类提供业务逻辑支持,因为基类是一个抽象类,不能实例化后返回,我们把简单类图画出来,如图38-3所示。

image-20211001222734100

图38-3 与规格的示意

基类对子类产生了依赖,然后进行递归计算,大家一定会发出这样的疑问:父类怎么可能依赖子类,这还是面向接口编程吗?想想看,我们提出面向接口编程的目的是什么?是为了适应变化,拥抱变化,对于不可能发生变化的部分为什么不能固化呢?与或非操作符号还会增加修改吗?规格书对象之间的操作还有其他吗?思考清楚这些问题后,答案就迎刃而解了。


注意 父类依赖子类的情景只有在非常明确不会发生变化的场景中存在,它不具备扩展性,是一种固化而不可变化的结构。


分析完毕,我们设计出详细的类图,如图38-4所示。

可能大家有很多的疑问,我们先来分析代码,代码分析完毕估计能解决你大部分的疑问。规格书接口如代码清单38-15所示,不再赘述。我们来看组合规格书 (CompositeSpecification),它是一个抽象类,实现了与或非的操作,如代码清单38-16所示。

代码清单38-16 组合规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class CompositeSpecification implements IUserSpecification {
//是否满足条件由实现类实现
public abstract boolean isSatisfiedBy(User user);
//and操作
public IUserSpecification and(IUserSpecification spec) {
return new AndSpecification(this,spec);
}
//not操作
public IUserSpecification not() {
return new NotSpecification(this);
}
//or操作
public IUserSpecification or(IUserSpecification spec) {
return new OrSpecification(this,spec);
}
}

image-20211001222904900

图38-4 完整规格书类图

候选对象是否满足条件是由isSatisfiedBy方法决定的,它代表的是一个判断逻辑,由各个实现类实现。三个与或非操作在抽象类中实现,它是通过直接new了一个子类,如此设计非常符合单一职责原则,每个子类都有一个独立的职责,要么完成“与”操作,要么完成“或”操作,要么完成“非”操作。我们先来看“与”操作规格书,如代码清单38-17所示。

代码清单38-17 与规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AndSpecification extends CompositeSpecification {
//传递两个规格书进行and操作
private IUserSpecification left;
private IUserSpecification right;
public AndSpecification(IUserSpecification _left,IUserSpecification _right){
this.left = _left;
this.right = _right;
}
//进行and运算
@Override
public boolean isSatisfiedBy(User user) {
return left.isSatisfiedBy(user) && right.isSatisfiedBy(user);
}
}

通过构造函数传递过来两个需要操作的规格书,然后通过isSatisfiedBy方法返回两者and 操作的结果。或规格书和非规格书与此类似,分别如代码清单38-18、代码清单38-19所示。

代码清单38-18 或规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OrSpecification extends CompositeSpecification {
//左右两个规格书
private IUserSpecification left;
private IUserSpecification right;
public OrSpecification(IUserSpecification _left,IUserSpecification _right){
this.left = _left;
this.right = _right;
}
//or运算
@Override
public boolean isSatisfiedBy(User user) {
return left.isSatisfiedBy(user) || right.isSatisfiedBy(user);
}
}

代码清单38-19 非规格书

1
2
3
4
5
6
7
8
9
10
11
12
public class NotSpecification extends CompositeSpecification {
//传递一个规格书
private IUserSpecification spec;
public NotSpecification(IUserSpecification _spec){
this.spec = _spec;
}
//not操作
@Override
public boolean isSatisfiedBy(User user) {
return !spec.isSatisfiedBy(user);
}
}

这三个规格书都是不发生变化的,只要使用该框架,三个规格书都要实现的,而且代码基本上是雷同的,所以才有了父类依赖子类的设计,否则是严禁出现父类依赖子类的情况的。大家再仔细看看这三个规格书和组合规格书,代码很简单,但也很巧妙,它跳出了我们面向对象设计的思维,不变部分使用一种固化方式实现。

姓名相同、年龄大于基准年龄、Like格式等规格书都有少许改变,把实现接口变为继承 基类,我们以名字相等规格书为例,如代码清单38-20所示。

代码清单38-20 姓名相同规格书

1
2
3
4
5
6
7
8
9
10
11
12
public class UserByNameEqual extends CompositeSpecification {
//基准姓名
private String name;
//构造函数传递基准姓名
public UserByNameEqual(String _name){
this.name = _name;
}
//检验用户是否满足条件
public boolean isSatisfiedBy(User user) {
return user.getName().equals(name);
}
}

仅仅修改了黑体部分,其他没有任何改变。另外两个规格书修改相同,不再赘述。其他的User及UserProvider没有任何改动,不再赘述。

我们修改一下场景类,如代码清单38-21所示。

代码清单38-21 场景类

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) {
//首先初始化一批用户
ArrayList<User> userList = new ArrayList<User>();
userList.add(new User("苏国庆",23));
userList.add(new User("国庆牛",82));
userList.add(new User("张国庆三",10));
userList.add(new User("李四",10));
//定义一个用户查询类
IUserProvider userProvider = new UserProvider(userList);
//打印出名字包含"国庆"的人员
System.out.println("===名字包含国庆的人员===");
//定义一个规格书
IUserSpecification spec = new UserByAgeThan(25);
IUserSpecification spec2 = new UserByNameLike("%国庆%");
for(User u:userProvider.findUser(spec.and(spec2))){
System.out.println(u);
}
}
}

在场景类中我们建立了两个规格书,一个是年龄大于25的用户,另一个是名字中包含“国庆”两个字的用户,这两个规格书之间的关系是“与”关系,运行结果如下:

1
2
===名字包含国庆的人员=== 
用户名:国庆牛 年龄:82

到此为止我们的LINQ已经完成了很大一部分了,SQL语句中的where后面部分已经可以解析了,完全可以再增加年龄相等的规格书、姓名字数规格书等,你在SQL中使用过的条件在这里都能实现了。功臣还是依赖于三个与或非规格书,有了它们三个栋梁才能组合出一个精彩的条件查询世界。

38.1.2 最佳实践

我们在例子中多次提到规格两个字,该实现模式就叫做规格模式(Specification Pattern),它不属于23个设计模式,它是其中一个模式的扩展,是哪个模式呢?

我们用全局的观点思考一下,基类代表的是所有的规格书,它的目的是描述一个完整的、可组合的规格书,它代表的是一个整体,其下的And规格书、Or规格书、Not规格书、年龄大于基准年龄规格书等都是一个真实的实现,也就是一个局部,现在我们又回到了整体和部分的关系了,那这是什么模式?对,组合模式,它是组合模式的一种特殊应用,我们来看它的通用类图,如图38-5所示。

image-20211001223346451

图38-5 规格模式通用类图

为什么在通用类图中把方法名称都定义出来呢?是因为只要使用规格模式,方法名称都是这四个,它是把组合模式更加具体化了,放在一个更狭小的应用空间中。我们再仔细看看,还能不能找到其他模式的身影?对,策略模式,每个规格书都是一个策略,它完成了一系列逻辑的封装,用年龄相等的规格书替换年龄大于指定年龄的规格书上层逻辑有什么改变吗?不需要任何改变!

规格模式非常重要,它巧妙地实现了对象筛选功能。我们来看其通用源码,首先看抽象规格书,如代码清单38-22所示。

代码清单38-22 抽象规格书

1
2
3
4
5
6
7
8
9
10
public interface ISpecification {
//候选者是否满足要求
public boolean isSatisfiedBy(Object candidate);
//and操作
public ISpecification and(ISpecification spec);
//or操作
public ISpecification or(ISpecification spec);
//not操作
public ISpecification not();
}

组合规格书实现与或非的算法,如代码清单38-23所示。

代码清单38-23 组合规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract class CompositeSpecification implements ISpecification {
//是否满足条件由实现类实现
public abstract boolean isSatisfiedBy(Object candidate);
//and操作
public ISpecification and(ISpecification spec) {
return new AndSpecification(this,spec);
}
//not操作
public ISpecification not() {
return new NotSpecification(this);
}
//or操作
public ISpecification or(ISpecification spec) {
return new OrSpecification(this,spec);
}
}

与或非规格书代码分别如代码清单38-24至代码清单38-26所示。

代码清单38-24 与规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class AndSpecification extends CompositeSpecification {
//传递两个规格书进行and操作
private ISpecification left;
private ISpecification right;
public AndSpecification(ISpecification _left,ISpecification _right){
this.left = _left;
this.right = _right;
}
//进行and运算
@Override
public boolean isSatisfiedBy(Object candidate) {
return left.isSatisfiedBy(candidate) && right.isSatisfiedBy(candidate);
}
}

代码清单38-25 或规格书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class OrSpecification extends CompositeSpecification {
//左右两个规格书
private ISpecification left;
private ISpecification right;
public OrSpecification(ISpecification _left,ISpecification _right){
this.left = _left;
this.right = _right;
}
//or运算
@Override
public boolean isSatisfiedBy(Object candidate) {
return left.isSatisfiedBy(candidate) || right.isSatisfiedBy(candidate);
}
}

代码清单38-26 非规格书

1
2
3
4
5
6
7
8
9
10
11
12
public class NotSpecification extends CompositeSpecification {
//传递一个规格书
private ISpecification spec;
public NotSpecification(ISpecification _spec){
this.spec = _spec;
}
//not操作
@Override
public boolean isSatisfiedBy(Object candidate) {
return !spec.isSatisfiedBy(candidate);
}
}

以上一个接口、一个抽象类、3个实现类只要在适用规格模式的地方都完全相同,不用做任何的修改,大家闭着眼照抄就成,要修改的是下面的规格书——业务规格书,如代码清单38-27所示。

代码清单38-27 业务规格书

1
2
3
4
5
6
7
8
9
10
11
12
public class BizSpecification extends CompositeSpecification {
//基准对象
private Object obj;
public BizSpecification(Object _obj){
this.obj = _obj;
}
@Override
public boolean isSatisfiedBy(Object candidate) {
//根据基准对象和候选对象,进行业务判断,返回boolean
return false;
}
}

然后就是看怎么使用了,场景类如代码清单38-28所示。

代码清单38-28 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
//待分析的对象
ArrayList<Object> list = new ArrayList<Object>();
//定义两个业务规格书
ISpecification spec1 = new BizSpecification(new Object());
ISpecification spec2 = new BizSpecification(new Object());
//规则的调用
for(Object obj:list){
if(spec1.and(spec2).isSatisfiedBy(obj)){
//and操作
System.out.println(obj);
}
}
}
}

规格模式已经是一个非常具体的应用框架了(相对于23个设计模式),大家遇到类似多个对象中筛选查找,或者业务规则不适于放在任何已有实体或值对象中,而且规则的变化和组合会掩盖那些领域对象的基本含义,或者是想自己编写一个类似LINQ的语言工具的时候就可以照搬这部分代码,只要实现自己的逻辑规格书即可。

35.1 迷你版的交易系统

大家可能对银行的交易系统充满敬畏之情,一听说是银行的IT人员,立马想当然地认为这是个很厉害的人物,那我们今天就来对银行的交易系统做一个初步探讨。国内一家大型集团(全球500强之一)计划建立全国“一卡通”计划,每个员工配备一张IC卡,该卡基本上就是万能的,门禁系统用它,办公系统用它,你想打开自己的邮箱,没有它就甭想了,它还可以用来进行消费,比如到食堂吃饭,到园区内的商店消费,甚至洗澡、理发、借书、买书等都可以用它,只要这张卡内有余额,在集团内部就是一张借记卡(当然还有一些内部的补助通过该卡发放)。我们要讲解的就是“一卡通”项目联机交易子系统,类似于银行的交易系统,可以说它是交易系统的mini版吧。

该项目具有一定的挑战性,集团公司的架构分为三层:总部、省级分部、市级机构,业务要求是“一卡通”推广到全国,一名员工从北京出差到了上海,凭一卡通能在北京做的事情在上海同样能完成。对于联机交易子项目,异地分支机构与总部之间的通信采用了MQ(Message Queue,消息队列)传递消息,也就是我们观察者模式的BOSS版,与目前的通过POS机刷信用卡基本上是一个道理。

联机交易子系统有一个非常重要的子模块(Module)——扣款子模块。这个模块太重要了!从业务上来说,扣款失败就代表着所有的商业交易关闭,这是不允许发生的;从技术上来说,扣款的异常处理、事务处理、鲁棒性都是不容忽视的,特别是饭点时间,并发量是很恐怖的,这对架构师提出了很高的要求。

我们详细分析一下扣款子模块,每个员工都有一张IC卡,他的IC卡上有以下两种金额。

  • 固定金额

固定金额是指员工不能提现的金额,这部分金额只能用来特定消费,即员工日常必需的消费,例如食堂内吃饭、理发、健身等活动。

  • 自由金额

自由金额是可以提现的,当然也可以用于消费。每个月初,总部都会为每个员工的IC卡中打入固定数量的金额,然后提倡大家在集团内的商店消费。

在实际的系统开发中,架构设计采用的是一张IC卡绑定两个账户:固定账户和自由账号,本书为了简化描述,还是使用固定金额和自由金额的概念。既然有消费,系统肯定有扣款处理,系统内有两套扣款规则。

  • 扣款策略一

该类型的扣款会对IC卡上的两个金额产生影响,计算公式如下:

1
2
IC卡固定余额=IC卡现有固定余额-交易金额/2 
IC卡自由余额=IC卡现有自由金额-交易金额/2

也就是说,该类型的消费分别在固定金额和自由金额上各扣除一半。它适用于固定消费场景例如吃饭、理发等情况下的扣款,这么做是为了防止乱请客,你请别人吃饭时自己也要出一半。

  • 扣款策略二

全部从自由金额上扣除,由于集团内的各种消费、服务非常齐全,而且比市面价格稍低,员工还是很乐意到这里消费的,而且很多员工本身就住在集团附近,基本上就是“公司即家,家即公司”。

今天要讲的重点就是这两种消费的扣款策略该怎样设计?要知道这种联机交易,日后允许大规模变更的可能性基本上是零,所以系统设计的时候要做到可拆卸(Pluggable),避免日后维护的大量开支。

很明显,这是一个策略模式的实际应用,但是你还记得策略模式是有缺陷的吗?它的具体策略必须暴露出去,而且还要由上层模块初始化,这不合适,与迪米特法则有冲突,高层次模块对低层次的模块应该仅仅处在“接触”的层次上,而不应该是“耦合”的关系,否则,维护的工作量就会非常大。问题提出了,那我们就应该想办法来修改这个缺陷,正好工厂方法模式可以帮我们产生指定的对象,但是问题又来了,工厂方法模式要指定一个类,它才能产生对象,怎么办?引入一个配置文件进行映射,避免系统僵化情况的发生,我们以枚举类完成该任务。

还有一个问题,一个交易的扣款模式是固定的,根据其交易编号而定,那我们怎样把交易编号与扣款策略对应起来呢?采用状态模式或责任链模式都可以,如果采用状态则认为交易编号就是一个交易对象的状态,对于一笔确定的交易(一个已经生成了的对象),它的状态不会从一个状态过渡到另一个状态,也就是说它的状态只有一个,执行完毕后即结束,不存在多状态的问题;如果采用责任链模式,则可以用交易编码作为链中的判断依据,由每个执行节点进行判断,返回相应的扣款模式。但是在实际中,采用了关系型数据库存储扣款规则与交易编码的对应关系,为了简化该部分的讲义,我们在下面的设计中使用了条件判断语句来代替。

还有,这么复杂的扣款模块总要进行一个封装吧,不能让上层的业务模块直接深入到模块的内部,于是门面模式又摆在了眼前。

分析完毕,我们要先画出类图,做设计要遵循这样一个原则:先选最简单的业务,然后画出类图。那我们先定义交易中用到的两个类:IC卡类和交易类,如图35-1所示。

image-20211001195939429

图35-1 IC卡类和交易类

每个IC卡有三个属性,分别是IC卡号码、固定金额、自由金额,然后通过getter/setter方法来访问,如代码清单35-1所示。

代码清单35-1 IC卡类

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 Card {
//IC卡号码
private String cardNo="";
//卡内的固定交易金额
private int steadyMoney =0;
//卡内自由交易金额
private int freeMoney =0;
//getter/setter方法
public String getCardNo() {
return cardNo;
}
public void setCardNo(String cardNo) {
this.cardNo = cardNo;
}
public int getSteadyMoney() {
return steadyMoney;
}
public void setSteadyMoney(int steadyMoney) {
this.steadyMoney = steadyMoney;

}
public int getFreeMoney() {
return freeMoney;
}
public void setFreeMoney(int freeMoney) {
this.freeMoney = freeMoney;
}
}

细心的读者可能注意到,金额怎么都是整数类型呀,应该是double类型或者BigDecimal 类型呀。是,一般非银行的交易系统,比如超市的收银系统,系统内都是存放的int类型,在显示的时候才转换为货币类型。

交易信息Trade类,负责记录每一笔交易,它是由监听程序监听MQ队列而产生的,有两个属性:交易编号和交易金额,其中的交易编号对整个交易非常重要,18位字符(在银行的交易系统中,这里可不是字符串,一般是十进制数字或二进制数字,要考虑系统的性能,数字运算可比字符运算快得多),包括POS机编号、商户编号、校验码等,我们这里暂时用不到,就不多做介绍,我们只要知道它是一个非常有用的编码就成。交易金额为整数类型,实际金额放大100倍即可。如代码清单35-2所示。

代码清单35-2 交易类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Trade {
//交易编号
private String tradeNo = "";
//交易金额
private int amount = 0;
//getter/setter方法
public String getTradeNo() {
return tradeNo;
}
public void setTradeNo(String postNo) {
this.tradeNo = postNo;
}
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
}

两个最简单也是在应用中最常使用的对象定义完毕,下面就需要来定义策略了,非常明显的策略模式,类图如图35-2所示。

典型的策略模式,扣款有两种策略:固定扣款和自由扣款。下面我们来看代码,先看抽象策略,也就是扣款接口,如代码清单35-3所示。

代码清单35-3 扣款策略接口

1
2
3
4
public interface IDeduction {
//扣款,提供交易和卡信息,进行扣款,并返回扣款是否成功
public boolean exec(Card card,Trade trade);
}

固定扣款的规则是固定金额和自由金额各扣除交易金额的一半,如代码清单35-4所示。

代码清单35-4 扣款策略一

1
2
3
4
5
6
7
8
9
10
public class SteadyDeduction implements IDeduction {
//固定性交易扣款
public boolean exec(Card card, Trade trade) {
//固定金额和自由金额各扣除50%
int halfMoney = (int)Math.rint(trade.getAmount() / 2.0);
card.setFreeMoney(card.getFreeMoney() - halfMoney);
card.setSteadyMoney(card.getSteadyMoney() - halfMoney);
return true;
}
}

image-20211001200222415

图35-2 扣款策略类图
这个具体策略也非常简单,就是两个金额各自减去交易额的一半(注意除数是2.0,可不是2),然后再四舍五入,算法确实简单。该逻辑没有考虑账户余额不足的情况,也没有考虑异常情况,比如并发情况,读者可以想想看,一张卡有两笔消费同时发生时,是不是就发生错误了?一张卡同时有两笔消费会出现这种情况吗?会的,网络阻塞的情况,MQ多通道发送,在网络繁忙的情况下是有可能出现该问题,这里就不多介绍,有兴趣的读者可以看看MQ的资料。我们在这里的讲解实现的是一个快乐路径,认为所有的交易都是在安全可靠的环境中发生的,并且所有的系统环境都满足我们的要求。我们再来看另一个策略,这个策略更简单,如代码清单35-5所示。

代码清单35-5 扣款策略二

1
2
3
4
5
6
7
8
public class FreeDeduction implements IDeduction {
//自由扣款
public boolean exec(Card card, Trade trade) {
//直接从自由余额中扣除
card.setFreeMoney(card.getFreeMoney() - trade.getAmount());
return true;
}
}

卡内的自由金额减去交易金额再修改卡内自由金额就完事了,异常情况不考虑。这两个具体的策略与我们的交易类型没有任何关系,也不应该有关系,策略模式就是提供两个可以相互替换的策略,至于在什么时候使用什么策略,则不是由策略模式来决定的。策略模式还有一个角色没出场,即封装角色,如代码清单35-6所示。

代码清单35-6 扣款策略的封装

1
2
3
4
5
6
7
8
9
10
11
12
public class DeductionContext {
//扣款策略
private IDeduction deduction = null;
//构造函数传递策略
public DeductionContext(IDeduction _deduction){
this.deduction = _deduction;
}
//执行扣款
public boolean exec(Card card,Trade trade){
return this.deduction.exec(card, trade);
}
}

典型的策略上下文角色。扣款模块的策略已经定义完毕了,然后需要想办法解决策略模式的缺陷:它把所有的策略类都暴露出去,暴露得越多以后的修改风险也就越大。怎么修改呢?增加一个映射配置文件,实现策略类的隐藏。我们使用枚举担当此任,对策略类进行映射处理,避免高层模块直接访问策略类,同时由工厂方法模式根据映射产生策略对象,类图如图35-3所示。

image-20211001200346462

图35-3 策略工厂类图

又是一个简单得不能再简单的模式——工厂方法模式,通过StrategyMan负责对具体策略的映射,如代码清单35-7所示。

代码清单35-7 策略枚举

1
2
3
4
5
6
7
8
9
10
public enum StrategyMan {
SteadyDeduction("com.cbf4life.common.SteadyDeduction"), FreeDeduction("com.cbf4life.common.FreeDeduction");
String value = "";
private StrategyMan(String _value){
this.value = _value;
}
public String getValue(){
return this.value;
}
}

类似的代码解释过很多遍了,不再多说,它就是一个登记容器,所有的具体策略都在这里登记,然后提供给工厂方法模式。策略工厂如代码清单35-8所示。

代码清单35-8 策略工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
public class StrategyFactory {
//策略工厂
public static IDeduction getDeduction(StrategyMan strategy){
IDeduction deduction = null;
try {
deduction = (IDeduction)Class.forName(strategy.getValue()).newInstance();
}
catch (Exception e) {
// 异常处理
}
return deduction;
}
}

一个简单的工厂,根据策略管理类的枚举项创建一个策略对象,简单而实用,策略模式的缺陷也弥补成功。那这么复杂的系统怎么让高层模块访问?(你看不出复杂?那是因为我们写的都是快乐路径,太多情况都没有考虑,在实际项目中仅就并发处理和事务管理这两部分就够你头疼了。)既然系统很复杂,是不是需要封装一下。我们请出门面模式进行封装, 如代码清单35-9所示。

代码清单35-9 扣款模块封装

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 DeductionFacade {
//对外公布的扣款信息
public static Card deduct(Card card,Trade trade){
//获得消费策略
StrategyMan reg = getDeductionType(trade);
//初始化一个消费策略对象
IDeduction deduction = StrategyFactory.getDeduction(reg);
//产生一个策略上下文
DeductionContext context = new DeductionContext(deduction);
//进行扣款处理
context.exec(card, trade);
//返回扣款处理完毕后的数据
return card;
}
//获得对应的商户消费策略
private static StrategyMan getDeductionType(Trade trade){
//模拟操作
if(trade.getTradeNo().contains("abc")){
return StrategyMan.FreeDeduction;
}
else{
return StrategyMan.SteadyDeduction;
}
}
}

这次为什么要先展示代码而后写类图呢?那是因为这段代码比写类图更能让你理解。读者注意一下getDeductionType方法,这个方法在实际项目中是存在的,但是与上面的写法有天壤之别,因为在实际项目中,数据库中保存了策略代码与交易编码的对应关系,直接通过数据库的SQL语句就可以返回对应的扣款策略。这里我们采用大家最熟悉的条件转移来实现,也是比较清晰和容易理解的。

可能读者要问了,在门面模式中已经明确地说明,门面类中不允许有业务逻辑存在,但是你这里还是有了一个getDeductionType方法,它可代表的是一个判断逻辑呀,这是为什么呢?是的,该方法完全可以移到其他Hepler类中,由于我们是示例代码,暂没有明确的业务含义,故编写在此处,读者在实际应用中,请把该方法放置到其他类中。

好,所有用到的模式都介绍完毕了,我们把完整的类图整理一下,如图35-4所示。

image-20211001201105393

图35-4 扣款子模块完整类图

真实系统比这复杂得多,有了我们之前的分析,这个图还是比较容易看懂的。我们所有的开发都完成了,是不是应该写一个测试类来展示一下我们的成果,如代码清单35-10所示。

代码清单35-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
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 class Client {
//模拟交易
public static void main(String[] args) {
//初始化一张IC卡
Card card = initIC();
//显示一下卡内信息
System.out.println("========初始卡信息:=========");
showCard(card);
//是否停止运行标志
boolean flag = true;
while(flag){
Trade trade = createTrade();
DeductionFacade.deduct(card, trade);
//交易成功,打印出成功处理消息
System.out.println("\n======交易凭证========");
System.out.println(trade.getTradeNo()+" 交易成功!");
System.out.println("本次发生的交易金额为:"+ trade.getAmount()/100.0+"元");
//展示一下卡内信息
showCard(card);
System.out.print("\n是否需要退出?(Y/N)");
if(getInput().equalsIgnoreCase("y")){
flag = false;
//退出
}
}
}
//初始化一个IC卡
private static Card initIC(){
Card card = new Card();
card.setCardNo("1100010001000");
card.setFreeMoney(100000);
//1000元
card.setSteadyMoney(80000);
//800元
return card;
}
//产生一条交易
private static Trade createTrade(){
Trade trade = new Trade();
System.out.print("请输入交易编号:");
trade.setTradeNo(getInput());
System.out.print("请输入交易金额:");
trade.setAmount(Integer.parseInt(getInput()));
//返回交易
return trade;
}
//打印出当前卡内交易余额
public static void showCard(Card card){
System.out.println("IC卡编号:" + card.getCardNo());
System.out.println("固定类型余额:"+ card.getSteadyMoney()/100.0 + " 元");
System.out.println("自由类型余额:"+ card.getFreeMoney()/100.0 + " 元");
}
//获得键盘输入
public static String getInput(){
String str ="";
try {
str=(new BufferedReader(new InputStreamReader(System.in))).readLine();
}
catch (IOException e) {
//异常处理
}
return str;
}
}

类比较长,耐心看还是非常简单的,对其中Client类的方法说明如下:

  • initIC方法

初始化一张IC卡,方便进行测试。

  • createTrade方法

创建一笔交易,完成测试任务。

  • showCard方法

显示IC卡内的信息。

  • getInput方法

获得从键盘输入的字符,以回车符作为终结标志。

方法介绍完毕了,我们运行一下看看,结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
========初始卡信息:========= 
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:1000.0 元
请输入交易编号:abcdef
请输入交易金额:10000
======交易凭证========
abcdef 交易成功!
本次发生的交易金额为:100.0 元
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:900.0 元
是否需要退出?(Y/N)

我们模拟了一笔自由消费,直接从自由类型金额中扣除了。我们再模拟一笔固定类型的消费,运行结果如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
========初始卡信息:========= 
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:1000.0 元
请输入交易编号:abcdef
请输入交易金额:10000
======交易凭证========
abcdef 交易成功!
本次发生的交易金额为:100.0 元
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:900.0 元
是否需要退出?(Y/N)n
请输入交易编号:1001
请输入交易金额:1234
======交易凭证========
1001 交易成功!
本次发生的交易金额为:12.34 元
IC卡编号:1100010001000
固定类型余额:793.83 元
自由类型余额:893.83 元
是否需要退出?(Y/N)

交易成功!到这里为止,联机交易中的扣款子模块开发完毕了!是不是很简单,银行业的交易系统也就是这么回事!

37.1 MVC框架的实现

相信这本书的读者对Struts的使用是得心应手了,也明白MVC框架有诸如视图与逻辑解耦、灵活稳定、业务逻辑可重用等优点,而且还对其他的MVC框架(例如JSF、Spring MVC、WebWork)也了解一点。SSH(Struts+Spring+Hibernate)框架是Java项目常用的框架,作为一个Java开发人员,应该对SSH框架很熟悉了!我们今天就学Struts怎么用!我们要讲的是MVC框架如何设计,你可以设计一个新的MVC框架与Struts抗衡。

在开始设计MVC框架前,首先要对MVC框架做一个简单的介绍。MVC(Model ViewController)的中文名称叫做模型视图控制器模型,就是因为它的英文名字太流行了, 中文名字反而被忽略了。它诞生于20世纪80年代,原本是为桌面应用程序建立起来的一个框架,现在反而在Web应用中大放异彩(其实也可以把B/S认为是C/S的瘦化结构),MVC框架的目的是通过控制器C将模型M(代表的是业务数据和业务逻辑)和视图V(人机交互的界面)实现代码分离,从而使同一个逻辑或行为或数据可以具有不同的表现形式,或者是同样的应用逻辑共享相同、不同视图。比如,可以用IE浏览器访问某应用网站(页面格式遵守HTML标准),也可以用手机通过WAP浏览器访问(页面格式遵守WML格式),对MVC框架来说,后台的程序(也就是模型)不用做任何修改,只是使用的视图不同而已。MVC框架如图37-1所示。

image-20211001213320931

图37-1 MVC框架示意图
该框架是Model2的结构。MVC框架有两个版本,一个是Model1,也就是MVC的第一个版本,它的视图中存在着大量的流程控制和代码开发,也就是控制器和视图还具有部分的耦合。也有人不认为Model1属于MVC框架,那也说得通,因为在JSP页面中融合了控制器和视图的功能,这其实就是早期的开发模式,开发一堆的JSP页面,然后再开发一堆的JavaBean,JavaBean就是模型了,它只是把JSP和JavaBean拆分开了。Model2版本则提倡视图和模型的彻底分离,视图仅仅负责展示服务,不再参与业务的行为和数据处理。我们举例来说明MVC框架是如何图37-1 MVC框架示意图控制器(Controller)视图(View)模型 (Model)第37章运行的。

在做Web开发时,例如开发一个数据展示界面,从一张表中把数据全部读出,然后展示到页面上,也是一个简单的表格,其中页面展示的格式就是视图V,怎么从数据库中取得数据则是模型M,那控制器C是做什么的呢?它负责把接收的浏览器的请求转发通知模型M处理,然后组合视图V,最终反馈一个带数据的视图到用户端,数据处理流程如图37-2所示。

image-20211001213507728

图37-2 MVC框架的逻辑流
浏览器通过HTTP协议发出数据请求①,由控制器接收请求,通过路径②委托给数据模型处理,模型通过与逻辑层和持久层的交互(路径③④),把处理结果反馈给控制器(路径 ⑤),控制器根据结果组装视图(路径⑥⑦),并最终反馈给浏览器可以接受的HTML数据 (路径⑧)。整体MVC框架还是比较简单的,但它带来的优点非常多。
  • 高重用性

一个模型可以有多个视图,比如同样是一批数据,可以是柱状展示,也可以是条形展示,还可以是波形展示。同样,多个模型也可以共享一个视图,同样是一个登录界面,不同用户看到的菜单数量(模型中的数据)不同,或者不同业务权限级别的用户在同一个视图中展示。

  • 低耦合

因为模型和视图分离,两者没有耦合关系,所以可以独立地扩展和修改而不会产生相互影响。

  • 快速开发和便捷部署

模型和视图分离,可以使各个开发人员自由发挥,做视图的人员和开发模型的人员可以制订自己的计划,然后在控制器的协作下实现完整的应用逻辑。

MVC框架还有很多优点,本章主要不是讲解MVC技术,主要是通过讲解设计MVC框架 来说明设计模式该怎么应用,所以想了解更详细的MVC框架信息请自行查阅资料。

37.1.1 MVC的系统架构

我们设计的MVC框架包含以下模块:核心控制器(FilterDispatcher)、拦截器 (Interceptor)、过滤器(Filter)、模型管理器(Model Action)、视图管理器(View Provider)等,基本上一个MVC框架上常用的功能我们都具备了,系统架构如图37-3所示。

image-20211001213719199

图37-3 MVC系统架构

各个模块的职责如下:

  • 核心控制器

MVC框架的入口,负责接收和反馈HTTP请求。

  • 过滤器

Servlet容器内的过滤器,实现对数据的过滤处理。由于它是容器内的,因此必须依靠容 器才能运行,它是容器的一项功能,与容器息息相关,本章就不详细讲述了。

  • 拦截器

对进出模型的数据进行过滤,它不依赖系统容器,只过滤MVC框架内的业务数据。

  • 模型管理器

提供一个模型框架,该框架内的所有业务操作都应该是无状态的,不关心容器对象,例如Session、线程池等。

  • 视图管理器

管理所有的视图,例如提供多语言的视图等。

  • 辅助工具

它其实就是一大堆的辅助管理工具,比如文件管理、对象管理等。

在我们的MVC框架中,核心控制器是最重要的,我们就先从它着手。核心控制器使用了Servlet容器的过滤器技术,需要编写一个过滤器,所有进入MVC框架的请求都需要经过核心控制器的转发,类图如图37-4所示。

image-20211001213950390

图37-4 核心控制器类图

由于类图中的部分输入参数类型较长,省略了,请读者仔细看代码。首先阅读FilterDispatcher代码,如代码清单37-1所示。

代码清单37-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
39
40
41
public class FilterDispatcher implements Filter {
//定义一个值栈辅助类
private ValueStackHelper valueStackHelper = new ValueStackHelper();
//应用IActionDispatcher
IActionDispather actionDispatcher = new ActionDispatcher();
//servlet销毁时要做的事情
public void destroy() {
}
//过滤器必须实现的方法
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
//转换为HttpServletRequest
HttpServletRequest req = (HttpServletRequest)request;
HttpServletResponse res = (HttpServletResponse)response;
//传递到其他过滤器处理
chain.doFilter(req, res);
//获得从HTTP请求的ACTION名称
String actionName = getActionNameFromURI(req);
//对ViewManager的应用
ViewManager viewManager = new ViewManager(actionName);
//所有参数放入值栈
ValueStack valueStack = valueStackHelper.putIntoStack(req);
//把所有的请求传递给ActionDispatcher处理
String result =actionDispatcher.actionInvoke(actionName);
String viewPath = viewManager.getViewPath(result);
//直接转向
RequestDispatcher rd = req.getRequestDispatcher(viewPath);
rd.forward(req, res);
}
public void init(FilterConfig arg0) throws ServletException {
/*
* 1、检查XML配置文件是否正确
* 2、启动监控程序,观察配置文件是否正确
*/
}
//通过url获得actionName
private String getActionNameFromURI(HttpServletRequest req){
String path = (String) req.getRequestURI();
String actionName = path.substring(path.lastIndexOf("/") + 1, path.lastIndexOf("."));
return actionName;
}
}

我们按照系统的执行顺序来讲解,首先在容器的配置文件中需要配置该过滤器,以tomcat为例,配置如代码清单37-2所示。

代码清单37-2 核心控制器的配置

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="UTF-8"?>
<web-app>
<filter>
<display-name>FilterDispatcher</display-name>
<filter-name>FilterDispatcher</filter-name>
<filter-class>{包名}.FilterDispatcher</filter-class>
</filter>
<filter-mapping>
<filter-name>FilterDispatcher</filter-name>
<url-pattern>*.do</url-pattern>
</filter-mapping>
</web-app>

在这里定义了对所有以.do结尾的请求进行拦截,拦截后由FilterDispatcher的doFilter方法处理。过滤器是在启动时自动初始化,初始化完毕后立刻调用inti方法,在init方法中我们做了两件事情。

  • 检查XML配置文件

所有的Action与视图的对应关系是在配置文件中配置的,因此若配置文件出错,该应用应该停止响应,这就需要在启动时对XML文件进行完整性检查和语法分析。

  • 启动监视器

配置文件随时都可以修改,但是它修改后不应该需要重新启动应用才能生效,否则对系统的正常运行有非常大的影响,因此这里要使用到Listener(监听)行为了。

init方法需要做的这两件事情是非常重要的,而且都还包含了几种不同的设计模式。首 先我们来看检查XML配置文件如何实现。先看我们定义的XML格式(框架中应该定义一个 DTD文件,XML文件的模板,读者可以自行实现),如代码清单37-3所示。

代码清单37-3 XML配置文件

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<mvc>
<action name="loginAction" class="{类名全路径}" method="execute">
<result name="success">/index2.jsp</result>
<result name="fail">/index.jsp</result>
</action>
</mvc>

读者思考一下该怎么检查这个XML文件,有两个不同的检查策略:一是检查XML文件的语法是否正确;二是框架逻辑检查,这是什么意思呢?比如我们在XML文件中配置了一个类A,它只有一个方法methodA,在method中编写的配置文件为method=”methoda”,方法名写错了,那这样的配置是肯定不能运行的,需要框架逻辑检查把它揪出来。这两种不同的算法是完全可以替换的,而且很有必要替换,逻辑检查在应用启动的时候需要对所有的类进行过滤处理,牺牲的是效率,这在测试机上没有问题,在生产机上要花20分钟才能把一个应用启动起来,在分秒必争的业务系统中这是不允许的,因此就要求该算法可以退休,想用的时候 (测试机环境)就用,不想用的时候(生产环境)就不用,想到什么模式了吗?策略模式, 这两个算法都是对同样的源文件进行检查,只是算法不同,当然可以相互替换了。类图比较简单,就不再画了,我们直接看代码,抽象策略如代码清单37-4所示。

代码清单37-4 XML文件校验

1
2
3
4
public interface IXmlValidate {
//只有一个方法,检查XML是否符合条件
public boolean validate(String xmlPath);
}

根据一个指定的路径,对XML进行校验,返回校验结果。普通XML校验如代码清单37-5 所示。

代码清单37-5 普通XML校验

1
2
3
4
5
6
public class CommonXmlValidate implements IXmlValidate {
//XML语法检查,比如是否少写了一个结束标志
public boolean validate(String xmlPath) {
return false;
}
}

由于读写XML文件一般使用DOM4J或者JDOM,都提供对XML文件的语法校验功能,不符合XML语法(比如一个节点少写了结束标志</node>)的文件是不能解析的,读者可以在自己编写框架时使用该类型工具。

框架的逻辑算法如代码清单37-6所示。

代码清单37-6 框架逻辑校验

1
2
3
4
5
6
public class LogicXmlValidate implements IXmlValidate {
//检查xmlPath是否符合逻辑,比如不会出现一个类中没有的方法
public boolean validate(String xmlPath) {
return false;
}
}

逻辑校验相对比较复杂,它的逻辑流程如下:

  • 读取XML文件。
  • 使用反射技术初始化一个对象(配置文件中的class属性值)。
  • 检查是否存在配置文件中配置的方法。
  • 检查方法的返回值是否是String,并且无输入参数,同时必须继承指定类或接口。

逻辑校验需要把所有的对象都初始化一遍,在Action类较多的情况下,效率较低,但它可以提前发现出现访问异常的情况,把问题解决在萌芽状态。我们继续来看两个策略的场景类,如代码清单37-7所示。

代码清单37-7 策略的场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Checker {
//使用哪一个策略
private IXmlValidate validate;
//xml配置文件的路径
String xmlPath;
//构造函数传递
public Checker(IXmlValidate _validate){
this.validate = _validate;
}
public void setXmlPath(String _xmlPath){

this.xmlPath = _xmlPath;
}
//检查
public boolean check(){
return validate.validate(xmlPath);
}
}

与通用策略模式稍有不同,每个模式在实际应用环境中都有其个性,很少出现完全照搬一个模式的情况,灵活应用设计模式才是关键。

在FilterDispatcher的init方法中,我们刚刚说它有两个职责:第一个职责是XML文件校验,这个我们完成了;第二个职责是启动监控程序。问题是要监控什么呢?监控XML有没有被修改,如果修改了就立刻通知校验程序对它进行校验。这就又用到了观察者模式:发现文件被修改,它立刻通知检查者处理,该片段的类图如图37-5所示。

image-20211001215149343

图37-5 XML文件监控类图

为什么要在这里定义一个Watchable接口呢?它表示所有可以监视的资源,比如数据库、日志文件、磁盘空间等。我们来看代码,监听接口如代码清单37-8所示。

代码清单37-8 监听接口

1
2
3
4
public interface Watchable {
//监听
public void watch();
}

文件监听者是观察者模式的被观察者,它一旦发现文件发生变化立刻通知观察者,如代码清单37-9所示。

代码清单37-9 文件监听者

1
2
3
4
5
6
7
8
9
10
11
public class FileWatcher extends Observable implements Watchable{
//是否要重新加载XML文件
private boolean isReload = false;
//启动监视
public void watch(){
//启动一个线程,每隔15秒扫描一下文件,发现文件日期被修改,立刻通知观察者
super.addObserver(new Checker());
super.setChanged();
super.notifyObservers(isReload);
}
}

由于框架是在操作系统之上运行的,文件变化时操作系统是不会通知应用系统的,因此我们能做的就是启动一个线程监视一批文件,发现文件改变了,立刻通知相关的处理者,它虽然有时间延迟,但对于一个应用框架来说是非常有必要的,避免了重启应用才能使配置生效的情况。

读者可能很疑惑,这种死循环的监控方式会不会对性能产生影响,答案是不会!为什么呢?

检查一个文件的时间一般是毫秒级的,相对于我们设置的运行周期(比如15秒执行一次)是一个非常微小的运行时间,对应用不会产生任何影响。大家都在使用Log4j进行日志处理,它有一个线程是每5秒检查一次日志是否满,大家觉得性能受影响了吗?基本上性能影响可以忽略不计。

由于Checker还要作为观察者,因此它要实现Observer接口,同时实现update方法,如代码清单37-10所示。

代码清单37-10 修正后的检查者

1
2
3
4
5
6
public class Checker implements Observer{
public void update(Observable arg0, Object arg1) {
//检查是否符合条件
arg1 = check();
}
}

到此为止,我们把init方法已经讲解完毕,它是在容器初始化时调用。有一个HTTP请求发送过来,容器调用我们编写的doFilter方法。仔细看一下我们的代码,其中有这样一句话:Chain.doFilter(req,res),这句话是什么意思呢?是说让后续的过滤器先运行,等它们运行完毕后该过滤器再运行,应该想到这是一个责任链模式,它的类型是FilterChain。Servlet 容器把所有的过滤器组合在一起形成了一个过滤器链,它是怎么做到的呢?容器启动的时候,把所有的过滤器都初始化完毕,然后根据它们在web.xml中的配置顺序,从上向下组装一个过滤器链。注意所有的过滤器都必须实现Filter接口,这是建立过滤器链的首要前提。

我们再回过头来仔细看看类图,是不是有点熟悉?对,类似于中介者模式,我们并没有把中介者传递到各个同事类,只是我们采用中介者模式的思想,把中介者的职责分发出去由各个同事类来处理。

37.1.2 模型管理器

模型管理器是整个MVC框架的难点,在这里我们会看到非常多的设计模式。我们在核心控制器的类图中看到有一个IActionDispatcher接口,它实现的模型行为分发是一个门面模式,如代码清单37-11所示。

代码清单37-11 模型行为分发接口

1
2
3
4
public interface IActionDispather {
//根据Action的名字,返回处理结果
public String actionInvoke(String actionName);
}

它的职责非常简单,得到actionName就执行,熟悉Struts的读者可能很清楚这个方法是非常复杂的,它要从配置文件中找到执行对象,然后执行方法,还要考虑值栈、异常等,非常复杂。我们这里就有一个方法,它对外提供一个门面,所有的访问都是通过该门面来完成, 其实现类如代码清单37-12所示。

代码清单37-12 模型分发实现

1
2
3
4
5
6
7
8
9
10
11
public class ActionDispather implements IActionDispather {
//需要执行的Action
private ActionManager actionManager = new ActionManager();
//拦截器链
private ArrayList<Interceptors> listInterceptors = InterceptorFactory.createInterceptors();
public String actionInvoke(String actionName) {
//前置拦截器
return actionManager.execAction(actionName);
//后置拦截器
}
}

它是一个非常简单的类,对外部提供统一封装好的行为。模型管理器的类图如图37-6所示。

首先说ActionManager类,它负责管理所有的行为类Action,那就必须定义一个行为类的接口或抽象类,如代码清单37-13所示。

代码清单37-13 抽象Action

1
2
3
4
5
6
7
8
public abstract class ActionSupport {
public final static String SUCCESS = "success";
public final static String FAIL = "fail";
//默认的执行方法
public String execute(){
return SUCCESS;
}
}

image-20211001215608539

图37-6 模型管理器类图
抽象的ActionSupport类看起来很简单,其实它可不简单,所有的模型行为都继承该类, 它之所以提供一个默认的execute方法,是因为在xml的配置文件中,可以省略掉method="XXX"这句话,默认就是调用该方法。它还有一个非常重要的行为:对象映射,把HTTP传递过来的字符串映射到一个业务对象上,我们会在值栈中详细讲解。

读者可能很疑惑,Action的操作是需要获得环境数据的,比如HTTPServletRequest的数据,还有系统中的Session数据,单单一个ActionManager如何获得这些数据呢?通过值栈,在值栈中保存着该Action需要的所有数据。

我们再来看ActionManager类,如代码清单37-14所示。

代码清单37-14 Action管理类

1
2
3
4
5
6
public class ActionManager {
//执行Action的指定方法
public String execAction(String actionName){
return null;
}
}

就这么简单吗?非也,其中的参数actionName指xml配置中的name属性值,它与从HTTP 传递过来的请求对象是一致的,根据HTTP传递过来的actionName在xml文件中查找对应的节点(Node),然后就可以获取到该类的名称和方法,通过动态代理的方式执行该方法,在这里我们使用到了代理模式。

有读者可能听说过反射是影响性能的,它提供解释型操作。是这样的,但是实际应用还没有这么高的要求,把数据库设计得优秀一点,系统架构多考虑一点,提升的性能远比这个多。

然后我们再来看拦截器,拦截器和过滤器的区别就是:拦截器可以脱离容器(J2EE容器)运行,而过滤器不行。拦截器的目的是对数据和行为进行过滤,符合条件的才可以执行Action,或者是在Action执行完毕后,调用拦截器进行回收处理。我们定义一个抽象的拦截器,如代码清单37-15所示。

代码清单37-15 抽象拦截器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public abstract class AbstractInterceptor {
//获得当前的值栈
private ValueStack valueStack = ValueStackHelper.getValueStack();
//拦截器类型:前置、后置、环绕
private int type =0;
//当前的值栈
protected ValueStack getValueStack(){
return valueStack;
}
//拦截处理
public final void exec(){
//根据type不同,处理方式也不同
}
//拦截器类型
protected abstract void setType(int type);
//子类实现的拦截器
protected abstract void intercept();
}

这怎么和Struts的拦截器不相同呀!是的,Struts的拦截器的拦截方法intercept是要接收一个ActionInvocation对象,这里却没有,我们主要是讲解模式,是为了技术实现,而类似Struts 的MVC框架属于工业级别的应用框架,考虑了太多的外界因素。拦截器分为三种。

  • 前置拦截器

在Action调用前执行,对Action需要的场景数据进行过滤或重构。

  • 后置拦截器

在Action调用后执行,负责回收场景,或对Action的后续事务进行处理。

  • 环绕拦截器

在Action调用前后都执行。

我们的框架在这里使用了一个模板方法模式,开发者继承AbstractInterceptor后,只要完成两个职责即可:定义拦截类型(setType)和实现拦截器要拦截的方法(intercept),不用考虑它到底如何调用ActionInvocation,相对来说简单又实用。

有拦截器就肯定有拦截器链,多个拦截器组合在一起就成了拦截器链,如代码清单37- 16所示。

代码清单37-16 拦截器链

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Interceptors implements Iterable<AbstractInterceptor> {
//根据拦截器列表建立一个拦截器链
public Interceptors(ArrayList<AbstractInterceptor> list){
}
//列出所有的拦截器
public Iterator<AbstractInterceptor> iterator() {
return null;
}
//拦截器链的执行方法
public void intercept(){
//委托拦截器执行
}
}

它实现了Iterable接口,提供了一个方便遍历拦截器的方法,这是迭代器模式。同时,由于是一个链结构,我们就想到了责任链,这里确实也是一个责任链模式,只是核心控制器上的过滤链是Servlet容器自己实现的,而拦截器链则需要我们自己编码实现。代码不复杂,读者可以参考责任链章节。

这里还有两个很有意思的方法。我们来看构造函数,它通过一个容纳有拦截器的动态数组生成一个拦截器链,它是一个自激行为,在XML文件中配置一个拦截器,其中包含多个拦截器,我们的构造函数就是这样的用途,自己建立一条链,而不是父类或者高层模块。再看intercept方法,链中每个节点都是一个拦截器,都有一个intercept方法,拦截器链中的intercept方法行为是委托第一个节点拦截器的intercept方法,然后所有的拦截器都会按照顺序执行一遍,这一点和我们的责任链模式是不同的,责任链模式是只要有节点处理就可以认为是结束,后续节点可以不再参与处理。

Struts还实现了方法拦截器,只要继承MethodFilterInterceptor即可,主要使用了反射技 术,有兴趣的话可以看看源代码。注意我们这里使用了拦截器链而不像Struts那样是拦截器 栈,一字之差,系统设计差别可就大了。


注意 拦截器是会影响系统性能的,所有的Action在执行前后都会被拦截器过滤一遍,即使不符合拦截条件的也会被检查一遍,所以非必要情况不要使用拦截器。


由于在XML配置文档中有太多的拦截器链,因此需要有一个工厂来创建它,否则太烦琐。如代码清单37-17所示。

代码清单37-17 拦截器链工厂

1
2
3
4
5
6
public class InterceptorFactory {
public static ArrayList<Interceptors> createInterceptors(){
//根据配置文件创建出所有的拦截器链
return null;
}
}

它的作用是根据配置文件一次性地创建出所有的拦截器,很简单的工厂方法模式。如果读者还记得我们刚刚讲的配置文件更新问题的话,应该想到这里也应该有一个观察者,配置文件修改了,拦截器链当然也要重建了,确实应该有这样一个观察者,读者可以自行思考如何实现。

37.1.3 值栈

值栈按道理说应该很简单,就是把HTTP传递过来的String字符串压到堆栈中。听起来很简单,实现起来就比较有难度了,它要完成两个职责。

  • 管理堆栈

不仅仅是出栈、入栈这么简单,它要管理栈中数据,同时还要允许前置拦截器对栈中数据进行修改,限制后置拦截器对栈的修改,还要把栈中数据与HTTPServletRequest中的数据建立关联。

  • 值映射

从HTTP传递过来的数据都是字符串结构,那怎么才能转化成一个业务对象呢?比如在页面上有一个登录框,输入用户名(userName)和密码(password)。提交到MVC框架中怎么才能转为一个User对象呢?这也是值栈要完成的职责。

这里说一下值映射,怎么实现一个值的映射,这也是一个反射操作的结果。首先是HTTP传递过来的参数名称中要明确映射到哪一个对象,例如使用点号(.)区分,点号前是对象名称,点号后是属性名,如此规定后就可以轻松地处理了。由于使用的模式较少,这里就不再赘述。读者若有兴趣可以考虑使用一些开源工具,比如dozer等。

37.1.4 视图管理器

视图管理器的功能很单一,按照模型指定的要求返回视图,在这里用到的主要模式就是桥梁模式,如果大家做过多语言的开发就非常清楚了,比如一个外部网站,提供中日英三种语言版本,我们不可能每个语言都写一套页面吧。一般是定义一个语言资源文件,然后视图根据不同的语言环境加载不同的语言。我们先来说视图,它包含三部分。

  • 静态页面

比如图片放在什么地方,字体大小是什么样子,菜单应该放置在什么地方,这部分工作是由前台人员开发的,不涉及业务逻辑和业务数据。

  • 动态页面元素

它指的是在一个固定场景下不发生变化但在异构场景中发生变化的元素,其中语言就属于动态页面元素,还有为使用不同浏览器而开发的代码。比如浏览器IE、Firefox、Chrome 等,虽然基本上都是符合HTML,但是还有一些细节差异,特别是在JavaScript的处理方面, 稍不注意就可能产生灾难。

  • 动态数据

由模型产生的数据,它对视图来说是结构固定,并可反复加载。

在这三部分中,静态页面是完全静态的,动态页面元素是稍微有点动感,动态数据完全是多变的(数据结构不发生变化,否则页面无法展现)。把动态数据融入到静态页面中比较容易,已经在配置文件中指定要把模型中的数据放到哪个页面中,现在的问题是怎么把动态页面元素融入到静态页面中。静态页面有很多,语言类型也有很多,怎么融合在一起提供给浏览器访问呢?

桥梁模式可以解决用什么笔(圆珠笔、铅笔)和画什么图形(圆形、方形)的问题,我们遇到的问题与此场景类似。先看类图,如图37-7所示。

image-20211001220257520

图37-7 视图与语言类图
大家还记得Struts是怎么配置多语言的文件吗?我们采用类似的结构,如代码清单37-18 所示。

代码清单37-18 资源配置文件

1
2
title=标题 
menu=菜单

英文配置菜单与此类似,它的结构就是一个Map类型,我们把它读入到Map中,抽象类如代码清单37-19所示。

代码清单37-19 抽象语言

1
2
3
4
public abstract class AbsLangData {
//获得所有的动态元素的配置项
public abstract Map<String,String> getItems();
}

getItems方法是获得一种语言下的所有配置。我们来看中文语言包,如代码清单37-20所 示。

代码清单37-20 中文语言

1
2
3
4
5
6
7
8
9
10
11
public class GBLangData extends AbsLangData {
@Override
public Map<String, String> getItems() {
/*
* Map 的结构为:
* key='title', value='标题'
* key='menu', value='菜单'
*/
return null;
}
}

英文语言如代码清单37-21所示。

代码清单37-21 英文语言

1
2
3
4
5
6
7
8
9
10
11
public class ENLangData extends AbsLangData {
@Override
public Map<String, String> getItems() {
/*
* Map结构为:
* key='title',value='title';
* key='menu', value='menu'
*/
return null;
}
}

视图分为两种类图,一种是需要直接替换资源文件的视图,比如JSP文件,框架直接把语言包中的资源项替换掉JSP中的条目即可,把{title}替换为“标题”,把{menu}替换为“菜单”,替换后存在框架的缓存目录中,提高系统的访问效率。另一种视图是不能替换的,比如SWF文件,它的资源可以通过类似HTTP传递参数的形式传递,重写一个URL即可。我们首先来看抽象视图,如代码清单37-22所示。

代码清单37-22 抽象视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public abstract class AbsView {
private AbsLangData langData;
//必须有一个语言文件
public AbsView(AbsLangData _langData){
this.langData = _langData;
}
//获得当前的语言
public AbsLangData getLangData(){
return langData;
}
//页面的URL路径
public String getURI(){
return null;
}
//组装一个页面
public abstract void assemble();
}

JSP视图是需要替换资源项,如代码清单37-23所示。

代码清单37-23 JSP视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class JspView extends AbsView {
//传递语言配置
public JspView(AbsLangData _langData){
super(_langData);
}
@Override
public void assemble() {
Map<String,String> langMap = getLangData().getItems();
for(String key:langMap.keySet()){
/*
* 直接替换文件中的语言条目
*
*/
}
}
}

SWF文件是不能替换的,采用重写URL的方式,如代码清单37-24所示。

代码清单37-24 SWF视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class SwfView extends AbsView {
public SwfView(AbsLangData _langData){
super(_langData);
}
@Override
public void assemble() {
Map<String,String> langMap = getLangData().getItems();
for(String key:langMap.keySet()){

/*
* 组装一个HTTP的请求格式:
* http://abc.com/xxx.swf?key1=value&key2=value
*/
}
}
}

ViewManager是一个视图模块的入口,所有的访问都是通过它传递进来的,如代码清单 37-25所示。

代码清单37-25 视图管理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ViewManager {
//Action的名称
private String actionName;
//当前的值栈
private ValueStack valueStack = ValueStackHelper.getValueStack();
//接收一个ActionName
public ViewManager(String _actionName){
this.actionName = _actionName;
}
//根据模型的返回结果提供视图
public String getViewPath(String result){
//根据值栈查找到需要提供的语言
AbsLangData langData = new GBLangData();
//根据action和result查找到指定的视图,并加载语言
AbsView view = new JspView(langData);
//返回视图的地址
return view.getURI();
}
}

通过桥梁模式我们把不同的语言和不同类型的视图结合起来,共同提供一个多语言的应用系统,即使以后增加语言也非常容易扩展。

37.1.5 工具类

每个框架或项目都有大量的工具类,MVC框架也不例外。先来看操作XML文件的工具类,不可能自己读写XML文件,我们使用DOM4J来实现,它在大文件的处理上性能很有优势,而且比较简单,架构也非常优秀。

使用DOM4J从XML文件中读出的对象是节点(Node)、元素(Element)、属性(Attribute)等,这些对象还是比较容易理解的,但是不能保证一个开发组的人对这些都了解,因此需要把它转换成每个开发成员都理解的对象,比如我们处理这样一段XML代码,如代码清单37-26所示。

代码清单37-26 XML文件片段

1
2
3
4
<action name="loginAction" class="{类名全路径}" method="execute">
<result name="success">/index2.jsp</result>
<result name="fail">/index.jsp</result>
</action>

使用DOM4J查找到该节点是一个Node对象,如果要取得属性,就需要转换为一个元素 (Element)对象,这不是每个开发成员都能理解的,于是给架构师提出的问题就是:如何把一个DOM4J对象转换成自己设计的对象。答案是适配器模式,我们首先定义一个Action节点类,如代码清单37-27所示。

代码清单37-27 Action节点类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public abstract class ActionNode {
//Action的名称
private String actionName;
//Action的类名
private String actionClass;
//方法名,默认是execute
private String methodName = "excuete";
//视图路径
private String view;
public String getActionName() {
return actionName;
}
public String getActionClass() {
return actionClass;
}
public String getMethodName() {
return methodName;
}
public abstract String getView(String Result);
}

它是一个抽象类,其中的getView是一个抽象方法,是根据执行结果查找到视图路径。 只要编写一个适配器就可以把Elemet对象转为Action节点,如代码清单37-28所示。

代码清单37-28 Action节点

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 XmlActionNode extends ActionNode {
//需要转换的element
private Element el;
//通过构造函数传递
public XmlActionNode(Element _el){
this.el = _el;
}
@Override
public String getActionName(){
return getAttValue("name");
}
@Override
public String getActionClass(){
return getAttValue("class");
}
@Override
public String getMethodName(){
return getAttValue("method");
}
public String getView(String result){
ViewPathVisitor visitor = new ViewPathVisitor("success");
el.accept(visitor);
return visitor.getViewPath();
}
//获得指定属性值
private String getAttValue(String attName){
Attribute att = el.attribute(attName);
return att.getText();
}
}

这是一个对象适配器,传递进来一个Element对象,把它转换为ActionNode对象,这样设计以后,系统开发人员就不用考虑开源工具对系统的影响,屏蔽了工具系统的影响,这是一个典型的适配器模式应用。

不知道读者是否注意到getView方法,它使用了一个访问者模式,这是DOM4J提供的一个非常优秀的API接口,传递进去一个访问者就可以遍历出我们需要的对象。我们来看自己定义的访问者,如代码清单37-29所示。

代码清单37-29 访问者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ViewPathVisitor extends VisitorSupport {
//获得指定的路径
private String viewPath;
private String result;
//传递模型结果
public ViewPathVisitor(String _result){
result = _result;
}

@Override
public void visit(Element el){
Attribute att = el.attribute("name");
if(att != null){
if(att.getName().equals("name") && att.getText().equals(result)){
viewPath = el.getText();
}
}
}
public String getViewPath(){
return viewPath;
}
}

DOM4J提供了VisitorSupport抽象接口,可以接受元素、节点、属性等访问者。我们这里 接受了一个元素访问者,对所有的元素过滤一遍,然后找到自己需要的元素,非常强大!

我们继续分析,在IoC容器中都会区分对象是单例模式还是多例模式。想想我们的框架,每个HTTP请求都会产生一个线程,如果我们的Action初始化的时候是单例模式会出现什么情况?当并发足够多的时候就会产生阻塞,性能会严重下降,在特殊情况下还会产生线程不安全,这时就需要考虑多例情况。那多例是如何处理呢?使用Clone技术,首先在系统启动时初始化所有的Action,然后每过来一个请求就拷贝一个Action,减少了初始化对象的性能消耗。典型的原型模式,但问题也同时产生了,并发较多时,就可能会产生内存溢出的情况,内存不够用了!于是享元模式就可以上场了,建立一个对象池以容纳足够多的对象。