设计模式之美总结(结构型篇)


title: 设计模式之美总结(结构型篇)
date: 2022-12-21 09:59:11
tags:

  • 设计模式
    categories:
  • 设计模式
    cover: https://cover.png
    feature: false


前四篇见:

1. 代理模式(Proxy Design Pattern)

1.1 原理解析

代理模式(Proxy Design Pattern)的原理和代码实现都不难掌握,它在不改变原始类(或叫被代理类)代码的情况下,通过引入代理类来给原始类附加功能。如下例,这是一个性能计数器,用来收集接口请求的原始数据,比如访问时间、处理时长等

public class UserController {
	//...省略其他属性和方法...
	private MetricsCollector metricsCollector; // 依赖注入
	public UserVo login(String telephone, String password) {
		long  = System.currentTimeMillis();
		// ... 省略login逻辑...
		long endTimeStamp = System.currentTimeMillis();
		long responseTime = endTimeStamp - startTimestamp;
		RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
		metricsCollector.recordRequest(requestInfo);
		//...返回UserVo数据...
	}
	public UserVo register(String telephone, String password) {
		long startTimestamp = System.currentTimeMillis();
		// ... 省略register逻辑...
		long endTimeStamp = System.currentTimeMillis();
		long responseTime = endTimeStamp - startTimestamp;
		RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
		metricsCollector.recordRequest(requestInfo);
		//...返回UserVo数据...
	}
}

很明显,上面的写法有两个问题。第一,性能计数器框架代码侵入到业务代码中,跟业务代码高度耦合。如果未来需要替换这个框架,那替换的成本会比较大。第二,收集接口请求的代码跟业务代码无关,本就不应该放到一个类中。业务类最好职责更加单一,只聚焦业务处理

为了将框架代码和业务代码解耦,代理模式就派上用场了。代理类 UserControllerProxy 和原始类 UserController 实现相同的接口 IUserController。UserController 类只负责业务功能。代理类 UserControllerProxy 负责在业务代码执行前后附加其他逻辑代码,并通过委托的方式调用原始类来执行业务代码。具体的代码实现如下所示:

public interface IUserController {
	UserVo login(String telephone, String password);
	UserVo register(String telephone, String password);
}
public class UserController implements IUserController {
	//...省略其他属性和方法...
	@Override
	public UserVo login(String telephone, String password) {
		//...省略login逻辑...
		//...返回UserVo数据...
	}
	@Override
	public UserVo register(String telephone, String password) {
		//...省略register逻辑...
		//...返回UserVo数据...
	}
}
public class UserControllerProxy implements IUserController {
	private MetricsCollector metricsCollector;
	private UserController userController;
	public UserControllerProxy(UserController userController) {
		this.userController = userController;
		this.metricsCollector = new MetricsCollector();
	}
	@Override
	public UserVo login(String telephone, String password) {
		long startTimestamp = System.currentTimeMillis();
		// 委托
		UserVo userVo = userController.login(telephone, password);
		long endTimeStamp = System.currentTimeMillis();
		long responseTime = endTimeStamp - startTimestamp;
		RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
		metricsCollector.recordRequest(requestInfo);
		return userVo;
	}
	@Override
	public UserVo register(String telephone, String password) {
		long startTimestamp = System.currentTimeMillis();
		UserVo userVo = userController.register(telephone, password);
		long endTimeStamp = System.currentTimeMillis();
		long responseTime = endTimeStamp - startTimestamp;
		RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
		metricsCollector.recordRequest(requestInfo);
		return userVo;
	}
}

//UserControllerProxy使用举例
//因为原始类和代理类实现相同的接口,是基于接口而非实现编程
//将UserController类对象替换为UserControllerProxy类对象,不需要改动太多代码
IUserController userController = new UserControllerProxy(new UserController())

参照基于接口而非实现编程的设计思想,将原始类对象替换为代理类对象的时候,为了让代码改动尽量少,在刚刚的代理模式的代码实现中,代理类和原始类需要实现相同的接口。但是,如果原始类并没有定义接口,并且原始类代码并不是我们开发维护的(比如它来自一个第三方的类库),我们也没办法直接修改原始类,给它重新定义一个接口。在这种情况下,该如何实现代理模式呢?

对于这种外部类的扩展,一般都是采用继承的方式。让代理类继承原始类,然后扩展附加功能,具体代码如下所示:

public class UserControllerProxy extends UserController {
	private MetricsCollector metricsCollector;
	public UserControllerProxy() {
		this.metricsCollector = new MetricsCollector();
	}
	public UserVo login(String telephone, String password) {
		long startTimestamp = System.currentTimeMillis();
		UserVo userVo = super.login(telephone, password);
		long endTimeStamp = System.currentTimeMillis();
		long responseTime = endTimeStamp - startTimestamp;
		RequestInfo requestInfo = new RequestInfo("login", responseTime, startTimestamp);
		metricsCollector.recordRequest(requestInfo);
		return userVo;
	}
	public UserVo register(String telephone, String password) {
		long startTimestamp = System.currentTimeMillis();
		UserVo userVo = super.register(telephone, password);
		long endTimeStamp = System.currentTimeMillis();
		long responseTime = endTimeStamp - startTimestamp;
		RequestInfo requestInfo = new RequestInfo("register", responseTime, startTimestamp);
		metricsCollector.recordRequest(requestInfo);
		return userVo;
	}
}
//UserControllerProxy使用举例
UserController userController = new UserControllerProxy();

1.2 动态代理

不过,刚刚的代码实现还是有点问题。一方面,需要在代理类中,将原始类中的所有的方法,都重新实现一遍,并且为每个方法都附加相似的代码逻辑。另一方面,如果要添加的附加功能的类有不止一个,需要针对每个类都创建一个代理类

如果有 50 个要添加附加功能的原始类,那就要创建 50 个对应的代理类。这会导致项目中类的个数成倍增加,增加了代码维护成本。并且,每个代理类中的代码都有点像模板式的“重复”代码,也增加了不必要的开发成本。那这个问题怎么解决呢?

可以使用动态代理来解决这个问题。所谓动态代理(Dynamic Proxy),就是不事先为每个原始类编写代理类,而是在运行的时候,动态地创建原始类对应的代理类,然后在系统中用代理类替换掉原始类。那如何实现动态代理呢?

如果熟悉的是 Java 语言,实现动态代理就是件很简单的事情。因为 Java 语言本身就已经提供了动态代理的语法(实际上,动态代理底层依赖的就是 Java 的反射语法),代码如下所示。其中,MetricsCollectorProxy 作为一个动态代理类,动态地给每个需要收集接口请求信息的类创建代理类

public class MetricsCollectorProxy {
	private MetricsCollector metricsCollector;
	public MetricsCollectorProxy() {
		this.metricsCollector = new MetricsCollector();
	}
	public Object createProxy(Object proxiedObject) {
		Class<?>[] interfaces = proxiedObject.getClass().getInterfaces();
		DynamicProxyHandler handler = new DynamicProxyHandler(proxiedObject);
		return Proxy.newProxyInstance(proxiedObject.getClass().getClassLoader(), interfaces
	}
	private class DynamicProxyHandler implements InvocationHandler {
		private Object proxiedObject;
		public DynamicProxyHandler(Object proxiedObject) {
			this.proxiedObject = proxiedObject;
		}
		@Override
		public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
			long startTimestamp = System.currentTimeMillis();
			Object result = method.invoke(proxiedObject, args);
			long endTimeStamp = System.currentTimeMillis();
			long responseTime = endTimeStamp - startTimestamp;
			String apiName = proxiedObject.getClass().getName() + ":" + method.getName();
			RequestInfo requestInfo = new RequestInfo(apiName, responseTime, startTimestamp);
			metricsCollector.recordRequest(requestInfo);
			return result;
		}
	}
}
//MetricsCollectorProxy使用举例
MetricsCollectorProxy proxy = new MetricsCollectorProxy();
IUserController userController = (IUserController) proxy.createProxy(new userController());

实际上,Spring AOP 底层的实现原理就是基于动态代理。用户配置好需要给哪些类创建代理,并定义好在执行原始类的业务代码前后执行哪些附加功能。Spring 为这些类创建动态代理对象,并在 JVM 中替代原始类对象。原本在代码中执行的原始类的方法,被换作执行代理类的方法,也就实现了给原始类添加附加功能的目的

1.3 应用场景

1、业务系统的非功能性需求开发

代理模式最常用的一个应用场景就是,在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。将这些附加功能与业务功能解耦,放到代理类中统一处理,让程序员只需要关注业务方面的开发

如果熟悉 Java 语言和 Spring 开发框架,这部分工作都是可以在 Spring AOP 切面中完成的,Spring AOP 底层的实现原理就是基于动态代理

2、代理模式在 RPC 中的应用

实际上,RPC 框架也可以看作一种代理模式,GoF 的《设计模式》一书中把它称作远程代理。通过远程代理,将网络通信、数据编解码等细节隐藏起来。客户端在使用 RPC 服务的时候,就像使用本地函数一样,无需了解跟服务器交互的细节。除此之外,RPC 服务的开发者也只需要开发业务逻辑,就像开发本地使用的函数一样,不需要关注跟客户端的交互细节

3、代理模式在缓存中的应用

假设要开发一个接口请求的缓存功能,对于某些接口请求,如果入参相同,在设定的过期时间内,直接返回缓存结果,而不用重新进行逻辑处理。比如,针对获取用户个人信息的需求,可以开发两个接口,一个支持缓存,一个支持实时查询。对于需要实时数据的需求,让其调用实时查询接口,对于不需要实时数据的需求,让其调用支持缓存的接口。那如何来实现接口请求的缓存功能呢?

最简单的实现方法就是上面讲到的,给每个需要支持缓存的查询需求都开发两个不同的接口,一个支持缓存,一个支持实时查询。但是,这样做显然增加了开发成本,而且会让代码看起来非常臃肿(接口个数成倍增加),也不方便缓存接口的集中管理(增加、删除缓存接口)、集中配置(比如配置每个接口缓存过期时间)

针对这些问题,代理模式就能派上用场了,确切地说,应该是动态代理。如果是基于 Spring 框架来开发的话,那就可以在 AOP 切面中完成接口缓存的功能。在应用启动的时候,从配置文件中加载需要支持缓存的接口,以及相应的缓存策略(比如过期时间)等。当请求到来的时候,在 AOP 切面中拦截请求,如果请求中带有支持缓存的字段(比如 http://…?..&cached=true),便从缓存(内存缓存或者 Redis 缓存等)中获取数据直接返回

2. 桥接/桥梁模式(Bridge Design Pattern)

2.1 原理解析

这个模式有两种不同的理解方式。当然,这其中“最纯正”的理解方式,当属 GoF 的《设计模式》一书中对桥接模式的定义。毕竟,这 23 种经典的设计模式,最初就是由这本书总结出来的。在 GoF 的《设计模式》一书中,桥接模式是这么定义的:“Decouple an abstraction from its implementation so that the two can vary independently。”翻译成中文就是:“将抽象和实现解耦,让它们可以独立变化。”

关于桥接模式,很多书籍、资料中,还有另外一种理解方式:“一个类存在两个(或多个)独立变化的维度,通过组合的方式,让这两个(或多个)维度可以独立进行扩展。”通过组合关系来替代继承关系,避免继承层次的指数级爆炸。这种理解方式非常类似于之前讲过的“组合优于继承”设计原则,所以这里重点看下 GoF 的理解方式

GoF 给出的定义非常的简短,单凭这一句话,估计没几个人能看懂是什么意思。所以,我们通过 JDBC 驱动的例子来解释一下。JDBC 驱动是桥接模式的经典应用。先来看一下,如何利用 JDBC 驱动来查询数据库。具体的代码如下所示:

Class.forName("com.mysql.jdbc.Driver");//加载及注册JDBC驱动程序
String url = "jdbc:mysql://localhost:3306/sample_db?user=root&password=your_password"
Connection con = DriverManager.getConnection(url);
Statement stmt = con.createStatement()String query = "select * from test";
ResultSet rs=stmt.executeQuery(query);
while(rs.next()) {
    rs.getString(1);
    rs.getInt(2);
}

如果想要把 MySQL 数据库换成 Oracle 数据库,只需要把第一行代码中的 com.mysql.jdbc.Driver 换成 oracle.jdbc.driver.OracleDriver 就可以了。当然,也有更灵活的实现方式,可以把需要加载的 Driver 类写到配置文件中,当程序启动的时候,自动从配置文件中加载,这样在切换数据库的时候,都不需要修改代码,只需要修改配置文件就可以了

不管是改代码还是改配置,在项目中,从一个数据库切换到另一种数据库,都只需要改动很少的代码,或者完全不需要改动代码,那如此优雅的数据库切换是如何实现的呢?

从 com.mysql.jdbc.Driver 这个类的代码看起,摘抄了部分相关源码,如下:

package com.mysql.jdbc;
import java.sql.SQLException;

public class Driver extends NonRegisteringDriver implements java.sql.Driver {
	static {
		try {
			java.sql.DriverManager.registerDriver(new Driver());
		} catch (SQLException E) {
			throw new RuntimeException("Can't register driver!");
		}
	}
	/**
	* Construct a new driver and register it with DriverManager
	* @throws SQLException if a database error occurs.
	*/
	public Driver() throws SQLException {
		// Required for Class.forName().newInstance()
	}
}

结合 com.mysql.jdbc.Driver 的代码实现,可以发现,当执行 Class.forName(“com.mysql.jdbc.Driver”) 这条语句的时候,实际上是做了两件事情。第一件事情是要求 JVM 查找并加载指定的 Driver 类,第二件事情是执行该类的静态代码,也就是将 MySQL Driver 注册到 DriverManager 类中

再来看一下,DriverManager 类是干什么用的。具体的代码如下所示。当把具体的 Driver 实现类(比如,com.mysql.jdbc.Driver)注册到 DriverManager 之后,后续所有对 JDBC 接口的调用,都会委派到对具体的 Driver 实现类来执行。而 Driver 实现类都实现了相同的接口(java.sql.Driver ),这也是可以灵活切换 Driver 的原因

public class DriverManager {
	private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
	//...
	static {
		loadInitialDrivers();
		println("JDBC DriverManager initialized");
	}
	//...
	public static synchronized void registerDriver(java.sql.Driver driver) throws NullPointerException {
		if (driver != null) {
			registeredDrivers.addIfAbsent(new DriverInfo(driver));
		} else {
			throw new NullPointerException();
		}
	}
	public static Connection getConnection(String url, String user, String password) {
		java.util.Properties info = new java.util.Properties();
		if (user != null) {
			info.put("user", user);
		}
		if (password != null) {
			info.put("password", password);
		}
		return (getConnection(url, info, Reflection.getCallerClass()));
	}
	//...
}

桥接模式的定义是“将抽象和实现解耦,让它们可以独立变化”。那弄懂定义中“抽象”、“实现”和“解耦”三个概念,就是理解桥接模式的关键

  • 抽象,可以理解为存在于多个实体中的共同的概念性联系,就是忽略一些信息,从而把不同的实体当做同样的实体对待
  • 实现,即抽象给出的具体实现,可能有多种不同的实现方式
  • 解耦,所谓耦合,就是两个实体的行为的某种强关联。而将它们的强关联去掉,就是耦合的解脱,或称解耦。在这里,解耦是指将抽象和实现之间的耦合解脱开,或者说是将它们之间的强关联改换成弱关联。将两个角色之间的继承关系改为聚合关系,就是将它们之间的强关联改换成为弱关联,即抽象和实现之间使用组合/聚合关系而不是继承关系

那在 JDBC 这个例子中,什么是“抽象”?什么是“实现”呢?

实际上,JDBC 本身就相当于“抽象”。注意,这里所说的“抽象”,指的并非“抽象类”或“接口”,而是跟具体的数据库无关的、被抽象出来的一套“类库”。具体的 Driver(比如,com.mysql.jdbc.Driver)就相当于“实现”。注意,这里所说的“实现”,也并非指“接口的实现类”,而是跟具体数据库相关的一套“类库”。JDBC 和 Driver 独立开发,通过对象之间的组合关系,组装在一起。JDBC 的所有逻辑操作,最终都委托给 Driver 来执行

在这里插入图片描述

2.2 应用举例

之前讲过一个 API 接口监控告警的例子:根据不同的告警规则,触发不同类型的告警。告警支持多种通知渠道,包括:邮件、短信、微信、自动语音电话。通知的紧急程度有多种类型,包括:SEVERE(严重)、URGENCY(紧急)、NORMAL(普通)、TRIVIAL(无关紧要)。不同的紧急程度对应不同的通知渠道。比如,SERVE(严重)级别的消息会通过“自动语音电话”告知相关人员。先来看最简单、最直接的一种实现方式。代码如下所示:

public enum NotificationEmergencyLevel {
	SEVERE, URGENCY, NORMAL, TRIVIAL
}
public class Notification {
	private List<String> emailAddresses;
	private List<String> telephones;
	private List<String> wechatIds;
	public Notification() {}

	public void setEmailAddress(List<String> emailAddress) {
		this.emailAddresses = emailAddress;
	}
	public void setTelephones(List<String> telephones) {
		this.telephones = telephones;
	}
	public void setWechatIds(List<String> wechatIds) {
		this.wechatIds = wechatIds;
	}

	public void notify(NotificationEmergencyLevel level, String message) {
		if (level.equals(NotificationEmergencyLevel.SEVERE)) {
			//...自动语音电话
		} else if (level.equals(NotificationEmergencyLevel.URGENCY)) {
			//...发微信
		} else if (level.equals(NotificationEmergencyLevel.NORMAL)) {
			//...发邮件
		} else if (level.equals(NotificationEmergencyLevel.TRIVIAL)) {
			//...发邮件
		}
	}
}
//在API监控告警的例子中,我们如下方式来使用Notification类:
public class ErrorAlertHandler extends AlertHandler {
	public ErrorAlertHandler(AlertRule rule, Notification notification) {
		super(rule, notification);
	}
	@Override
	public void check(ApiStatInfo apiStatInfo) {
		if (apiStatInfo.getErrorCount() > rule.getMatchedRule(apiStatInfo.getApi())) {
			notification.notify(NotificationEmergencyLevel.SEVERE, "...");
		}
	}
}

Notification 类的代码实现有一个最明显的问题,那就是有很多 if-else 分支逻辑。实际上,如果每个分支中的代码都不复杂,后期也没有无限膨胀的可能(增加更多 if-else 分支判断),那这样的设计问题并不大,没必要非得一定要摒弃 if-else 分支逻辑

不过,Notification 的代码显然不符合这个条件。因为每个 if-else 分支中的代码逻辑都比较复杂,发送通知的所有逻辑都扎堆在 Notification 类中。类的代码越多,就越难读懂,越难修改,维护的成本也就越高。很多设计模式都是试图将庞大的类拆分成更细小的类,然后再通过某种更合理的结构组装在一起

针对 Notification 的代码,将不同渠道的发送逻辑剥离出来,形成独立的消息发送类(MsgSender 相关类)。其中,Notification 类相当于抽象,MsgSender 类相当于实现,两者可以独立开发,通过组合关系(也就是桥梁)任意组合在一起。所谓任意组合的意思就是,不同紧急程度的消息和发送渠道之间的对应关系,不是在代码中固定写死的,可以动态地去指定(比如,通过读取配置来获取对应关系)

public interface MsgSender {
	void send(String message);
}
public class TelephoneMsgSender implements MsgSender {
	private List<String> telephones;
	public TelephoneMsgSender(List<String> telephones) {
		this.telephones = telephones;
	}
	@Override
	public void send(String message) {
		//...
	}
}
public class EmailMsgSender implements MsgSender {
	// 与TelephoneMsgSender代码结构类似,所以省略...
}
public class WechatMsgSender implements MsgSender {
	// 与TelephoneMsgSender代码结构类似,所以省略...
}
public abstract class Notification {
	protected MsgSender msgSender;
	public Notification(MsgSender msgSender) {
		this.msgSender = msgSender;
	}
	public abstract void notify(String message);
}

public class SevereNotification extends Notification {
	public SevereNotification(MsgSender msgSender) {
		super(msgSender);
	}
	@Override
	public void notify(String message) {
		msgSender.send(message);
	}
}
public class UrgencyNotification extends Notification {
	// 与SevereNotification代码结构类似,所以省略...
}
public class NormalNotification extends Notification {
	// 与SevereNotification代码结构类似,所以省略...
}
public class TrivialNotification extends Notification {
	// 与SevereNotification代码结构类似,所以省略...
}

3. 装饰器模式(Decorator Design Pattern)

3.1 Java IO 类

Java IO 类库非常庞大和复杂,有几十个类,负责 IO 数据的读取和写入。如果对 Java IO 类做一下分类,可以从下面两个维度将它划分为四类。具体如下所示:

在这里插入图片描述

针对不同的读取和写入场景,Java IO 又在这四个父类基础之上,扩展出了很多子类。具体如下所示:

在这里插入图片描述

假如要打开文件 test.txt,从中读取数据。其中,InputStream 是一个抽象类,FileInputStream 是专门用来读取文件流的子类。BufferedInputStream 是一个支持带缓存功能的数据读取类,可以提高数据读取的效率

InputStream in = new FileInputStream("/user/wangzheng/test.txt");
InputStream bin = new BufferedInputStream(in);
byte[] data = new byte[128];
while (bin.read(data) != -1) {
	//...
}

初看上面的代码,会觉得 Java IO 的用法比较麻烦,需要先创建一个 FileInputStream对象,然后再传递给 BufferedInputStream 对象来使用。Java IO 为什么不设计一个继承 FileInputStream 并且支持缓存的 BufferedFileInputStream 类呢?这样就可以像下面的代码中这样,直接创建一个 BufferedFileInputStream 类对象,打开文件读取数据,用起来岂不是更加简单?

InputStream bin = new BufferedFileInputStream("/user/wangzheng/test.txt");
byte[] data = new byte[128];
while (bin.read(data) != -1) {
    //...
}

3.2 基于继承的设计方案

如果 InputStream 只有一个子类 FileInputStream 的话,那在 FileInputStream 基础之上,再设计一个孙子类 BufferedFileInputStream,也算是可以接受的,毕竟继承结构还算简单。但实际上,继承 InputStream 的子类有很多。需要给每一个 InputStream的子类,再继续派生支持缓存读取的子类

除了支持缓存读取之外,如果我们还需要对功能进行其他方面的增强,比如下面的 DataInputStream 类,支持按照基本数据类型(int、boolean、long 等)来读取数据

FileInputStream in = new FileInputStream("/user/wangzheng/test.txt");
DataInputStream din = new DataInputStream(in);
int data = din.readInt();

在这种情况下,如果继续按照继承的方式来实现的话,就需要再继续派生出 DataFileInputStream、DataPipedInputStream 等类。如果还需要既支持缓存、又支持按照基本类型读取数据的类,那就要再继续派生出 BufferedDataFileInputStream、BufferedDataPipedInputStream 等 n 多类。这还只是附加了两个增强功能,如果需要附加更多的增强功能,那就会导致组合爆炸,类继承结构变得无比复杂,代码既不好扩展,也不好维护

3.3 基于装饰器模式的设计方案

这里展示了 Java IO 的这种设计思路,对代码做了简化:

public abstract class InputStream {
	//...
	public int read(byte b[]) throws IOException {
		return read(b, 0, b.length);
	}
	public int read(byte b[], int off, int len) throws IOException {
		//...
	}
	public long skip(long n) throws IOException {
		//...
	}
	public int available() throws IOException {
		return 0;
	}
	public void close() throws IOException {}
	public synchronized void mark(int readlimit) {}
	public synchronized void reset() throws IOException {
		throw new IOException("mark/reset not supported");
	}
	public boolean markSupported() {
		return false;
	}
}
public class BufferedInputStream extends InputStream {
	protected volatile InputStream in;
	protected BufferedInputStream(InputStream in) {
		this.in = in;
	}
	//...实现基于缓存的读数据接口...
}
public class DataInputStream extends InputStream {
	protected volatile InputStream in;
	protected DataInputStream(InputStream in) {
		this.in = in;
	}
	//...实现读取基本类型数据的接口
}

看了上面的代码可能会问,那装饰器模式就是简单的“用组合替代继承”吗?当然不是。从 Java IO 的设计来看,装饰器模式相对于简单的组合关系,还有两个比较特殊的地方

1、装饰器类和原始类继承同样的父类,这样可以对原始类“嵌套”多个装饰器类

比如,下面这样一段代码,对 FileInputStream 嵌套了两个装饰器类:BufferedInputStream 和DataInputStream,让它既支持缓存读取,又支持按照基本数据类型来读取数据

InputStream in = new FileInputStream("/user/wangzheng/test.txt");
InputStream bin = new BufferedInputStream(in);
DataInputStream din = new DataInputStream(bin);
int data = din.readInt();

2、装饰器类是对功能的增强,这也是装饰器模式应用场景的一个重要特点

实际上,符合“组合关系”这种代码结构的设计模式有很多,比如之前讲过的代理模式、桥接模式,还有现在的装饰器模式。尽管它们的代码结构很相似,但是每种设计模式的意图是不同的。就拿比较相似的代理模式和装饰器模式来说,代理模式中,代理类附加的是跟原始类无关的功能,而在装饰器模式中,装饰器类附加的是跟原始类相关的增强功能

// 代理模式的代码结构(下面的接口也可以替换成抽象类
public interface IA {
	void f();
}
public class A impelements IA {
	public void f() {
		//...
	}
}
public class AProxy impements IA {
	private IA a;
	public AProxy(IA a) {
		this.a = a;
	}
	public void f() {
		// 新添加的代理逻辑
		a.f();
		// 新添加的代理逻辑
	}
}

// 装饰器模式的代码结构(下面的接口也可以替换成抽象类)
public interface IA {
	void f();
}
public class A impelements IA {
	public void f() {
		//...
	}
}
public class ADecorator impements IA {
	private IA a;
	public ADecorator(IA a) {
		this.a = a;
	}
	public void f() {
		// 功能增强代码
		a.f();
		// 功能增强代码
	}
}

实际上,如果去查看 JDK 的源码会发现,BufferedInputStream、DataInputStream并非继承自 InputStream,而是另外一个叫 FilterInputStream 的类。那这又是出于什么样的设计意图,才引入这样一个类呢?

再重新来看一下 BufferedInputStream 类的代码。InputStream 是一个抽象类而非接口,而且它的大部分函数(比如 read()、available())都有默认实现,按理来说,只需要在 BufferedInputStream 类中重新实现那些需要增加缓存功能的函数就可以了,其他函数继承 InputStream 的默认实现。但实际上,这样做是行不通的

对于即便是不需要增加缓存功能的函数来说,BufferedInputStream 还是必须把它重新实现一遍,简单包裹对 InputStream 对象的函数调用。具体的代码示例如下所示:

public class BufferedInputStream extends InputStream {
	protected volatile InputStream in;
	protected BufferedInputStream(InputStream in) {
		this.in = in;
	}
	// f()函数不需要增强,只是重新调用一下InputStream in对象的f()
	public void f() {
		in.f();
	}
}

如果不重新实现,那 BufferedInputStream 类就无法将最终读取数据的任务,委托给传递进来的 InputStream 对象来完成。因为假设我们没有重写方法 f(),然后在内部调用传递进来的 InputStream 对象的 f(),在使用 f()方法时调用的只会是最顶层的 f()方法,假如存在多个装饰器时就会出现问题,出现链式中断(整体的调用过程其实就是一个链式调用)。同样,假如我们重写了 f()方法,但是在内部忘记调用了传递进来的 InputStream 对象的 f() 方法,即上一个 InputStream 对象,也会出现链式中断

实际上,DataInputStream 也存在跟 BufferedInputStream 同样的问题。为了避免代码重复,Java IO 抽象出了一个装饰器父类 FilterInputStream,代码实现如下所示。InputStream 的所有的装饰器类(BufferedInputStream、DataInputStream)都继承自这个装饰器父类。这样,装饰器类只需要实现它需要增强的方法就可以了,其他方法继承装饰器父类的默认实现

public class FilterInputStream extends InputStream {
	protected volatile InputStream in;

	protected FilterInputStream(InputStream in) {
		this.in = in;
	}
	public int read() throws IOException {
		return in.read();
	}
	public int read(byte b[]) throws IOException {
		return read(b, 0, b.length);
	}
	public int read(byte b[], int off, int len) throws IOException {
		return in.read(b, off, len);
	}
	public long skip(long n) throws IOException {
		return in.skip(n);
	}
	public int available() throws IOException {
		return in.available();
	}
	public void close() throws IOException {
		in.close();
	}
	public synchronized void mark(int readlimit) {
		in.mark(readlimit);
	}
	public synchronized void reset() throws IOException {
		in.reset();
	}
	public boolean markSupported() {
		return in.markSupported();
	}
}

这里贴一段 BufferedInputStream 的 read() 方法调用的源码,省略了逻辑部分

public synchronized int read() throws IOException {
        // 省略其他
        fill();
}

private void fill() throws IOException {
        // 省略其他
        int n = getInIfOpen().read(buffer, pos, buffer.length - pos);
}

private InputStream getInIfOpen() throws IOException {
        // in 就是通过构造函数传递进来的 InputStream 对象
        InputStream input = in;
        if (input == null)
            throw new IOException("Stream closed");
        return input;
}

可以看到 BufferedInputStream 重写了 read() 方法后,通过 getInIfOpen() 这个方法获取了传进来的 InputStream in,然后通过 in.read() 调用了前面传递进来的 InputStream 对象的 read() 方法。但从这个方法看 FilterInputStream 的 read() 的包裹调用是完全没起作用的

public class FilterInputStream extends InputStream {
	protected volatile InputStream in;

	protected FilterInputStream(InputStream in) {
		this.in = in;
	}
	public int read() throws IOException {
		return in.read();
	}
	public int read(byte b[]) throws IOException {
		return read(b, 0, b.length);
	}
	public int read(byte b[], int off, int len) throws IOException {
		return in.read(b, off, len);
	}
        // 省略其他
}

但是就像上面所说,没有重写的方法,就必须包裹调用,不然会出现链式中断。为了代码复用,扩展性,包括避免出现漏调用的情况,所以这里 FilterInputStream 实现了所有 InputStream 方法的包裹调用。假如子类(如 BufferedInputStream)重写了,就用子类的,子类没有重写,则用 FilterInputStream 的(这点其实是继承的知识)

完整示例如下:

class Father {
    public void run() {
        System.out.println("Father run");
    }
}

class Son extends Father{
    public void run() {
        System.out.println("Son run");
    }
}

class ChildDecorator extends Father {
    protected Father father;

    public ChildDecorator(Father father) {
        this.father = father;
    }

    public void run() {
        father.run();
        System.out.println("ChildDecorator run");
    }
}

class Child1 extends ChildDecorator{

    public Child1(Father father) {
        super(father);
    }

    public void run() {
        father.run();
        System.out.println("Child1 run");
    }
}

class Child2 extends ChildDecorator {

    public Child2(Father father) {
        super(father);
    }

    public void run() {
        father.run();
        System.out.println("Child2 run");
    }
}
public static void main(String[] args) {
        Father son = new Son();
        Father child1 = new Child1(son);
        Child2 child2 = new Child2(child1);
        child2.run();
}

在这里插入图片描述

4. 适配器模式(Adapter Design Pattern)

4.1 原理与实现

顾名思义,这个模式就是用来做适配的,它将不兼容的接口转换为可兼容的接口,让原本由于接口不兼容而不能一起工作的类可以一起工作。对于这个模式,有一个经常被拿来解释它的例子,就是 USB 转接头充当适配器,把两种不兼容的接口,通过转接变得可以一起工作

原理很简单,再来看下它的代码实现。适配器模式有两种实现方式:类适配器和对象适配器。其中,类适配器使用继承关系来实现,对象适配器使用组合关系来实现。具体的代码实现如下所示。其中,ITarget 表示要转化成的接口定义。Adaptee 是一组不兼容 ITarget接口定义的接口,Adaptor 将 Adaptee 转化成一组符合 ITarget 接口定义的接口

// 类适配器: 基于继承
public interface ITarget {
	void f1();
	void f2();
	void fc();
}
public class Adaptee {
	public void fa() {
		//...
	}
	public void fb() {
		//...
	}
	public void fc() {
		//...
	}
}
public class Adaptor extends Adaptee implements ITarget {
	public void f1() {
		super.fa();
	}
	public void f2() {
		//...重新实现f2()...
	}
	// 这里fc()不需要实现,直接继承自Adaptee,这是跟对象适配器最大的不同点
}
// 对象适配器:基于组合
public interface ITarget {
	void f1();
	void f2();
	void fc();
}
public class Adaptee {
	public void fa() {
		//...
	}
	public void fb() {
		//...
	}
	public void fc() {
		//...
	}
}
public class Adaptor implements ITarget {
	private Adaptee adaptee;
	public Adaptor(Adaptee adaptee) {
		this.adaptee = adaptee;
	}
	public void f1() {
		adaptee.fa(); //委托给Adaptee
	}
	public void f2() {
		//...重新实现f2()...
	}
	public void fc() {
		adaptee.fc();
	}
}

针对这两种实现方式,在实际的开发中,到底该如何选择使用哪一种呢?判断的标准主要有两个,一个是 Adaptee 接口的个数,另一个是 Adaptee 和 ITarget 的契合程度

如果 Adaptee 接口并不多,那两种实现方式都可以。如果 Adaptee 接口很多,而且 Adaptee 和 ITarget 接口定义大部分都相同,那推荐使用类适配器,因为 Adaptor 复用父类 Adaptee 的接口,比起对象适配器的实现方式,Adaptor 的代码量要少一些。如果 Adaptee 接口很多,而且 Adaptee 和 ITarget 接口定义大部分都不相同,那推荐使用对象适配器,因为组合结构相对于继承更加灵活

4.2 应用场景

一般来说,适配器模式可以看作一种“补偿模式”,用来补救设计上的缺陷。应用这种模式算是“无奈之举”。如果在设计初期,就能协调规避接口不兼容的问题,那这种模式就没有应用的机会了

1、封装有缺陷的接口设计

假设依赖的外部系统在接口设计方面有缺陷(比如包含大量静态方法),引入之后会影响到我们自身代码的可测试性。为了隔离设计上的缺陷,希望对外部系统提供的接口进行二次封装,抽象出更好的接口设计,这个时候就可以使用适配器模式了

public class CD { //这个类来自外部sdk,我们无权修改它的代码
	//...
	public static void staticFunction1() {
		//...
	}
	public void uglyNamingFunction2() {
		//...
	}
	public void tooManyParamsFunction3(int paramA, int paramB, ...) {
		//...
	}
	public void lowPerformanceFunction4() {
		//...
	}
}

// 使用适配器模式进行重构
public interface ITarget {
	void function1();
	void function2();
	void fucntion3(ParamsWrapperDefinition paramsWrapper);
	void function4();
	//...
}
// 注意:适配器类的命名不一定非得末尾带Adaptor
public class CDAdaptor extends CD implements ITarget {
	//...
	public void function1() {
		super.staticFunction1();
	}
	public void function2() {
		super.uglyNamingFucntion2();
	}
	public void function3(ParamsWrapperDefinition paramsWrapper) {
		super.tooManyParamsFunction3(paramsWrapper.getParamA(), ...);
	}
	public void function4() {
		//...reimplement it...
	}
}

2、统一多个类的接口设计

某个功能的实现依赖多个外部系统(或者说类)。通过适配器模式,将它们的接口适配为统一的接口定义,然后就可以使用多态的特性来复用代码逻辑

假设我们的系统要对用户输入的文本内容做敏感词过滤,为了提高过滤的召回率,引入了多款第三方敏感词过滤系统,依次对用户输入的内容进行过滤,过滤掉尽可能多的敏感词。但是,每个系统提供的过滤接口都是不同的。这就意味着没法复用一套逻辑来调用各个系统。这个时候,就可以使用适配器模式,将所有系统的接口适配为统一的接口定义,这样可以复用调用敏感词过滤的代码

// A敏感词过滤系统提供的接口
public class ASensitiveWordsFilter {
	//text是原始文本,函数输出用***替换敏感词之后的文本
	public String filterSexyWords(String text) {
		// ...
	}
	public String filterPoliticalWords(String text) {
		// ...
	}
}
// B敏感词过滤系统提供的接口
public class BSensitiveWordsFilter {
	public String filter(String text) {
		//...
	}
}
// C敏感词过滤系统提供的接口
public class CSensitiveWordsFilter {
	public String filter(String text, String mask) {
		//...
	}
}
// 未使用适配器模式之前的代码:代码的可测试性、扩展性不好
public class RiskManagement {
	private ASensitiveWordsFilter aFilter = new ASensitiveWordsFilter();
	private BSensitiveWordsFilter bFilter = new BSensitiveWordsFilter();
	private CSensitiveWordsFilter cFilter = new CSensitiveWordsFilter();
	public String filterSensitiveWords(String text) {
		String maskedText = aFilter.filterSexyWords(text);
		maskedText = aFilter.filterPoliticalWords(maskedText);
		maskedText = bFilter.filter(maskedText);
		maskedText = cFilter.filter(maskedText, "***");
		return maskedText;
	}
}
// 使用适配器模式进行改造
public interface ISensitiveWordsFilter { // 统一接口定义
	String filter(String text);
}
public class ASensitiveWordsFilterAdaptor implements ISensitiveWordsFilter {
	private ASensitiveWordsFilter aFilter;
	public String filter(String text) {
		String maskedText = aFilter.filterSexyWords(text);
		maskedText = aFilter.filterPoliticalWords(maskedText);
		return maskedText;
	}
}
//...省略BSensitiveWordsFilterAdaptor、CSensitiveWordsFilterAdaptor...

// 扩展性更好,更加符合开闭原则,如果添加一个新的敏感词过滤系统,
// 这个类完全不需要改动;而且基于接口而非实现编程,代码的可测试性更好。
public class RiskManagement {
	private List<ISensitiveWordsFilter> filters = new ArrayList<>();
	public void addSensitiveWordsFilter(ISensitiveWordsFilter filter) {
		filters.add(filter);
	}
	public String filterSensitiveWords(String text) {
		String maskedText = text;
		for (ISensitiveWordsFilter filter : filters) {
			maskedText = filter.filter(maskedText);
		}
		return maskedText;
	}
}

3、替换依赖的外部系统

当把项目中依赖的一个外部系统替换为另一个外部系统的时候,利用适配器模式,可以减少对代码的改动。具体的代码示例如下所示:

// 外部系统A
public interface IA {
	//...
	void fa();
}
public class A implements IA {
	//...
	public void fa() {
		//...
	}
}
// 在我们的项目中,外部系统A的使用示例
public class Demo {
	private IA a;
	public Demo(IA a) {
		this.a = a;
	}
	//...
}
Demo d = new Demo(new A());

// 将外部系统A替换成外部系统B
public class BAdaptor implemnts IA {
	private B b;
	public BAdaptor(B b) {
		this.b= b;
	}
	public void fa() {
		//...
		b.fb();
	}
}
// 借助BAdaptor,Demo的代码中,调用IA接口的地方都无需改动,
// 只需要将BAdaptor如下注入到Demo即可。
Demo d = new Demo(new BAdaptor(new B()));

4、兼容老版本接口

在做版本升级的时候,对于一些要废弃的接口,不直接将其删除,而是暂时保留,并且标注为 deprecated,并将内部实现逻辑委托为新的接口实现。这样做的好处是,让使用它的项目有个过渡期,而不是强制进行代码修改

JDK1.0 中包含一个遍历集合容器的类 Enumeration。JDK2.0 对这个类进行了重构,将它改名为 Iterator 类,并且对它的代码实现做了优化。但是考虑到如果将 Enumeration 直接从 JDK2.0 中删除,那使用 JDK1.0 的项目如果切换到 JDK2.0,代码就会编译不通过。为了避免这种情况的发生,必须把项目中所有使用到 Enumeration 的地方,都修改为使用 Iterator 才行

单独一个项目做 Enumeration 到 Iterator 的替换,勉强还能接受。但是,使用 Java 开发的项目太多了,一次 JDK 的升级,导致所有的项目不做代码修改就会编译报错,这显然是不合理的。这就是经常所说的不兼容升级。为了做到兼容使用低版本 JDK 的老代码,可以暂时保留 Enumeration 类,并将其实现替换为直接调用 Itertor。代码示例如下所示:

public class Collections {
	public static Emueration emumeration(final Collection c) {
		return new Enumeration() {
			Iterator i = c.iterator();
			public boolean hasMoreElments() {
				return i.hashNext();
			}
			public Object nextElement() {
				return i.next():
			}
		}
	}
}

5、适配不同格式的数据

前面说到,适配器模式主要用于接口的适配,实际上,它还可以用在不同格式的数据之间的适配。比如,把从不同征信系统拉取的不同格式的征信数据,统一为相同的格式,以方便存储和使用。再比如,Java 中的 Arrays.asList() 也可以看作一种数据适配器,将数组类型的数据转化为集合容器类型

List stooges = Arrays.asList(“Larry”, “Moe”, “Curly”);

4.3 适配器模式在 Java 日志中的应用

Java 中有很多日志框架,在项目开发中,常常用它们来打印日志信息。其中,比较常用的有 log4j、logback,以及 JDK 提供的 JUL(java.util.logging) 和 Apache 的 JCL(Jakarta Commons Logging) 等

大部分日志框架都提供了相似的功能,比如按照不同级别(debug、info、warn、error……)打印日志等,但它们却并没有实现统一的接口。这主要可能是历史的原因,它不像 JDBC 那样,一开始就制定了数据库操作的接口规范

如果只是开发一个自己用的项目,那用什么日志框架都可以,log4j、logback 随便选一个就好。但是,如果开发的是一个集成到其他系统的组件、框架、类库等,那日志框架的选择就没那么随意了

比如,项目中用到的某个组件使用 log4j 来打印日志,而我们项目本身使用的是 logback。将组件引入到项目之后,我们的项目就相当于有了两套日志打印框架。每种日志框架都有自己特有的配置方式。所以,我们要针对每种日志框架编写不同的配置文件(比如,日志存储的文件地址、打印日志的格式)。如果引入多个组件,每个组件使用的日志框架都不一样,那日志本身的管理工作就变得非常复杂。所以,为了解决这个问题,我们需要统一日志打印框架

如果是做 Java 开发的,那 Slf4j 这个日志框架肯定不陌生,它相当于 JDBC 规范,提供了一套打印日志的统一接口规范。不过,它只定义了接口,并没有提供具体的实现,需要配合其他日志框架(log4j、logback……)来使用

不仅如此,Slf4j 的出现晚于 JUL、JCL、log4j 等日志框架,所以,这些日志框架也不可能牺牲掉版本兼容性,将接口改造成符合 Slf4j 接口规范。Slf4j 也事先考虑到了这个问题,所以,它不仅仅提供了统一的接口定义,还提供了针对不同日志框架的适配器。对不同日志框架的接口进行二次封装,适配成统一的 Slf4j 接口定义。具体的代码示例如下所示:

// slf4j统一的接口定义
package org.slf4j;
public interface Logger {
	public boolean isTraceEnabled();
	public void trace(String msg);
	public void trace(String format, Object arg);
	public void trace(String format, Object arg1, Object arg2);
	public void trace(String format, Object[] argArray);
	public void trace(String msg, Throwable t);
	public boolean isDebugEnabled();
	public void debug(String msg);
	public void debug(String format, Object arg);
	public void debug(String format, Object arg1, Object arg2)
	public void debug(String format, Object[] argArray)
	public void debug(String msg, Throwable t);
	//...省略info、warn、error等一堆接口
}

// log4j日志框架的适配器
// Log4jLoggerAdapter实现了LocationAwareLogger接口,
// 其中LocationAwareLogger继承自Logger接口,
// 也就相当于Log4jLoggerAdapter实现了Logger接口。
package org.slf4j.impl;
public final class Log4jLoggerAdapter extends MarkerIgnoringBase
	implements LocationAwareLogger, Serializable {
	final transient org.apache.log4j.Logger logger; // log4j
	public boolean isDebugEnabled() {
		return logger.isDebugEnabled();
	}
	public void debug(String msg) {
		logger.log(FQCN, Level.DEBUG, msg, null);
	}
	public void debug(String format, Object arg) {
		if (logger.isDebugEnabled()) {
			FormattingTuple ft = MessageFormatter.format(format, arg);
			logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
		}
	}
	public void debug(String format, Object arg1, Object arg2) {
		if (logger.isDebugEnabled()) {
			FormattingTuple ft = MessageFormatter.format(format, arg1, arg2);
			logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
		}
	}
	public void debug(String format, Object[] argArray) {
		if (logger.isDebugEnabled()) {
			FormattingTuple ft = MessageFormatter.arrayFormat(format, argArray);
			logger.log(FQCN, Level.DEBUG, ft.getMessage(), ft.getThrowable());
		}
	}
	public void debug(String msg, Throwable t) {
		logger.log(FQCN, Level.DEBUG, msg, t);
	}
	//...省略一堆接口的实现...
}

所以,在开发业务系统或者开发框架、组件的时候,我们统一使用 Slf4j 提供的接口来编写打印日志的代码,具体使用哪种日志框架实现(log4j、logback……),是可以动态地指定的(使用 Java 的 SPI 技术),只需要将相应的 SDK 导入到项目中即可

如果一些老的项目没有使用 Slf4j,而是直接使用比如 JCL 来打印日志,那如果想要替换成其他日志框架,比如 log4j,该怎么办呢?实际上,Slf4j 不仅仅提供了从其他日志框架到 Slf4j 的适配器,还提供了反向适配器,也就是从 Slf4j 到其他日志框架的适配。可以先将 JCL 切换为 Slf4j,然后再将 Slf4j 切换为 log4j。经过两次适配器的转换,就能成功将 log4j 切换为了 logback

4.4 代理、桥接、装饰器、适配器 4 种设计模式的区别

代理、桥接、装饰器、适配器,这 4 种模式是比较常用的结构型设计模式。它们的代码结构非常相似。笼统来说,它们都可以称为 Wrapper 模式,也就是通过 Wrapper 类二次封装原始类

尽管代码结构相似,但这 4 种设计模式的用意完全不同,也就是说要解决的问题、应用场景不同,这也是它们的主要区别

代理模式: 代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同

桥接模式: 桥接模式的目的是将接口部分和实现部分分离,从而让它们可以较为容易、也相对独立地加以改变

装饰器模式: 装饰者模式在不改变原始类接口的情况下,对原始类功能进行增强,并且支持多个装饰器的嵌套使用

适配器模式: 适配器模式是一种事后的补救策略。适配器提供跟原始类不同的接口,而代理模式、装饰器模式提供的都是跟原始类相同的接口

5. 门面/外观模式(Facade Design Pattern)

如果平时的工作涉及接口开发,不知道你有没有遇到关于接口粒度的问题呢?

为了保证接口的可复用性(或者叫通用性),我们需要将接口尽量设计得细粒度一点,责任单一一点,但是,如果接口的粒度过小,在接口的使用者开发一个业务功能时,就会导致需要调用 n 多细粒度的接口才能完成。调用者肯定会抱怨接口不好用

相反,如果接口粒度设计得太大,一个接口返回 n 多数据,要做 n 多事情,就会导致接口不够通用、可复用性不好。接口不可复用,那针对不同的调用者的业务需求,就需要开发不同的接口来满足,这就会导致系统的接口无限膨胀。那如何来解决接口的可复用性(通用性)和易用性之间的矛盾呢?

5.1 原理与实现

门面模式,也叫外观模式,英文全称是 Facade Design Pattern。在 GoF 的《设计模式》一书中,门面模式是这样定义的:

Provide a unified interface to a set of interfaces in a subsystem. Facade Pattern defines a higher-level interface that makes the subsystem easier to use.
门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用

假设有一个系统 A,提供了 a、b、c、d 四个接口。系统 B 完成某个业务功能,需要调用 A 系统的 a、b、d 接口。利用门面模式,我们提供一个包裹 a、b、d 接口调用的门面接口 x,给系统 B 直接使用

这时可能会有这样的疑问,让系统 B 直接调用 a、b、d 感觉没有太大问题呀,为什么还要提供一个包裹 a、b、d 的接口 x 呢?

假设刚刚提到的系统 A 是一个后端服务器,系统 B 是 App 客户端。App 客户端通过后端服务器提供的接口来获取数据。App 和服务器之间是通过移动网络通信的,网络通信耗时比较多,为了提高 App 的响应速度,我们要尽量减少 App 与服务器之间的网络通信次数

假设,完成某个业务功能(比如显示某个页面信息)需要“依次”调用 a、b、d 三个接口,因自身业务的特点,不支持并发调用这三个接口

如果现在发现 App 客户端的响应速度比较慢,排查之后发现,是因为过多的接口调用过多的网络通信。针对这种情况,就可以利用门面模式,让后端服务器提供一个包裹 a、b、d 三个接口调用的接口 x。App 客户端调用一次接口 x,来获取到所有想要的数据,将网络通信的次数从 3 次减少到 1 次,也就提高了 App 的响应速度

这里举的例子只是应用门面模式的其中一个意图,也就是解决性能问题。实际上,不同的应用场景下,使用门面模式的意图也不同

5.2 应用场景

在 GoF 给出的定义中提到,“门面模式让子系统更加易用”,实际上,它除了解决易用性问题之外,还能解决其他很多方面的问题。除此之外,还要强调一下,门面模式定义中的“子系统(subsystem)”也可以有多种理解方式。它既可以是一个完整的系统,也可以是更细粒度的类或者模块

1、解决易用性问题

门面模式可以用来封装系统的底层实现,隐藏系统的复杂性,提供一组更加简单易用、更高层的接口。比如,Linux 系统调用函数就可以看作一种“门面”。它是 Linux 操作系统暴露给开发者的一组“特殊”的编程接口,它封装了底层更基础的 Linux 内核调用。再比如,Linux 的 Shell 命令,实际上也可以看作一种门面模式的应用。它继续封装系统调用,提供更加友好、简单的命令,让我们可以直接通过执行命令来跟操作系统交互

设计原则、思想、模式很多都是相通的,是同一个道理不同角度的表述。实际上,从隐藏实现复杂性,提供更易用接口这个意图来看,门面模式有点类似之前讲到的迪米特法则(最少知识原则)和接口隔离原则:两个有交互的系统,只暴露有限的必要的接口。除此之外,门面模式还有点类似之前提到封装、抽象的设计思想,提供更抽象的接口,封装底层实现细节

2、解决性能问题

关于利用门面模式解决性能问题这一点,前面已经讲过了。通过将多个接口调用替换为一个门面接口调用,减少网络通信成本,提高 App 客户端的响应速度。现在来讨论一下这样一个问题:从代码实现的角度来看,该如何组织门面接口和非门面接口?

如果门面接口不多,完全可以将它跟非门面接口放到一块,也不需要特殊标记,当作普通接口来用即可。如果门面接口很多,可以在已有的接口之上,再重新抽象出一层,专门放置门面接口,从类、包的命名上跟原来的接口层做区分。如果门面接口特别多,并且很多都是跨多个子系统的,可以将门面接口放到一个新的子系统中

3、解决分布式事务问题

在一个金融系统中,有两个业务领域模型,用户和钱包。这两个业务领域模型都对外暴露了一系列接口,比如用户的增删改查接口、钱包的增删改查接口。假设有这样一个业务场景:在用户注册的时候,不仅会创建用户(在数据库 User 表中),还会给用户创建一个钱包(在数据库的 Wallet 表中)

对于这样一个简单的业务需求,可以通过依次调用用户的创建接口和钱包的创建接口来完成。但是,用户注册需要支持事务,也就是说,创建用户和钱包的两个操作,要么都成功,要么都失败,不能一个成功、一个失败

要支持两个接口调用在一个事务中执行,是比较难实现的,这涉及分布式事务问题。虽然可以通过引入分布式事务框架或者事后补偿的机制来解决,但代码实现都比较复杂。而最简单的解决方案是,利用数据库事务或者 Spring 框架提供的事务(如果是 Java 语言的话),在一个事务中,执行创建用户和创建钱包这两个 SQL 操作。这就要求两个 SQL 操作要在一个接口中完成,所以,可以借鉴门面模式的思想,再设计一个包裹这两个操作的新接口,让新接口在一个事务中执行两个 SQL 操作

6. 组合模式(Composite Design Pattern)

组合模式跟之前讲的面向对象设计中的“组合关系(通过组合来组装两个类)”,完全是两码事。这里讲的“组合模式”,主要是用来处理树形结构数据。这里的“数据”,可以简单理解为一组对象集合

正因为其应用场景的特殊性,数据必须能表示成树形结构,这也导致了这种模式在实际的项目开发中并不那么常用。但是,一旦数据满足树形结构,应用这种模式就能发挥很大的作用,能让代码变得非常简洁

6.1 原理与实现

在 GoF 的《设计模式》一书中,组合模式是这样定义的:

Compose objects into tree structure to represent part-whole hierarchies.Composite lets client treat individual objects and compositions of objects uniformly.
将一组对象组织(Compose)成树形结构,以表示一种“部分 - 整体”的层次结构。组合让客户端(在很多设计模式书籍中,“客户端”代指代码的使用者)可以统一单个对象和组合对象的处理逻辑

假设我们有这样一个需求:设计一个类来表示文件系统中的目录,能方便地实现下面这些功能:

  • 动态地添加、删除某个目录下的子目录或文件
  • 统计指定目录下的文件个数
  • 统计指定目录下的文件总大小

骨架代码如下所示,其中的核心逻辑并未实现。在下面的代码实现中,把文件和目录统一用 FileSystemNode 类来表示,并且通过 isFile 属性来区分

public class FileSystemNode {
	private String path;
	private boolean isFile;
	private List<FileSystemNode> subNodes = new ArrayList<>();
	public FileSystemNode(String path, boolean isFile) {
		this.path = path;
		this.isFile = isFile;
	}
	public int countNumOfFiles() {
		// TODO:...
	}
	public long countSizeOfFiles() {
		// TODO:...
	}
	public String getPath() {
		return path;
	}
	public void addSubNode(FileSystemNode fileOrDir) {
		subNodes.add(fileOrDir);
	}
	public void removeSubNode(FileSystemNode fileOrDir) {
		int size = subNodes.size();
		int i = 0;
		for (; i < size; ++i) {
			if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) {
				break;
			}
		}
		if (i < size) {
			subNodes.remove(i);
		}
	}
}

countNumOfFiles()countSizeOfFiles() 这两个函数,实际上这就是树的递归遍历算法。对于文件,可以直接返回文件的个数(返回 1)或大小。对于目录,遍历目录中每个子目录或者文件,递归计算它们的个数或大小,然后求和,就是这个目录下的文件个数和文件大小

public int countNumOfFiles() {
	if (isFile) {
		return 1;
	}
	int numOfFiles = 0;
	for (FileSystemNode fileOrDir : subNodes) {
		numOfFiles += fileOrDir.countNumOfFiles();
	}
	return numOfFiles;
}
public long countSizeOfFiles() {
	if (isFile) {
		File file = new File(path);
		if (!file.exists()) return 0;
		return file.length();
	}
	long sizeofFiles = 0;
	for (FileSystemNode fileOrDir : subNodes) {
		sizeofFiles += fileOrDir.countSizeOfFiles();
	}
	return sizeofFiles;
}

单纯从功能实现角度来说,上面的代码没有问题,已经实现了想要的功能。但是,如果开发的是一个大型系统,从扩展性(文件或目录可能会对应不同的操作)、业务建模(文件和目录从业务上是两个概念)、代码的可读性(文件和目录区分对待更加符合人们对业务的认知)的角度来说,最好对文件和目录进行区分设计,定义为 File 和 Directory两个类

public abstract class FileSystemNode {
	protected String path;
	public FileSystemNode(String path) {
		this.path = path;
	}
	public abstract int countNumOfFiles();
	public abstract long countSizeOfFiles();
	public String getPath() {
		return path;
	}
}
public class File extends FileSystemNode {
	public File(String path) {
		super(path);
	}
	@Override
	public int countNumOfFiles() {
		return 1;
	}
	@Override
	public long countSizeOfFiles() {
		java.io.File file = new java.io.File(path);
		if (!file.exists()) return 0;
		return file.length();
	}
}
public class Directory extends FileSystemNode {
	private List<FileSystemNode> subNodes = new ArrayList<>();
	public Directory(String path) {
		super(path);
	}
	@Override
	public int countNumOfFiles() {
		int numOfFiles = 0;
		for (FileSystemNode fileOrDir : subNodes) {
			numOfFiles += fileOrDir.countNumOfFiles();
		}
		return numOfFiles;
	}
	@Override
	public long countSizeOfFiles() {
		long sizeofFiles = 0;
		for (FileSystemNode fileOrDir : subNodes) {
			sizeofFiles += fileOrDir.countSizeOfFiles();
		}
		return sizeofFiles;
	}
	public void addSubNode(FileSystemNode fileOrDir) {
		subNodes.add(fileOrDir);
	}
	public void removeSubNode(FileSystemNode fileOrDir) {
		int size = subNodes.size();
		int i = 0;
		for (; i < size; ++i) {
			if (subNodes.get(i).getPath().equalsIgnoreCase(fileOrDir.getPath())) {
				break;
			}
		}
		if (i < size) {
			subNodes.remove(i);
		}
	}
}

文件和目录类都设计好了,现在来看如何用它们来表示一个文件系统中的目录树结构。具体的代码示例如下所示:

public class Demo {
	public static void main(String[] args) {
		/**
		* /
		* /wz/
		* /wz/a.txt
		* /wz/b.txt
		* /wz/movies/
		* /wz/movies/c.avi
		* /xzg/
		* /xzg/docs/
		* /xzg/docs/d.txt
		*/
		Directory fileSystemTree = new Directory("/");
		Directory node_wz = new Directory("/wz/");
		Directory node_xzg = new Directory("/xzg/");
		fileSystemTree.addSubNode(node_wz);
		fileSystemTree.addSubNode(node_xzg);
		File node_wz_a = new File("/wz/a.txt");
		File node_wz_b = new File("/wz/b.txt");
		Directory node_wz_movies = new Directory("/wz/movies/");
		node_wz.addSubNode(node_wz_a);
		node_wz.addSubNode(node_wz_b);
		node_wz.addSubNode(node_wz_movies);
		File node_wz_movies_c = new File("/wz/movies/c.avi");
		node_wz_movies.addSubNode(node_wz_movies_c);
		Directory node_xzg_docs = new Directory("/xzg/docs/");
		node_xzg.addSubNode(node_xzg_docs);
		File node_xzg_docs_d = new File("/xzg/docs/d.txt");
		node_xzg_docs.addSubNode(node_xzg_docs_d);
		System.out.println("/ files num:" + fileSystemTree.countNumOfFiles());
		System.out.println("/wz/ files num:" + node_wz.countNumOfFiles());
	}
}

对照着这个例子,再重新看一下组合模式的定义:“将一组对象(文件和目录)组织成树形结构,以表示一种‘部分 - 整体’的层次结构(目录与子目录的嵌套结构)。组合模式让客户端可以统一单个对象(文件)和组合对象(目录)的处理逻辑(递归遍历)。”实际上,刚才讲的这种组合模式的设计思路,与其说是一种设计模式,倒不如说是对业务场景的一种数据结构和算法的抽象。其中,数据可以表示成树这种数据结构,业务需求可以通过在树上的递归遍历算法来实现

6.2 应用场景

假设在开发一个 OA 系统(办公自动化系统)。公司的组织结构包含部门和员工两种数据类型。其中,部门又可以包含子部门和员工。在数据库中的表结构如下所示:

在这里插入图片描述

现在希望在内存中构建整个公司的人员架构图(部门、子部门、员工的隶属关系),并且提供接口计算出部门的薪资成本(隶属于这个部门的所有员工的薪资和)

部门包含子部门和员工,这是一种嵌套结构,可以表示成树这种数据结构。计算每个部门的薪资开支这样一个需求,也可以通过在树上的遍历算法来实现。所以,从这个角度来看,这个应用场景可以使用组合模式来设计和实现

示例代码如下,其中,HumanResource 是部门类(Department)和员工类(Employee)抽象出来的父类,为的是能统一薪资的处理逻辑。Demo 中的代码负责从数据库中读取数据并在内存中构建组织架构图

public abstract class HumanResource {
	protected long id;
	protected double salary;
	public HumanResource(long id) {
		this.id = id;
	}
	public long getId() {
		return id;
	}
	public abstract double calculateSalary();
}
public class Employee extends HumanResource {
	public Employee(long id, double salary) {
		super(id);
		this.salary = salary;
	}
	@Override
	public double calculateSalary() {
		return salary;
	}
}
public class Department extends HumanResource {
	private List<HumanResource> subNodes = new ArrayList<>();
	public Department(long id) {
		super(id);
	}
	@Override
	public double calculateSalary() {
		double totalSalary = 0;
		for (HumanResource hr : subNodes) {
			totalSalary += hr.calculateSalary();
		}
		this.salary = totalSalary;
		return totalSalary;
	}
	public void addSubNode(HumanResource hr) {
		subNodes.add(hr);
	}
}
// 构建组织架构的代码
public class Demo {
	private static final long ORGANIZATION_ROOT_ID = 1001;
	private DepartmentRepo departmentRepo; // 依赖注入
	private EmployeeRepo employeeRepo; // 依赖注入
	public void buildOrganization() {
		Department rootDepartment = new Department(ORGANIZATION_ROOT_ID);
		buildOrganization(rootDepartment);
	}
	private void buildOrganization(Department department) {
		List<Long> subDepartmentIds = departmentRepo.getSubDepartmentIds(department);
		for (Long subDepartmentId : subDepartmentIds) {
		Department subDepartment = new Department(subDepartmentId);
			department.addSubNode(subDepartment);
			buildOrganization(subDepartment);
		}
		List<Long> employeeIds = employeeRepo.getDepartmentEmployeeIds(department.getId());
		for (Long employeeId : employeeIds) {
		double salary = employeeRepo.getEmployeeSalary(employeeId);
			department.addSubNode(new Employee(employeeId, salary));
		}
	}
}

再拿组合模式的定义跟这个例子对照一下:“将一组对象(员工和部门)组织成树形结构,以表示一种‘部分 - 整体’的层次结构(部门与子部门的嵌套结构)。组合模式让客户端可以统一单个对象(员工)和组合对象(部门)的处理逻辑(递归遍历)。”

7. 享元模式(Flyweight Design Pattern)

7.1 原理与实现

所谓“享元”,顾名思义就是被共享的单元。享元模式的意图是复用对象,节省内存,前提是享元对象是不可变对象

具体来讲,当一个系统中存在大量重复对象的时候,如果这些重复的对象是不可变对象,就可以利用享元模式将对象设计成享元,在内存中只保留一份实例,供多处代码引用。这样可以减少内存中对象的数量,起到节省内存的目的。实际上,不仅仅相同对象可以设计成享元,对于相似对象,也可以将这些对象中相同的部分(字段)提取出来,设计成享元,让这些大量相似对象引用这些享元

定义中的“不可变对象”指的是,一旦通过构造函数初始化完成之后,它的状态(对象的成员变量或者属性)就不会再被修改了。所以,不可变对象不能暴露任何 set() 等修改内部状态的方法。之所以要求享元是不可变对象,那是因为它会被多处代码共享使用,避免一处代码对享元进行了修改,影响到其他使用它的代码

假设我们在开发一个棋牌游戏(比如象棋)。一个游戏厅中有成千上万个“房间”,每个房间对应一个棋局。棋局要保存每个棋子的数据,比如:棋子类型(将、相、士、炮等)、棋子颜色(红方、黑方)、棋子在棋局中的位置。利用这些数据,就能显示一个完整的棋盘给玩家。具体的代码如下所示。其中,ChessPiece 类表示棋子,ChessBoard 类表示一个棋局,里面保存了象棋中 30 个棋子的信息

public class ChessPiece {//棋子
	private int id;
	private String text;
	private Color color;
	private int positionX;
	private int positionY;
	public ChessPiece(int id, String text, Color color, int positionX, int positionY) {
		this.id = id;
		this.text = text;
		this.color = color;
		this.positionX = positionX;
		this.positionY = positionX;
	}
	public static enum Color {
		RED, BLACK
	}
	// ...省略其他属性和getter/setter方法...
}
public class ChessBoard {//棋局
	private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
	public ChessBoard() {
		init();
	}
	private void init() {
		chessPieces.put(1, new ChessPiece(1, "車", ChessPiece.Color.BLACK, 0, 0));
		chessPieces.put(2, new ChessPiece(2,"馬", ChessPiece.Color.BLACK, 0, 1));
		//...省略摆放其他棋子的代码...
	}
	public void move(int chessPieceId, int toPositionX, int toPositionY) {
		//...省略...
	}
}

为了记录每个房间当前的棋局情况,需要给每个房间都创建一个 ChessBoard 棋局对象。因为游戏大厅中有成千上万的房间(实际上,百万人同时在线的游戏大厅也有很多),那保存这么多棋局对象就会消耗大量的内存。有没有什么办法来节省内存呢?

这个时候,享元模式就可以派上用场了。像刚刚的实现方式,在内存中会有大量的相似对象。这些相似对象的 id、text、color 都是相同的,唯独 positionX、positionY 不同。实际上,我们可以将棋子的 id、text、color 属性拆分出来,设计成独立的类,并且作为享元供多个棋盘复用。这样,棋盘只需要记录每个棋子的位置信息就可以了。具体的代码实现如下所示:

// 享元类
public class ChessPieceUnit {
	private int id;
	private String text;
	private Color color;
	public ChessPieceUnit(int id, String text, Color color) {
		this.id = id;
		this.text = text;
		this.color = color;
	}
	public static enum Color {
		RED, BLACK
	}
	// ...省略其他属性和getter方法...
}
public class ChessPieceUnitFactory {
	private static final Map<Integer, ChessPieceUnit> pieces = new HashMap<>();
	static {
		pieces.put(1, new ChessPieceUnit(1, "車", ChessPieceUnit.Color.BLACK));
		pieces.put(2, new ChessPieceUnit(2,"馬", ChessPieceUnit.Color.BLACK));
		//...省略摆放其他棋子的代码...
	}
	public static ChessPieceUnit getChessPiece(int chessPieceId) {
		return pieces.get(chessPieceId);
	}
}
public class ChessPiece {
	private ChessPieceUnit chessPieceUnit;
	private int positionX;
	private int positionY;
	public ChessPiece(ChessPieceUnit unit, int positionX, int positionY) {
		this.chessPieceUnit = unit;
		this.positionX = positionX;
		this.positionY = positionY;
	}
	// 省略getter、setter方法
}
public class ChessBoard {
	private Map<Integer, ChessPiece> chessPieces = new HashMap<>();
	public ChessBoard() {
		init();
	}
	private void init() {
		chessPieces.put(1, new ChessPiece(
		                    ChessPieceUnitFactory.getChessPiece(1), 0,0));
		chessPieces.put(1, new ChessPiece(
		                    ChessPieceUnitFactory.getChessPiece(2), 1,0));
		//...省略摆放其他棋子的代码...
	}
	public void move(int chessPieceId, int toPositionX, int toPositionY) {
		//...省略...
	}
}

在上面的代码实现中,利用工厂类来缓存 ChessPieceUnit 信息(也就是 id、text、color)。通过工厂类获取到的 ChessPieceUnit 就是享元。所有的 ChessBoard 对象共享这 30 个 ChessPieceUnit 对象(因为象棋中只有 30 个棋子)。在使用享元模式之前,记录 1 万个棋局,要创建 30 万(30*1 万)个棋子的 ChessPieceUnit 对象。利用享元模式,只需要创建 30 个享元对象供所有棋局共享使用即可,大大节省了内存

总结一下它的代码结构。实际上,代码实现非常简单,主要是通过工厂模式,在工厂类中,通过一个 Map 来缓存已经创建过的享元对象,来达到复用的目的

7.2 在文本编辑器中的应用

可以把这里提到的文本编辑器想象成 Office 的 Word。不过,为了简化需求背景,假设这个文本编辑器只实现了文字编辑功能,不包含图片、表格等复杂的编辑功能。对于简化之后的文本编辑器,要在内存中表示一个文本文件,只需要记录文字和格式两部分信息就可以了,其中,格式又包括文字的字体、大小、颜色等信息

尽管在实际的文档编写中,一般都是按照文本类型(标题、正文……)来设置文字的格式,标题是一种格式,正文是另一种格式等等。但是,从理论上讲,可以给文本文件中的每个文字都设置不同的格式。为了实现如此灵活的格式设置,并且代码实现又不过于太复杂,把每个文字都当作一个独立的对象来看待,并且在其中包含它的格式信息。具体的代码示例如下所示:

public class Character {//文字
	private char c;
	private Font font;
	private int size;
	private int colorRGB;
	public Character(char c, Font font, int size, int colorRGB) {
		this.c = c;
		this.font = font;
		this.size = size;
		this.colorRGB = colorRGB;
	}
}
public class Editor {
	private List<Character> chars = new ArrayList<>();
	public void appendCharacter(char c, Font font, int size, int colorRGB) {
		Character character = new Character(c, font, size, colorRGB);
		chars.add(character);
	}
}

在文本编辑器中,每敲一个文字,都会调用 Editor 类中的 appendCharacter() 方法,创建一个新的 Character 对象,保存到 chars 数组中。如果一个文本文件中,有上万、十几万、几十万的文字,那就要在内存中存储这么多 Character 对象。那有没有办法可以节省一点内存呢?

实际上,在一个文本文件中,用到的字体格式不会太多,毕竟不大可能有人把每个文字都设置成不同的格式。所以,对于字体格式,可以将它设计成享元,让不同的文字共享使用。按照这个设计思路,对上面的代码进行重构。重构后的代码如下所示:

public class CharacterStyle {
	private Font font;
	private int size;
	private int colorRGB;
	public CharacterStyle(Font font, int size, int colorRGB) {
		this.font = font;
		this.size = size;
		this.colorRGB = colorRGB;
	}
	@Override
	public boolean equals(Object o) {
		CharacterStyle otherStyle = (CharacterStyle) o;
		return font.equals(otherStyle.font)
		       && size == otherStyle.size
		       && colorRGB == otherStyle.colorRGB;
	}
}
public class CharacterStyleFactory {
	private static final List<CharacterStyle> styles = new ArrayList<>();
	public static CharacterStyle getStyle(Font font, int size, int colorRGB) {
		CharacterStyle newStyle = new CharacterStyle(font, size, colorRGB);
		for (CharacterStyle style : styles) {
			if (style.equals(newStyle)) {
				return style;
			}
		}
		styles.add(newStyle);
		return newStyle;
	}
}
public class Character {
	private char c;
	private CharacterStyle style;
	public Character(char c, CharacterStyle style) {
		this.c = c;
		this.style = style;
	}
}
public class Editor {
	private List<Character> chars = new ArrayList<>();
	public void appendCharacter(char c, Font font, int size, int colorRGB) {
		Character character = new Character(c, CharacterStyleFactory.getStyle(font, size, colorRGB);
		chars.add(character);
	}
}

7.3 享元模式 vs 单例、缓存、对象池

1、享元模式跟单例的区别

在单例模式中,一个类只能创建一个对象,而在享元模式中,一个类可以创建多个对象,每个对象被多处代码引用共享。实际上,享元模式有点类似于之前讲到的单例的变体:多例

但区别两种设计模式,不能光看代码实现,而是要看设计意图,也就是要解决的问题。尽管从代码实现上来看,享元模式和多例有很多相似之处,但从设计意图上来看,它们是完全不同的。应用享元模式是为了对象复用,节省内存,而应用多例模式是为了限制对象的个数

2、享元模式跟缓存的区别

在享元模式的实现中,通过工厂类来“缓存”已经创建好的对象。这里的“缓存”实际上是“存储”的意思,跟平时所说的“数据库缓存”“CPU 缓存”“MemCache 缓存”是两回事。平时所讲的缓存,主要是为了提高访问效率,而非复用

3、享元模式跟对象池的区别

对象池、连接池(比如数据库连接池)、线程池等也是为了复用,那它们跟享元模式有什么区别呢?

很多人可能对连接池、线程池比较熟悉,对对象池比较陌生,这里简单解释一下对象池。像 C++ 这样的编程语言,内存的管理是由程序员负责的。为了避免频繁地进行对象创建和释放导致内存碎片,可以预先申请一片连续的内存空间,也就是这里说的对象池。每次创建对象时,从对象池中直接取出一个空闲对象来使用,对象使用完成之后,再放回到对象池中以供后续复用,而非直接释放掉

虽然对象池、连接池、线程池、享元模式都是为了复用,但是,如果再细致地抠一抠“复用”这个字眼的话,对象池、连接池、线程池等池化技术中的“复用”和享元模式中的“复用”实际上是不同的概念

池化技术中的“复用”可以理解为“重复使用”,主要目的是节省时间(比如从数据库池中取一个连接,不需要重新创建)。在任意时刻,每一个对象、连接、线程,并不会被多处使用,而是被一个使用者独占,当使用完成之后,放回到池中,再由其他使用者重复利用。享元模式中的“复用”可以理解为“共享使用”,在整个生命周期中,都是被所有使用者共享的,主要目的是节省空间

7.4 在 Java Integer 中的应用

先来看下面这样一段代码,思考下这段代码会输出什么样的结果:

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

如果不熟悉 Java 语言,可能会觉得,i1 和 i2 值都是 56,i3 和 i4 值都是 129,i1 跟 i2值相等,i3 跟 i4 值相等,所以输出结果应该是两个 true。这样的分析是不对的,要正确地分析上面的代码,需要弄清楚下面两个问题:

  • 如何判定两个 Java 对象是否相等(也就代码中的“==”操作符的含义)?
  • 什么是自动装箱(Autoboxing)和自动拆箱(Unboxing)?

Java 为基本数据类型提供了对应的包装器类型。具体如下所示:

在这里插入图片描述

所谓的自动装箱,就是自动将基本数据类型转换为包装器类型。所谓的自动拆箱,也就是自动将包装器类型转化为基本数据类型。具体的代码示例如下所示:

Integer i = 56; //自动装箱
int j = i; //自动拆箱

数值 56 是基本数据类型 int,当赋值给包装器类型(Integer)变量的时候,触发自动装箱操作,创建一个 Integer 类型的对象,并且赋值给变量 i。其底层相当于执行了下面这条语句:

Integer i = 59; 底层执行了: Integer i = Integer.valueOf(59);

反过来,当把包装器类型的变量 i,赋值给基本数据类型变量 j 的时候,触发自动拆箱操作,将 i 中的数据取出,赋值给 j。其底层相当于执行了下面这条语句:

int j = i; 底层执行了: int j = i.intValue();

弄清楚了自动装箱和自动拆箱,再来看,如何判定两个对象是否相等?不过,在此之前,先要搞清楚,Java 对象在内存中是如何存储的。如下例:

User a = new User(123, 23); // id=123, age=23

a 存储的值是 User 对象的内存地址,在图中就表现为 a 指向 User 对象。

在这里插入图片描述

当通过“==”来判定两个对象是否相等的时候,实际上是在判断两个局部变量存储的地址是否相同,换句话说,是在判断两个局部变量是否指向相同的对象

再来看之前的代码:

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

前 4 行赋值语句都会触发自动装箱操作,也就是会创建 Integer 对象并且赋值给 i1、i2、i3、i4 这四个变量。根据刚刚的讲解,i1、i2 尽管存储的数值相同,都是 56,但是指向不同的 Integer 对象,所以通过“==”来判定是否相同的时候,会返回 false。同理,i3==i4 判定语句也会返回 false

不过,上面的分析还是不对,答案并非是两个 false,而是一个 true,一个 false。看到这里可能会比较纳闷了。实际上,这正是因为 Integer 用到了享元模式来复用对象,才导致了这样的运行结果。当通过自动装箱,也就是调用 valueOf() 来创建 Integer 对象的时候,如果要创建的 Integer 对象的值在 -128 到 127 之间,会从 IntegerCache 类中直接返回,否则才调用 new 方法创建。看代码更加清晰一些,Integer 类的 valueOf() 函数的具体代码如下所示:

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

实际上,这里的 IntegerCache 相当于前面讲的生成享元对象的工厂类,只不过名字不叫 xxxFactory 而已。下面来看它的具体代码实现。这个类是 Integer 的内部类,也可以自行查看 JDK 源码:

/**
* Cache to support the object identity semantics of autoboxing for values betw
* -128 and 127 (inclusive) as required by JLS.
*
* The cache is initialized on first usage. The size of the cache
* may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
* During VM initialization, java.lang.Integer.IntegerCache.high property
* may be set and saved in the private system properties in the
* sun.misc.VM class.
*/
private static class IntegerCache {
	static final int low = -128;
	static final int high;
	static final Integer cache[];
	static {
		// high value may be configured by property
		int h = 127;
		String integerCacheHighPropValue =
		    sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high")
		if (integerCacheHighPropValue != null) {
			try {
				int i = parseInt(integerCacheHighPropValue);
				i = Math.max(i, 127);
				// Maximum array size is Integer.MAX_VALUE
				h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
			} catch( NumberFormatException nfe) {
				// If the property cannot be parsed into an int, ignore it.
			}
		}
		high = h;
		cache = new Integer[(high - low) + 1];
		int j = low;
		for(int k = 0; k < cache.length; k++)
			cache[k] = new Integer(j++);
		// range [-128, 127] must be interned (JLS7 5.1.7)
		assert IntegerCache.high >= 127;
	}
	private IntegerCache() {}
}

为什么 IntegerCache 只缓存 -128 到 127 之间的整型值呢?

在 IntegerCache 的代码实现中,当这个类被加载的时候,缓存的享元对象会被集中一次性创建好。毕竟整型值太多了,不可能在 IntegerCache 类中预先创建好所有的整型值,这样既占用太多内存,也使得加载 IntegerCache 类的时间过长。所以,只能选择缓存对于大部分应用来说最常用的整型值,也就是一个字节的大小(-128 到 127 之间的数据)

实际上,JDK 也提供了方法来让我们可以自定义缓存的最大值,有下面两种方式。如果通过分析应用的 JVM 内存占用情况,发现 -128 到 255 之间的数据占用的内存比较多,就可以用如下方式,将缓存的最大值从 127 调整到 255。不过,这里注意一下,JDK 并没有提供设置最小值的方法

//方法一:
-Djava.lang.Integer.IntegerCache.high=255
//方法二:
-XX:AutoBoxCacheMax=255

实际上,除了 Integer 类型之外,其他包装器类型,比如 Long、Short、Byte 等,也都利用了享元模式来缓存 -128 到 127 之间的数据。比如,Long 类型对应的 LongCache 享元工厂类及 valueOf() 函数代码如下所示:

private static class LongCache {
	private LongCache() {}
	static final Long cache[] = new Long[-(-128) + 127 + 1];
	static {
		for(int i = 0; i < cache.length; i++)
			cache[i] = new Long(i - 128);
	}
}
public static Long valueOf(long l) {
	final int offset = 128;
	if (l >= -128 && l <= 127) { // will cache
		return LongCache.cache[(int)l + offset];
	}
	return new Long(l);
}

在平时的开发中,对于下面这样三种创建整型对象的方式,优先使用后两种:

Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);

第一种创建方式并不会使用到 IntegerCache,而后面两种创建方法可以利用 IntegerCache 缓存,返回共享的对象,以达到节省内存的目的。举一个极端一点的例子,假设程序需要创建 1 万个 -128 到 127 之间的 Integer 对象。使用第一种创建方式,需要分配 1 万个 Integer 对象的内存空间;使用后两种创建方式,最多只需要分配 256 个 Integer 对象的内存空间

7.5 在 Java String 中的应用

同样,还是先来看一段代码,这段代码输出的结果是什么呢?

String s1 = "小争哥";
String s2 = "小争哥";
String s3 = new String("小争哥");
System.out.println(s1 == s2);
System.out.println(s1 == s3);

上面代码的运行结果是:一个 true,一个 false。跟 Integer 类的设计思路相似,String 类利用享元模式来复用相同的字符串常量(也就是代码中的“小争哥”)。JVM 会专门开辟一块存储区来存储字符串常量,这块存储区叫作“字符串常量池”。上面代码对应的内存存储结构如下所示:

在这里插入图片描述

不过,String 类的享元模式的设计,跟 Integer 类稍微有些不同。Integer 类中要共享的对象,是在类加载的时候,就集中一次性创建好的。但是,对于字符串来说,没法事先知道要共享哪些字符串常量,所以没办法事先创建好,只能在某个字符串常量第一次被用到的时候,存储到常量池中,当之后再用到的时候,直接引用常量池中已经存在的即可,就不需要再重新创建了

实际上,享元模式对 JVM 的垃圾回收并不友好。因为享元工厂类一直保存了对享元对象的引用,这就导致享元对象在没有任何代码使用的情况下,也并不会被 JVM 垃圾回收机制自动回收掉。因此,在某些情况下,如果对象的生命周期很短,也不会被密集使用,利用享元模式反倒可能会浪费更多的内存。所以,除非经过线上验证,利用享元模式真的可以大大节省内存,否则,就不要过度使用这个模式,为了一点点内存的节省而引入一个复杂的设计模式,得不偿失

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
软件设计与体系结构中的结构设计模式是一种用于解决软件系统中对象之间的组织和通信问题的设计模式。它们关注的是如何将类和对象组合成更大的结构,并提供了一种灵活的方式来实现对象之间的交互。 以下是几种常见的结构设计模式: 1. 适配器模式(Adapter Pattern):将一个类的接口转换成客户端所期望的另一个接口。适配器模式可以让原本不兼容的类能够一起工作。 2. 桥接模式(Bridge Pattern):将抽象部分与实现部分分离,使它们可以独立变化。桥接模式可以将一个类的抽象和实现层次结构分离,从而使它们能够独立地变化。 3. 组合模式(Composite Pattern):将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。 4. 装饰器模式(Decorator Pattern):动态地给一个对象添加额外的职责。装饰器模式提供了一种灵活的方式来扩展对象的功能。 5. 外观模式(Facade Pattern):为子系统中的一组接口提供一个统一的接口。外观模式可以简化复杂系统的接口,使得客户端更容易使用。 6. 享元模式(Flyweight Pattern):运用共享技术来有效地支持大量细粒度对象的复用。享元模式可以减少系统中对象的数量,从而提高系统的性能。 7. 代理模式(Proxy Pattern):为其他对象提供一种代理以控制对这个对象的访问。代理模式可以在不改变原始对象的情况下,通过引入代理对象来控制对原始对象的访问。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Fan 

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值