JAVA-设计模式-结构型模式
结构型模式主要涉及如何组合各种对象以便获得更好、更灵活的结构。虽然面向对象的继承机制提供了最基本的子类扩展父类的功能,但结构型模式不仅仅简单地使用继承,而更多地通过组合与运行期的动态组合来实现更灵活的功能。
结构型模式有:
- 适配器
- 桥接
- 组合
- 装饰器
- 外观
- 享元
- 代理
将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
适配器模式是Adapter,也称Wrapper,是指如果一个接口需要B接口,但是待传入的对象却是A接口,怎么办?
我们举个例子。如果去美国,我们随身带的电器是无法直接使用的,因为美国的插座标准和中国不同,所以,我们需要一个适配器:
在程序设计中,适配器也是类似的。我们已经有一个Task
类,实现了Callable
接口:
1 | public class Task implements Callable<Long> { |
现在,我们想通过一个线程去执行它:
1 | Callable<Long> callable = new Task(123450000L); |
发现编译不过!因为Thread
接收Runnable
接口,但不接收Callable
接口,肿么办?
一个办法是改写Task
类,把实现的Callable
改为Runnable
,但这样做不好,因为Task
很可能在其他地方作为Callable
被引用,改写Task
的接口,会导致其他正常工作的代码无法编译。
另一个办法不用改写Task
类,而是用一个Adapter,把这个Callable
接口“变成”Runnable
接口,这样,就可以正常编译:
1 | Callable<Long> callable = new Task(123450000L); |
这个RunnableAdapter
类就是Adapter,它接收一个Callable
,输出一个Runnable
。怎么实现这个RunnableAdapter
呢?我们先看完整的代码:
1 | public class RunnableAdapter implements Runnable { |
编写一个Adapter的步骤如下:
- 实现目标接口,这里是
Runnable
; - 内部持有一个待转换接口的引用,这里是通过字段持有
Callable
接口; - 在目标接口的实现方法内部,调用待转换接口的方法。
这样一来,Thread就可以接收这个RunnableAdapter
,因为它实现了Runnable
接口。Thread
作为调用方,它会调用RunnableAdapter
的run()
方法,在这个run()
方法内部,又调用了Callable
的call()
方法,相当于Thread
通过一层转换,间接调用了Callable
的call()
方法。
适配器模式在Java标准库中有广泛应用。比如我们持有数据类型是String[]
,但是需要List
接口时,可以用一个Adapter:
1 | String[] exist = new String[] {"Good", "morning", "Bob", "and", "Alice"}; |
注意到List<T> Arrays.asList(T[])
就相当于一个转换器,它可以把数组转换为List
。
我们再看一个例子:假设我们持有一个InputStream
,希望调用readText(Reader)
方法,但它的参数类型是Reader
而不是InputStream
,怎么办?
当然是使用适配器,把InputStream
“变成”Reader
:
1 | InputStream input = Files.newInputStream(Paths.get("/path/to/file")); |
InputStreamReader
就是Java标准库提供的Adapter
,它负责把一个InputStream
适配为Reader
。类似的还有OutputStreamWriter
。
如果我们把readText(Reader)
方法参数从Reader
改为FileReader
,会有什么问题?这个时候,因为我们需要一个FileReader
类型,就必须把InputStream
适配为FileReader
:
1 | FileReader reader = new InputStreamReader(input, "UTF-8"); // compile error! |
直接使用InputStreamReader
这个Adapter是不行的,因为它只能转换出Reader
接口。事实上,要把InputStream
转换为FileReader
也不是不可能,但需要花费十倍以上的功夫。这时,面向抽象编程这一原则就体现出了威力:持有高层接口不但代码更灵活,而且把各种接口组合起来也更容易。一旦持有某个具体的子类类型,要想做一些改动就非常困难。
练习
使用Adapter模式将Callable
接口适配为Runnable
。
小结
Adapter模式可以将一个A接口转换为B接口,使得新的对象符合B接口规范。
编写Adapter实际上就是编写一个实现了B接口,并且内部持有A接口的类:
1 | public BAdapter implements B { |
在Adapter内部将B接口的调用“转换”为对A接口的调用。
只有A、B接口均为抽象接口时,才能非常简单地实现Adapter模式。
将抽象部分与它的实现部分分离,使它们都可以独立地变化。
桥接模式的定义非常玄乎,直接理解不太容易,所以我们还是举例子。
假设某个汽车厂商生产三种品牌的汽车:Big、Tiny和Boss,每种品牌又可以选择燃油、纯电和混合动力。如果用传统的继承来表示各个最终车型,一共有3个抽象类加9个最终子类:
1 | ┌───────┐ |
如果要新增一个品牌,或者加一个新的引擎(比如核动力),那么子类的数量增长更快。
所以,桥接模式就是为了避免直接继承带来的子类爆炸。
我们来看看桥接模式如何解决上述问题。
在桥接模式中,首先把Car
按品牌进行子类化,但是,每个品牌选择什么发动机,不再使用子类扩充,而是通过一个抽象的“修正”类,以组合的形式引入。我们来看看具体的实现。
首先定义抽象类Car
,它引用一个Engine
:
1 | public abstract class Car { |
Engine
的定义如下:
1 | public interface Engine { |
紧接着,在一个“修正”的抽象类RefinedCar
中定义一些额外操作:
1 | public abstract class RefinedCar extends Car { |
这样一来,最终的不同品牌继承自RefinedCar
,例如BossCar
:
1 | public class BossCar extends RefinedCar { |
而针对每一种引擎,继承自Engine
,例如HybridEngine
:
1 | public class HybridEngine implements Engine { |
客户端通过自己选择一个品牌,再配合一种引擎,得到最终的Car:
1 | RefinedCar car = new BossCar(new HybridEngine()); |
使用桥接模式的好处在于,如果要增加一种引擎,只需要针对Engine
派生一个新的子类,如果要增加一个品牌,只需要针对RefinedCar
派生一个子类,任何RefinedCar
的子类都可以和任何一种Engine
自由组合,即一辆汽车的两个维度:品牌和引擎都可以独立地变化。
1 | ┌───────────┐ |
桥接模式实现比较复杂,实际应用也非常少,但它提供的设计思想值得借鉴,即不要过度使用继承,而是优先拆分某些部件,使用组合的方式来扩展功能。
练习
使用桥接模式扩展一种新的品牌和新的核动力引擎。
小结
桥接模式通过分离一个抽象接口和它的实现部分,使得设计可以按两个维度独立扩展。
组合
将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
组合模式(Composite)经常用于树形结构,为了简化代码,使用Composite可以把一个叶子节点与一个父节点统一起来处理。
我们来看一个具体的例子。在XML或HTML中,从根节点开始,每个节点都可能包含任意个其他节点,这些层层嵌套的节点就构成了一颗树。
要以树的结构表示XML,我们可以先抽象出节点类型Node
:
1 | public interface Node { |
对于一个<abc>
这样的节点,我们称之为ElementNode
,它可以作为容器包含多个子节点:
1 | public class ElementNode implements Node { |
对于普通文本,我们把它看作TextNode
,它没有子节点:
1 | public class TextNode implements Node { |
此外,还可以有注释节点:
1 | public class CommentNode implements Node { |
通过ElementNode
、TextNode
和CommentNode
,我们就可以构造出一颗树:
1 | Node root = new ElementNode("school"); |
最后通过root
节点输出的XML如下:
1 | <school> |
可见,使用Composite模式时,需要先统一单个节点以及“容器”节点的接口:
1 | ┌───────────┐ |
作为容器节点的ElementNode
又可以添加任意个Node
,这样就可以构成层级结构。
类似的,像文件夹和文件、GUI窗口的各种组件,都符合Composite模式的定义,因为它们的结构天生就是层级结构。
练习
使用Composite模式构造XML。
小结
Composite模式使得叶子对象和容器对象具有一致性,从而形成统一的树形结构,并用一致的方式去处理它们。
动态地给一个对象添加一些额外的职责。就增加功能来说,相比生成子类更为灵活。
装饰器(Decorator)模式,是一种在运行期动态给某个对象的实例增加功能的方法。
我们在IO的Filter模式一节中其实已经讲过装饰器模式了。在Java标准库中,InputStream
是抽象类,FileInputStream
、ServletInputStream
、Socket.getInputStream()
这些InputStream
都是最终数据源。
现在,如果要给不同的最终数据源增加缓冲功能、计算签名功能、加密解密功能,那么,3个最终数据源、3种功能一共需要9个子类。如果继续增加最终数据源,或者增加新功能,子类会爆炸式增长,这种设计方式显然是不可取的。
Decorator模式的目的就是把一个一个的附加功能,用Decorator的方式给一层一层地累加到原始数据源上,最终,通过组合获得我们想要的功能。
例如:给FileInputStream
增加缓冲和解压缩功能,用Decorator模式写出来如下:
1 | // 创建原始的数据源: |
或者一次性写成这样:
1 | InputStream input = new GZIPInputStream( // 第二层装饰 |
观察BufferedInputStream
和GZIPInputStream
,它们实际上都是从FilterInputStream
继承的,这个FilterInputStream
就是一个抽象的Decorator。我们用图把Decorator模式画出来如下:
1 | ┌───────────┐ |
最顶层的Component是接口,对应到IO的就是InputStream
这个抽象类。ComponentA、ComponentB是实际的子类,对应到IO的就是FileInputStream
、ServletInputStream
这些数据源。Decorator是用于实现各个附加功能的抽象装饰器,对应到IO的就是FilterInputStream
。而从Decorator派生的就是一个一个的装饰器,它们每个都有独立的功能,对应到IO的就是BufferedInputStream
、GZIPInputStream
等。
Decorator模式有什么好处?它实际上把核心功能和附加功能给分开了。核心功能指FileInputStream
这些真正读数据的源头,附加功能指加缓冲、压缩、解密这些功能。如果我们要新增核心功能,就增加Component的子类,例如ByteInputStream
。如果我们要增加附加功能,就增加Decorator的子类,例如CipherInputStream
。两部分都可以独立地扩展,而具体如何附加功能,由调用方自由组合,从而极大地增强了灵活性。
如果我们要自己设计完整的Decorator模式,应该如何设计?
我们还是举个栗子:假设我们需要渲染一个HTML的文本,但是文本还可以附加一些效果,比如加粗、变斜体、加下划线等。为了实现动态附加效果,可以采用Decorator模式。
首先,仍然需要定义顶层接口TextNode
:
1 | public interface TextNode { |
对于核心节点,例如<span>
,它需要从TextNode
直接继承:
1 | public class SpanNode implements TextNode { |
紧接着,为了实现Decorator模式,需要有一个抽象的Decorator类:
1 | public abstract class NodeDecorator implements TextNode { |
这个NodeDecorator
类的核心是持有一个TextNode
,即将要把功能附加到的TextNode
实例。接下来就可以写一个加粗功能:
1 | public class BoldDecorator extends NodeDecorator { |
类似的,可以继续加ItalicDecorator
、UnderlineDecorator
等。客户端可以自由组合这些Decorator:
1 | TextNode n1 = new SpanNode(); |
练习
使用Decorator添加一个<del>
标签表示删除。
小结
使用Decorator模式,可以独立增加核心功能,也可以独立增加附加功能,二者互不影响;
可以在运行期动态地给核心功能增加任意个附加功能。
外观
为子系统中的一组接口提供一个一致的界面。Facade模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
外观模式,即Facade,是一个比较简单的模式。它的基本思想如下:
如果客户端要跟许多子系统打交道,那么客户端需要了解各个子系统的接口,比较麻烦。如果有一个统一的“中介”,让客户端只跟中介打交道,中介再去跟各个子系统打交道,对客户端来说就比较简单。所以Facade就相当于搞了一个中介。
我们以注册公司为例,假设注册公司需要三步:
- 向工商局申请公司营业执照;
- 在银行开设账户;
- 在税务局开设纳税号。
以下是三个系统的接口:
1 | // 工商注册: |
如果子系统比较复杂,并且客户对流程也不熟悉,那就把这些流程全部委托给中介:
1 | public class Facade { |
这样,客户端只跟Facade打交道,一次完成公司注册的所有繁琐流程:
1 | Company c = facade.openCompany("Facade Software Ltd."); |
很多Web程序,内部有多个子系统提供服务,经常使用一个统一的Facade入口,例如一个RestApiController
,使得外部用户调用的时候,只关心Facade提供的接口,不用管内部到底是哪个子系统处理的。
更复杂的Web程序,会有多个Web服务,这个时候,经常会使用一个统一的网关入口来自动转发到不同的Web服务,这种提供统一入口的网关就是Gateway,它本质上也是一个Facade,但可以附加一些用户认证、限流限速的额外服务。
练习
使用Facade模式实现一个注册公司的“中介”服务。
小结
Facade模式是为了给客户端提供一个统一入口,并对外屏蔽内部子系统的调用细节。
享元
运用共享技术有效地支持大量细粒度的对象。
享元(Flyweight)的核心思想很简单:如果一个对象实例一经创建就不可变,那么反复创建相同的实例就没有必要,直接向调用方返回一个共享的实例就行,这样即节省内存,又可以减少创建对象的过程,提高运行速度。
享元模式在Java标准库中有很多应用。我们知道,包装类型如Byte
、Integer
都是不变类,因此,反复创建同一个值相同的包装类型是没有必要的。以Integer
为例,如果我们通过Integer.valueOf()
这个静态工厂方法创建Integer
实例,当传入的int
范围在-128
~+127
之间时,会直接返回缓存的Integer
实例:
1 | // 享元模式 |
对于Byte
来说,因为它一共只有256个状态,所以,通过Byte.valueOf()
创建的Byte
实例,全部都是缓存对象。
因此,享元模式就是通过工厂方法创建对象,在工厂方法内部,很可能返回缓存的实例,而不是新创建实例,从而实现不可变实例的复用。
提示
总是使用工厂方法而不是new操作符创建实例,可获得享元模式的好处。
在实际应用中,享元模式主要应用于缓存,即客户端如果重复请求某些对象,不必每次查询数据库或者读取文件,而是直接返回内存中缓存的数据。
我们以Student
为例,设计一个静态工厂方法,它在内部可以返回缓存的对象:
1 | public class Student { |
在实际应用中,我们经常使用成熟的缓存库,例如Guava的Cache,因为它提供了最大缓存数量限制、定时过期等实用功能。
练习
使用享元模式实现缓存。
小结
享元模式的设计思想是尽量复用已创建的对象,常用于工厂方法内部的优化。
为其他对象提供一种代理以控制对这个对象的访问。
代理模式,即Proxy,它和Adapter模式很类似。我们先回顾Adapter模式,它用于把A接口转换为B接口:
1 | public class BAdapter implements B { |
而Proxy模式不是把A接口转换成B接口,它还是转换成A接口:
1 | public class AProxy implements A { |
合着Proxy就是为了给A接口再包一层,这不是脱了裤子放屁吗?
当然不是。我们观察Proxy的实现A接口的方法:
1 | public void a() { |
这样写当然没啥卵用。但是,如果我们在调用a.a()
的前后,加一些额外的代码:
1 | public void 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,它也是一种代理对象,如果有很多客户端对它进行访问,通过内部的计数器可以在外部调用者都不使用后自动释放它。
我们来看一下如何应用代理模式编写一个JDBC连接池(DataSource
)。我们首先来编写一个虚代理,即如果调用者获取到Connection
后,并没有执行任何SQL操作,那么这个Connection Proxy实际上并不会真正打开JDBC连接。调用者代码如下:
1 | DataSource lazyDataSource = new LazyDataSource(jdbcUrl, jdbcUsername, jdbcPassword); |
现在我们来思考如何实现这个LazyConnectionProxy
。为了简化代码,我们首先针对Connection
接口做一个抽象的代理类:
1 | public abstract class AbstractConnectionProxy implements Connection { |
这个AbstractConnectionProxy
代理类的作用是把Connection
接口定义的方法全部实现一遍,因为Connection
接口定义的方法太多了,后面我们要编写的LazyConnectionProxy
只需要继承AbstractConnectionProxy
,就不必再把Connection
接口方法挨个实现一遍。
LazyConnectionProxy
实现如下:
1 | public class LazyConnectionProxy extends AbstractConnectionProxy { |
如果调用者没有执行任何SQL语句,那么target
字段始终为null
。只有第一次执行SQL语句时(即调用任何类似prepareStatement()
方法时,触发getRealConnection()
调用),才会真正打开实际的JDBC Connection。
最后,我们还需要编写一个LazyDataSource
来支持这个LazyConnectionProxy
:
1 | public class LazyDataSource implements DataSource { |
我们执行代码,输出如下:
1 | get lazy connection... |
可见第一个getConnection()
调用获取到的LazyConnectionProxy
并没有实际打开真正的JDBC Connection。
使用连接池的时候,我们更希望能重复使用连接。如果调用方编写这样的代码:
1 | DataSource pooledDataSource = new PooledDataSource(jdbcUrl, jdbcUsername, jdbcPassword); |
调用方并不关心是否复用了Connection
,但从PooledDataSource
获取的Connection
确实自带这个优化功能。如何实现可复用Connection
的连接池?答案仍然是使用代理模式。
1 | public class PooledConnectionProxy extends AbstractConnectionProxy { |
复用连接的关键在于覆写close()
方法,它并没有真正关闭底层JDBC连接,而是把自己放回一个空闲队列,以便下次使用。
空闲队列由PooledDataSource
负责维护:
1 | public class PooledDataSource implements DataSource { |
我们执行调用方代码,输出如下:
1 | Open new connection: com.mysql.jdbc.JDBC4Connection@61ca2dfa |
除了第一次打开了一个真正的JDBC Connection,后续获取的Connection
实际上是同一个JDBC Connection。但是,对于调用方来说,完全不需要知道底层做了哪些优化。
我们实际使用的DataSource,例如HikariCP,都是基于代理模式实现的,原理同上,但增加了更多的如动态伸缩的功能(一个连接空闲一段时间后自动关闭)。
有的童鞋会发现Proxy模式和Decorator模式有些类似。确实,这两者看起来很像,但区别在于:Decorator模式让调用者自己创建核心类,然后组合各种功能,而Proxy模式决不能让调用者自己创建再组合,否则就失去了代理的功能。Proxy模式让调用者认为获取到的是核心类接口,但实际上是代理类。
练习
使用代理模式编写一个JDBC连接池。
小结
代理模式通过封装一个已有接口,并向调用方返回相同的接口类型,能让调用方在不改变任何代码的前提下增强某些功能(例如,鉴权、延迟加载、连接池复用等)。
使用Proxy模式要求调用方持有接口,作为Proxy的类也必须实现相同的接口类型。