设计模式,即Design Patterns,是指在软件设计中,被反复使用的代码设计经验。使用设计模式的目的是为了可重用代码,提高代码的可扩展性和可维护性。
概述
为什么要使用设计模式?根本原因还是软件开发要实现可维护、可扩展,就必须尽量复用代码,并且降低代码耦合度。设计模式主要是基于OOP编程提炼的,它基于以下几个原则:
- 开闭原则。开闭原则(Open Closed Principle)是指,软件应该对扩展开放,而对修改关闭。这里的意思是,在增加新功能时,能不改代码就尽量不要改,如果只增加代码就完成了新功能,那是最好的。
- 里氏替换原则。里氏替换原则是一种面向对象的设计原则,即如果我们调用一个父类的方法可以成功,那么替换成子类调用也应该完全可以运行。
设计模式是把一些常用的设计模式提炼出一个个模式,然后给每个模式命名,这样在使用的时候更方便交流。GoF(提出设计模式这个术语的四个人)把23个常用模式分为创建型模式、结构型模式和行为型模式三类。学习设计模式,关键的是学习设计思想,不能简单地生搬硬套,也不能为了使用设计模式而过度设计,要合理平衡设计的复杂性和灵活性,并意识到设计模式也不是万能的。
创建型模式
创建型模式关注点是如何创建对象,其核心思想是把对象的创建和使用相分离,这样使得两者能相对独立地变换。
工厂方法
定义一个用于创建对象的接口,让子类决定实例化哪一个类。Factory Method使一个类的实例化延迟到其子类。工厂方法即Factory Method,是一种对象创建型模式。工厂方法的目的是使得创建对象和使用对象是想分离的,并且客户端总是引用抽象工厂和抽象产品。
我们来举个例子,假设我们希望实现一个解析字符串到Number
的Factory
,可以这么定义。
1 |
|
客户端如何创建NumberFactoryImpl
呢?通常我们会在接口Factory
中定义一个静态方法getFactory()
来返回真正的子类。
有了工厂接口,再编写一个工厂的实现类。
1 |
|
产品接口是Number
,NumberFactoryImpl
返回的实际产品是BigDecimal
。
在客户端中,我们只需要和工厂接口NumberFactory
以及抽象产品Number
打交道。
1 |
|
调用方可以完全忽略真正的工厂NumberFactoryImpl
和实际产品BigDecimal
,这样做的好处是允许创建产品的代码独立地变化,而不会影响到调用方。
有的同学就会问了:一个简单的parse()
需要写这么复杂的工厂吗?实际上,大多数情况下,我们并不需要抽象工厂,而是通过静态方法直接返回产品,即:
1 |
|
这种简化的使用静态方法创建产品的方式,称为静态工厂方法(Static Factory Method)。静态工厂方法广泛应用在Java标准库中,例如:
1 |
|
Integer
既是产品也是静态工厂。它提供了静态方法valueOf()
来创建Integer
,那么,这种方式和直接使用new
操作符有何区别?使用静态方法的好处在于,valueOf()
内部可能会使用new
创建一个新的Integer
实例,也可能直接返回一个缓存的Integer
实例,此时会减少内存消耗提升速度。对调用方来说没必要在意这些细节。而如果调用方直接使用new操作符,那么就失去了使用缓存优化的可能性。
我们经常使用的另一个静态工厂方法是List.of()
,这个静态工厂方法可以接收可变参数,然后返回List接口。需要注意的是,调用方获取的产品总是List
接口,而且并不关心它的实际类型。即使调用方知道List
产品的实际类型是java.util.ImmutableCollections$ListN
,也不要去强制转型为子类,因为静态工厂方法List.of()
保证返回List
,但也完全可以修改为返回java.util.ArrayList
。
总是引用接口而非实现类,能允许变换子类而不影响调用方,即尽可能面向对象编程
小结
工厂方法是指定义工厂接口和产品接口,但如何创建实际工厂和实际产品被推迟到子类实现,从而使调用方只和抽象工厂与抽象产品打交道。
实际更常用的是更简单的静态工厂方法,它允许工厂内部对创建产品进行优化。
调用方尽量持有接口或抽象类,避免持有具体类型的子类,以便工厂方法能随时切换不同的子类返回,却不影响调用方代码。
抽象工厂
抽象工厂模式(Abstract Factory)是一个比较复杂的创建型模式。抽象工厂模式和工厂方法不太一样,它要解决的问题比较复杂,不但工厂是抽象的,产品是抽象的,而且有多个产品需要创建,因此,这个抽象工厂会对应到多个实际工厂,每个实际工厂负责创建多个实际产品。
这类似于多个供应商提供相类似产品。我们举个例子:假设我们希望为用户提供一个Markdown文本转换为HTML和Word的服务,它的接口定义如下:
1 |
|
上面的抽象工厂仅仅是一个接口,没有任何代码。同样的,因为HtmlDocument和WordDocument都比较复杂,现在我们并不知道如何实现它们,所以只有接口:
1 |
|
这样,我们就定义好了抽象工厂AbstractFactory和两个抽象产品HtmlDocument和WordDocument。实现它们比较困难,我们决定让供应商来完成。
现在市场上有两家供应商:FastDoc Soft的产品便宜,并且转换速度快,而GoodDoc Soft的产品贵,但转换效果好。我们决定同时使用这两家供应商的产品,以便给免费用户和付费用户提供不同的服务。
我们先看看FastDoc Soft的产品是如何实现的。首先,FastDoc Soft必须要有实际的产品,即FastHtmlDocument
和FastWordDocument
:
1 |
|
然后,FastDoc Soft必须提供一个实际的工厂来生产这两种产品,即FastFactory
:
1 |
|
这样,我们就可以使用FastDoc Soft的服务了。客户端编写代码如下:
1 |
|
如果我们要同时使用GoodDoc Soft的服务怎么办?因为用了抽象工厂模式,GoodDoc Soft只需要根据我们定义的抽象工厂和抽象产品接口,实现自己的实际工厂和实际产品即可:
1 |
|
客户端要使用GoodDoc Soft的服务,只需要把原来的new FastFactory()
切换为new GoodFactory()
。
注意到,客户端代码除了通过new创建了FastFactory
和GoodFactory
外,其余代码只引用了产品接口,并未引用任何实际产品。如果把创建工厂的代码放到AbstractFactory
中,就可以连实际工厂也屏蔽了。
1 |
|
生成器
生成器模式(Builder)是使用多个“小型工厂”来最终创建出一个完整对象。当我们使用Builder时,一般来说,是因为创建这个对象的步骤比较多,每个步骤都需要一个零部件,最终组合成一个完整的对象。
我们仍然以Markdown转HTML为例,因为直接编写一个完整的转换器比较困难,但如果针对类似下面的一行文本:
1 |
|
转换成HTML就很简单:
1 |
|
因此,我们把Markdown转HTML看作一行一行转换,每一行根据语法,使用不同的转换器:
- 如果以
#
开头,使用HeadingBuilder
转换 - 如果以
>
开头,使用QuoteBuilder
转换 - 如果以
---
开头,使用HrBuilder
转换 - 其余使用
ParagraphBuilder
转换
这个HtmlBuilder写出来如下:
1 |
|
注意观察上述代码,HtmlBuilder
并不是一次性把整个Markdown转换为HTML,而是一行一行转换,而且它自己并不会将某一行转换为特定的HTML,而是根据特性把每一行都“委托”给一个XxxBuilder
去转换。最后,把所有转换的结果组合起来,返回给客户端。
这样一来,我们只需要针对每一种类型编写不同的Builder。例如,针对以#
开头的行,需要HeadingBuilder
:
1 |
|
注意:实际解析Markdown是带有状态的,即下一行的语义可能与上一行相关。这里我们做了简化,认为每一行可以独立转换。
可见,使用Builder模式时,适用于创建的对象比较复杂,最好一步一步创建出“零件”,最后再装配起来。
JavaMail的MimeMessage
就可以看作是一个Builder模式,只不过Builder和最终产品合二为一,都是MimeMessage
:
1 |
|
很多时候,我们可以简化Builder模式,以链式调用的方式来创建对象。例如,我们经常这样编写代码:
1 |
|
由于我们经常需要构造URL字符串,可以使用Builder模式编写一个URLBuilder,调用方式如下:
1 |
|
原型
原型模式,即Prototype,指创建新对象的时候,根据现有的一个原型创建。
我们举个例子:如果我们已经有了一个String[]
数组,想再创建一个一模一样的String[]
数组,怎么写?实际上创建过程很简单,就是把现有数组的元素复制到新数组。如果我们把这个创建过程封装一下,就成了原型模式。用代码实现如下:
1 |
|
对于普通类,我们如何实现原型拷贝?Java的Object提供了一个clone()
方法,它的意图就是复制一个新的对象出来,我们需要实现一个Cloneable
接口,来标识一个对象是“可复制”的。
1 |
|
使用的时候,因为clone()的方法签名定义在Object中,返回类型也是Object,所以强制转型,比较麻烦。
1 |
|
实际上,使用原型模式更好的方式是定义一个copy()
方法,返回明确的类型。
1 |
|
原型模式应用不是很广泛,因为很多实例会持有类似文件、Socket这样的资源,而这些资源是无法复制给另一个对象共享的,只有存储简单类型的“值”对象可以复制。
单例
单例模式,即Singleton,是为了保证在一个进程中,某个类有且仅有一个实例。因为这个类只有一个实例,因此,自然不能让调用方使用new Xyz()
来创建实例了。所以,单例的构造方法必须是private
,这样就防止了调用方自己创建实例。但在类的内部,可以用一个静态字段来引用唯一创建的实例。
1 |
|
那么,外部调用方如何获取这唯一的实例呢?提供一个静态方法返回这个实例。
1 |
|
或者直接把static变量暴露出去。
1 |
|
所以,单例模式的实现方式很简单:
- 只有
private
构造方法,确保外部无法实例化; - 通过
private static
变量持有唯一实例,保证全局唯一性; - 通过
public static
方法返回此唯一实例,使外部调用方能获取到实例。
Java标准库有一些类就是单例,例如Runtime
这个类:
1 |
|
有些童鞋可能听说过延迟加载,即在调用方第一次调用getInstance()
时才初始化全局唯一实例,类似这样:
1 |
|
遗憾的是,这种写法在多线程中是错误的,在竞争条件下会创建出多个实例。必须对整个方法进行加锁:
1 |
|
但加锁会严重影响并发性能。还有些童鞋听说过双重检查,类似这样:
1 |
|
然而,由于Java的内存模型,双重检查在这里不成立。要真正实现延迟加载,只能通过Java的ClassLoader机制完成。如果没有特殊的需求,使用Singleton模式的时候,最好不要延迟加载,这样会使代码更简单。
另一种实现Singleton的方式是利用Java的enum
,因为Java保证枚举类的每个枚举都是单例,所以我们只需要编写一个只有一个枚举的类即可:
1 |
|
枚举类也完全可以像其他类那样定义自己的字段、方法,这样上面这个World
类在调用方看来就可以这么用:
1 |
|
使用枚举实现Singleton还避免了第一种方式实现Singleton的一个潜在问题:即序列化和反序列化会绕过普通类的private
构造方法从而创建出多个实例,而枚举类就没有这个问题。
那我们什么时候应该用Singleton呢?实际上,很多程序,尤其是Web程序,大部分服务类都应该被视作Singleton,如果全部按Singleton的写法写,会非常麻烦,所以,通常是通过约定让框架(例如Spring)来实例化这些类,保证只有一个实例,调用方自觉通过框架获取实例而不是new
操作符:
1 |
|
因此,除非确有必要,否则Singleton模式一般以“约定”为主,不会刻意实现它。
小结
Singleton模式是为了保证一个程序的运行期间,某个类有且只有一个全局唯一实例;
Singleton模式既可以严格实现,也可以以约定的方式把普通类视作单例。
结构型模式
结构型模式主要涉及如何组合各种对象一遍获得更好、更灵活的结构。虽然面向对象的继承机制提供了最基本的子类扩展父类的功能,但结构型模式不仅仅简单地使用继承,而更多地通过组合和运行期的动态组合来实现更灵活的功能。
适配器
适配器模式是Adapter,也称Wrapper,是指如果一个接口需要B结构,但是待传入的对象却是A接口,怎么办?
如果去美国,我们随身带的电器是无法直接使用的,因为美国的插座标准和中国不同,所以,我们需要一个适配器
在程序设计中,适配器也是类似的。我们已经有一个Task
类,实现了Callable
接口:
1 |
|
现在,我们想通过一个线程去执行它:
1 |
|
发现编译不过!因为Thread
接收Runnable
接口,但不接收Callable
接口,怎么办?一个办法是改写Task类,把实现的Callable
改为Runnable
,但这样做不好,因为Task很可能在其他地方作为Callable被调用,改写Task的接口,会导致其他正常工作的代码无法编译。
另一个办法不用改写Task
类,而是用一个Adapter,把这个Callable
接口“变成”Runnable
接口,这样,就可以正常编译:
1 |
|
这个RunnableAdapter
类就是Adapter,它接收一个Callable
,输出一个Runnable
。怎么实现这个RunnableAdapter
呢?我们先看完整的代码:
1 |
|
编写一个Adapter的步骤如下:
- 实现目标接口,这里是
Runnable
- 内部持有一个待转换接口的引用,这里是通过字段持有
Callable
接口 - 在目标接口的实现方法内部,调用待转换接口的方法
这样一来,Thread就可以接收这个RunnableAdapter
,因为它实现了Runnable
接口。Thread
作为调用方,它会调用RunnableAdapter
的run()
方法,在这个run()
方法内部,又调用了Callable
的call()
方法,相当于Thread
通过一层转换,间接调用了Callable
的call()
方法。
适配器模式在Java标准库中有广泛应用。比如我们持有数据类型是String[]
,但是需要List
接口时,可以用一个Adapter:
1 |
|
注意到List<T> Arrays.asList(T[])
就相当于一个转换器,它可以把数组转换为List
。
我们再看一个例子:假设我们持有一个InputStream
,希望调用readText(Reader)
方法,但它的参数类型是Reader
而不是InputStream
,怎么办?
当然是使用适配器,把InputStream
“变成”Reader
:
1 |
|
InputStreamReader
就是Java标准库提供的Adapter
,它负责把一个InputStream
适配为Reader
。类似的还有OutputStreamWriter
。
如果我们把readText(Reader)
方法参数从Reader
改为FileReader
,会有什么问题?这个时候,因为我们需要一个FileReader
类型,就必须把InputStream
适配为FileReader
:
1 |
|
直接使用InputStreamReader
这个Adapter是不行的,因为它只能转换出Reader
接口。事实上,要把InputStream
转换为FileReader
也不是不可能,但需要花费十倍以上的功夫。这时,面向抽象编程这一原则就体现出了威力:持有高层接口不但代码更灵活,而且把各种接口组合起来也更容易。一旦持有某个具体的子类类型,要想做一些改动就非常困难。
小结
Adapter模式可以将一个A接口转换为B接口。编写Adapter实际上就是编写了一个实现了B接口,并且内部持有A接口的类,在Adapter内部将B接口的调用“转换”为A接口的调用。只有A、B接口均是抽象接口时,才能非常简单地实现Adapter模式。
桥接
桥接模式的定义非常玄乎,直接理解不太容易,所以我们还是举例子。
假设某个汽车厂商生产三种品牌的汽车:Big、Tiny和Boss,每种品牌又可以选择燃油、纯电和混合动力。如果用传统的继承来表示各个最终车型,一共有3个抽象类加9个最终子类。如果要新增一个品牌,或者加一个新的引擎(比如核动力),那么子类的数量增长更快。所以,桥接模式就是为了避免直接继承带来的子类爆炸。
我们来看看桥接模式如何解决上述问题。
在桥接模式中,首先把Car
按品牌进行子类化,但是,每个品牌选择什么发动机,不再使用子类扩充,而是通过一个抽象的“修正”类,以组合的形式引入。我们来看看具体的实现。
首先定义抽象类Car
,它引用一个Engine
:
1 |
|
Engine
的定义如下:
1 |
|
紧接着,在一个“修正”的抽象类RefinedCar
中定义一些额外操作:
1 |
|
这样一来,最终的不同品牌继承自RefinedCar
,例如BossCar
:
1 |
|
而针对每一种引擎,继承自Engine
,例如HybridEngine
:
1 |
|
客户端通过自己选择一个品牌,再配合一种引擎,得到最终的Car:
1 |
|
使用桥接模式的好处在于,如果要增加一种引擎,只需要针对Engine
派生一个新的子类,如果要增加一个品牌,只需要针对RefinedCar
派生一个子类,任何RefinedCar
的子类都可以和任何一种Engine
自由组合,即一辆汽车的两个维度:品牌和引擎都可以独立地变化。
桥接模式实现比较复杂,实际应用也非常少,但它提供的设计思想值得借鉴,即不要过度使用继承,而是优先拆分某些部件,使用组合的方式来扩展功能。
桥接模式通过分离一个抽象接口和它的实现部分,使得设计可以按两个维度独立扩展。
组合
组合模式(Composite)经常用于树形结构,为了简化代码,使用Composite可以把一个叶节点和父节点统一起来处理。举个例子,在XML和HTML中,从根结点开始,每个节点都可能包含任意个其他节点,这些层层嵌套的节点就构成了一棵树。
要以树的结构表示XML,我们可以先抽象出节点类型Node。对于一个<abc>
这样的节点,我们称之为ElementNode,他可以包含任意个子节点。而对于普通文本,我们把它看作TextNode,它没有子节点。此外,还可以有注释节点。
通过ElementNode、TextNode和CommentNode,我们就可以构造出一棵树。
类似地,像文件夹和文件、GUI窗口的各种组件,都符合Composite模式的定义。
装饰器
装饰器模式(Decorator),是一种在运行期动态给某个对象的实例增加功能的方法。
我们在IO的Filter模式一节中其实已经讲过装饰器模式了。在Java标准库中,InputStream
是抽象类,FileInputStream
、ServletInputStream
、Socket.getInputStream()
这些InputStream
都是最终数据源。
现在,如果要给不同的最终数据源增加缓冲功能、计算签名功能、加密解密功能,那么,3个最终数据源、3种功能一共需要9个子类。如果继续增加最终数据源,或者增加新功能,子类会爆炸式增长,这种设计方式显然是不可取的。
Decorator模式的目的就是把一个一个的附加功能,用Decorator的方式给一层一层地累加到原始数据源上,最终,通过组合获得我们想要的功能。
例如:给FileInputStream
增加缓冲和解压缩功能,用Decorator模式写出来如下:
1 |
|
或者一次性写成这样:
1 |
|
观察BufferedInputStream
和GZIPInputStream
,它们实际上都是从FilterInputStream
继承的,这个FilterInputStream
就是一个抽象的Decorator。Decorator是用于实现各个附加功能的抽象装饰器,对应到IO的就是FilterInputStream
。而从Decorator派生的就是一个一个的装饰器,它们每个都有独立的功能,对应到IO的就是BufferedInputStream
、GZIPInputStream
等。
Decorator模式有什么好处?它实际上把核心功能和附加功能给分开了。核心功能指FileInputStream
这些真正读数据的源头,附加功能指加缓冲、压缩、解密这些功能。如果我们要新增核心功能,就增加Component的子类,例如ByteInputStream
。如果我们要增加附加功能,就增加Decorator的子类,例如CipherInputStream
。两部分都可以独立地扩展,而具体如何附加功能,由调用方自由组合,从而极大地增强了灵活性。
外观
外观模式,即Facade,它的基本思想是:如果客户端要跟许多子系统打交道,那么客户端需要了解各个子系统的接口,比较麻烦。如果有一个统一的“中介”,让客户端只跟中介打交道,中介再去跟各个子系统打交道,对客户端来说就比较简单。所以Facade就相当于搞了一个中介。
很多Web程序,内部有多个子系统提供服务,经常使用一个统一的Facade入口,例如一个RestApiController
,使得外部用户调用的时候,只关心Facade提供的接口,不用管内部到底是哪个子系统处理的。
更复杂的Web程序,会有多个Web服务,这个时候,经常会使用一个统一的网关入口来自动转发到不同的Web服务,这种提供统一入口的网关就是Gateway,它本质上也是一个Facade,但可以附加一些用户认证、限流限速的额外服务。
享元
享元(Flyweight)的核心思想很简单,如果一个对象实例一经创建就不可变,那么反复创建相同的实例就没有必要,直接向调用方返回一个共享的实例就行,这样即节省内存,又可以减少创建对象的过程,提高运行速度。
享元模式在Java标准库中有很多应用。我们知道,包装类型如Byte
、Integer
都是不变类,因此,反复创建同一个值相同的包装类型是没有必要的。以Integer
为例,如果我们通过Integer.valueOf()
这个静态工厂方法创建Integer
实例,当传入的int
范围在-128
~`+127之间时,会直接返回缓存的
Integer实例。对于
Byte来说,因为它一共只有256个状态,所以,通过
Byte.valueOf()创建的
Byte`实例,全部都是缓存对象。
因此,享元模式就是通过工厂方法创建对象,在工厂方法内部,很可能返回缓存的实例,而不是新创建实例,从而实现不可变实例的复用。
在实际应用中,享元模式主要应用于缓存,即客户端如果重复请求某些对象,不必每次查询数据库或者读取文件,而是直接返回内存中缓存的数据。
在实际应用中,我们经常使用成熟的缓存库,例如Guava的Cache,因为它提供了最大缓存数量限制、定时过期等实用功能。
代理
代理模式,即Proxy,它和Adapter模式很相似。Adapter模式用于把A接口转换为B接口,而Proxy模式把A接口转为A接口,Proxy就是给A接口再包一层。
1 |
|
我们在调用this.a.a()
的前后,加了一些额外代码。这样一来,我们就可以实现权限检查,只有复合要求的用户,才会真正调用目标方法,否则会直接抛出异常。
为啥不把权限检查的功能直接写到目标实例A的内部?
因为我们编写代码的原则有:
- 职责清晰:一个类只负责一件事;
- 易于测试:一次只测一个功能。
用Proxy实现这个权限检查,我们可以获得更清晰、更简洁的代码:
- A接口:只定义接口;
- ABusiness类:只实现A接口的业务逻辑;
- APermissionProxy类:只实现A接口的权限检查代理。
如果我们希望编写其他类型的代理,可以继续增加类似ALogProxy,而不必对现有的A接口、ABusiness类进行修改。
实际上权限检查只是代理模式的一种应用。Proxy还广泛应用在:
远程代理
远程代理即Remote Proxy,本地的调用者持有的接口实际上是一个代理,这个代理负责把对接口的方法访问转换成远程调用,然后返回结果。Java内置的RMI机制就是一个完整的远程代理模式。
虚代理
虚代理即Virtual Proxy,它让调用者先持有一个代理对象,但真正的对象尚未创建。如果没有必要,这个真正的对象是不会被创建的,直到客户端需要真的必须调用时,才创建真正的对象。JDBC的连接池返回的JDBC连接(Connection对象)就可以是一个虚代理,即获取连接时根本没有任何实际的数据库连接,直到第一次执行JDBC查询或更新操作时,才真正创建实际的JDBC连接。
保护代理
保护代理即Protection Proxy,它用代理对象控制对原始对象的访问,常用于鉴权。
智能引用
智能引用即Smart Reference,它也是一种代理对象,如果有很多客户端对它进行访问,通过内部的计数器可以在外部调用者都不使用后自动释放它。
小结
有的童鞋会发现Proxy模式和Decorator模式有些类似。确实,这两者看起来很像,但区别在于:Decorator模式让调用者自己创建核心类,然后组合各种功能,而Proxy模式决不能让调用者自己创建再组合,否则就失去了代理的功能。Proxy模式让调用者认为获取到的是核心类接口,但实际上是代理类。
代理模式通过封装一个已有接口,并向调用方返回相同的接口类型,能让调用方在不改变任何代码的前提下增强某些功能(例如,鉴权、延迟加载、连接池复用等)。使用Proxy模式要求调用方持有接口,作为Proxy的类也必须实现相同的接口类型。
行为型模式
行为型模式主要涉及算法和对象间的职责分配。通过使用对象组合,行为型模式可以描述一组对象应该如何协作来完成一个整体任务。
责任链
责任链模式(Chain of Responsibility)是一种处理请求的模式,它让多个处理器都有机会处理该请求,直到其中某个处理成功为止。责任链模式把多个处理器串成链,然后让请求在链上传递。
在实际场景中,财务审批就是一个责任链模式。假设某个员工需要报销一笔费用,审核者可以分为:
- Manager:只能审核1000元以下的报销;
- Director:只能审核10000元以下的报销;
- CEO:可以审核任意额度。
用责任链模式设计此报销流程时,每个审核者只关心自己责任范围内的请求,并且处理它。对于超出自己责任范围的,扔给下一个审核者处理,这样,将来继续添加审核者的时候,不用改动现有逻辑。
责任链模式很容易理解,但需要注意的是,Handler
的添加顺序很重要,如果顺序不对,处理的结果可能就是不符合要求的。
此外,责任链模式有很多变种。有些责任链的实现方式是通过某个Handler
手动调用下一个Handler
来传递Request
。还有一些责任链模式,每个Handler
都有机会处理Request
,通常这种责任链被称为拦截器(Interceptor
)或者过滤器(Filter
),它的目的不是找到某个Handler
处理掉Request
,而是每个Handler
都做一些工作,比如:记录日志,检查权限,准备相关资源……。
例如,JavaEE的Servlet规范定义的Filter就是一种责任链模式,它不但允许每个Filter都有机会处理请求,还允许每个Filter决定是否将请求“放行”给下一个Filter。这种模式不但允许一个Filter自行决定处理ServletRequest
和ServletResponse
,还可以“伪造”ServletRequest
和ServletResponse
以便让下一个Filter
处理,能实现非常复杂的功能。
命令
命令模式(Command)是指,把请求封装成一个命令,然后执行该命令。
我们用一个StringBuilder
模拟一个文本编辑器,它支持copy()
、paste()
、add()
、delete()
等方法。
正常情况,我们像这样调用TextEditor
:
1 |
|
这是直接调用方法,调用方需要了解TextEditor
的所有接口信息。
如果改用命令模式,我们就要把调用方发送命令和执行方执行命令分开。怎么分?
解决方案是引入一个Command
接口:
1 |
|
调用方创建一个对应的Command
,然后执行,并不关心内部是如何具体执行的。
为了支持CopyCommand
和PasteCommand
这两个命令,我们从Command
接口派生:
1 |
|
最后我们把Command
和TextEditor
组装一下,客户端这么写:
1 |
|
有同学会问了:写了一大堆Command,多了好多个类,还不如直接写方便。实际上,使用命令模式,确实增加了系统的复杂度。如果需求很简单,那么直接调用显然更直观且更简单。那么我们还需要命令模式吗?答案是视需求而定。如果TextEditor
复杂到一定程度,并且需要支持Undo、Redo的功能时,就需要使用命令模式,因为我们可以给每个命令增加undo()
:
1 |
|
然后把执行的一系列命令用List
保存起来,就既能支持Undo,又能支持Redo。这个时候,我们又需要一个Invoker
对象,负责执行命令并保存历史命令.
可见,模式带来的设计复杂度的增加是随着需求而增加的,它减少的是系统各组件的耦合度。
命令模式的设计思想是把命令的创建和执行想分离,使得调用者无需关心具体的执行过程。
解释器
解释器模式(Interpreter)是一种针对特定问题设计的一种解决方案。例如,匹配字符串的时候,由于匹配条件非常灵活,使得通过代码来实现非常不灵活。举个例子,针对以下的匹配条件:
- 以
+
开头的数字表示的区号和电话号码,如+861012345678
; - 以英文开头,后接英文和数字,并以.分隔的域名,如
www.liaoxuefeng.com
; - 以
/
开头的文件路径,如/path/to/file.txt
; - …
因此,需要一种通用的表示方法——正则表达式来进行匹配。正则表达式就是一个字符串,但要把正则表达式解析为语法树,然后再匹配指定的字符串,就需要一个解释器。
实现一个完整的正则表达式的解释器非常复杂,但是使用解释器模式却很简单:
1 |
|
类似的,当我们使用JDBC时,执行的SQL语句虽然是字符串,但最终需要数据库服务器的SQL解释器来把SQL“翻译”成数据库服务器能执行的代码,这个执行引擎也非常复杂,但对于使用者来说,仅仅需要写出SQL字符串即可。
解释器模式通过抽象语法树实现对用户输入的解释执行,解释器模式的实现通常非常复杂,且一般只能解决一类特定问题。
迭代器
迭代器模式(Iterator)实际上在Java的集合类中已经广泛使用了。我们以List
为例,要遍历ArrayList
,即使我们知道它的内部存储了一个Object[]
数组,也不应该直接使用数组索引去遍历,因为这样需要了解集合内部的存储结构。如果使用Iterator
遍历,那么,ArrayList
和LinkedList
都可以以一种统一的接口来遍历:
1 |
|
实际上,Iterator模式十分有用,因此,Java允许我们直接把任何支持Iterator
的集合对象用foreach
循环写出来:
1 |
|
然后由Java编译器完成Iterator模式的所有循环代码。
虽然我们对如何使用Iterator有了一定了解,但是如何实现一个Iterator模式呢?我们以一个自定义集合为例,通过Iterator模式实现倒序遍历。
1 |
|
实现Iterator模式的关键是返回一个Iterator
对象,该对象知道集合的内部结构,因为它可以实现倒序遍历。我们使用Java的内部类实现这个Iterator
:
1 |
|
使用内部类的好处是内部类隐含地持有一个它所在对象的this
引用,可以通过ReverseArrayCollection.this
引用到它所在的集合。上述代码实现的逻辑非常简单,但是实际应用时,如果考虑到多线程访问,当一个线程正在迭代某个集合,而另一个线程修改了集合的内容时,是否能继续安全地迭代,还是抛出ConcurrentModificationException
,就需要更仔细地设计。
中介
中介模式(Mediator)又称调停者模式,它的目的是把多方会谈变成双方会谈,从而实现多方的松耦合。
比如考虑一个简单的点餐输入。复选框有汉堡、鸡块、薯条、咖啡,按钮有全选、反选、取消所有。它的复杂性在于,当复选框变化时,它会影响三个按钮的是否可点击的状态。所以这是一个多方会谈,逻辑写起来很复杂。如果我们引入一个中介,把多方会谈变成多个双方会谈,虽然多了一个对象,但对象之间的关系变简单了。
1 |
|
使用Mediator模式后,我们得到了以下好处:
- 各个UI组件互不引用,这样就减少了组件之间的耦合关系;
- Mediator用于当一个组件发生状态变化时,根据当前所有组件的状态决定更新某些组件;
- 如果新增一个UI组件,我们只需要修改Mediator更新状态的逻辑,现有的其他UI组件代码不变。
Mediator模式经常用在有众多交互组件的UI上。为了简化UI程序,MVC模式以及MVVM模式都可以看作是Mediator模式的扩展。
备忘录
备忘录模式(Memento),主要用于捕获一个对象的内部状态,以便在将来的某个时候恢复此状态。
其实我们使用的几乎所有软件都用到了备忘录模式。最简单的备忘录模式就是保存到文件,打开文件。对于文本编辑器来说,保存就是把TextEditor类的字符串存储到文件,打开就是恢复TextEditor类的状态。对于图像编辑器来说,原理是一样的,只是保存和恢复的数据格式比较复杂而已。Java的序列化也可以看作备忘录模式。
在使用文本编辑器时,我们还经常使用Undo、Redo这些功能。这些功能也可以由备忘录模式实现,即不定期地把TextEditor类的字符串复制一份存起来,这样就可以Undo或Redo。
标准的备忘录模式有这么几种角色:
- Memento:存储的内部状态
- Originator:创建一个备忘录并设置其状态
- Caretaker:负责保存备忘录
实际上我们在使用备忘录模式时,不必设计地这么复杂,只需要对类似TextEditor
的类,添加getState()
和setState()
即可。
我们以一个文本编辑器TextEditor
为例,它内部使用StringBuilder
允许用户增删字符:
1 |
|
对这个简单的文本编辑器,用一个String就可以表示其状态,对于复杂的对象模型,通常我们会用JSON、XML等更复杂的格式。
观察者
观察者模式(Observer)又称发布-订阅模式(Publish-Subscribe:Pub/Sub)。它是一种通知机制,让发送通知的一方和接收通知的一方能彼此分离,互不影响。
广义的观察者模式包括所有消息系统。所谓消息系统,就是把观察者和被观察者完全分离,通过消息系统本身来通知。消息发送方称为Producer,消息接收方称为Consumer。Producer发送消息的时候,必须选择发送到哪个Topic;Consumer可以订阅自己感兴趣的Topic,从而只获得特定类型的消息。
使用消息系统时,Producer和Consumer经常不在一台机器上,并且互相对对方一无所知。因为注册观察者这个动作本身都在消息系统中完成,而不是在Producer内部完成。
状态
状态模式(State)经常用在带有状态的对象中。
什么是状态?以QQ为例,一个用户有离线、登录、在线、忙等状态。我们用一个enum
就可以表示不同的状态,但不同的状态要对应不同的行为,比如收到消息时:
1 |
|
状态模式的目的就是为了把上述一大串if...else...
的逻辑拆分到不同的状态类中,使得将来增加状态类比较容易。
例如,我们设计一个聊天机器人,它有两个状态:
- 未连线;
- 已连线。
对于未连线状态,我们收到消息也不回复:
1 |
|
对于已连线状态,我们回应收到的消息:
1 |
|
状态模式的关键设计思想在于状态切换,我们引入一个BotContext
完成状态切换。
1 |
|
策略
策略模式(Strategy)指定义一组算法,并封装到一个对象中,在运行时,可以灵活地使用其中的一个算法。策略模式在Java标准库中应用非常广泛,我们以排序为例,看看如何通过Arrays.sort()
实现忽略大小写排序。
如果我们想忽略大小写排序,就传入String::compareToIgnoreCase
,如果我们想倒序排序,就传入(s1, s2) -> -s1.compareTo(s2)
,这个比较两个元素大小的算法就是策略。
我们观察Arrays.sort(T[] a, Comparator<? super T> c)
这个排序方法,它在内部实现了TimSort排序,但是,排序算法在比较两个元素大小的时候,需要借助我们传入的Comparator
对象,才能完成比较。因此,这里的策略是指比较两个元素大小的策略,可以是忽略大小写比较,可以是倒序比较,也可以根据字符串长度比较。
因此,上述排序使用到了策略模式,它实际上指,在一个方法中,流程是确定的,但是,某些关键步骤的算法依赖调用方传入的策略,这样,传入不同的策略,即可获得不同的结果,大大增强了系统的灵活性。
策略模式的核心思想是在一个计算方法中把容易变换的算法抽出来作为“策略”参数传进去,从而使得新增策略不必修改原有逻辑。
模板方法
模板方法(Template Method)是一个比较简单的模式。它的主要思想是,定义一个操作的一系列步骤,对于某些暂时确定不下来的步骤,就留给子类去实现好了,这样不同的子类就可以定义出不同的步骤。因此,模板方法的核心在于定义一个骨架。
假设我们开发了一个从数据库读取设置的类:
1 |
|
由于从数据库读取数据较慢,我们可以考虑把读取的设置缓存起来,这样下一次读取同样的key就不必再访问数据库了。但是怎么实现缓存,暂时没想好,但不妨碍我们先写出使用缓存的代码:
1 |
|
整个流程没有问题,但是,lookupCache(key)
和putIntoCache(key, value)
这两个方法还根本没实现,怎么编译通过?这个不要紧,我们声明抽象方法就可以:
1 |
|
因为声明了抽象方法,自然整个类也必须是抽象类。如何实现lookupCache(key)
和putIntoCache(key, value)
这两个方法就交给子类了。子类其实并不关心核心代码getSetting(key)
的逻辑,它只需要关心如何完成两个小小的子任务就可以了。
假设我们希望用一个Map
做缓存,那么可以写一个LocalSetting
:
1 |
|
如果我们要使用Redis做缓存,那么可以再写一个RedisSetting
:
1 |
|
客户端代码使用本地缓存的代码这么写:
1 |
|
要改成Redis缓存,只需要把LocalSetting
替换为RedisSetting
:
1 |
|
可见,模板方法的核心思想是:父类定义骨架,子类实现某些细节。
为了防止子类复写父类的骨架方法,可以在父类中对骨架方法使用final
。对于需要子类实现的抽象方法,一般声明为protected
,使得这些方法对外部客户端不可见。
Java标准库也有很多模板方法的应用。在集合类中,AbstractList
和AbstractQueuedSynchronizer
都定义了很多通用操作,子类只需要实现某些必要方法。
访问者
访问者模式(Visitor)是一种操作一组对象的操作,它的目的是不改变对象的定义,但允许新增不同的访问者,来定义新的操作。
这里我们只介绍简化的访问者模式。假设我们要递归遍历某个文件夹的所有子文件夹和文件,然后找出.java
文件,正常的做法是写个递归:
1 |
|
上述代码的问题在于,扫描目录的逻辑和处理.java文件的逻辑混在了一起。如果下次需要增加一个清理.class
文件的功能,就必须再重复写扫描逻辑。
因此,访问者模式先把数据结构和其对应的操作分离开,以后如果要新增操作,只需要新增访问者,不需要改变现有逻辑。
用访问者模式改写上述代码步骤如下:
首先,我们需要定义访问者接口,即该访问者能够干的事情:
1 |
|
紧接着,我们要定义能持有文件夹和文件的数据结构FileStructure
:
1 |
|
然后,我们给FileStructure
增加一个handle()
方法,传入一个访问者:
1 |
|
这样,我们就把访问者的行为抽象出来了。如果我们要实现一种操作,例如,查找.java
文件,就传入JavaFileVisitor
:
1 |
|
这个JavaFileVisitor
实现如下:
1 |
|
类似的,如果要清理.class
文件,可以再写一个ClassFileClearnerVisitor
:
1 |
|
可见,访问者模式的核心是为了访问比较复杂的数据结构,不去改变数据结构,而是把对数据的操作抽象出来,在“访问”的过程中以回调函数形式在访问者中处理操作逻辑。如果要新增一组操作,那么只需要增加一个新的访问者。