结构性
结构型模式主要涉及如何组合各种对象以便获得更好、更灵活的结构。虽然面向对象的继承机制提供了最基本的子类扩展父类的功能,但结构型模式不仅仅简单地使用继承,而更多地通过组合与运行期的动态组合来实现更灵活的功能。
结构型模式有:
- 适配器
- 桥接
- 组合
- 装饰器
- 外观
- 享元
- 代理
# 适配器 Adapter
将一个类的接口转换成客户希望的另外一个接口,使得原本由于接口不兼容而不能一起工作的那些类可以一起工作。
适配器模式是Adapter,也称Wrapper,是指如果一个接口需要B接口,但是待传入的对象却是A接口,怎么办?
我们举个例子。如果去美国,我们随身带的电器是无法直接使用的,因为美国的插座标准和中国不同,所以,我们需要一个适配器:
比如我们原本有一个Task继承的callable接口,但是Thread并不接受callable接口,那么我们应该如何让这个正常运行呢?
public class Task implements Callable<Long> {
private long num;
public Task(long num) {
this.num = num;
}
public Long call() throws Exception {
long r = 0;
for (long n = 1; n <= this.num; n++) {
r = r + n;
}
System.out.println("Result: " + r);
return r;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
我们可以使用adapter来实现
public class RunnableAdapter implements Runnable {
// 引用待转换接口:
private Callable<?> callable;
public RunnableAdapter(Callable<?> callable) {
this.callable = callable;
}
// 实现指定接口:
public void run() {
// 将指定接口调用委托给转换接口调用:
try {
callable.call();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
编写一个Adapter的步骤如下:
- 实现目标接口,这里是
Runnable
; - 内部持有一个待转换接口的引用,这里是通过字段持有
Callable
接口; - 在目标接口的实现方法内部,调用待转换接口的方法。
这样一来,Thread就可以接收这个RunnableAdapter
,因为它实现了Runnable
接口。Thread
作为调用方,它会调用RunnableAdapter
的run()
方法,在这个run()
方法内部,又调用了Callable
的call()
方法,相当于Thread
通过一层转换,间接调用了Callable
的call()
方法。
适配器模式在Java标准库中有广泛应用。比如我们持有数据类型是String[]
,但是需要List
接口时,可以用一个Adapter:
String[] exist = new String[] {"Good", "morning", "Bob", "and", "Alice"};
Set<String> set = new HashSet<>(Arrays.asList(exist));
2
注意到List<T> Arrays.asList(T[])
就相当于一个转换器,它可以把数组转换为List
。
小结
Adapter模式可以将一个A接口转换为B接口,使得新的对象符合B接口规范。
编写Adapter实际上就是编写一个实现了B接口,并且内部持有A接口的类:
public BAdapter implements B {
private A a;
public BAdapter(A a) {
this.a = a;
}
public void b() {
a.a();
}
}
2
3
4
5
6
7
8
9
在Adapter内部将B接口的调用“转换”为对A接口的调用。
只有A、B接口均为抽象接口时,才能非常简单地实现Adapter模式。
# 桥接模式 Barage
将抽象部分与它的实现部分分离,使它们都可以独立地变化。
桥接模式的定义非常玄乎,直接理解不太容易,所以我们还是举例子。
假设某个汽车厂商生产三种品牌的汽车:Big、Tiny和Boss,每种品牌又可以选择燃油、纯电和混合动力。如果用传统的继承来表示各个最终车型,一共有3个抽象类加9个最终子类:
如果要新增一个品牌,或者加一个新的引擎(比如核动力),那么子类的数量增长更快。所以,桥接模式就是为了避免直接继承带来的子类爆炸。
在桥接模式中,首先把Car
按品牌进行子类化,但是,每个品牌选择什么发动机,不再使用子类扩充,而是通过一个抽象的“修正”类,以组合的形式引入。我们来看看具体的实现。首先定义抽象类Car
,它引用一个Engine
:
public abstract class Car {
// 引用Engine:
protected Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public abstract void drive();
}
2
3
4
5
6
7
8
9
Engine
的定义如下:
public interface Engine {
void start();
}
2
3
紧接着,在一个“修正”的抽象类RefinedCar
中定义一些额外操作:
public abstract class RefinedCar extends Car {
public RefinedCar(Engine engine) {
super(engine);
}
public void drive() {
this.engine.start();
System.out.println("Drive " + getBrand() + " car...");
}
public abstract String getBrand();
}
2
3
4
5
6
7
8
9
10
11
12
这样一来,最终的不同品牌继承自RefinedCar
,例如BossCar
:
public class BossCar extends RefinedCar {
public BossCar(Engine engine) {
super(engine);
}
public String getBrand() {
return "Boss";
}
}
2
3
4
5
6
7
8
9
而针对每一种引擎,继承自Engine
,例如HybridEngine
:
public class HybridEngine implements Engine {
public void start() {
System.out.println("Start Hybrid Engine...");
}
}
2
3
4
5
客户端通过自己选择一个品牌,再配合一种引擎,得到最终的Car:
RefinedCar car = new BossCar(new HybridEngine());
car.drive();
2
使用桥接模式的好处在于,如果要增加一种引擎,只需要针对Engine
派生一个新的子类,如果要增加一个品牌,只需要针对RefinedCar
派生一个子类,任何RefinedCar
的子类都可以和任何一种Engine
自由组合,即一辆汽车的两个维度:品牌和引擎都可以独立地变化。
桥接模式实现比较复杂,实际应用也非常少,但它提供的设计思想值得借鉴,即不要过度使用继承,而是优先拆分某些部件,使用组合的方式来扩展功能。
# 组合 Composite
将对象组合成树形结构以表示“部分-整体”的层次结构,使得用户对单个对象和组合对象的使用具有一致性。
组合模式(Composite)经常用于树形结构,为了简化代码,使用Composite可以把一个叶子节点与一个父节点统一起来处理。
我们来看一个具体的例子。在XML或HTML中,从根节点开始,每个节点都可能包含任意个其他节点,这些层层嵌套的节点就构成了一颗树。
要以树的结构表示XML,我们可以先抽象出节点类型Node
:
public interface Node {
// 添加一个节点为子节点:
Node add(Node node);
// 获取子节点:
List<Node> children();
// 输出为XML:
String toXml();
}
2
3
4
5
6
7
8
比如我们按照这个要求定义三种节点,一个是ElementNode
一个是TextNode
还有一个CommentNode
这三种节点按照下面要求定义
public class ElementNode implements Node {
private String name;
private List<Node> list = new ArrayList<>();
public ElementNode(String name) {
this.name = name;
}
public Node add(Node node) {
list.add(node);
return this;
}
public List<Node> children() {
return list;
}
public String toXml() {
String start = "<" + name + ">\n";
String end = "</" + name + ">\n";
StringJoiner sj = new StringJoiner("", start, end);
list.forEach(node -> {
sj.add(node.toXml() + "\n");
});
return sj.toString();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class TextNode implements Node {
private String text;
public TextNode(String text) {
this.text = text;
}
public Node add(Node node) {
throw new UnsupportedOperationException();
}
public List<Node> children() {
return List.of();
}
public String toXml() {
return text;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CommentNode implements Node {
private String text;
public CommentNode(String text) {
this.text = text;
}
public Node add(Node node) {
throw new UnsupportedOperationException();
}
public List<Node> children() {
return List.of();
}
public String toXml() {
return "<!-- " + text + " -->";
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
通过ElementNode
、TextNode
和CommentNode
,我们就可以构造出一颗树:
Node root = new ElementNode("school");
root.add(new ElementNode("classA")
.add(new TextNode("Tom"))
.add(new TextNode("Alice")));
root.add(new ElementNode("classB")
.add(new TextNode("Bob"))
.add(new TextNode("Grace"))
.add(new CommentNode("comment...")));
System.out.println(root.toXml());
2
3
4
5
6
7
8
9
最后输出结果
<school>
<classA>
Tom
Alice
</classA>
<classB>
Bob
Grace
<!-- comment... -->
</classB>
</school>
2
3
4
5
6
7
8
9
10
11
小结
Composite模式使得叶子对象和容器对象具有一致性,从而形成统一的树形结构,并用一致的方式去处理它们。
# 装饰器 Decorator
动态地给一个对象添加一些额外的职责。就增加功能来说,相比生成子类更为灵活。
装饰器(Decorator)模式,是一种在运行期动态给某个对象的实例增加功能的方法。我们在IO的Filter模式 (opens new window)一节中其实已经讲过装饰器模式了。在Java标准库中,InputStream
是抽象类,FileInputStream
、ServletInputStream
、Socket.getInputStream()
这些InputStream
都是最终数据源。
现在,如果要给不同的最终数据源增加缓冲功能、计算签名功能、加密解密功能,那么,3个最终数据源、3种功能一共需要9个子类。如果继续增加最终数据源,或者增加新功能,子类会爆炸式增长,这种设计方式显然是不可取的。Decorator模式的目的就是把一个一个的附加功能,用Decorator的方式给一层一层地累加到原始数据源上,最终,通过组合获得我们想要的功能。
// 创建原始的数据源:
InputStream fis = new FileInputStream("test.gz");
// 增加缓冲功能:
InputStream bis = new BufferedInputStream(fis);
// 增加解压缩功能:
InputStream gis = new GZIPInputStream(bis);
2
3
4
5
6
或者一次性写成这样:
InputStream input = new GZIPInputStream( // 第二层装饰
new BufferedInputStream( // 第一层装饰
new FileInputStream("test.gz") // 核心功能
));
2
3
4
观察BufferedInputStream
和GZIPInputStream
,它们实际上都是从FilterInputStream
继承的,这个FilterInputStream
就是一个抽象的Decorator。我们用图把Decorator模式画出来如下:
Decorator模式有什么好处?它实际上把核心功能和附加功能给分开了。核心功能指FileInputStream
这些真正读数据的源头,附加功能指加缓冲、压缩、解密这些功能。如果我们要新增核心功能,就增加Component的子类,例如ByteInputStream
。如果我们要增加附加功能,就增加Decorator的子类,例如CipherInputStream
。两部分都可以独立地扩展,而具体如何附加功能,由调用方自由组合,从而极大地增强了灵活性。
实际例子
我们同样定义一个TextNode
public interface TextNode {
// 设置text:
void setText(String text);
// 获取text:
String getText();
}
2
3
4
5
6
对于核心节点,例如<span>
,它需要从TextNode
直接继承:
public class SpanNode implements TextNode {
private String text;
public void setText(String text) {
this.text = text;
}
public String getText() {
return "<span>" + text + "</span>";
}
}
2
3
4
5
6
7
8
9
紧接着,为了实现Decorator模式,需要有一个抽象的Decorator类:
public abstract class NodeDecorator implements TextNode {
protected final TextNode target;
protected NodeDecorator(TextNode target) {
this.target = target;
}
public void setText(String text) {
this.target.setText(text);
}
}
2
3
4
5
6
7
8
9
这个NodeDecorator
类的核心是持有一个TextNode
,即将要把功能附加到的TextNode
实例。接下来就可以写一个加粗功能:
public class BoldDecorator extends NodeDecorator {
public BoldDecorator(TextNode target) {
super(target);
}
public String getText() {
return "<b>" + target.getText() + "</b>";
}
}
2
3
4
5
6
7
8
类似的,可以继续加ItalicDecorator
、UnderlineDecorator
等。客户端可以自由组合这些Decorator:
TextNode n1 = new SpanNode();
TextNode n2 = new BoldDecorator(new UnderlineDecorator(new SpanNode()));
TextNode n3 = new ItalicDecorator(new BoldDecorator(new SpanNode()));
n1.setText("Hello");
n2.setText("Decorated");
n3.setText("World");
System.out.println(n1.getText());
// 输出<span>Hello</span>
System.out.println(n2.getText());
// 输出<b><u><span>Decorated</span></u></b>
System.out.println(n3.getText());
// 输出<i><b><span>World</span></b></i>
2
3
4
5
6
7
8
9
10
11
12
13
14
小结
使用Decorator模式,可以独立增加核心功能,也可以独立增加附加功能,二者互不影响;
可以在运行期动态地给核心功能增加任意个附加功能。
# 外观 Facade
为子系统中的一组接口提供一个一致的界面。Facade模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。
外观模式,即Facade,是一个比较简单的模式。它的基本思想如下:
如果客户端要跟许多子系统打交道,那么客户端需要了解各个子系统的接口,比较麻烦。如果有一个统一的“中介”,让客户端只跟中介打交道,中介再去跟各个子系统打交道,对客户端来说就比较简单。所以Facade就相当于搞了一个中介。
我们以注册公司为例,假设注册公司需要三步:
- 向工商局申请公司营业执照;
- 在银行开设账户;
- 在税务局开设纳税号。
以下是三个系统的接口:
// 工商注册:
public class AdminOfIndustry {
public Company register(String name) {
...
}
}
// 银行开户:
public class Bank {
public String openAccount(String companyId) {
...
}
}
// 纳税登记:
public class Taxation {
public String applyTaxCode(String companyId) {
...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
如果子系统比较复杂,并且客户对流程也不熟悉,那就把这些流程全部委托给中介:
public class Facade {
public Company openCompany(String name) {
Company c = this.admin.register(name);
String bankAccount = this.bank.openAccount(c.getId());
c.setBankAccount(bankAccount);
String taxCode = this.taxation.applyTaxCode(c.getId());
c.setTaxCode(taxCode);
return c;
}
}
2
3
4
5
6
7
8
9
10
这样,客户端只跟Facade打交道,一次完成公司注册的所有繁琐流程:
Company c = facade.openCompany("Facade Software Ltd.");
很多Web程序,内部有多个子系统提供服务,经常使用一个统一的Facade入口,例如一个RestApiController
,使得外部用户调用的时候,只关心Facade提供的接口,不用管内部到底是哪个子系统处理的。
更复杂的Web程序,会有多个Web服务,这个时候,经常会使用一个统一的网关入口来自动转发到不同的Web服务,这种提供统一入口的网关就是Gateway,它本质上也是一个Facade,但可以附加一些用户认证、限流限速的额外服务。
小结
Facade模式是为了给客户端提供一个统一入口,并对外屏蔽内部子系统的调用细节。
# 享元 Flyweight
运用共享技术有效地支持大量细粒度的对象。
享元(Flyweight)的核心思想很简单:如果一个对象实例一经创建就不可变,那么反复创建相同的实例就没有必要,直接向调用方返回一个共享的实例就行,这样即节省内存,又可以减少创建对象的过程,提高运行速度。
享元模式在Java标准库中有很多应用。我们知道,包装类型如Byte
、Integer
都是不变类,因此,反复创建同一个值相同的包装类型是没有必要的。以Integer
为例,如果我们通过Integer.valueOf()
这个静态工厂方法创建Integer
实例,当传入的int
范围在-128
~+127
之间时,会直接返回缓存的Integer
实例:
对于Byte
来说,因为它一共只有256个状态,所以,通过Byte.valueOf()
创建的Byte
实例,全部都是缓存对象。因此,享元模式就是通过工厂方法创建对象,在工厂方法内部,很可能返回缓存的实例,而不是新创建实例,从而实现不可变实例的复用。总是使用工厂方法而不是new操作符创建实例,可获得享元模式的好处。
在实际应用中,享元模式主要应用于缓存,即客户端如果重复请求某些对象,不必每次查询数据库或者读取文件,而是直接返回内存中缓存的数据。我们以Student
为例,设计一个静态工厂方法,它在内部可以返回缓存的对象:
public class Student {
// 持有缓存:
private static final Map<String, Student> cache = new HashMap<>();
// 静态工厂方法:
public static Student create(int id, String name) {
String key = id + "\n" + name;
// 先查找缓存:
Student std = cache.get(key);
if (std == null) {
// 未找到,创建新对象:
System.out.println(String.format("create new Student(%s, %s)", id, name));
std = new Student(id, name);
// 放入缓存:
cache.put(key, std);
} else {
// 缓存中存在:
System.out.println(String.format("return cached Student(%s, %s)", std.id, std.name));
}
return std;
}
private final int id;
private final String name;
public Student(int id, String name) {
this.id = id;
this.name = name;
}
}
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
在实际应用中,我们经常使用成熟的缓存库,例如Guava (opens new window)的Cache (opens new window),因为它提供了最大缓存数量限制、定时过期等实用功能。
小结
享元模式的设计思想是尽量复用已创建的对象,常用于工厂方法内部的优化。
# 代理 Proxy
为其他对象提供一种代理以控制对这个对象的访问。
代理模式,即Proxy,它和Adapter模式很类似。我们先回顾Adapter模式,它用于把A接口转换为B接口:
public BAdapter implements B {
private A a;
public BAdapter(A a) {
this.a = a;
}
public void b() {
a.a();
}
}
2
3
4
5
6
7
8
9
Proxy模式的代码如下
public AProxy implements A {
private A a;
public AProxy(A a) {
this.a = a;
}
public void a() {
this.a.a();
}
}
2
3
4
5
6
7
8
9
看起来好像是一样的,但是不同的地方在这里 this.a.a();
,看起来好像啥也没干,但是我们可以在代码里面加上权限检查的功能,这样我们就实现了权限检查:
public void a() {
if (getCurrentUser().isRoot()) {
this.a.a();
} else {
throw new SecurityException("Forbidden");
}
}
2
3
4
5
6
7
有的童鞋会问,为啥不把权限检查的功能直接写到目标实例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模式要求调用方持有接口,作为Proxy的类也必须实现相同的接口类型。