This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
<audio id="audio" title="56 | 观察者模式(上):详解各种应用场景下观察者模式的不同实现方式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/13/93b41626690bbc806d4af3a72b473313.mp3"></audio>
我们常把23种经典的设计模式分为三类创建型、结构型、行为型。前面我们已经学习了创建型和结构型从今天起我们开始学习行为型设计模式。我们知道创建型设计模式主要解决“对象的创建”问题结构型设计模式主要解决“类或对象的组合或组装”问题那行为型设计模式主要解决的就是“类或对象之间的交互”问题。
行为型设计模式比较多有11个几乎占了23种经典设计模式的一半。它们分别是观察者模式、模板模式、策略模式、职责链模式、状态模式、迭代器模式、访问者模式、备忘录模式、命令模式、解释器模式、中介模式。
今天我们学习第一个行为型设计模式也是在实际的开发中用得比较多的一种模式观察者模式。根据应用场景的不同观察者模式会对应不同的代码实现方式有同步阻塞的实现方式也有异步非阻塞的实现方式有进程内的实现方式也有跨进程的实现方式。今天我会重点讲解原理、实现、应用场景。下一节课我会带你一块实现一个基于观察者模式的异步非阻塞的EventBus加深你对这个模式的理解。
话不多说,让我们正式开始今天的学习吧!
## 原理及应用场景剖析
**观察者模式**Observer Design Pattern也被称为**发布订阅模式**Publish-Subscribe Design Pattern。在GoF的《设计模式》一书中它的定义是这样的
>
Define a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.
翻译成中文就是:在对象之间定义一个一对多的依赖,当一个对象状态改变的时候,所有依赖的对象都会自动收到通知。
一般情况下,被依赖的对象叫作**被观察者**Observable依赖的对象叫作**观察者**Observer。不过在实际的项目开发中这两种对象的称呼是比较灵活的有各种不同的叫法比如Subject-Observer、Publisher-Subscriber、Producer-Consumer、EventEmitter-EventListener、Dispatcher-Listener。不管怎么称呼只要应用场景符合刚刚给出的定义都可以看作观察者模式。
实际上,观察者模式是一个比较抽象的模式,根据不同的应用场景和需求,有完全不同的实现方式,待会我们会详细地讲到。现在,我们先来看其中最经典的一种实现方式。这也是在讲到这种模式的时候,很多书籍或资料给出的最常见的实现方式。具体的代码如下所示:
```
public interface Subject {
void registerObserver(Observer observer);
void removeObserver(Observer observer);
void notifyObservers(Message message);
}
public interface Observer {
void update(Message message);
}
public class ConcreteSubject implements Subject {
private List&lt;Observer&gt; observers = new ArrayList&lt;Observer&gt;();
@Override
public void registerObserver(Observer observer) {
observers.add(observer);
}
@Override
public void removeObserver(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers(Message message) {
for (Observer observer : observers) {
observer.update(message);
}
}
}
public class ConcreteObserverOne implements Observer {
@Override
public void update(Message message) {
//TODO: 获取消息通知,执行自己的逻辑...
System.out.println(&quot;ConcreteObserverOne is notified.&quot;);
}
}
public class ConcreteObserverTwo implements Observer {
@Override
public void update(Message message) {
//TODO: 获取消息通知,执行自己的逻辑...
System.out.println(&quot;ConcreteObserverTwo is notified.&quot;);
}
}
public class Demo {
public static void main(String[] args) {
ConcreteSubject subject = new ConcreteSubject();
subject.registerObserver(new ConcreteObserverOne());
subject.registerObserver(new ConcreteObserverTwo());
subject.notifyObservers(new Message());
}
}
```
实际上上面的代码算是观察者模式的“模板代码”只能反映大体的设计思路。在真实的软件开发中并不需要照搬上面的模板代码。观察者模式的实现方法各式各样函数、类的命名等会根据业务场景的不同有很大的差别比如register函数还可以叫作attachremove函数还可以叫作detach等等。不过万变不离其宗设计思路都是差不多的。
原理和代码实现都非常简单,也比较好理解,不需要我过多的解释。我们还是通过一个具体的例子来重点讲一下,什么情况下需要用到这种设计模式?或者说,这种设计模式能解决什么问题呢?
假设我们在开发一个P2P投资理财系统用户注册成功之后我们会给用户发放投资体验金。代码实现大致是下面这个样子的
```
public class UserController {
private UserService userService; // 依赖注入
private PromotionService promotionService; // 依赖注入
public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
promotionService.issueNewUserExperienceCash(userId);
return userId;
}
}
```
虽然注册接口做了两件事情,注册和发放体验金,违反单一职责原则,但是,如果没有扩展和修改的需求,现在的代码实现是可以接受的。如果非得用观察者模式,就需要引入更多的类和更加复杂的代码结构,反倒是一种过度设计。
相反如果需求频繁变动比如用户注册成功之后不再发放体验金而是改为发放优惠券并且还要给用户发送一封“欢迎注册成功”的站内信。这种情况下我们就需要频繁地修改register()函数中的代码违反开闭原则。而且如果注册成功之后需要执行的后续操作越来越多那register()函数的逻辑会变得越来越复杂,也就影响到代码的可读性和可维护性。
这个时候,观察者模式就能派上用场了。利用观察者模式,我对上面的代码进行了重构。重构之后的代码如下所示:
```
public interface RegObserver {
void handleRegSuccess(long userId);
}
public class RegPromotionObserver implements RegObserver {
private PromotionService promotionService; // 依赖注入
@Override
public void handleRegSuccess(long userId) {
promotionService.issueNewUserExperienceCash(userId);
}
}
public class RegNotificationObserver implements RegObserver {
private NotificationService notificationService;
@Override
public void handleRegSuccess(long userId) {
notificationService.sendInboxMessage(userId, &quot;Welcome...&quot;);
}
}
public class UserController {
private UserService userService; // 依赖注入
private List&lt;RegObserver&gt; regObservers = new ArrayList&lt;&gt;();
// 一次性设置好,之后也不可能动态的修改
public void setRegObservers(List&lt;RegObserver&gt; observers) {
regObservers.addAll(observers);
}
public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
for (RegObserver observer : regObservers) {
observer.handleRegSuccess(userId);
}
return userId;
}
}
```
当我们需要添加新的观察者的时候比如用户注册成功之后推送用户注册信息给大数据征信系统基于观察者模式的代码实现UserController类的register()函数完全不需要修改只需要再添加一个实现了RegObserver接口的类并且通过setRegObservers()函数将它注册到UserController类中即可。
不过你可能会说当我们把发送体验金替换为发送优惠券的时候需要修改RegPromotionObserver类中handleRegSuccess()函数的代码这还是违反开闭原则呀你说得没错不过相对于register()函数来说handleRegSuccess()函数的逻辑要简单很多修改更不容易出错引入bug的风险更低。
前面我们已经学习了很多设计模式,不知道你有没有发现,实际上,**设计模式要干的事情就是解耦。创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦,具体到观察者模式,它是将观察者和被观察者代码解耦。**借助设计模式,我们利用更好的代码结构,将一大坨代码拆分成职责更单一的小类,让其满足开闭原则、高内聚松耦合等特性,以此来控制和应对代码的复杂性,提高代码的可扩展性。
## 基于不同应用场景的不同实现方式
观察者模式的应用场景非常广泛小到代码层面的解耦大到架构层面的系统解耦再或者一些产品的设计思路都有这种模式的影子比如邮件订阅、RSS Feeds本质上都是观察者模式。
不同的应用场景和需求下,这个模式也有截然不同的实现方式,开篇的时候我们也提到,有同步阻塞的实现方式,也有异步非阻塞的实现方式;有进程内的实现方式,也有跨进程的实现方式。
之前讲到的实现方式从刚刚的分类方式上来看它是一种同步阻塞的实现方式。观察者和被观察者代码在同一个线程内执行被观察者一直阻塞直到所有的观察者代码都执行完成之后才执行后续的代码。对照上面讲到的用户注册的例子register()函数依次调用执行每个观察者的handleRegSuccess()函数,等到都执行完成之后,才会返回结果给客户端。
如果注册接口是一个调用比较频繁的接口对性能非常敏感希望接口的响应时间尽可能短那我们可以将同步阻塞的实现方式改为异步非阻塞的实现方式以此来减少响应时间。具体来讲当userService.register()函数执行完成之后我们启动一个新的线程来执行观察者的handleRegSuccess()函数这样userController.register()函数就不需要等到所有的handleRegSuccess()函数都执行完成之后才返回结果给客户端。userController.register()函数从执行3个SQL语句才返回减少到只需要执行1个SQL语句就返回响应时间粗略来讲减少为原来的1/3。
那如何实现一个异步非阻塞的观察者模式呢简单一点的做法是在每个handleRegSuccess()函数中创建一个新的线程执行代码。不过我们还有更加优雅的实现方式那就是基于EventBus来实现。今天我们就不展开讲解了。在下一讲中我会用一节课的时间借鉴Google Guava EventBus框架的设计思想手把手带你开发一个支持异步非阻塞的EventBus框架。它可以复用在任何需要异步非阻塞观察者模式的应用场景中。
刚刚讲到的两个场景,不管是同步阻塞实现方式还是异步非阻塞实现方式,都是进程内的实现方式。如果用户注册成功之后,我们需要发送用户信息给大数据征信系统,而大数据征信系统是一个独立的系统,跟它之间的交互是跨不同进程的,那如何实现一个跨进程的观察者模式呢?
如果大数据征信系统提供了发送用户注册信息的RPC接口我们仍然可以沿用之前的实现思路在handleRegSuccess()函数中调用RPC接口来发送数据。但是我们还有更加优雅、更加常用的一种实现方式那就是基于消息队列Message Queue比如ActiveMQ来实现。
当然,这种实现方式也有弊端,那就是需要引入一个新的系统(消息队列),增加了维护成本。不过,它的好处也非常明显。在原来的实现方式中,观察者需要注册到被观察者中,被观察者需要依次遍历观察者来发送消息。而基于消息队列的实现方式,被观察者和观察者解耦更加彻底,两部分的耦合更小。被观察者完全不感知观察者,同理,观察者也完全不感知被观察者。被观察者只管发送消息到消息队列,观察者只管从消息队列中读取消息来执行相应的逻辑。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
设计模式要干的事情就是解耦,创建型模式是将创建和使用代码解耦,结构型模式是将不同功能代码解耦,行为型模式是将不同的行为代码解耦,具体到观察者模式,它将观察者和被观察者代码解耦。借助设计模式,我们利用更好的代码结构,将一大坨代码拆分成职责更单一的小类,让其满足开闭原则、高内聚低耦合等特性,以此来控制和应对代码的复杂性,提高代码的可扩展性。
观察者模式的应用场景非常广泛小到代码层面的解耦大到架构层面的系统解耦再或者一些产品的设计思路都有这种模式的影子比如邮件订阅、RSS Feeds本质上都是观察者模式。不同的应用场景和需求下这个模式也有截然不同的实现方式有同步阻塞的实现方式也有异步非阻塞的实现方式有进程内的实现方式也有跨进程的实现方式。
## 课堂讨论
1. 请对比一下“生产者-消费者”模型和观察者模式的区别和联系。
1. 除了今天提到的观察者模式的几个应用场景,比如邮件订阅,你还能想到有哪些其他的应用场景吗?
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,391 @@
<audio id="audio" title="57 | 观察者模式如何实现一个异步非阻塞的EventBus框架" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8c/62/8c44e5bf82dc14468a3a21fe4e080362.mp3"></audio>
上一节课中,我们学习了观察者模式的原理、实现、应用场景,重点介绍了不同应用场景下,几种不同的实现方式,包括:同步阻塞、异步非阻塞、进程内、进程间的实现方式。
同步阻塞是最经典的实现方式,主要是为了代码解耦;异步非阻塞除了能实现代码解耦之外,还能提高代码的执行效率;进程间的观察者模式解耦更加彻底,一般是基于消息队列来实现,用来实现不同进程间的被观察者和观察者之间的交互。
今天我们聚焦于异步非阻塞的观察者模式带你实现一个类似Google Guava EventBus的通用框架。等你学完本节课之后你会发现实现一个框架也并非一件难事。
话不多说,让我们正式开始今天的学习吧!
## 异步非阻塞观察者模式的简易实现
上一节课中,我们讲到,对于异步非阻塞观察者模式,如果只是实现一个简易版本,不考虑任何通用性、复用性,实际上是非常容易的。
我们有两种实现方式。其中一种是在每个handleRegSuccess()函数中创建一个新的线程执行代码逻辑另一种是在UserController的register()函数中使用线程池来执行每个观察者的handleRegSuccess()函数。两种实现方式的具体代码如下所示:
```
// 第一种实现方式,其他类代码不变,就没有再重复罗列
public class RegPromotionObserver implements RegObserver {
private PromotionService promotionService; // 依赖注入
@Override
public void handleRegSuccess(Long userId) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
promotionService.issueNewUserExperienceCash(userId);
}
});
thread.start();
}
}
// 第二种实现方式,其他类代码不变,就没有再重复罗列
public class UserController {
private UserService userService; // 依赖注入
private List&lt;RegObserver&gt; regObservers = new ArrayList&lt;&gt;();
private Executor executor;
public UserController(Executor executor) {
this.executor = executor;
}
public void setRegObservers(List&lt;RegObserver&gt; observers) {
regObservers.addAll(observers);
}
public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
for (RegObserver observer : regObservers) {
executor.execute(new Runnable() {
@Override
public void run() {
observer.handleRegSuccess(userId);
}
});
}
return userId;
}
}
```
对于第一种实现方式频繁地创建和销毁线程比较耗时并且并发线程数无法控制创建过多的线程会导致堆栈溢出。第二种实现方式尽管利用了线程池解决了第一种实现方式的问题但线程池、异步执行逻辑都耦合在了register()函数中,增加了这部分业务代码的维护成本。
如果我们的需求更加极端一点需要在同步阻塞和异步非阻塞之间灵活切换那就要不停地修改UserController的代码。除此之外如果在项目中不止一个业务模块需要用到异步非阻塞观察者模式那这样的代码实现也无法做到复用。
我们知道框架的作用有隐藏实现细节降低开发难度做到代码复用解耦业务与非业务代码让程序员聚焦业务开发。针对异步非阻塞观察者模式我们也可以将它抽象成框架来达到这样的效果而这个框架就是我们这节课要讲的EventBus。
## EventBus框架功能需求介绍
EventBus翻译为“事件总线”它提供了实现观察者模式的骨架代码。我们可以基于此框架非常容易地在自己的业务场景中实现观察者模式不需要从零开始开发。其中Google Guava EventBus就是一个比较著名的EventBus框架它不仅仅支持异步非阻塞模式同时也支持同步阻塞模式
现在我们就通过例子来看一下Guava EventBus具有哪些功能。还是上节课那个用户注册的例子我们用Guava EventBus重新实现一下代码如下所示
```
public class UserController {
private UserService userService; // 依赖注入
private EventBus eventBus;
private static final int DEFAULT_EVENTBUS_THREAD_POOL_SIZE = 20;
public UserController() {
//eventBus = new EventBus(); // 同步阻塞模式
eventBus = new AsyncEventBus(Executors.newFixedThreadPool(DEFAULT_EVENTBUS_THREAD_POOL_SIZE)); // 异步非阻塞模式
}
public void setRegObservers(List&lt;Object&gt; observers) {
for (Object observer : observers) {
eventBus.register(observer);
}
}
public Long register(String telephone, String password) {
//省略输入参数的校验代码
//省略userService.register()异常的try-catch代码
long userId = userService.register(telephone, password);
eventBus.post(userId);
return userId;
}
}
public class RegPromotionObserver {
private PromotionService promotionService; // 依赖注入
@Subscribe
public void handleRegSuccess(Long userId) {
promotionService.issueNewUserExperienceCash(userId);
}
}
public class RegNotificationObserver {
private NotificationService notificationService;
@Subscribe
public void handleRegSuccess(Long userId) {
notificationService.sendInboxMessage(userId, &quot;...&quot;);
}
}
```
利用EventBus框架实现的观察者模式跟从零开始编写的观察者模式相比从大的流程上来说实现思路大致一样都需要定义Observer并且通过register()函数注册Observer也都需要通过调用某个函数比如EventBus中的post()函数来给Observer发送消息在EventBus中消息被称作事件event
但在实现细节方面它们又有些区别。基于EventBus我们不需要定义Observer接口任意类型的对象都可以注册到EventBus中通过@Subscribe注解来标明类中哪个函数可以接收被观察者发送的消息
接下来我们详细地讲一下Guava EventBus的几个主要的类和函数。
- EventBus、AsyncEventBus
Guava EventBus对外暴露的所有可调用接口都封装在EventBus类中。其中EventBus实现了同步阻塞的观察者模式AsyncEventBus继承自EventBus提供了异步非阻塞的观察者模式。具体使用方式如下所示
```
EventBus eventBus = new EventBus(); // 同步阻塞模式
EventBus eventBus = new AsyncEventBus(Executors.newFixedThreadPool(8))// 异步阻塞模式
```
- register()函数
EventBus类提供了register()函数用来注册观察者。具体的函数定义如下所示。它可以接受任何类型Object的观察者。而在经典的观察者模式的实现中register()函数必须接受实现了同一Observer接口的类对象。
```
public void register(Object object);
```
- unregister()函数
相对于register()函数unregister()函数用来从EventBus中删除某个观察者。我就不多解释了具体的函数定义如下所示
```
public void unregister(Object object);
```
- post()函数
EventBus类提供了post()函数,用来给观察者发送消息。具体的函数定义如下所示:
```
public void post(Object event);
```
跟经典的观察者模式的不同之处在于当我们调用post()函数发送消息的时候并非把消息发送给所有的观察者而是发送给可匹配的观察者。所谓可匹配指的是能接收的消息类型是发送消息post函数定义中的event类型的父类。我举个例子来解释一下。
比如AObserver能接收的消息类型是XMsgBObserver能接收的消息类型是YMsgCObserver能接收的消息类型是ZMsg。其中XMsg是YMsg的父类。当我们如下发送消息的时候相应能接收到消息的可匹配观察者如下所示
```
XMsg xMsg = new XMsg();
YMsg yMsg = new YMsg();
ZMsg zMsg = new ZMsg();
post(xMsg); =&gt; AObserver接收到消息
post(yMsg); =&gt; AObserver、BObserver接收到消息
post(zMsg); =&gt; CObserver接收到消息
```
你可能会问每个Observer能接收的消息类型是在哪里定义的呢我们来看下Guava EventBus最特别的一个地方那就是@Subscribe注解
- @Subscribe注解
EventBus通过@Subscribe注解来标明某个函数能接收哪种类型的消息。具体的使用代码如下所示。在DObserver类中我们通过@Subscribe注解了两个函数f1()、f2()。
```
public DObserver {
//...省略其他属性和方法...
@Subscribe
public void f1(PMsg event) { //... }
@Subscribe
public void f2(QMsg event) { //... }
}
```
当通过register()函数将DObserver 类对象注册到EventBus的时候EventBus会根据@Subscribe注解找到f1()和f2()并且将两个函数能接收的消息类型记录下来PMsg-&gt;f1QMsg-&gt;f2。当我们通过post()函数发送消息比如QMsg消息的时候EventBus会通过之前的记录QMsg-&gt;f2调用相应的函数f2
## 手把手实现一个EventBus框架
Guava EventBus的功能我们已经讲清楚了总体上来说还是比较简单的。接下来我们就重复造轮子“山寨”一个EventBus出来。
我们重点来看EventBus中两个核心函数register()和post()的实现原理。弄懂了它们基本上就弄懂了整个EventBus框架。下面两张图是这两个函数的实现原理图。
<img src="https://static001.geekbang.org/resource/image/ce/c6/ce842666fa3dc92bb8f4f2d8e75d12c6.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/bf/45/bf7ef52a40b1e35b18f369265caca645.jpg" alt="">
从图中我们可以看出最关键的一个数据结构是Observer注册表记录了消息类型和可接收消息函数的对应关系。当调用register()函数注册观察者的时候EventBus通过解析@Subscribe注解生成Observer注册表。当调用post()函数发送消息的时候EventBus通过注册表找到相应的可接收消息的函数然后通过Java的反射语法来动态地创建对象、执行函数。对于同步阻塞模式EventBus在一个线程内依次执行相应的函数。对于异步非阻塞模式EventBus通过一个线程池来执行相应的函数。
弄懂了原理实现起来就简单多了。整个小框架的代码实现包括5个类EventBus、AsyncEventBus、Subscribe、ObserverAction、ObserverRegistry。接下来我们依次来看下这5个类。
### 1.Subscribe
Subscribe是一个注解用于标明观察者中的哪个函数可以接收消息。
```
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Beta
public @interface Subscribe {}
```
### 2.ObserverAction
ObserverAction类用来表示@Subscribe注解的方法其中target表示观察者类method表示方法。它主要用在ObserverRegistry观察者注册表中。
```
public class ObserverAction {
private Object target;
private Method method;
public ObserverAction(Object target, Method method) {
this.target = Preconditions.checkNotNull(target);
this.method = method;
this.method.setAccessible(true);
}
public void execute(Object event) { // event是method方法的参数
try {
method.invoke(target, event);
} catch (InvocationTargetException | IllegalAccessException e) {
e.printStackTrace();
}
}
}
```
### 3.ObserverRegistry
ObserverRegistry类就是前面讲到的Observer注册表是最复杂的一个类框架中几乎所有的核心逻辑都在这个类中。这个类大量使用了Java的反射语法不过代码整体来说都不难理解其中一个比较有技巧的地方是CopyOnWriteArraySet的使用。
CopyOnWriteArraySet顾名思义在写入数据的时候会创建一个新的set并且将原始数据clone到新的set中在新的set中写入数据完成之后再用新的set替换老的set。这样就能保证在写入数据的时候不影响数据的读取操作以此来解决读写并发问题。除此之外CopyOnWriteSet还通过加锁的方式避免了并发写冲突。具体的作用你可以去查看一下CopyOnWriteSet类的源码一目了然。
```
public class ObserverRegistry {
private ConcurrentMap&lt;Class&lt;?&gt;, CopyOnWriteArraySet&lt;ObserverAction&gt;&gt; registry = new ConcurrentHashMap&lt;&gt;();
public void register(Object observer) {
Map&lt;Class&lt;?&gt;, Collection&lt;ObserverAction&gt;&gt; observerActions = findAllObserverActions(observer);
for (Map.Entry&lt;Class&lt;?&gt;, Collection&lt;ObserverAction&gt;&gt; entry : observerActions.entrySet()) {
Class&lt;?&gt; eventType = entry.getKey();
Collection&lt;ObserverAction&gt; eventActions = entry.getValue();
CopyOnWriteArraySet&lt;ObserverAction&gt; registeredEventActions = registry.get(eventType);
if (registeredEventActions == null) {
registry.putIfAbsent(eventType, new CopyOnWriteArraySet&lt;&gt;());
registeredEventActions = registry.get(eventType);
}
registeredEventActions.addAll(eventActions);
}
}
public List&lt;ObserverAction&gt; getMatchedObserverActions(Object event) {
List&lt;ObserverAction&gt; matchedObservers = new ArrayList&lt;&gt;();
Class&lt;?&gt; postedEventType = event.getClass();
for (Map.Entry&lt;Class&lt;?&gt;, CopyOnWriteArraySet&lt;ObserverAction&gt;&gt; entry : registry.entrySet()) {
Class&lt;?&gt; eventType = entry.getKey();
Collection&lt;ObserverAction&gt; eventActions = entry.getValue();
if (postedEventType.isAssignableFrom(eventType)) {
matchedObservers.addAll(eventActions);
}
}
return matchedObservers;
}
private Map&lt;Class&lt;?&gt;, Collection&lt;ObserverAction&gt;&gt; findAllObserverActions(Object observer) {
Map&lt;Class&lt;?&gt;, Collection&lt;ObserverAction&gt;&gt; observerActions = new HashMap&lt;&gt;();
Class&lt;?&gt; clazz = observer.getClass();
for (Method method : getAnnotatedMethods(clazz)) {
Class&lt;?&gt;[] parameterTypes = method.getParameterTypes();
Class&lt;?&gt; eventType = parameterTypes[0];
if (!observerActions.containsKey(eventType)) {
observerActions.put(eventType, new ArrayList&lt;&gt;());
}
observerActions.get(eventType).add(new ObserverAction(observer, method));
}
return observerActions;
}
private List&lt;Method&gt; getAnnotatedMethods(Class&lt;?&gt; clazz) {
List&lt;Method&gt; annotatedMethods = new ArrayList&lt;&gt;();
for (Method method : clazz.getDeclaredMethods()) {
if (method.isAnnotationPresent(Subscribe.class)) {
Class&lt;?&gt;[] parameterTypes = method.getParameterTypes();
Preconditions.checkArgument(parameterTypes.length == 1,
&quot;Method %s has @Subscribe annotation but has %s parameters.&quot;
+ &quot;Subscriber methods must have exactly 1 parameter.&quot;,
method, parameterTypes.length);
annotatedMethods.add(method);
}
}
return annotatedMethods;
}
}
```
### 4.EventBus
EventBus实现的是阻塞同步的观察者模式。看代码你可能会有些疑问这明明就用到了线程池Executor啊。实际上MoreExecutors.directExecutor()是Google Guava提供的工具类看似是多线程实际上是单线程。之所以要这么实现主要还是为了跟AsyncEventBus统一代码逻辑做到代码复用。
```
public class EventBus {
private Executor executor;
private ObserverRegistry registry = new ObserverRegistry();
public EventBus() {
this(MoreExecutors.directExecutor());
}
protected EventBus(Executor executor) {
this.executor = executor;
}
public void register(Object object) {
registry.register(object);
}
public void post(Object event) {
List&lt;ObserverAction&gt; observerActions = registry.getMatchedObserverActions(event);
for (ObserverAction observerAction : observerActions) {
executor.execute(new Runnable() {
@Override
public void run() {
observerAction.execute(event);
}
});
}
}
}
```
### 5.AsyncEventBus
有了EventBusAsyncEventBus的实现就非常简单了。为了实现异步非阻塞的观察者模式它就不能再继续使用MoreExecutors.directExecutor()了,而是需要在构造函数中,由调用者注入线程池。
```
public class AsyncEventBus extends EventBus {
public AsyncEventBus(Executor executor) {
super(executor);
}
}
```
至此我们用了不到200行代码就实现了一个还算凑活能用的EventBus从功能上来讲它跟Google Guava EventBus几乎一样。不过如果去查看[Google Guava EventBus的源码](https://github.com/google/guava),你会发现,在实现细节方面,相比我们现在的实现,它其实做了很多优化,比如优化了在注册表中查找消息可匹配函数的算法。如果有时间的话,建议你去读一下它的源码。
## 重点回顾
好了,今天的内容到此就讲完了。我们来一块总结回顾一下,你需要重点掌握的内容。
框架的作用有隐藏实现细节降低开发难度做到代码复用解耦业务与非业务代码让程序员聚焦业务开发。针对异步非阻塞观察者模式我们也可以将它抽象成框架来达到这样的效果而这个框架就是我们这节课讲的EventBus。EventBus翻译为“事件总线”它提供了实现观察者模式的骨架代码。我们可以基于此框架非常容易地在自己的业务场景中实现观察者模式不需要从零开始开发。
很多人觉得做业务开发没有技术挑战实际上做业务开发也会涉及很多非业务功能的开发比如今天讲到的EventBus。在平时的业务开发中我们要善于抽象这些非业务的、可复用的功能并积极地把它们实现成通用的框架。
## 课堂讨论
在今天内容的第二个模块“EventBus框架功能需求介绍”中我们用Guava EventBus重新实现了UserController实际上代码还是不够解耦。UserController还是耦合了很多跟观察者模式相关的非业务代码比如创建线程池、注册Observer。为了让UserController更加聚焦在业务功能上你有什么重构的建议吗
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,336 @@
<audio id="audio" title="58 | 模板模式剖析模板模式在JDK、Servlet、JUnit等中的应用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/74/b6/74b4161b42a466d1ee2eefd6327066b6.mp3"></audio>
上两节课我们学习了第一个行为型设计模式观察者模式。针对不同的应用场景我们讲解了不同的实现方式有同步阻塞、异步非阻塞的实现方式也有进程内、进程间的实现方式。除此之外我还带你手把手实现了一个简单的EventBus框架。
今天我们再学习另外一种行为型设计模式模板模式。我们多次强调绝大部分设计模式的原理和实现都非常简单难的是掌握应用场景搞清楚能解决什么问题。模板模式也不例外。模板模式主要是用来解决复用和扩展两个问题。我们今天会结合Java Servlet、JUnit TestCase、Java InputStream、Java AbstractList四个例子来具体讲解这两个作用。
话不多说,让我们正式开始今天的学习吧!
## 模板模式的原理与实现
模板模式全称是模板方法设计模式英文是Template Method Design Pattern。在GoF的《设计模式》一书中它是这么定义的
>
Define the skeleton of an algorithm in an operation, deferring some steps to subclasses. Template Method lets subclasses redefine certain steps of an algorithm without changing the algorithms structure.
翻译成中文就是:模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。
这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。
原理很简单代码实现就更加简单我写了一个示例代码如下所示。templateMethod()函数定义为final是为了避免子类重写它。method1()和method2()定义为abstract是为了强迫子类去实现。不过这些都不是必须的在实际的项目开发中模板模式的代码实现比较灵活待会儿讲到应用场景的时候我们会有具体的体现。
```
public abstract class AbstractClass {
public final void templateMethod() {
//...
method1();
//...
method2();
//...
}
protected abstract void method1();
protected abstract void method2();
}
public class ConcreteClass1 extends AbstractClass {
@Override
protected void method1() {
//...
}
@Override
protected void method2() {
//...
}
}
public class ConcreteClass2 extends AbstractClass {
@Override
protected void method1() {
//...
}
@Override
protected void method2() {
//...
}
}
AbstractClass demo = ConcreteClass1();
demo.templateMethod();
```
## 模板模式作用一:复用
开篇的时候,我们讲到模板模式有两大作用:复用和扩展。我们先来看它的第一个作用:复用。
模板模式把一个算法中不变的流程抽象到父类的模板方法templateMethod()中将可变的部分method1()、method2()留给子类ContreteClass1和ContreteClass2来实现。所有的子类都可以复用父类中模板方法定义的流程代码。我们通过两个小例子来更直观地体会一下。
### 1.Java InputStream
Java IO类库中有很多类的设计用到了模板模式比如InputStream、OutputStream、Reader、Writer。我们拿InputStream来举例说明一下。
我把InputStream部分相关代码贴在了下面。在代码中read()函数是一个模板方法定义了读取数据的整个流程并且暴露了一个可以由子类来定制的抽象方法。不过这个方法也被命名为了read(),只是参数跟模板方法不同。
```
public abstract class InputStream implements Closeable {
//...省略其他代码...
public int read(byte b[], int off, int len) throws IOException {
if (b == null) {
throw new NullPointerException();
} else if (off &lt; 0 || len &lt; 0 || len &gt; b.length - off) {
throw new IndexOutOfBoundsException();
} else if (len == 0) {
return 0;
}
int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;
int i = 1;
try {
for (; i &lt; len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}
public abstract int read() throws IOException;
}
public class ByteArrayInputStream extends InputStream {
//...省略其他代码...
@Override
public synchronized int read() {
return (pos &lt; count) ? (buf[pos++] &amp; 0xff) : -1;
}
}
```
### 2.Java AbstractList
在Java AbstractList类中addAll()函数可以看作模板方法add()是子类需要重写的方法尽管没有声明为abstract的但函数实现直接抛出了UnsupportedOperationException异常。前提是如果子类不重写是不能使用的。
```
public boolean addAll(int index, Collection&lt;? extends E&gt; c) {
rangeCheckForAdd(index);
boolean modified = false;
for (E e : c) {
add(index++, e);
modified = true;
}
return modified;
}
public void add(int index, E element) {
throw new UnsupportedOperationException();
}
```
## 模板模式作用二:扩展
模板模式的第二大作用的是扩展。这里所说的扩展,并不是指代码的扩展性,而是指框架的扩展性,有点类似我们之前讲到的控制反转,你可以结合[第19节](https://time.geekbang.org/column/article/177444)来一块理解。基于这个作用模板模式常用在框架的开发中让框架用户可以在不修改框架源码的情况下定制化框架的功能。我们通过Junit TestCase、Java Servlet两个例子来解释一下。
### 1.Java Servlet
对于Java Web项目开发来说常用的开发框架是SpringMVC。利用它我们只需要关注业务代码的编写底层的原理几乎不会涉及。但是如果我们抛开这些高级框架来开发Web项目必然会用到Servlet。实际上使用比较底层的Servlet来开发Web项目也不难。我们只需要定义一个继承HttpServlet的类并且重写其中的doGet()或doPost()方法来分别处理get和post请求。具体的代码示例如下所示
```
public class HelloServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
this.doPost(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.getWriter().write(&quot;Hello World.&quot;);
}
}
```
除此之外我们还需要在配置文件web.xml中做如下配置。Tomcat、Jetty等Servlet容器在启动的时候会自动加载这个配置文件中的URL和Servlet之间的映射关系。
```
&lt;servlet&gt;
&lt;servlet-name&gt;HelloServlet&lt;/servlet-name&gt;
&lt;servlet-class&gt;com.xzg.cd.HelloServlet&lt;/servlet-class&gt;
&lt;/servlet&gt;
&lt;servlet-mapping&gt;
&lt;servlet-name&gt;HelloServlet&lt;/servlet-name&gt;
&lt;url-pattern&gt;/hello&lt;/url-pattern&gt;
&lt;/servlet-mapping&gt;
```
当我们在浏览器中输入网址(比如,[http://127.0.0.1:8080/hello](http://127.0.0.1:8080/hello) 的时候Servlet容器会接收到相应的请求并且根据URL和Servlet之间的映射关系找到相应的ServletHelloServlet然后执行它的service()方法。service()方法定义在父类HttpServlet中它会调用doGet()或doPost()方法然后输出数据“Hello world”到网页。
我们现在来看HttpServlet的service()函数长什么样子。
```
public void service(ServletRequest req, ServletResponse res)
throws ServletException, IOException
{
HttpServletRequest request;
HttpServletResponse response;
if (!(req instanceof HttpServletRequest &amp;&amp;
res instanceof HttpServletResponse)) {
throw new ServletException(&quot;non-HTTP request or response&quot;);
}
request = (HttpServletRequest) req;
response = (HttpServletResponse) res;
service(request, response);
}
protected void service(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException
{
String method = req.getMethod();
if (method.equals(METHOD_GET)) {
long lastModified = getLastModified(req);
if (lastModified == -1) {
// servlet doesn't support if-modified-since, no reason
// to go through further expensive logic
doGet(req, resp);
} else {
long ifModifiedSince = req.getDateHeader(HEADER_IFMODSINCE);
if (ifModifiedSince &lt; lastModified) {
// If the servlet mod time is later, call doGet()
// Round down to the nearest second for a proper compare
// A ifModifiedSince of -1 will always be less
maybeSetLastModified(resp, lastModified);
doGet(req, resp);
} else {
resp.setStatus(HttpServletResponse.SC_NOT_MODIFIED);
}
}
} else if (method.equals(METHOD_HEAD)) {
long lastModified = getLastModified(req);
maybeSetLastModified(resp, lastModified);
doHead(req, resp);
} else if (method.equals(METHOD_POST)) {
doPost(req, resp);
} else if (method.equals(METHOD_PUT)) {
doPut(req, resp);
} else if (method.equals(METHOD_DELETE)) {
doDelete(req, resp);
} else if (method.equals(METHOD_OPTIONS)) {
doOptions(req,resp);
} else if (method.equals(METHOD_TRACE)) {
doTrace(req,resp);
} else {
String errMsg = lStrings.getString(&quot;http.method_not_implemented&quot;);
Object[] errArgs = new Object[1];
errArgs[0] = method;
errMsg = MessageFormat.format(errMsg, errArgs);
resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED, errMsg);
}
}
```
从上面的代码中我们可以看出HttpServlet的service()方法就是一个模板方法它实现了整个HTTP请求的执行流程doGet()、doPost()是模板中可以由子类来定制的部分。实际上这就相当于Servlet框架提供了一个扩展点doGet()、doPost()方法让框架用户在不用修改Servlet框架源码的情况下将业务代码通过扩展点镶嵌到框架中执行。
### 2.JUnit TestCase
跟Java Servlet类似JUnit框架也通过模板模式提供了一些功能扩展点setUp()、tearDown()等),让框架用户可以在这些扩展点上扩展功能。
在使用JUnit测试框架来编写单元测试的时候我们编写的测试类都要继承框架提供的TestCase类。在TestCase类中runBare()函数是模板方法它定义了执行测试用例的整体流程先执行setUp()做些准备工作然后执行runTest()运行真正的测试代码最后执行tearDown()做扫尾工作。
TestCase类的具体代码如下所示。尽管setUp()、tearDown()并不是抽象函数,还提供了默认的实现,不强制子类去重新实现,但这部分也是可以在子类中定制的,所以也符合模板模式的定义。
```
public abstract class TestCase extends Assert implements Test {
public void runBare() throws Throwable {
Throwable exception = null;
setUp();
try {
runTest();
} catch (Throwable running) {
exception = running;
} finally {
try {
tearDown();
} catch (Throwable tearingDown) {
if (exception == null) exception = tearingDown;
}
}
if (exception != null) throw exception;
}
/**
* Sets up the fixture, for example, open a network connection.
* This method is called before a test is executed.
*/
protected void setUp() throws Exception {
}
/**
* Tears down the fixture, for example, close a network connection.
* This method is called after a test is executed.
*/
protected void tearDown() throws Exception {
}
}
```
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
模板方法模式在一个方法中定义一个算法骨架,并将某些步骤推迟到子类中实现。模板方法模式可以让子类在不改变算法整体结构的情况下,重新定义算法中的某些步骤。这里的“算法”,我们可以理解为广义上的“业务逻辑”,并不特指数据结构和算法中的“算法”。这里的算法骨架就是“模板”,包含算法骨架的方法就是“模板方法”,这也是模板方法模式名字的由来。
在模板模式经典的实现中模板方法定义为final可以避免被子类重写。需要子类重写的方法定义为abstract可以强迫子类去实现。不过在实际项目开发中模板模式的实现比较灵活以上两点都不是必须的。
模板模式有两大作用:复用和扩展。其中,复用指的是,所有的子类可以复用父类中提供的模板方法的代码。扩展指的是,框架通过模板模式提供功能扩展点,让框架用户可以在不修改框架源码的情况下,基于扩展点定制化框架的功能。
## 课堂讨论
假设一个框架中的某个类暴露了两个模板方法,并且定义了一堆供模板方法调用的抽象方法,代码示例如下所示。在项目开发中,即便我们只用到这个类的其中一个模板方法,我们还是要在子类中把所有的抽象方法都实现一遍,这相当于无效劳动,有没有其他方式来解决这个问题呢?
```
public abstract class AbstractClass {
public final void templateMethod1() {
//...
method1();
//...
method2();
//...
}
public final void templateMethod2() {
//...
method3();
//...
method4();
//...
}
protected abstract void method1();
protected abstract void method2();
protected abstract void method3();
protected abstract void method4();
}
```
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,349 @@
<audio id="audio" title="59 | 模板模式模板模式与Callback回调函数有何区别和联系" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2c/55/2c92ee0b3425f37b484469c338f1ae55.mp3"></audio>
上一节课中,我们学习了模板模式的原理、实现和应用。它常用在框架开发中,通过提供功能扩展点,让框架用户在不修改框架源码的情况下,基于扩展点定制化框架的功能。除此之外,模板模式还可以起到代码复用的作用。
复用和扩展是模板模式的两大作用,实际上,还有另外一个技术概念,也能起到跟模板模式相同的作用,那就是**回调**Callback。今天我们今天就来看一下回调的原理、实现和应用以及它跟模板模式的区别和联系。
话不多说,让我们正式开始今天的学习吧!
## 回调的原理解析
相对于普通的函数调用来说回调是一种双向调用关系。A类事先注册某个函数F到B类A类在调用B类的P函数的时候B类反过来调用A类注册给它的F函数。这里的F函数就是“回调函数”。A调用BB反过来又调用A这种调用机制就叫作“回调”。
A类如何将回调函数传递给B类呢不同的编程语言有不同的实现方法。C语言可以使用函数指针Java则需要使用包裹了回调函数的类对象我们简称为回调对象。这里我用Java语言举例说明一下。代码如下所示
```
public interface ICallback {
void methodToCallback();
}
public class BClass {
public void process(ICallback callback) {
//...
callback.methodToCallback();
//...
}
}
public class AClass {
public static void main(String[] args) {
BClass b = new BClass();
b.process(new ICallback() { //回调对象
@Override
public void methodToCallback() {
System.out.println(&quot;Call back me.&quot;);
}
});
}
}
```
上面就是Java语言中回调的典型代码实现。从代码实现中我们可以看出回调跟模板模式一样也具有复用和扩展的功能。除了回调函数之外BClass类的process()函数中的逻辑都可以复用。如果ICallback、BClass类是框架代码AClass是使用框架的客户端代码我们可以通过ICallback定制process()函数,也就是说,框架因此具有了扩展的能力。
实际上回调不仅可以应用在代码设计上在更高层次的架构设计上也比较常用。比如通过三方支付系统来实现支付功能用户在发起支付请求之后一般不会一直阻塞到支付结果返回而是注册回调接口类似回调函数一般是一个回调用的URL给三方支付系统等三方支付系统执行完成之后将结果通过回调接口返回给用户。
回调可以分为同步回调和异步回调或者延迟回调。同步回调指在函数返回之前执行回调函数异步回调指的是在函数返回之后执行回调函数。上面的代码实际上是同步回调的实现方式在process()函数返回之前执行完回调函数methodToCallback()。而上面支付的例子是异步回调的实现方式,发起支付之后不需要等待回调接口被调用就直接返回。从应用场景上来看,同步回调看起来更像模板模式,异步回调看起来更像观察者模式。
## 应用举例一JdbcTemplate
Spring提供了很多Template类比如JdbcTemplate、RedisTemplate、RestTemplate。尽管都叫作xxxTemplate但它们并非基于模板模式来实现的而是基于回调来实现的确切地说应该是同步回调。而同步回调从应用场景上很像模板模式所以在命名上这些类使用Template模板这个单词作为后缀。
这些Template类的设计思路都很相近所以我们只拿其中的JdbcTemplate来举例分析一下。对于其他Template类你可以阅读源码自行分析。
在前面的章节中我们也多次提到Java提供了JDBC类库来封装不同类型的数据库操作。不过直接使用JDBC来编写操作数据库的代码还是有点复杂的。比如下面这段是使用JDBC来查询用户信息的代码。
```
public class JdbcDemo {
public User queryUser(long id) {
Connection conn = null;
Statement stmt = null;
try {
//1.加载驱动
Class.forName(&quot;com.mysql.jdbc.Driver&quot;);
conn = DriverManager.getConnection(&quot;jdbc:mysql://localhost:3306/demo&quot;, &quot;xzg&quot;, &quot;xzg&quot;);
//2.创建statement类对象用来执行SQL语句
stmt = conn.createStatement();
//3.ResultSet类用来存放获取的结果集
String sql = &quot;select * from user where id=&quot; + id;
ResultSet resultSet = stmt.executeQuery(sql);
String eid = null, ename = null, price = null;
while (resultSet.next()) {
User user = new User();
user.setId(resultSet.getLong(&quot;id&quot;));
user.setName(resultSet.getString(&quot;name&quot;));
user.setTelephone(resultSet.getString(&quot;telephone&quot;));
return user;
}
} catch (ClassNotFoundException e) {
// TODO: log...
} catch (SQLException e) {
// TODO: log...
} finally {
if (conn != null)
try {
conn.close();
} catch (SQLException e) {
// TODO: log...
}
if (stmt != null)
try {
stmt.close();
} catch (SQLException e) {
// TODO: log...
}
}
return null;
}
}
```
queryUser()函数包含很多流程性质的代码跟业务无关比如加载驱动、创建数据库连接、创建statement、关闭连接、关闭statement、处理异常。针对不同的SQL执行请求这些流程性质的代码是相同的、可以复用的我们不需要每次都重新敲一遍。
针对这个问题Spring提供了JdbcTemplate对JDBC进一步封装来简化数据库编程。使用JdbcTemplate查询用户信息我们只需要编写跟这个业务有关的代码其中包括查询用户的SQL语句、查询结果与User对象之间的映射关系。其他流程性质的代码都封装在了JdbcTemplate类中不需要我们每次都重新编写。我用JdbcTemplate重写了上面的例子代码简单了很多如下所示
```
public class JdbcTemplateDemo {
private JdbcTemplate jdbcTemplate;
public User queryUser(long id) {
String sql = &quot;select * from user where id=&quot;+id;
return jdbcTemplate.query(sql, new UserRowMapper()).get(0);
}
class UserRowMapper implements RowMapper&lt;User&gt; {
public User mapRow(ResultSet rs, int rowNum) throws SQLException {
User user = new User();
user.setId(rs.getLong(&quot;id&quot;));
user.setName(rs.getString(&quot;name&quot;));
user.setTelephone(rs.getString(&quot;telephone&quot;));
return user;
}
}
}
```
那JdbcTemplate底层具体是如何实现的呢我们来看一下它的源码。因为JdbcTemplate代码比较多我只摘抄了部分相关代码贴到了下面。其中JdbcTemplate通过回调的机制将不变的执行流程抽离出来放到模板方法execute()中将可变的部分设计成回调StatementCallback由用户来定制。query()函数是对execute()函数的二次封装,让接口用起来更加方便。
```
@Override
public &lt;T&gt; List&lt;T&gt; query(String sql, RowMapper&lt;T&gt; rowMapper) throws DataAccessException {
return query(sql, new RowMapperResultSetExtractor&lt;T&gt;(rowMapper));
}
@Override
public &lt;T&gt; T query(final String sql, final ResultSetExtractor&lt;T&gt; rse) throws DataAccessException {
Assert.notNull(sql, &quot;SQL must not be null&quot;);
Assert.notNull(rse, &quot;ResultSetExtractor must not be null&quot;);
if (logger.isDebugEnabled()) {
logger.debug(&quot;Executing SQL query [&quot; + sql + &quot;]&quot;);
}
class QueryStatementCallback implements StatementCallback&lt;T&gt;, SqlProvider {
@Override
public T doInStatement(Statement stmt) throws SQLException {
ResultSet rs = null;
try {
rs = stmt.executeQuery(sql);
ResultSet rsToUse = rs;
if (nativeJdbcExtractor != null) {
rsToUse = nativeJdbcExtractor.getNativeResultSet(rs);
}
return rse.extractData(rsToUse);
}
finally {
JdbcUtils.closeResultSet(rs);
}
}
@Override
public String getSql() {
return sql;
}
}
return execute(new QueryStatementCallback());
}
@Override
public &lt;T&gt; T execute(StatementCallback&lt;T&gt; action) throws DataAccessException {
Assert.notNull(action, &quot;Callback object must not be null&quot;);
Connection con = DataSourceUtils.getConnection(getDataSource());
Statement stmt = null;
try {
Connection conToUse = con;
if (this.nativeJdbcExtractor != null &amp;&amp;
this.nativeJdbcExtractor.isNativeConnectionNecessaryForNativeStatements()) {
conToUse = this.nativeJdbcExtractor.getNativeConnection(con);
}
stmt = conToUse.createStatement();
applyStatementSettings(stmt);
Statement stmtToUse = stmt;
if (this.nativeJdbcExtractor != null) {
stmtToUse = this.nativeJdbcExtractor.getNativeStatement(stmt);
}
T result = action.doInStatement(stmtToUse);
handleWarnings(stmt);
return result;
}
catch (SQLException ex) {
// Release Connection early, to avoid potential connection pool deadlock
// in the case when the exception translator hasn't been initialized yet.
JdbcUtils.closeStatement(stmt);
stmt = null;
DataSourceUtils.releaseConnection(con, getDataSource());
con = null;
throw getExceptionTranslator().translate(&quot;StatementCallback&quot;, getSql(action), ex);
}
finally {
JdbcUtils.closeStatement(stmt);
DataSourceUtils.releaseConnection(con, getDataSource());
}
}
```
## 应用举例二setClickListener(
在客户端开发中我们经常给控件注册事件监听器比如下面这段代码就是在Android应用开发中给Button控件的点击事件注册监听器。
```
Button button = (Button)findViewById(R.id.button);
button.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
System.out.println(&quot;I am clicked.&quot;);
}
});
```
从代码结构上来看事件监听器很像回调即传递一个包含回调函数onClick()的对象给另一个函数。从应用场景上来看它又很像观察者模式即事先注册观察者OnClickListener当用户点击按钮的时候发送点击事件给观察者并且执行相应的onClick()函数。
我们前面讲到回调分为同步回调和异步回调。这里的回调算是异步回调我们往setOnClickListener()函数中注册好回调函数之后,并不需要等待回调函数执行。这也印证了我们前面讲的,异步回调比较像观察者模式。
## 应用举例三addShutdownHook()
Hook可以翻译成“钩子”那它跟Callback有什么区别呢
网上有人认为Hook就是Callback两者说的是一回事儿只是表达不同而已。而有人觉得Hook是Callback的一种应用。Callback更侧重语法机制的描述Hook更加侧重应用场景的描述。我个人比较认可后面一种说法。不过这个也不重要我们只需要见了代码能认识遇到场景会用就可以了。
Hook比较经典的应用场景是Tomcat和JVM的shutdown hook。接下来我们拿JVM来举例说明一下。JVM提供了Runtime.addShutdownHook(Thread hook)方法可以注册一个JVM关闭的Hook。当应用程序关闭的时候JVM会自动调用Hook代码。代码示例如下所示
```
public class ShutdownHookDemo {
private static class ShutdownHook extends Thread {
public void run() {
System.out.println(&quot;I am called during shutting down.&quot;);
}
}
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new ShutdownHook());
}
}
```
我们再来看addShutdownHook()的代码实现,如下所示。这里我只给出了部分相关代码。
```
public class Runtime {
public void addShutdownHook(Thread hook) {
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
sm.checkPermission(new RuntimePermission(&quot;shutdownHooks&quot;));
}
ApplicationShutdownHooks.add(hook);
}
}
class ApplicationShutdownHooks {
/* The set of registered hooks */
private static IdentityHashMap&lt;Thread, Thread&gt; hooks;
static {
hooks = new IdentityHashMap&lt;&gt;();
} catch (IllegalStateException e) {
hooks = null;
}
}
static synchronized void add(Thread hook) {
if(hooks == null)
throw new IllegalStateException(&quot;Shutdown in progress&quot;);
if (hook.isAlive())
throw new IllegalArgumentException(&quot;Hook already running&quot;);
if (hooks.containsKey(hook))
throw new IllegalArgumentException(&quot;Hook previously registered&quot;);
hooks.put(hook, hook);
}
static void runHooks() {
Collection&lt;Thread&gt; threads;
synchronized(ApplicationShutdownHooks.class) {
threads = hooks.keySet();
hooks = null;
}
for (Thread hook : threads) {
hook.start();
}
for (Thread hook : threads) {
while (true) {
try {
hook.join();
break;
} catch (InterruptedException ignored) {
}
}
}
}
}
```
从代码中我们可以发现有关Hook的逻辑都被封装到ApplicationShutdownHooks类中了。当应用程序关闭的时候JVM会调用这个类的runHooks()方法创建多个线程并发地执行多个Hook。我们在注册完Hook之后并不需要等待Hook执行完成所以这也算是一种异步回调。
## 模板模式 VS 回调
回调的原理、实现和应用到此就都讲完了。接下来,我们从应用场景和代码实现两个角度,来对比一下模板模式和回调。
从应用场景上来看,同步回调跟模板模式几乎一致。它们都是在一个大的算法骨架中,自由替换其中的某个步骤,起到代码复用和扩展的目的。而异步回调跟模板模式有较大差别,更像是观察者模式。
从代码实现上来看,回调和模板模式完全不同。回调基于组合关系来实现,把一个对象传递给另一个对象,是一种对象之间的关系;模板模式基于继承关系来实现,子类重写父类的抽象方法,是一种类之间的关系。
前面我们也讲到,组合优于继承。实际上,这里也不例外。在代码实现上,回调相对于模板模式会更加灵活,主要体现在下面几点。
- 像Java这种只支持单继承的语言基于模板模式编写的子类已经继承了一个父类不再具有继承的能力。
- 回调可以使用匿名类来创建回调对象,可以不用事先定义类;而模板模式针对不同的实现都要定义不同的子类。
- 如果某个类中定义了多个模板方法,每个方法都有对应的抽象方法,那即便我们只用到其中的一个模板方法,子类也必须实现所有的抽象方法。而回调就更加灵活,我们只需要往用到的模板方法中注入回调对象即可。
还记得上一节课的课堂讨论题目吗?看到这里,相信你应该有了答案了吧?
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
今天,我们重点介绍了回调。它跟模板模式具有相同的作用:代码复用和扩展。在一些框架、类库、组件等的设计中经常会用到。
相对于普通的函数调用回调是一种双向调用关系。A类事先注册某个函数F到B类A类在调用B类的P函数的时候B类反过来调用A类注册给它的F函数。这里的F函数就是“回调函数”。A调用BB反过来又调用A这种调用机制就叫作“回调”。
回调可以细分为同步回调和异步回调。从应用场景上来看,同步回调看起来更像模板模式,异步回调看起来更像观察者模式。回调跟模板模式的区别,更多的是在代码实现上,而非应用场景上。回调基于组合关系来实现,模板模式基于继承关系来实现,回调比模板模式更加灵活。
## 课堂讨论
对于Callback和Hook的区别你有什么不同的理解吗在你熟悉的编程语言中有没有提供相应的语法概念是叫Callback还是Hook呢
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,247 @@
<audio id="audio" title="60 | 策略模式如何避免冗长的if-else/switch分支判断代码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/bc/ea222c6642f138d498bb397f9a0cc1bc.mp3"></audio>
上两节课中,我们学习了模板模式。模板模式主要起到代码复用和扩展的作用。除此之外,我们还讲到了回调,它跟模板模式的作用类似,但使用起来更加灵活。它们之间的主要区别在于代码实现,模板模式基于继承来实现,回调基于组合来实现。
今天我们开始学习另外一种行为型模式策略模式。在实际的项目开发中这个模式也比较常用。最常见的应用场景是利用它来避免冗长的if-else或switch分支判断。不过它的作用还不止如此。它也可以像模板模式那样提供框架的扩展点等等。
对于策略模式,我们分两节课来讲解。今天,我们讲解策略模式的原理和实现,以及如何用它来避免分支判断逻辑。下一节课,我会通过一个具体的例子,来详细讲解策略模式的应用场景以及真正的设计意图。
话不多说,让我们正式开始今天的学习吧!
## 策略模式的原理与实现
策略模式英文全称是Strategy Design Pattern。在GoF的《设计模式》一书中它是这样定义的
>
Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.
翻译成中文就是:定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。
我们知道,工厂模式是解耦对象的创建和使用,观察者模式是解耦观察者和被观察者。策略模式跟两者类似,也能起到解耦的作用,不过,它解耦的是策略的定义、创建、使用这三部分。接下来,我就详细讲讲一个完整的策略模式应该包含的这三个部分。
### 1.策略的定义
策略类的定义比较简单,包含一个策略接口和一组实现这个接口的策略类。因为所有的策略类都实现相同的接口,所以,客户端代码基于接口而非实现编程,可以灵活地替换不同的策略。示例代码如下所示:
```
public interface Strategy {
void algorithmInterface();
}
public class ConcreteStrategyA implements Strategy {
@Override
public void algorithmInterface() {
//具体的算法...
}
}
public class ConcreteStrategyB implements Strategy {
@Override
public void algorithmInterface() {
//具体的算法...
}
}
```
### 2.策略的创建
因为策略模式会包含一组策略在使用它们的时候一般会通过类型type来判断创建哪个策略来使用。为了封装创建逻辑我们需要对客户端代码屏蔽创建细节。我们可以把根据type创建策略的逻辑抽离出来放到工厂类中。示例代码如下所示
```
public class StrategyFactory {
private static final Map&lt;String, Strategy&gt; strategies = new HashMap&lt;&gt;();
static {
strategies.put(&quot;A&quot;, new ConcreteStrategyA());
strategies.put(&quot;B&quot;, new ConcreteStrategyB());
}
public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException(&quot;type should not be empty.&quot;);
}
return strategies.get(type);
}
}
```
一般来讲如果策略类是无状态的不包含成员变量只是纯粹的算法实现这样的策略对象是可以被共享使用的不需要在每次调用getStrategy()的时候,都创建一个新的策略对象。针对这种情况,我们可以使用上面这种工厂类的实现方式,事先创建好每个策略对象,缓存到工厂类中,用的时候直接返回。
相反,如果策略类是有状态的,根据业务场景的需要,我们希望每次从工厂方法中,获得的都是新创建的策略对象,而不是缓存好可共享的策略对象,那我们就需要按照如下方式来实现策略工厂类。
```
public class StrategyFactory {
public static Strategy getStrategy(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException(&quot;type should not be empty.&quot;);
}
if (type.equals(&quot;A&quot;)) {
return new ConcreteStrategyA();
} else if (type.equals(&quot;B&quot;)) {
return new ConcreteStrategyB();
}
return null;
}
}
```
### 3.策略的使用
刚刚讲了策略的定义和创建,现在,我们再来看一下,策略的使用。
我们知道,策略模式包含一组可选策略,客户端代码一般如何确定使用哪个策略呢?最常见的是运行时动态确定使用哪种策略,这也是策略模式最典型的应用场景。
这里的“运行时动态”指的是,我们事先并不知道会使用哪个策略,而是在程序运行期间,根据配置、用户输入、计算结果等这些不确定因素,动态决定使用哪种策略。接下来,我们通过一个例子来解释一下。
```
// 策略接口EvictionStrategy
// 策略类LruEvictionStrategy、FifoEvictionStrategy、LfuEvictionStrategy...
// 策略工厂EvictionStrategyFactory
public class UserCache {
private Map&lt;String, User&gt; cacheData = new HashMap&lt;&gt;();
private EvictionStrategy eviction;
public UserCache(EvictionStrategy eviction) {
this.eviction = eviction;
}
//...
}
// 运行时动态确定,根据配置文件的配置决定使用哪种策略
public class Application {
public static void main(String[] args) throws Exception {
EvictionStrategy evictionStrategy = null;
Properties props = new Properties();
props.load(new FileInputStream(&quot;./config.properties&quot;));
String type = props.getProperty(&quot;eviction_type&quot;);
evictionStrategy = EvictionStrategyFactory.getEvictionStrategy(type);
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}
// 非运行时动态确定,在代码中指定使用哪种策略
public class Application {
public static void main(String[] args) {
//...
EvictionStrategy evictionStrategy = new LruEvictionStrategy();
UserCache userCache = new UserCache(evictionStrategy);
//...
}
}
```
从上面的代码中我们也可以看出“非运行时动态确定”也就是第二个Application中的使用方式并不能发挥策略模式的优势。在这种应用场景下策略模式实际上退化成了“面向对象的多态特性”或“基于接口而非实现编程原则”。
## 如何利用策略模式避免分支判断?
实际上,能够移除分支判断逻辑的模式不仅仅有策略模式,后面我们要讲的状态模式也可以。对于使用哪种模式,具体还要看应用场景来定。 策略模式适用于根据不同类型的动态,决定使用哪种策略这样一种应用场景。
我们先通过一个例子来看下if-else或switch-case分支判断逻辑是如何产生的。具体的代码如下所示。在这个例子中我们没有使用策略模式而是将策略的定义、创建、使用直接耦合在一起。
```
public class OrderService {
public double discount(Order order) {
double discount = 0.0;
OrderType type = order.getType();
if (type.equals(OrderType.NORMAL)) { // 普通订单
//...省略折扣计算算法代码
} else if (type.equals(OrderType.GROUPON)) { // 团购订单
//...省略折扣计算算法代码
} else if (type.equals(OrderType.PROMOTION)) { // 促销订单
//...省略折扣计算算法代码
}
return discount;
}
}
```
如何来移除掉分支判断逻辑呢?那策略模式就派上用场了。我们使用策略模式对上面的代码重构,将不同类型订单的打折策略设计成策略类,并由工厂类来负责创建策略对象。具体的代码如下所示:
```
// 策略的定义
public interface DiscountStrategy {
double calDiscount(Order order);
}
// 省略NormalDiscountStrategy、GrouponDiscountStrategy、PromotionDiscountStrategy类代码...
// 策略的创建
public class DiscountStrategyFactory {
private static final Map&lt;OrderType, DiscountStrategy&gt; strategies = new HashMap&lt;&gt;();
static {
strategies.put(OrderType.NORMAL, new NormalDiscountStrategy());
strategies.put(OrderType.GROUPON, new GrouponDiscountStrategy());
strategies.put(OrderType.PROMOTION, new PromotionDiscountStrategy());
}
public static DiscountStrategy getDiscountStrategy(OrderType type) {
return strategies.get(type);
}
}
// 策略的使用
public class OrderService {
public double discount(Order order) {
OrderType type = order.getType();
DiscountStrategy discountStrategy = DiscountStrategyFactory.getDiscountStrategy(type);
return discountStrategy.calDiscount(order);
}
}
```
重构之后的代码就没有了if-else分支判断语句了。实际上这得益于策略工厂类。在工厂类中我们用Map来缓存策略根据type直接从Map中获取对应的策略从而避免if-else分支判断逻辑。等后面讲到使用状态模式来避免分支判断逻辑的时候你会发现它们使用的是同样的套路。本质上都是借助“查表法”根据type查表代码中的strategies就是表替代根据type分支判断。
但是,如果业务场景需要每次都创建不同的策略对象,我们就要用另外一种工厂类的实现方式了。具体的代码如下所示:
```
public class DiscountStrategyFactory {
public static DiscountStrategy getDiscountStrategy(OrderType type) {
if (type == null) {
throw new IllegalArgumentException(&quot;Type should not be null.&quot;);
}
if (type.equals(OrderType.NORMAL)) {
return new NormalDiscountStrategy();
} else if (type.equals(OrderType.GROUPON)) {
return new GrouponDiscountStrategy();
} else if (type.equals(OrderType.PROMOTION)) {
return new PromotionDiscountStrategy();
}
return null;
}
}
```
这种实现方式相当于把原来的if-else分支逻辑从OrderService类中转移到了工厂类中实际上并没有真正将它移除。关于这个问题如何解决我今天先暂时卖个关子。你可以在留言区说说你的想法我在下一节课中再讲解。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
策略模式定义一族算法类,将每个算法分别封装起来,让它们可以互相替换。策略模式可以使算法的变化独立于使用它们的客户端(这里的客户端代指使用算法的代码)。
策略模式用来解耦策略的定义、创建、使用。实际上,一个完整的策略模式就是由这三个部分组成的。
- 策略类的定义比较简单,包含一个策略接口和一组实现这个接口的策略类。
- 策略的创建由工厂类来完成,封装策略创建的细节。
- 策略模式包含一组策略可选,客户端代码如何选择使用哪个策略,有两种确定方法:编译时静态确定和运行时动态确定。其中,“运行时动态确定”才是策略模式最典型的应用场景。
除此之外我们还可以通过策略模式来移除if-else分支判断。实际上这得益于策略工厂类更本质上点讲是借助“查表法”根据type查表替代根据type分支判断。
## 课堂讨论
今天我们讲到在策略工厂类中如果每次都要返回新的策略对象我们还是需要在工厂类中编写if-else分支判断逻辑那这个问题该如何解决呢
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,257 @@
<audio id="audio" title="61 | 策略模式(下):如何实现一个支持给不同大小文件排序的小程序?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/25/27267a7dc10c14c20e0076e8a0952325.mp3"></audio>
上一节课我们主要介绍了策略模式的原理和实现以及如何利用策略模式来移除if-else或者switch-case分支判断逻辑。今天我们结合“给文件排序”这样一个具体的例子来详细讲一讲策略模式的设计意图和应用场景。
除此之外,在今天的讲解中,我还会通过一步一步地分析、重构,给你展示一个设计模式是如何“创造”出来的。通过今天的学习,你会发现,**设计原则和思想其实比设计模式更加普适和重要,掌握了代码的设计原则和思想,我们甚至可以自己创造出来新的设计模式**。
话不多说,让我们正式开始今天的学习吧!
## 问题与解决思路
假设有这样一个需求,希望写一个小程序,实现对一个文件进行排序的功能。文件中只包含整型数,并且,相邻的数字通过逗号来区隔。如果由你来编写这样一个小程序,你会如何来实现呢?你可以把它当作面试题,先自己思考一下,再来看我下面的讲解。
你可能会说,这不是很简单嘛,只需要将文件中的内容读取出来,并且通过逗号分割成一个一个的数字,放到内存数组中,然后编写某种排序算法(比如快排),或者直接使用编程语言提供的排序函数,对数组进行排序,最后再将数组中的数据写入文件就可以了。
但是如果文件很大呢比如有10GB大小因为内存有限比如只有8GB大小我们没办法一次性加载文件中的所有数据到内存中这个时候我们就要利用外部排序算法具体怎么做可以参看我的另一个专栏《数据结构与算法之美》中的“排序”相关章节了。
如果文件更大比如有100GB大小我们为了利用CPU多核的优势可以在外部排序的基础之上进行优化加入多线程并发排序的功能这就有点类似“单机版”的MapReduce。
如果文件非常大比如有1TB大小即便是单机多线程排序这也算很慢了。这个时候我们可以使用真正的MapReduce框架利用多机的处理能力提高排序的效率。
## 代码实现与分析
解决思路讲完了,不难理解。接下来,我们看一下,如何将解决思路翻译成代码实现。
我先用最简单直接的方式将它实现出来。具体代码我贴在下面了,你可以先看一下。因为我们是在讲设计模式,不是讲算法,所以,在下面的代码实现中,我只给出了跟设计模式相关的骨架代码,并没有给出每种排序算法的具体代码实现。感兴趣的话,你可以自行实现一下。
```
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
if (fileSize &lt; 6 * GB) { // [0, 6GB)
quickSort(filePath);
} else if (fileSize &lt; 10 * GB) { // [6GB, 10GB)
externalSort(filePath);
} else if (fileSize &lt; 100 * GB) { // [10GB, 100GB)
concurrentExternalSort(filePath);
} else { // [100GB, ~)
mapreduceSort(filePath);
}
}
private void quickSort(String filePath) {
// 快速排序
}
private void externalSort(String filePath) {
// 外部排序
}
private void concurrentExternalSort(String filePath) {
// 多线程外部排序
}
private void mapreduceSort(String filePath) {
// 利用MapReduce多机排序
}
}
public class SortingTool {
public static void main(String[] args) {
Sorter sorter = new Sorter();
sorter.sortFile(args[0]);
}
}
```
在“编码规范”那一部分我们讲过函数的行数不能过多最好不要超过一屏的大小。所以为了避免sortFile()函数过长我们把每种排序算法从sortFile()函数中抽离出来拆分成4个独立的排序函数。
如果只是开发一个简单的工具,那上面的代码实现就足够了。毕竟,代码不多,后续修改、扩展的需求也不多,怎么写都不会导致代码不可维护。但是,如果我们是在开发一个大型项目,排序文件只是其中的一个功能模块,那我们就要在代码设计、代码质量上下点儿功夫了。只有每个小的功能模块都写好,整个项目的代码才能不差。
在刚刚的代码中我们并没有给出每种排序算法的代码实现。实际上如果自己实现一下的话你会发现每种排序算法的实现逻辑都比较复杂代码行数都比较多。所有排序算法的代码实现都堆在Sorter一个类中这就会导致这个类的代码很多。而在“编码规范”那一部分中我们也讲到一个类的代码太多也会影响到可读性、可维护性。除此之外所有的排序算法都设计成Sorter的私有函数也会影响代码的可复用性。
## 代码优化与重构
只要掌握了我们之前讲过的设计原则和思想针对上面的问题即便我们想不到该用什么设计模式来重构也应该能知道该如何解决那就是将Sorter类中的某些代码拆分出来独立成职责更加单一的小类。实际上拆分是应对类或者函数代码过多、应对代码复杂性的一个常用手段。按照这个解决思路我们对代码进行重构。重构之后的代码如下所示
```
public interface ISortAlg {
void sort(String filePath);
}
public class QuickSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class ExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class ConcurrentExternalSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class MapReduceSort implements ISortAlg {
@Override
public void sort(String filePath) {
//...
}
}
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize &lt; 6 * GB) { // [0, 6GB)
sortAlg = new QuickSort();
} else if (fileSize &lt; 10 * GB) { // [6GB, 10GB)
sortAlg = new ExternalSort();
} else if (fileSize &lt; 100 * GB) { // [10GB, 100GB)
sortAlg = new ConcurrentExternalSort();
} else { // [100GB, ~)
sortAlg = new MapReduceSort();
}
sortAlg.sort(filePath);
}
}
```
经过拆分之后每个类的代码都不会太多每个类的逻辑都不会太复杂代码的可读性、可维护性提高了。除此之外我们将排序算法设计成独立的类跟具体的业务逻辑代码中的if-else那部分逻辑解耦也让排序算法能够复用。这一步实际上就是策略模式的第一步也就是将策略的定义分离出来。
实际上,上面的代码还可以继续优化。每种排序类都是无状态的,我们没必要在每次使用的时候,都重新创建一个新的对象。所以,我们可以使用工厂模式对对象的创建进行封装。按照这个思路,我们对代码进行重构。重构之后的代码如下所示:
```
public class SortAlgFactory {
private static final Map&lt;String, ISortAlg&gt; algs = new HashMap&lt;&gt;();
static {
algs.put(&quot;QuickSort&quot;, new QuickSort());
algs.put(&quot;ExternalSort&quot;, new ExternalSort());
algs.put(&quot;ConcurrentExternalSort&quot;, new ConcurrentExternalSort());
algs.put(&quot;MapReduceSort&quot;, new MapReduceSort());
}
public static ISortAlg getSortAlg(String type) {
if (type == null || type.isEmpty()) {
throw new IllegalArgumentException(&quot;type should not be empty.&quot;);
}
return algs.get(type);
}
}
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg;
if (fileSize &lt; 6 * GB) { // [0, 6GB)
sortAlg = SortAlgFactory.getSortAlg(&quot;QuickSort&quot;);
} else if (fileSize &lt; 10 * GB) { // [6GB, 10GB)
sortAlg = SortAlgFactory.getSortAlg(&quot;ExternalSort&quot;);
} else if (fileSize &lt; 100 * GB) { // [10GB, 100GB)
sortAlg = SortAlgFactory.getSortAlg(&quot;ConcurrentExternalSort&quot;);
} else { // [100GB, ~)
sortAlg = SortAlgFactory.getSortAlg(&quot;MapReduceSort&quot;);
}
sortAlg.sort(filePath);
}
}
```
经过上面两次重构之后现在的代码实际上已经符合策略模式的代码结构了。我们通过策略模式将策略的定义、创建、使用解耦让每一部分都不至于太复杂。不过Sorter类中的sortFile()函数还是有一堆if-else逻辑。这里的if-else逻辑分支不多、也不复杂这样写完全没问题。但如果你特别想将if-else分支判断移除掉那也是有办法的。我直接给出代码你一看就能明白。实际上这也是基于查表法来解决的其中的“algs”就是“表”。
```
public class Sorter {
private static final long GB = 1000 * 1000 * 1000;
private static final List&lt;AlgRange&gt; algs = new ArrayList&lt;&gt;();
static {
algs.add(new AlgRange(0, 6*GB, SortAlgFactory.getSortAlg(&quot;QuickSort&quot;)));
algs.add(new AlgRange(6*GB, 10*GB, SortAlgFactory.getSortAlg(&quot;ExternalSort&quot;)));
algs.add(new AlgRange(10*GB, 100*GB, SortAlgFactory.getSortAlg(&quot;ConcurrentExternalSort&quot;)));
algs.add(new AlgRange(100*GB, Long.MAX_VALUE, SortAlgFactory.getSortAlg(&quot;MapReduceSort&quot;)));
}
public void sortFile(String filePath) {
// 省略校验逻辑
File file = new File(filePath);
long fileSize = file.length();
ISortAlg sortAlg = null;
for (AlgRange algRange : algs) {
if (algRange.inRange(fileSize)) {
sortAlg = algRange.getAlg();
break;
}
}
sortAlg.sort(filePath);
}
private static class AlgRange {
private long start;
private long end;
private ISortAlg alg;
public AlgRange(long start, long end, ISortAlg alg) {
this.start = start;
this.end = end;
this.alg = alg;
}
public ISortAlg getAlg() {
return alg;
}
public boolean inRange(long size) {
return size &gt;= start &amp;&amp; size &lt; end;
}
}
}
```
现在的代码实现就更加优美了。我们把可变的部分隔离到了策略工厂类和Sorter类中的静态代码段中。当要添加一个新的排序算法时我们只需要修改策略工厂类和Sort类中的静态代码段其他代码都不需要修改这样就将代码改动最小化、集中化了。
你可能会说,即便这样,当我们添加新的排序算法的时候,还是需要修改代码,并不完全符合开闭原则。有什么办法让我们完全满足开闭原则呢?
对于Java语言来说我们可以通过反射来避免对策略工厂类的修改。具体是这么做的我们通过一个配置文件或者自定义的annotation来标注都有哪些策略类策略工厂类读取配置文件或者搜索被annotation标注的策略类然后通过反射动态地加载这些策略类、创建策略对象当我们新添加一个策略的时候只需要将这个新添加的策略类添加到配置文件或者用annotation标注即可。还记得上一节课的课堂讨论题吗我们也可以用这种方法来解决。
对于Sorter来说我们可以通过同样的方法来避免修改。我们通过将文件大小区间和算法之间的对应关系放到配置文件中。当添加新的排序算法时我们只需要改动配置文件即可不需要改动代码。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
一提到if-else分支判断有人就觉得它是烂代码。如果if-else分支判断不复杂、代码不多这并没有任何问题毕竟if-else分支判断几乎是所有编程语言都会提供的语法存在即有理由。遵循KISS原则怎么简单怎么来就是最好的设计。非得用策略模式搞出n多类反倒是一种过度设计。
一提到策略模式有人就觉得它的作用是避免if-else分支判断逻辑。实际上这种认识是很片面的。策略模式主要的作用还是解耦策略的定义、创建和使用控制代码的复杂度让每个部分都不至于过于复杂、代码量过多。除此之外对于复杂代码来说策略模式还能让其满足开闭原则添加新策略的时候最小化、集中化代码改动减少引入bug的风险。
实际上,设计原则和思想比设计模式更加普适和重要。掌握了代码的设计原则和思想,我们能更清楚的了解,为什么要用某种设计模式,就能更恰到好处地应用设计模式。
## 课堂讨论
1. 在过去的项目开发中,你有没有用过策略模式,都是为了解决什么问题才使用的?
1. 你可以说一说在什么情况下我们才有必要去掉代码中的if-else或者switch-case分支逻辑呢
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,396 @@
<audio id="audio" title="62 | 职责链模式(上):如何实现可灵活扩展算法的敏感信息过滤框架?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/42/56/42c8dd5fdb97c83f21cae2129f496456.mp3"></audio>
前几节课中,我们学习了模板模式、策略模式,今天,我们来学习职责链模式。这三种模式具有相同的作用:复用和扩展,在实际的项目开发中比较常用,特别是框架开发中,我们可以利用它们来提供框架的扩展点,能够让框架的使用者在不修改框架源码的情况下,基于扩展点定制化框架的功能。
今天我们主要讲解职责链模式的原理和实现。除此之外我还会利用职责链模式带你实现一个可以灵活扩展算法的敏感词过滤框架。下一节课我们会更加贴近实战通过剖析Servlet Filter、Spring Interceptor来看如何利用职责链模式实现框架中常用的过滤器、拦截器。
话不多说,让我们正式开始今天的学习吧!
## 职责链模式的原理和实现
职责链模式的英文翻译是Chain Of Responsibility Design Pattern。在GoF的《设计模式》中它是这么定义的
>
Avoid coupling the sender of a request to its receiver by giving more than one object a chance to handle the request. Chain the receiving objects and pass the request along the chain until an object handles it.
翻译成中文就是:将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。
这么说比较抽象,我用更加容易理解的话来进一步解读一下。
在职责链模式中多个处理器也就是刚刚定义中说的“接收对象”依次处理同一个请求。一个请求先经过A处理器处理然后再把请求传递给B处理器B处理器处理完后再传递给C处理器以此类推形成一个链条。链条上的每个处理器各自承担各自的处理职责所以叫作职责链模式。
关于职责链模式,我们先来看看它的代码实现。结合代码实现,你会更容易理解它的定义。职责链模式有多种实现方式,我们这里介绍两种比较常用的。
第一种实现方式如下所示。其中Handler是所有处理器类的抽象父类handle()是抽象方法。每个具体的处理器类HandlerA、HandlerB的handle()函数的代码结构类似如果它能处理该请求就不继续往下传递如果不能处理则交由后面的处理器来处理也就是调用successor.handle()。HandlerChain是处理器链从数据结构的角度来看它就是一个记录了链头、链尾的链表。其中记录链尾是为了方便添加处理器。
```
public abstract class Handler {
protected Handler successor = null;
public void setSuccessor(Handler successor) {
this.successor = successor;
}
public abstract void handle();
}
public class HandlerA extends Handler {
@Override
public void handle() {
boolean handled = false;
//...
if (!handled &amp;&amp; successor != null) {
successor.handle();
}
}
}
public class HandlerB extends Handler {
@Override
public void handle() {
boolean handled = false;
//...
if (!handled &amp;&amp; successor != null) {
successor.handle();
}
}
}
public class HandlerChain {
private Handler head = null;
private Handler tail = null;
public void addHandler(Handler handler) {
handler.setSuccessor(null);
if (head == null) {
head = handler;
tail = handler;
return;
}
tail.setSuccessor(handler);
tail = handler;
}
public void handle() {
if (head != null) {
head.handle();
}
}
}
// 使用举例
public class Application {
public static void main(String[] args) {
HandlerChain chain = new HandlerChain();
chain.addHandler(new HandlerA());
chain.addHandler(new HandlerB());
chain.handle();
}
}
```
实际上上面的代码实现不够优雅。处理器类的handle()函数不仅包含自己的业务逻辑还包含对下一个处理器的调用也就是代码中的successor.handle()。一个不熟悉这种代码结构的程序员在添加新的处理器类的时候很有可能忘记在handle()函数中调用successor.handle()这就会导致代码出现bug。
针对这个问题我们对代码进行重构利用模板模式将调用successor.handle()的逻辑从具体的处理器类中剥离出来,放到抽象父类中。这样具体的处理器类只需要实现自己的业务逻辑就可以了。重构之后的代码如下所示:
```
public abstract class Handler {
protected Handler successor = null;
public void setSuccessor(Handler successor) {
this.successor = successor;
}
public final void handle() {
boolean handled = doHandle();
if (successor != null &amp;&amp; !handled) {
successor.handle();
}
}
protected abstract boolean doHandle();
}
public class HandlerA extends Handler {
@Override
protected boolean doHandle() {
boolean handled = false;
//...
return handled;
}
}
public class HandlerB extends Handler {
@Override
protected boolean doHandle() {
boolean handled = false;
//...
return handled;
}
}
// HandlerChain和Application代码不变
```
我们再来看第二种实现方式代码如下所示。这种实现方式更加简单。HandlerChain类用数组而非链表来保存所有的处理器并且需要在HandlerChain的handle()函数中依次调用每个处理器的handle()函数。
```
public interface IHandler {
boolean handle();
}
public class HandlerA implements IHandler {
@Override
public boolean handle() {
boolean handled = false;
//...
return handled;
}
}
public class HandlerB implements IHandler {
@Override
public boolean handle() {
boolean handled = false;
//...
return handled;
}
}
public class HandlerChain {
private List&lt;IHandler&gt; handlers = new ArrayList&lt;&gt;();
public void addHandler(IHandler handler) {
this.handlers.add(handler);
}
public void handle() {
for (IHandler handler : handlers) {
boolean handled = handler.handle();
if (handled) {
break;
}
}
}
}
// 使用举例
public class Application {
public static void main(String[] args) {
HandlerChain chain = new HandlerChain();
chain.addHandler(new HandlerA());
chain.addHandler(new HandlerB());
chain.handle();
}
}
```
在GoF给出的定义中如果处理器链上的某个处理器能够处理这个请求那就不会继续往下传递请求。实际上职责链模式还有一种变体那就是请求会被所有的处理器都处理一遍不存在中途终止的情况。这种变体也有两种实现方式用链表存储处理器和用数组存储处理器跟上面的两种实现方式类似只需要稍微修改即可。
我这里只给出其中一种实现方式,如下所示。另外一种实现方式你对照着上面的实现自行修改。
```
public abstract class Handler {
protected Handler successor = null;
public void setSuccessor(Handler successor) {
this.successor = successor;
}
public final void handle() {
doHandle();
if (successor != null) {
successor.handle();
}
}
protected abstract void doHandle();
}
public class HandlerA extends Handler {
@Override
protected void doHandle() {
//...
}
}
public class HandlerB extends Handler {
@Override
protected void doHandle() {
//...
}
}
public class HandlerChain {
private Handler head = null;
private Handler tail = null;
public void addHandler(Handler handler) {
handler.setSuccessor(null);
if (head == null) {
head = handler;
tail = handler;
return;
}
tail.setSuccessor(handler);
tail = handler;
}
public void handle() {
if (head != null) {
head.handle();
}
}
}
// 使用举例
public class Application {
public static void main(String[] args) {
HandlerChain chain = new HandlerChain();
chain.addHandler(new HandlerA());
chain.addHandler(new HandlerB());
chain.handle();
}
}
```
## 职责链模式的应用场景举例
职责链模式的原理和实现讲完了,我们再通过一个实际的例子,来学习一下职责链模式的应用场景。
对于支持UGCUser Generated Content用户生成内容的应用比如论坛来说用户生成的内容比如在论坛中发表的帖子可能会包含一些敏感词比如涉黄、广告、反动等词汇。针对这个应用场景我们就可以利用职责链模式来过滤这些敏感词。
对于包含敏感词的内容,我们有两种处理方式,一种是直接禁止发布,另一种是给敏感词打马赛克(比如,用***替换敏感词之后再发布。第一种处理方式符合GoF给出的职责链模式的定义第二种处理方式是职责链模式的变体。
我们这里只给出第一种实现方式的代码示例,如下所示,并且,我们只给出了代码实现的骨架,具体的敏感词过滤算法并没有给出,你可以参看我的另一个专栏[《数据结构与算法之美》](https://time.geekbang.org/column/intro/100017301)中多模式字符串匹配的相关章节自行实现。
```
public interface SensitiveWordFilter {
boolean doFilter(Content content);
}
public class SexyWordFilter implements SensitiveWordFilter {
@Override
public boolean doFilter(Content content) {
boolean legal = true;
//...
return legal;
}
}
// PoliticalWordFilter、AdsWordFilter类代码结构与SexyWordFilter类似
public class SensitiveWordFilterChain {
private List&lt;SensitiveWordFilter&gt; filters = new ArrayList&lt;&gt;();
public void addFilter(SensitiveWordFilter filter) {
this.filters.add(filter);
}
// return true if content doesn't contain sensitive words.
public boolean filter(Content content) {
for (SensitiveWordFilter filter : filters) {
if (!filter.doFilter(content)) {
return false;
}
}
return true;
}
}
public class ApplicationDemo {
public static void main(String[] args) {
SensitiveWordFilterChain filterChain = new SensitiveWordFilterChain();
filterChain.addFilter(new AdsWordFilter());
filterChain.addFilter(new SexyWordFilter());
filterChain.addFilter(new PoliticalWordFilter());
boolean legal = filterChain.filter(new Content());
if (!legal) {
// 不发表
} else {
// 发表
}
}
}
```
看了上面的实现,你可能会说,我像下面这样也可以实现敏感词过滤功能,而且代码更加简单,为什么非要使用职责链模式呢?这是不是过度设计呢?
```
public class SensitiveWordFilter {
// return true if content doesn't contain sensitive words.
public boolean filter(Content content) {
if (!filterSexyWord(content)) {
return false;
}
if (!filterAdsWord(content)) {
return false;
}
if (!filterPoliticalWord(content)) {
return false;
}
return true;
}
private boolean filterSexyWord(Content content) {
//....
}
private boolean filterAdsWord(Content content) {
//...
}
private boolean filterPoliticalWord(Content content) {
//...
}
}
```
我们前面多次讲过,应用设计模式主要是为了应对代码的复杂性,让其满足开闭原则,提高代码的扩展性。这里应用职责链模式也不例外。实际上,我们在讲解[策略模式](https://time.geekbang.org/column/article/214014)的时候,也讲过类似的问题,比如,为什么要用策略模式?当时的给出的理由,与现在应用职责链模式的理由,几乎是一样的,你可以结合着当时的讲解一块来看下。
**首先,我们来看,职责链模式如何应对代码的复杂性。**
将大块代码逻辑拆分成函数将大类拆分成小类是应对代码复杂性的常用方法。应用职责链模式我们把各个敏感词过滤函数继续拆分出来设计成独立的类进一步简化了SensitiveWordFilter类让SensitiveWordFilter类的代码不会过多过复杂。
**其次,我们再来看,职责链模式如何让代码满足开闭原则,提高代码的扩展性。**
当我们要扩展新的过滤算法的时候比如我们还需要过滤特殊符号按照非职责链模式的代码实现方式我们需要修改SensitiveWordFilter的代码违反开闭原则。不过这样的修改还算比较集中也是可以接受的。而职责链模式的实现方式更加优雅只需要新添加一个Filter类并且通过addFilter()函数将它添加到FilterChain中即可其他代码完全不需要修改。
不过你可能会说即便使用职责链模式来实现当添加新的过滤算法的时候还是要修改客户端代码ApplicationDemo这样做也没有完全符合开闭原则。
实际上细化一下的话我们可以把上面的代码分成两类框架代码和客户端代码。其中ApplicationDemo属于客户端代码也就是使用框架的代码。除ApplicationDemo之外的代码属于敏感词过滤框架代码。
假设敏感词过滤框架并不是我们开发维护的,而是我们引入的一个第三方框架,我们要扩展一个新的过滤算法,不可能直接去修改框架的源码。这个时候,利用职责链模式就能达到开篇所说的,在不修改框架源码的情况下,基于职责链模式提供的扩展点,来扩展新的功能。换句话说,我们在框架这个代码范围内实现了开闭原则。
除此之外,利用职责链模式相对于不用职责链的实现方式,还有一个好处,那就是配置过滤算法更加灵活,可以只选择使用某几个过滤算法。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块儿总结回顾一下,你需要重点掌握的内容。
在职责链模式中多个处理器依次处理同一个请求。一个请求先经过A处理器处理然后再把请求传递给B处理器B处理器处理完后再传递给C处理器以此类推形成一个链条。链条上的每个处理器各自承担各自的处理职责所以叫作职责链模式。
在GoF的定义中一旦某个处理器能处理这个请求就不会继续将请求传递给后续的处理器了。当然在实际的开发中也存在对这个模式的变体那就是请求不会中途终止传递而是会被所有的处理器都处理一遍。
职责链模式有两种常用的实现。一种是使用链表来存储处理器,另一种是使用数组来存储处理器,后面一种实现方式更加简单。
## 课堂讨论
今天讲到利用职责链模式,我们可以让框架代码满足开闭原则。添加一个新的处理器,只需要修改客户端代码。如果我们希望客户端代码也满足开闭原则,不修改任何代码,你有什么办法可以做到呢?
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,226 @@
<audio id="audio" title="63 | 职责链模式(下):框架中常用的过滤器、拦截器是如何实现的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/0c/6ce516539251f1a59ee783f50f07ff0c.mp3"></audio>
上一节课,我们学习职责链模式的原理与实现,并且通过一个敏感词过滤框架的例子,展示了职责链模式的设计意图。本质上来说,它跟大部分设计模式一样,都是为了解耦代码,应对代码的复杂性,让代码满足开闭原则,提高代码的可扩展性。
除此之外我们还提到职责链模式常用在框架的开发中为框架提供扩展点让框架的使用者在不修改框架源码的情况下基于扩展点添加新的功能。实际上更具体点来说职责链模式最常用来开发框架的过滤器和拦截器。今天我们就通过Servlet Filter、Spring Interceptor这两个Java开发中常用的组件来具体讲讲它在框架开发中的应用。
话不多说,让我们正式开始今天的学习吧!
## Servlet Filter
Servlet Filter是Java Servlet规范中定义的组件翻译成中文就是过滤器它可以实现对HTTP请求的过滤功能比如鉴权、限流、记录日志、验证参数等等。因为它是Servlet规范的一部分所以只要是支持Servlet的Web容器比如Tomcat、Jetty等都支持过滤器功能。为了帮助你理解我画了一张示意图阐述它的工作原理如下所示。
<img src="https://static001.geekbang.org/resource/image/32/21/3296abd63a61ebdf4eff3a6530979e21.jpg" alt="">
在实际项目中我们该如何使用Servlet Filter呢我写了一个简单的示例代码如下所示。添加一个过滤器我们只需要定义一个实现javax.servlet.Filter接口的过滤器类并且将它配置在web.xml配置文件中。Web容器启动的时候会读取web.xml中的配置创建过滤器对象。当有请求到来的时候会先经过过滤器然后才由Servlet来处理。
```
public class LogFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
// 在创建Filter时自动调用
// 其中filterConfig包含这个Filter的配置参数比如name之类的从配置文件中读取的
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
System.out.println(&quot;拦截客户端发送来的请求.&quot;);
chain.doFilter(request, response);
System.out.println(&quot;拦截发送给客户端的响应.&quot;);
}
@Override
public void destroy() {
// 在销毁Filter时自动调用
}
}
// 在web.xml配置文件中如下配置
&lt;filter&gt;
&lt;filter-name&gt;logFilter&lt;/filter-name&gt;
&lt;filter-class&gt;com.xzg.cd.LogFilter&lt;/filter-class&gt;
&lt;/filter&gt;
&lt;filter-mapping&gt;
&lt;filter-name&gt;logFilter&lt;/filter-name&gt;
&lt;url-pattern&gt;/*&lt;/url-pattern&gt;
&lt;/filter-mapping&gt;
```
从刚刚的示例代码中我们发现添加过滤器非常方便不需要修改任何代码定义一个实现javax.servlet.Filter的类再改改配置就搞定了完全符合开闭原则。那Servlet Filter是如何做到如此好的扩展性的呢我想你应该已经猜到了它利用的就是职责链模式。现在我们通过剖析它的源码详细地看看它底层是如何实现的。
在上一节课中我们讲到职责链模式的实现包含处理器接口IHandler或抽象类Handler以及处理器链HandlerChain。对应到Servlet Filterjavax.servlet.Filter就是处理器接口FilterChain就是处理器链。接下来我们重点来看FilterChain是如何实现的。
不过我们前面也讲过Servlet只是一个规范并不包含具体的实现所以Servlet中的FilterChain只是一个接口定义。具体的实现类由遵从Servlet规范的Web容器来提供比如ApplicationFilterChain类就是Tomcat提供的FilterChain的实现类源码如下所示。
为了让代码更易读懂我对代码进行了简化只保留了跟设计思路相关的代码片段。完整的代码你可以自行去Tomcat中查看。
```
public final class ApplicationFilterChain implements FilterChain {
private int pos = 0; //当前执行到了哪个filter
private int n; //filter的个数
private ApplicationFilterConfig[] filters;
private Servlet servlet;
@Override
public void doFilter(ServletRequest request, ServletResponse response) {
if (pos &lt; n) {
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
filter.doFilter(request, response, this);
} else {
// filter都处理完毕后执行servlet
servlet.service(request, response);
}
}
public void addFilter(ApplicationFilterConfig filterConfig) {
for (ApplicationFilterConfig filter:filters)
if (filter==filterConfig)
return;
if (n == filters.length) {//扩容
ApplicationFilterConfig[] newFilters = new ApplicationFilterConfig[n + INCREMENT];
System.arraycopy(filters, 0, newFilters, 0, n);
filters = newFilters;
}
filters[n++] = filterConfig;
}
}
```
ApplicationFilterChain中的doFilter()函数的代码实现比较有技巧实际上是一个递归调用。你可以用每个Filter比如LogFilter的doFilter()的代码实现直接替换ApplicationFilterChain的第12行代码一眼就能看出是递归调用了。我替换了一下如下所示。
```
@Override
public void doFilter(ServletRequest request, ServletResponse response) {
if (pos &lt; n) {
ApplicationFilterConfig filterConfig = filters[pos++];
Filter filter = filterConfig.getFilter();
//filter.doFilter(request, response, this);
//把filter.doFilter的代码实现展开替换到这里
System.out.println(&quot;拦截客户端发送来的请求.&quot;);
chain.doFilter(request, response); // chain就是this
System.out.println(&quot;拦截发送给客户端的响应.&quot;)
} else {
// filter都处理完毕后执行servlet
servlet.service(request, response);
}
}
```
这样实现主要是为了在一个doFilter()方法中支持双向拦截既能拦截客户端发送来的请求也能拦截发送给客户端的响应你可以结合着LogFilter那个例子以及对比待会要讲到的Spring Interceptor来自己理解一下。而我们上一节课给出的两种实现方式都没法做到在业务逻辑执行的前后同时添加处理代码。
## Spring Interceptor
刚刚讲了Servlet Filter现在我们来讲一个功能上跟它非常类似的东西Spring Interceptor翻译成中文就是拦截器。尽管英文单词和中文翻译都不同但这两者基本上可以看作一个概念都用来实现对HTTP请求进行拦截处理。
它们不同之处在于Servlet Filter是Servlet规范的一部分实现依赖于Web容器。Spring Interceptor是Spring MVC框架的一部分由Spring MVC框架来提供实现。客户端发送的请求会先经过Servlet Filter然后再经过Spring Interceptor最后到达具体的业务代码中。我画了一张图来阐述一个请求的处理流程具体如下所示。
<img src="https://static001.geekbang.org/resource/image/fe/68/febaa9220cb9ad2f0aafd4e5c3c19868.jpg" alt="">
在项目中我们该如何使用Spring Interceptor呢我写了一个简单的示例代码如下所示。LogInterceptor实现的功能跟刚才的LogFilter完全相同只是实现方式上稍有区别。LogFilter对请求和响应的拦截是在doFilter()一个函数中实现的而LogInterceptor对请求的拦截在preHandle()中实现对响应的拦截在postHandle()中实现。
```
public class LogInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println(&quot;拦截客户端发送来的请求.&quot;);
return true; // 继续后续的处理
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
System.out.println(&quot;拦截发送给客户端的响应.&quot;);
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
System.out.println(&quot;这里总是被执行.&quot;);
}
}
//在Spring MVC配置文件中配置interceptors
&lt;mvc:interceptors&gt;
&lt;mvc:interceptor&gt;
&lt;mvc:mapping path=&quot;/*&quot;/&gt;
&lt;bean class=&quot;com.xzg.cd.LogInterceptor&quot; /&gt;
&lt;/mvc:interceptor&gt;
&lt;/mvc:interceptors&gt;
```
同样我们还是来剖析一下Spring Interceptor底层是如何实现的。
当然它也是基于职责链模式实现的。其中HandlerExecutionChain类是职责链模式中的处理器链。它的实现相较于Tomcat中的ApplicationFilterChain来说逻辑更加清晰不需要使用递归来实现主要是因为它将请求和响应的拦截工作拆分到了两个函数中实现。HandlerExecutionChain的源码如下所示同样我对代码也进行了一些简化只保留了关键代码。
```
public class HandlerExecutionChain {
private final Object handler;
private HandlerInterceptor[] interceptors;
public void addInterceptor(HandlerInterceptor interceptor) {
initInterceptorList().add(interceptor);
}
boolean applyPreHandle(HttpServletRequest request, HttpServletResponse response) throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = 0; i &lt; interceptors.length; i++) {
HandlerInterceptor interceptor = interceptors[i];
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
}
}
return true;
}
void applyPostHandle(HttpServletRequest request, HttpServletResponse response, ModelAndView mv) throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = interceptors.length - 1; i &gt;= 0; i--) {
HandlerInterceptor interceptor = interceptors[i];
interceptor.postHandle(request, response, this.handler, mv);
}
}
}
void triggerAfterCompletion(HttpServletRequest request, HttpServletResponse response, Exception ex)
throws Exception {
HandlerInterceptor[] interceptors = getInterceptors();
if (!ObjectUtils.isEmpty(interceptors)) {
for (int i = this.interceptorIndex; i &gt;= 0; i--) {
HandlerInterceptor interceptor = interceptors[i];
try {
interceptor.afterCompletion(request, response, this.handler, ex);
} catch (Throwable ex2) {
logger.error(&quot;HandlerInterceptor.afterCompletion threw exception&quot;, ex2);
}
}
}
}
}
```
在Spring框架中DispatcherServlet的doDispatch()方法来分发请求它在真正的业务逻辑执行前后执行HandlerExecutionChain中的applyPreHandle()和applyPostHandle()函数,用来实现拦截的功能。具体的代码实现很简单,你自己应该能脑补出来,这里就不罗列了。感兴趣的话,你可以自行去查看。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
职责链模式常用在框架开发中,用来实现框架的过滤器、拦截器功能,让框架的使用者在不需要修改框架源码的情况下,添加新的过滤拦截功能。这也体现了之前讲到的对扩展开放、对修改关闭的设计原则。
今天我们通过Servlet Filter、Spring Interceptor两个实际的例子给你展示了在框架开发中职责链模式具体是怎么应用的。从源码中我们还可以发现尽管上一节课中我们有给出职责链模式的经典代码实现但在实际的开发中我们还是要具体问题具体对待代码实现会根据不同的需求有所变化。实际上这一点对于所有的设计模式都适用。
## 课堂讨论
1. 前面在讲代理模式的时候我们提到Spring AOP是基于代理模式来实现的。在实际的项目开发中我们可以利用AOP来实现访问控制功能比如鉴权、限流、日志等。今天我们又讲到Servlet Filter、Spring Interceptor也可以用来实现访问控制。那在项目开发中类似权限这样的访问控制功能我们该选择三者AOP、Servlet Filter、Spring Interceptor中的哪个来实现呢有什么参考标准吗
1. 除了我们讲到的Servlet Filter、Spring Interceptor之外Dubbo Filter、Netty ChannelPipeline也是职责链模式的实际应用案例你能否找一个你熟悉的并且用到职责链模式的框架像我一样分析一下它的底层实现呢
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,504 @@
<audio id="audio" title="64 | 状态模式:游戏、工作流引擎中常用的状态机是如何实现的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0a/b6/0a589efc563395dd1559f4bff118adb6.mp3"></audio>
从今天起,我们开始学习状态模式。在实际的软件开发中,状态模式并不是很常用,但是在能够用到的场景里,它可以发挥很大的作用。从这一点上来看,它有点像我们之前讲到的组合模式。
状态模式一般用来实现状态机,而状态机常用在游戏、工作流引擎等系统开发中。不过,状态机的实现方式有多种,除了状态模式,比较常用的还有分支逻辑法和查表法。今天,我们就详细讲讲这几种实现方式,并且对比一下它们的优劣和应用场景。
话不多说,让我们正式开始今天的学习吧!
## 什么是有限状态机?
有限状态机英文翻译是Finite State Machine缩写为FSM简称为状态机。状态机有3个组成部分状态State、事件Event、动作Action。其中事件也称为转移条件Transition Condition。事件触发状态的转移及动作的执行。不过动作不是必须的也可能只转移状态不执行任何动作。
对于刚刚给出的状态机的定义,我结合一个具体的例子,来进一步解释一下。
“超级马里奥”游戏不知道你玩过没有在游戏中马里奥可以变身为多种形态比如小马里奥Small Mario、超级马里奥Super Mario、火焰马里奥Fire Mario、斗篷马里奥Cape Mario等等。在不同的游戏情节下各个形态会互相转化并相应的增减积分。比如初始形态是小马里奥吃了蘑菇之后就会变成超级马里奥并且增加100积分。
实际上马里奥形态的转变就是一个状态机。其中马里奥的不同形态就是状态机中的“状态”游戏情节比如吃了蘑菇就是状态机中的“事件”加减积分就是状态机中的“动作”。比如吃蘑菇这个事件会触发状态的转移从小马里奥转移到超级马里奥以及触发动作的执行增加100积分
为了方便接下来的讲解,我对游戏背景做了简化,只保留了部分状态和事件。简化之后的状态转移如下图所示:
<img src="https://static001.geekbang.org/resource/image/5a/6c/5aa0310b9b3ea08794cfc2f376c8f96c.jpg" alt="">
我们如何编程来实现上面的状态机呢?换句话说,如何将上面的状态转移图翻译成代码呢?
我写了一个骨架代码如下所示。其中obtainMushRoom()、obtainCape()、obtainFireFlower()、meetMonster()这几个函数,能够根据当前的状态和事件,更新状态和增减积分。不过,具体的代码实现我暂时并没有给出。你可以把它当做面试题,试着补全一下,然后再来看我下面的讲解,这样你的收获会更大。
```
public enum State {
SMALL(0),
SUPER(1),
FIRE(2),
CAPE(3);
private int value;
private State(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
}
public class MarioStateMachine {
private int score;
private State currentState;
public MarioStateMachine() {
this.score = 0;
this.currentState = State.SMALL;
}
public void obtainMushRoom() {
//TODO
}
public void obtainCape() {
//TODO
}
public void obtainFireFlower() {
//TODO
}
public void meetMonster() {
//TODO
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState;
}
}
public class ApplicationDemo {
public static void main(String[] args) {
MarioStateMachine mario = new MarioStateMachine();
mario.obtainMushRoom();
int score = mario.getScore();
State state = mario.getCurrentState();
System.out.println(&quot;mario score: &quot; + score + &quot;; state: &quot; + state);
}
}
```
## 状态机实现方式一:分支逻辑法
对于如何实现状态机我总结了三种方式。其中最简单直接的实现方式是参照状态转移图将每一个状态转移原模原样地直译成代码。这样编写的代码会包含大量的if-else或switch-case分支判断逻辑甚至是嵌套的分支判断逻辑所以我把这种方法暂且命名为分支逻辑法。
按照这个实现思路,我将上面的骨架代码补全一下。补全之后的代码如下所示:
```
public class MarioStateMachine {
private int score;
private State currentState;
public MarioStateMachine() {
this.score = 0;
this.currentState = State.SMALL;
}
public void obtainMushRoom() {
if (currentState.equals(State.SMALL)) {
this.currentState = State.SUPER;
this.score += 100;
}
}
public void obtainCape() {
if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
this.currentState = State.CAPE;
this.score += 200;
}
}
public void obtainFireFlower() {
if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER) ) {
this.currentState = State.FIRE;
this.score += 300;
}
}
public void meetMonster() {
if (currentState.equals(State.SUPER)) {
this.currentState = State.SMALL;
this.score -= 100;
return;
}
if (currentState.equals(State.CAPE)) {
this.currentState = State.SMALL;
this.score -= 200;
return;
}
if (currentState.equals(State.FIRE)) {
this.currentState = State.SMALL;
this.score -= 300;
return;
}
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState;
}
}
```
对于简单的状态机来说分支逻辑这种实现方式是可以接受的。但是对于复杂的状态机来说这种实现方式极易漏写或者错写某个状态转移。除此之外代码中充斥着大量的if-else或者switch-case分支判断逻辑可读性和可维护性都很差。如果哪天修改了状态机中的某个状态转移我们要在冗长的分支逻辑中找到对应的代码进行修改很容易改错引入bug。
## 状态机实现方式二:查表法
实际上上面这种实现方法有点类似hard code对于复杂的状态机来说不适用而状态机的第二种实现方式查表法就更加合适了。接下来我们就一块儿来看下如何利用查表法来补全骨架代码。
实际上,除了用状态转移图来表示之外,状态机还可以用二维表来表示,如下所示。在这个二维表中,第一维表示当前状态,第二维表示事件,值表示当前状态经过事件之后,转移到的新状态及其执行的动作。
<img src="https://static001.geekbang.org/resource/image/4f/91/4f4ea3787bd955918578181e18173491.jpg" alt="">
相对于分支逻辑的实现方式查表法的代码实现更加清晰可读性和可维护性更好。当修改状态机时我们只需要修改transitionTable和actionTable两个二维数组即可。实际上如果我们把这两个二维数组存储在配置文件中当需要修改状态机时我们甚至可以不修改任何代码只需要修改配置文件就可以了。具体的代码如下所示
```
public enum Event {
GOT_MUSHROOM(0),
GOT_CAPE(1),
GOT_FIRE(2),
MET_MONSTER(3);
private int value;
private Event(int value) {
this.value = value;
}
public int getValue() {
return this.value;
}
}
public class MarioStateMachine {
private int score;
private State currentState;
private static final State[][] transitionTable = {
{SUPER, CAPE, FIRE, SMALL},
{SUPER, CAPE, FIRE, SMALL},
{CAPE, CAPE, CAPE, SMALL},
{FIRE, FIRE, FIRE, SMALL}
};
private static final int[][] actionTable = {
{+100, +200, +300, +0},
{+0, +200, +300, -100},
{+0, +0, +0, -200},
{+0, +0, +0, -300}
};
public MarioStateMachine() {
this.score = 0;
this.currentState = State.SMALL;
}
public void obtainMushRoom() {
executeEvent(Event.GOT_MUSHROOM);
}
public void obtainCape() {
executeEvent(Event.GOT_CAPE);
}
public void obtainFireFlower() {
executeEvent(Event.GOT_FIRE);
}
public void meetMonster() {
executeEvent(Event.MET_MONSTER);
}
private void executeEvent(Event event) {
int stateValue = currentState.getValue();
int eventValue = event.getValue();
this.currentState = transitionTable[stateValue][eventValue];
this.score += actionTable[stateValue][eventValue];
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState;
}
}
```
## 状态机实现方式三:状态模式
在查表法的代码实现中事件触发的动作只是简单的积分加减所以我们用一个int类型的二维数组actionTable就能表示二维数组中的值表示积分的加减值。但是如果要执行的动作并非这么简单而是一系列复杂的逻辑操作比如加减积分、写数据库还有可能发送消息通知等等我们就没法用如此简单的二维数组来表示了。这也就是说查表法的实现方式有一定局限性。
虽然分支逻辑的实现方式不存在这个问题,但它又存在前面讲到的其他问题,比如分支判断逻辑较多,导致代码可读性和可维护性不好等。实际上,针对分支逻辑法存在的问题,我们可以使用状态模式来解决。
状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,来避免分支判断逻辑。我们还是结合代码来理解这句话。
利用状态模式我们来补全MarioStateMachine类补全后的代码如下所示。
其中IMario是状态的接口定义了所有的事件。SmallMario、SuperMario、CapeMario、FireMario是IMario接口的实现类分别对应状态机中的4个状态。原来所有的状态转移和动作执行的代码逻辑都集中在MarioStateMachine类中现在这些代码逻辑被分散到了这4个状态类中。
```
public interface IMario { //所有状态类的接口
State getName();
//以下是定义的事件
void obtainMushRoom();
void obtainCape();
void obtainFireFlower();
void meetMonster();
}
public class SmallMario implements IMario {
private MarioStateMachine stateMachine;
public SmallMario(MarioStateMachine stateMachine) {
this.stateMachine = stateMachine;
}
@Override
public State getName() {
return State.SMALL;
}
@Override
public void obtainMushRoom() {
stateMachine.setCurrentState(new SuperMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 100);
}
@Override
public void obtainCape() {
stateMachine.setCurrentState(new CapeMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 200);
}
@Override
public void obtainFireFlower() {
stateMachine.setCurrentState(new FireMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 300);
}
@Override
public void meetMonster() {
// do nothing...
}
}
public class SuperMario implements IMario {
private MarioStateMachine stateMachine;
public SuperMario(MarioStateMachine stateMachine) {
this.stateMachine = stateMachine;
}
@Override
public State getName() {
return State.SUPER;
}
@Override
public void obtainMushRoom() {
// do nothing...
}
@Override
public void obtainCape() {
stateMachine.setCurrentState(new CapeMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 200);
}
@Override
public void obtainFireFlower() {
stateMachine.setCurrentState(new FireMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() + 300);
}
@Override
public void meetMonster() {
stateMachine.setCurrentState(new SmallMario(stateMachine));
stateMachine.setScore(stateMachine.getScore() - 100);
}
}
// 省略CapeMario、FireMario类...
public class MarioStateMachine {
private int score;
private IMario currentState; // 不再使用枚举来表示状态
public MarioStateMachine() {
this.score = 0;
this.currentState = new SmallMario(this);
}
public void obtainMushRoom() {
this.currentState.obtainMushRoom();
}
public void obtainCape() {
this.currentState.obtainCape();
}
public void obtainFireFlower() {
this.currentState.obtainFireFlower();
}
public void meetMonster() {
this.currentState.meetMonster();
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState.getName();
}
public void setScore(int score) {
this.score = score;
}
public void setCurrentState(IMario currentState) {
this.currentState = currentState;
}
}
```
上面的代码实现不难看懂我只强调其中的一点即MarioStateMachine和各个状态类之间是双向依赖关系。MarioStateMachine依赖各个状态类是理所当然的但是反过来各个状态类为什么要依赖MarioStateMachine呢这是因为各个状态类需要更新MarioStateMachine中的两个变量score和currentState。
实际上上面的代码还可以继续优化我们可以将状态类设计成单例毕竟状态类中不包含任何成员变量。但是当将状态类设计成单例之后我们就无法通过构造函数来传递MarioStateMachine了而状态类又要依赖MarioStateMachine那该如何解决这个问题呢
实际上,在[第42讲](https://time.geekbang.org/column/article/194068)单例模式的讲解中我们提到过几种解决方法你可以回过头去再查看一下。在这里我们可以通过函数参数将MarioStateMachine传递进状态类。根据这个设计思路我们对上面的代码进行重构。重构之后的代码如下所示
```
public interface IMario {
State getName();
void obtainMushRoom(MarioStateMachine stateMachine);
void obtainCape(MarioStateMachine stateMachine);
void obtainFireFlower(MarioStateMachine stateMachine);
void meetMonster(MarioStateMachine stateMachine);
}
public class SmallMario implements IMario {
private static final SmallMario instance = new SmallMario();
private SmallMario() {}
public static SmallMario getInstance() {
return instance;
}
@Override
public State getName() {
return State.SMALL;
}
@Override
public void obtainMushRoom(MarioStateMachine stateMachine) {
stateMachine.setCurrentState(SuperMario.getInstance());
stateMachine.setScore(stateMachine.getScore() + 100);
}
@Override
public void obtainCape(MarioStateMachine stateMachine) {
stateMachine.setCurrentState(CapeMario.getInstance());
stateMachine.setScore(stateMachine.getScore() + 200);
}
@Override
public void obtainFireFlower(MarioStateMachine stateMachine) {
stateMachine.setCurrentState(FireMario.getInstance());
stateMachine.setScore(stateMachine.getScore() + 300);
}
@Override
public void meetMonster(MarioStateMachine stateMachine) {
// do nothing...
}
}
// 省略SuperMario、CapeMario、FireMario类...
public class MarioStateMachine {
private int score;
private IMario currentState;
public MarioStateMachine() {
this.score = 0;
this.currentState = SmallMario.getInstance();
}
public void obtainMushRoom() {
this.currentState.obtainMushRoom(this);
}
public void obtainCape() {
this.currentState.obtainCape(this);
}
public void obtainFireFlower() {
this.currentState.obtainFireFlower(this);
}
public void meetMonster() {
this.currentState.meetMonster(this);
}
public int getScore() {
return this.score;
}
public State getCurrentState() {
return this.currentState.getName();
}
public void setScore(int score) {
this.score = score;
}
public void setCurrentState(IMario currentState) {
this.currentState = currentState;
}
}
```
实际上,像游戏这种比较复杂的状态机,包含的状态比较多,我优先推荐使用查表法,而状态模式会引入非常多的状态类,会导致代码比较难维护。相反,像电商下单、外卖下单这种类型的状态机,它们的状态并不多,状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能会比较复杂,所以,更加推荐使用状态模式来实现。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
今天我们讲解了状态模式。虽然网上有各种状态模式的定义但是你只要记住状态模式是状态机的一种实现方式即可。状态机又叫有限状态机它有3个部分组成状态、事件、动作。其中事件也称为转移条件。事件触发状态的转移及动作的执行。不过动作不是必须的也可能只转移状态不执行任何动作。
针对状态机,今天我们总结了三种实现方式。
第一种实现方式叫分支逻辑法。利用if-else或者switch-case分支逻辑参照状态转移图将每一个状态转移原模原样地直译成代码。对于简单的状态机来说这种实现方式最简单、最直接是首选。
第二种实现方式叫查表法。对于状态很多、状态转移比较复杂的状态机来说,查表法比较合适。通过二维数组来表示状态转移图,能极大地提高代码的可读性和可维护性。
第三种实现方式叫状态模式。对于状态并不多、状态转移也比较简单,但事件触发执行的动作包含的业务逻辑可能比较复杂的状态机来说,我们首选这种实现方式。
## 课堂讨论
状态模式的代码实现还存在一些问题,比如,状态接口中定义了所有的事件函数,这就导致,即便某个状态类并不需要支持其中的某个或者某些事件,但也要实现所有的事件函数。不仅如此,添加一个事件到状态接口,所有的状态类都要做相应的修改。针对这些问题,你有什么解决方法吗?
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="65 | 迭代器模式(上):相比直接遍历集合数据,使用迭代器有哪些优势?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1d/5d/1d607e6d414f08f04b2e5b4b5f5a8d5d.mp3"></audio>
上一节课,我们学习了状态模式。状态模式是状态机的一种实现方法。它通过将事件触发的状态转移和动作执行,拆分到不同的状态类中,以此来避免状态机类中的分支判断逻辑,应对状态机类代码的复杂性。
今天,我们学习另外一种行为型设计模式,迭代器模式。它用来遍历集合对象。不过,很多编程语言都将迭代器作为一个基础的类库,直接提供出来了。在平时开发中,特别是业务开发,我们直接使用即可,很少会自己去实现一个迭代器。不过,知其然知其所以然,弄懂原理能帮助我们更好的使用这些工具类,所以,我觉得还是有必要学习一下这个模式。
我们知道大部分编程语言都提供了多种遍历集合的方式比如for循环、foreach循环、迭代器等。所以今天我们除了讲解迭代器的原理和实现之外还会重点讲一下相对于其他遍历方式利用迭代器来遍历集合的优势。
话不多说,让我们正式开始今天的学习吧!
## 迭代器模式的原理和实现
迭代器模式Iterator Design Pattern也叫作游标模式Cursor Design Pattern
在开篇中我们讲到,它用来遍历集合对象。这里说的“集合对象”也可以叫“容器”“聚合对象”,实际上就是包含一组对象的对象,比如数组、链表、树、图、跳表。迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一。
迭代器是用来遍历容器的,所以,一个完整的迭代器模式一般会涉及**容器**和**容器迭代器**两部分内容。为了达到基于接口而非实现编程的目的,容器又包含容器接口、容器实现类,迭代器又包含迭代器接口、迭代器实现类。对于迭代器模式,我画了一张简单的类图,你可以看一看,先有个大致的印象。
<img src="https://static001.geekbang.org/resource/image/cb/ec/cb72b5921681ac13d4fc05237597d2ec.jpg" alt="">
接下来,我们通过一个例子来具体讲,如何实现一个迭代器。
开篇中我们有提到,大部分编程语言都提供了遍历容器的迭代器类,我们在平时开发中,直接拿来用即可,几乎不大可能从零编写一个迭代器。不过,这里为了讲解迭代器的实现原理,我们假设某个新的编程语言的基础类库中,还没有提供线性容器对应的迭代器,需要我们从零开始开发。现在,我们一块来看具体该如何去做。
我们知道线性数据结构包括数组和链表在大部分编程语言中都有对应的类来封装这两种数据结构在开发中直接拿来用就可以了。假设在这种新的编程语言中这两个数据结构分别对应ArrayList和LinkedList两个类。除此之外我们从两个类中抽象出公共的接口定义为List接口以方便开发者基于接口而非实现编程编写的代码能在两种数据存储结构之间灵活切换。
现在我们针对ArrayList和LinkedList两个线性容器设计实现对应的迭代器。按照之前给出的迭代器模式的类图我们定义一个迭代器接口Iterator以及针对两种容器的具体的迭代器实现类ArrayIterator和ListIterator。
我们先来看下Iterator接口的定义。具体的代码如下所示
```
// 接口定义方式一
public interface Iterator&lt;E&gt; {
boolean hasNext();
void next();
E currentItem();
}
// 接口定义方式二
public interface Iterator&lt;E&gt; {
boolean hasNext();
E next();
}
```
Iterator接口有两种定义方式。
在第一种定义中next()函数用来将游标后移一位元素currentItem()函数用来返回当前游标指向的元素。在第二种定义中返回当前元素与后移一位这两个操作要放到同一个函数next()中完成。
第一种定义方式更加灵活一些比如我们可以多次调用currentItem()查询当前元素,而不移动游标。所以,在接下来的实现中,我们选择第一种接口定义方式。
现在我们再来看下ArrayIterator的代码实现具体如下所示。代码实现非常简单不需要太多解释。你可以结合着我给出的demo自己理解一下。
```
public class ArrayIterator&lt;E&gt; implements Iterator&lt;E&gt; {
private int cursor;
private ArrayList&lt;E&gt; arrayList;
public ArrayIterator(ArrayList&lt;E&gt; arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
return cursor != arrayList.size(); //注意这里cursor在指向最后一个元素的时候hasNext()仍旧返回true。
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor &gt;= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
public class Demo {
public static void main(String[] args) {
ArrayList&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;xzg&quot;);
names.add(&quot;wang&quot;);
names.add(&quot;zheng&quot;);
Iterator&lt;String&gt; iterator = new ArrayIterator(names);
while (iterator.hasNext()) {
System.out.println(iterator.currentItem());
iterator.next();
}
}
}
```
在上面的代码实现中我们需要将待遍历的容器对象通过构造函数传递给迭代器类。实际上为了封装迭代器的创建细节我们可以在容器中定义一个iterator()方法来创建对应的迭代器。为了能实现基于接口而非实现编程我们还需要将这个方法定义在List接口中。具体的代码实现和使用示例如下所示
```
public interface List&lt;E&gt; {
Iterator iterator();
//...省略其他接口函数...
}
public class ArrayList&lt;E&gt; implements List&lt;E&gt; {
//...
public Iterator iterator() {
return new ArrayIterator(this);
}
//...省略其他代码
}
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;xzg&quot;);
names.add(&quot;wang&quot;);
names.add(&quot;zheng&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
while (iterator.hasNext()) {
System.out.println(iterator.currentItem());
iterator.next();
}
}
}
```
对于LinkedIterator它的代码结构跟ArrayIterator完全相同我这里就不给出具体的代码实现了你可以参照ArrayIterator自己去写一下。
结合刚刚的例子我们来总结一下迭代器的设计思路。总结下来就三句话迭代器中需要定义hasNext()、currentItem()、next()三个最基本的方法。待遍历的容器对象通过依赖注入传递到迭代器类中。容器通过iterator()方法来创建迭代器。
这里我画了一张类图,如下所示。实际上就是对上面那张类图的细化,你可以结合着一块看。
<img src="https://static001.geekbang.org/resource/image/b6/30/b685b61448aaa638b03b5bf3d9d93330.jpg" alt="">
## 迭代器模式的优势
迭代器的原理和代码实现讲完了。接下来,我们来一块看一下,使用迭代器遍历集合的优势。
一般来讲遍历集合数据有三种方法for循环、foreach循环、iterator迭代器。对于这三种方式我拿Java语言来举例说明一下。具体的代码如下所示
```
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;xzg&quot;);
names.add(&quot;wang&quot;);
names.add(&quot;zheng&quot;);
// 第一种遍历方式for循环
for (int i = 0; i &lt; names.size(); i++) {
System.out.print(names.get(i) + &quot;,&quot;);
}
// 第二种遍历方式foreach循环
for (String name : names) {
System.out.print(name + &quot;,&quot;)
}
// 第三种遍历方式:迭代器遍历
Iterator&lt;String&gt; iterator = names.iterator();
while (iterator.hasNext()) {
System.out.print(iterator.next() + &quot;,&quot;);//Java中的迭代器接口是第二种定义方式next()既移动游标又返回数据
}
```
实际上foreach循环只是一个语法糖而已底层是基于迭代器来实现的。也就是说上面代码中的第二种遍历方式foreach循环代码的底层实现就是第三种遍历方式迭代器遍历代码。这两种遍历方式可以看作同一种遍历方式也就是迭代器遍历方式。
从上面的代码来看for循环遍历方式比起迭代器遍历方式代码看起来更加简洁。那我们为什么还要用迭代器来遍历容器呢为什么还要给容器设计对应的迭代器呢原因有以下三个。
首先对于类似数组和链表这样的数据结构遍历方式比较简单直接使用for循环来遍历就足够了。但是对于复杂的数据结构比如树、图来说有各种复杂的遍历方式。比如树有前中后序、按层遍历图有深度优先、广度优先遍历等等。如果由客户端代码来实现这些遍历算法势必增加开发成本而且容易写错。如果将这部分遍历的逻辑写到容器类中也会导致容器类代码的复杂性。
前面也多次提到应对复杂性的方法就是拆分。我们可以将遍历操作拆分到迭代器类中。比如针对图的遍历我们就可以定义DFSIterator、BFSIterator两个迭代器类让它们分别来实现深度优先遍历和广度优先遍历。
其次,将游标指向的当前位置等信息,存储在迭代器类中,每个迭代器独享游标信息。这样,我们就可以创建多个不同的迭代器,同时对同一个容器进行遍历而互不影响。
最后容器和迭代器都提供了抽象的接口方便我们在开发的时候基于接口而非具体的实现编程。当需要切换新的遍历算法的时候比如从前往后遍历链表切换成从后往前遍历链表客户端代码只需要将迭代器类从LinkedIterator切换为ReversedLinkedIterator即可其他代码都不需要修改。除此之外添加新的遍历算法我们只需要扩展新的迭代器类也更符合开闭原则。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
迭代器模式,也叫游标模式。它用来遍历集合对象。这里说的“集合对象”,我们也可以叫“容器”“聚合对象”,实际上就是包含一组对象的对象,比如,数组、链表、树、图、跳表。
一个完整的迭代器模式一般会涉及容器和容器迭代器两部分内容。为了达到基于接口而非实现编程的目的容器又包含容器接口、容器实现类迭代器又包含迭代器接口、迭代器实现类。容器中需要定义iterator()方法用来创建迭代器。迭代器接口中需要定义hasNext()、currentItem()、next()三个最基本的方法。容器对象通过依赖注入传递到迭代器类中。
遍历集合一般有三种方式for循环、foreach循环、迭代器遍历。后两种本质上属于一种都可以看作迭代器遍历。相对于for循环遍历利用迭代器来遍历有下面三个优势
- 迭代器模式封装集合内部的复杂数据结构,开发者不需要了解如何遍历,直接使用容器提供的迭代器即可;
- 迭代器模式将集合对象的遍历操作从集合类中拆分出来,放到迭代器类中,让两者的职责更加单一;
- 迭代器模式让添加新的遍历算法更加容易,更符合开闭原则。除此之外,因为迭代器都实现自相同的接口,在开发中,基于接口而非实现编程,替换迭代器也变得更加容易。
## 课堂讨论
1. 在Java中如果在使用迭代器的同时删除容器中的元素会导致迭代器报错这是为什么呢如何来解决这个问题呢
1. 除了编程语言中基础类库提供的针对集合对象的迭代器之外实际上迭代器还有其他的应用场景比如MySQL ResultSet类提供的first()、last()、previous()等方法,也可以看作一种迭代器,你能分析一下它的代码实现吗?
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,349 @@
<audio id="audio" title="66 | 迭代器模式(中):遍历集合的同时,为什么不能增删集合元素?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/21/b4/21baae81b9e452b99b4a3b20411279b4.mp3"></audio>
上一节课中我们通过给ArrayList、LinkedList容器实现迭代器学习了迭代器模式的原理、实现和设计意图。迭代器模式主要作用是解耦容器代码和遍历代码这也印证了我们前面多次讲过的应用设计模式的主要目的是解耦。
上一节课中讲解的内容都比较基础,今天,我们来深挖一下,如果在使用迭代器遍历集合的同时增加、删除集合中的元素,会发生什么情况?应该如何应对?如何在遍历的同时安全地删除集合元素?
话不多说,让我们正式开始今天的内容吧!
## 在遍历的同时增删集合元素会发生什么?
在通过迭代器来遍历集合元素的同时,增加或者删除集合中的元素,有可能会导致某个元素被重复遍历或遍历不到。不过,并不是所有情况下都会遍历出错,有的时候也可以正常遍历,所以,这种行为称为**结果不可预期行为**或者**未决行为**,也就是说,运行结果到底是对还是错,要视情况而定。
怎么理解呢我们通过一个例子来解释一下。我们还是延续上一节课实现的ArrayList迭代器的例子。为了方便你查看我把相关的代码都重新拷贝到这里了。
```
public interface Iterator&lt;E&gt; {
boolean hasNext();
void next();
E currentItem();
}
public class ArrayIterator&lt;E&gt; implements Iterator&lt;E&gt; {
private int cursor;
private ArrayList&lt;E&gt; arrayList;
public ArrayIterator(ArrayList&lt;E&gt; arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
}
@Override
public boolean hasNext() {
return cursor &lt; arrayList.size();
}
@Override
public void next() {
cursor++;
}
@Override
public E currentItem() {
if (cursor &gt;= arrayList.size()) {
throw new NoSuchElementException();
}
return arrayList.get(cursor);
}
}
public interface List&lt;E&gt; {
Iterator iterator();
}
public class ArrayList&lt;E&gt; implements List&lt;E&gt; {
//...
public Iterator iterator() {
return new ArrayIterator(this);
}
//...
}
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
names.remove(&quot;a&quot;);
}
}
```
我们知道ArrayList底层对应的是数组这种数据结构在执行完第55行代码的时候数组中存储的是a、b、c、d四个元素迭代器的游标cursor指向元素a。当执行完第56行代码的时候游标指向元素b到这里都没有问题。
为了保持数组存储数据的连续性数组的删除操作会涉及元素的搬移详细的讲解你可以去看我的另一个专栏《数据结构与算法之美》。当执行到第57行代码的时候我们从数组中将元素a删除掉b、c、d三个元素会依次往前搬移一位这就会导致游标本来指向元素b现在变成了指向元素c。原本在执行完第56行代码之后我们还可以遍历到b、c、d三个元素但在执行完第57行代码之后我们只能遍历到c、d两个元素b遍历不到了。
对于上面的描述,我画了一张图,你可以对照着理解。
<img src="https://static001.geekbang.org/resource/image/d8/e9/d86223f2b0f996ebb2b21e5abbeceae9.jpg" alt="">
不过如果第57行代码删除的不是游标前面的元素元素a以及游标所在位置的元素元素b而是游标后面的元素元素c和d这样就不会存在任何问题了不会存在某个元素遍历不到的情况了。
所以我们前面说在遍历的过程中删除集合元素结果是不可预期的有时候没问题删除元素c或d有时候就有问题删除元素a或b这个要视情况而定到底删除的是哪个位置的元素就是这个意思。
在遍历的过程中删除集合元素,有可能会导致某个元素遍历不到,那在遍历的过程中添加集合元素,会发生什么情况呢?还是结合刚刚那个例子来讲解,我们将上面的代码稍微改造一下,把删除元素改为添加元素。具体的代码如下所示:
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
names.add(0, &quot;x&quot;);
}
}
```
在执行完第10行代码之后数组中包含a、b、c、d四个元素游标指向b这个元素已经跳过了元素a。在执行完第11行代码之后我们将x插入到下标为0的位置a、b、c、d四个元素依次往后移动一位。这个时候游标又重新指向了元素a。元素a被游标重复指向两次也就是说元素a存在被重复遍历的情况。
跟删除情况类似,如果我们在游标的后面添加元素,就不会存在任何问题。所以,在遍历的同时添加集合元素也是一种不可预期行为。
同样,对于上面的添加元素的情况,我们也画了一张图,如下所示,你可以对照着理解。
<img src="https://static001.geekbang.org/resource/image/4c/d2/4cd27c2dcdb2be169ef30194899c19d2.jpg" alt="">
## 如何应对遍历时改变集合导致的未决行为?
当通过迭代器来遍历集合的时候增加、删除集合元素会导致不可预期的遍历结果。实际上“不可预期”比直接出错更加可怕有的时候运行正确有的时候运行错误一些隐藏很深、很难debug的bug就是这么产生的。那我们如何才能避免出现这种不可预期的运行结果呢
有两种比较干脆利索的解决方案:一种是遍历的时候不允许增删元素,另一种是增删元素之后让遍历报错。
实际上,第一种解决方案比较难实现,我们要确定遍历开始和结束的时间点。遍历开始的时间节点我们很容易获得。我们可以把创建迭代器的时间点作为遍历开始的时间点。但是,遍历结束的时间点该如何来确定呢?
你可能会说遍历到最后一个元素的时候就算结束呗。但是在实际的软件开发中每次使用迭代器来遍历元素并不一定非要把所有元素都遍历一遍。如下所示我们找到一个值为b的元素就提前结束了遍历。
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals(&quot;b&quot;)) {
break;
}
}
}
}
```
你可能还会说那我们可以在迭代器类中定义一个新的接口finishIteration(),主动告知容器迭代器使用完了,你可以增删元素了,示例代码如下所示。但是,这就要求程序员在使用完迭代器之后要主动调用这个函数,也增加了开发成本,还很容易漏掉。
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.currentItem();
if (name.equals(&quot;b&quot;)) {
iterator.finishIteration();//主动告知容器这个迭代器用完了
break;
}
}
}
}
```
实际上第二种解决方法更加合理。Java语言就是采用的这种解决方案增删元素之后让遍历报错。接下来我们具体来看一下如何实现。
怎么确定在遍历时候集合有没有增删元素呢我们在ArrayList中定义一个成员变量modCount记录集合被修改的次数集合每调用一次增加或删除元素的函数就会给modCount加1。当通过调用集合上的iterator()函数来创建迭代器的时候我们把modCount值传递给迭代器的expectedModCount成员变量之后每次调用迭代器上的hasNext()、next()、currentItem()函数我们都会检查集合上的modCount是否等于expectedModCount也就是看在创建完迭代器之后modCount是否改变过。
如果两个值不相同那就说明集合存储的元素已经改变了要么增加了元素要么删除了元素之前创建的迭代器已经不能正确运行了再继续使用就会产生不可预期的结果所以我们选择fail-fast解决方式抛出运行时异常结束掉程序让程序员尽快修复这个因为不正确使用迭代器而产生的bug。
上面的描述翻译成代码就是下面这样子。你可以结合着代码一起理解我刚才的讲解。
```
public class ArrayIterator implements Iterator {
private int cursor;
private ArrayList arrayList;
private int expectedModCount;
public ArrayIterator(ArrayList arrayList) {
this.cursor = 0;
this.arrayList = arrayList;
this.expectedModCount = arrayList.modCount;
}
@Override
public boolean hasNext() {
checkForComodification();
return cursor &lt; arrayList.size();
}
@Override
public void next() {
checkForComodification();
cursor++;
}
@Override
public Object currentItem() {
checkForComodification();
return arrayList.get(cursor);
}
private void checkForComodification() {
if (arrayList.modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
//代码示例
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
names.remove(&quot;a&quot;);
iterator.next();//抛出ConcurrentModificationException异常
}
}
```
## 如何在遍历的同时安全地删除集合元素?
像Java语言迭代器类中除了前面提到的几个最基本的方法之外还定义了一个remove()方法,能够在遍历集合的同时,安全地删除集合中的元素。不过,需要说明的是,它并没有提供添加元素的方法。毕竟迭代器的主要作用是遍历,添加元素放到迭代器里本身就不合适。
我个人觉得Java迭代器中提供的remove()方法还是比较鸡肋的作用有限。它只能删除游标指向的前一个元素而且一个next()函数之后只能跟着最多一个remove()操作多次调用remove()操作会报错。我还是通过一个例子来解释一下。
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator = names.iterator();
iterator.next();
iterator.remove();
iterator.remove(); //报错抛出IllegalStateException异常
}
}
```
现在我们一块来看下为什么通过迭代器就能安全的删除集合中的元素呢源码之下无秘密。我们来看下remove()函数是如何实现的代码如下所示。稍微提醒一下在Java实现中迭代器类是容器类的内部类并且next()函数不仅将游标后移一位,还会返回当前的元素。
```
public class ArrayList&lt;E&gt; {
transient Object[] elementData;
private int size;
public Iterator&lt;E&gt; iterator() {
return new Itr();
}
private class Itr implements Iterator&lt;E&gt; {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;
Itr() {}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings(&quot;unchecked&quot;)
public E next() {
checkForComodification();
int i = cursor;
if (i &gt;= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i &gt;= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
public void remove() {
if (lastRet &lt; 0)
throw new IllegalStateException();
checkForComodification();
try {
ArrayList.this.remove(lastRet);
cursor = lastRet;
lastRet = -1;
expectedModCount = modCount;
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}
}
}
```
在上面的代码实现中迭代器类新增了一个lastRet成员变量用来记录游标指向的前一个元素。通过迭代器去删除这个元素的时候我们可以更新迭代器中的游标和lastRet值来保证不会因为删除元素而导致某个元素遍历不到。如果通过容器来删除元素并且希望更新迭代器中的游标值来保证遍历不出错我们就要维护这个容器都创建了哪些迭代器每个迭代器是否还在使用等信息代码实现就变得比较复杂了。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
在通过迭代器来遍历集合元素的同时增加或者删除集合中的元素有可能会导致某个元素被重复遍历或遍历不到。不过并不是所有情况下都会遍历出错有的时候也可以正常遍历所以这种行为称为结果不可预期行为或者未决行为。实际上“不可预期”比直接出错更加可怕有的时候运行正确有的时候运行错误一些隐藏很深、很难debug的bug就是这么产生的。
有两种比较干脆利索的解决方案来避免出现这种不可预期的运行结果。一种是遍历的时候不允许增删元素另一种是增删元素之后让遍历报错。第一种解决方案比较难实现因为很难确定迭代器使用结束的时间点。第二种解决方案更加合理。Java语言就是采用的这种解决方案。增删元素之后我们选择fail-fast解决方式让遍历操作直接抛出运行时异常。
像Java语言迭代器类中除了前面提到的几个最基本的方法之外还定义了一个remove()方法,能够在遍历集合的同时,安全地删除集合中的元素。
## 课堂讨论
1、基于文章中给出的Java迭代器的实现代码如果一个容器对象同时创建了两个迭代器一个迭代器调用了remove()方法删除了集合中的一个元素那另一个迭代器是否还可用或者我换个问法下面代码中的第13行的运行结果是什么
```
public class Demo {
public static void main(String[] args) {
List&lt;String&gt; names = new ArrayList&lt;&gt;();
names.add(&quot;a&quot;);
names.add(&quot;b&quot;);
names.add(&quot;c&quot;);
names.add(&quot;d&quot;);
Iterator&lt;String&gt; iterator1 = names.iterator();
Iterator&lt;String&gt; iterator2 = names.iterator();
iterator1.next();
iterator1.remove();
iterator2.next(); // 运行结果?
}
}
```
2、LinkedList底层基于链表如果在遍历的同时增加删除元素会出现哪些不可预期的行为呢
欢迎留言和我分享你的想法。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,271 @@
<audio id="audio" title="67 | 迭代器模式如何设计实现一个支持“快照”功能的iterator" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/34/e3/34113447e067e27945bd0aa575a34ee3.mp3"></audio>
上两节课,我们学习了迭代器模式的原理、实现,并且分析了在遍历集合的同时增删集合元素,产生不可预期结果的原因以及应对策略。
今天,我们再来看这样一个问题:如何实现一个支持“快照”功能的迭代器?这个问题算是对上一节课内容的延伸思考,为的是帮你加深对迭代器模式的理解,也是对你分析、解决问题的一种锻炼。你可以把它当作一个面试题或者练习题,在看我的讲解之前,先试一试自己能否顺利回答上来。
话不多说,让我们正式开始今天的学习吧!
## 问题描述
我们先来介绍一下问题的背景:如何实现一个支持“快照”功能的迭代器模式?
理解这个问题最关键的是理解“快照”两个字。所谓“快照”指我们为容器创建迭代器的时候相当于给容器拍了一张快照Snapshot。之后即便我们增删容器中的元素快照中的元素并不会做相应的改动。而迭代器遍历的对象是快照而非容器这样就避免了在使用迭代器遍历的过程中增删容器中的元素导致的不可预期的结果或者报错。
接下来我举一个例子来解释一下上面这段话。具体的代码如下所示。容器list中初始存储了3、8、2三个元素。尽管在创建迭代器iter1之后容器list删除了元素3只剩下8、2两个元素但是通过iter1遍历的对象是快照而非容器list本身。所以遍历的结果仍然是3、8、2。同理iter2、iter3也是在各自的快照上遍历输出的结果如代码中注释所示。
```
List&lt;Integer&gt; list = new ArrayList&lt;&gt;();
list.add(3);
list.add(8);
list.add(2);
Iterator&lt;Integer&gt; iter1 = list.iterator();//snapshot: 3, 8, 2
list.remove(new Integer(2));//list3, 8
Iterator&lt;Integer&gt; iter2 = list.iterator();//snapshot: 3, 8
list.remove(new Integer(3));//list8
Iterator&lt;Integer&gt; iter3 = list.iterator();//snapshot: 3
// 输出结果3 8 2
while (iter1.hasNext()) {
System.out.print(iter1.next() + &quot; &quot;);
}
System.out.println();
// 输出结果3 8
while (iter2.hasNext()) {
System.out.print(iter1.next() + &quot; &quot;);
}
System.out.println();
// 输出结果8
while (iter3.hasNext()) {
System.out.print(iter1.next() + &quot; &quot;);
}
System.out.println();
```
如果由你来实现上面的功能你会如何来做呢下面是针对这个功能需求的骨架代码其中包含ArrayList、SnapshotArrayIterator两个类。对于这两个类我只定义了必须的几个关键接口完整的代码实现我并没有给出。你可以试着去完善一下然后再看我下面的讲解。
```
public ArrayList&lt;E&gt; implements List&lt;E&gt; {
// TODO: 成员变量、私有函数等随便你定义
@Override
public void add(E obj) {
//TODO: 由你来完善
}
@Override
public void remove(E obj) {
// TODO: 由你来完善
}
@Override
public Iterator&lt;E&gt; iterator() {
return new SnapshotArrayIterator(this);
}
}
public class SnapshotArrayIterator&lt;E&gt; implements Iterator&lt;E&gt; {
// TODO: 成员变量、私有函数等随便你定义
@Override
public boolean hasNext() {
// TODO: 由你来完善
}
@Override
public E next() {//返回当前元素,并且游标后移一位
// TODO: 由你来完善
}
}
```
## 解决方案一
我们先来看最简单的一种解决办法。在迭代器类中定义一个成员变量snapshot来存储快照。每当创建迭代器的时候都拷贝一份容器中的元素到快照中后续的遍历操作都基于这个迭代器自己持有的快照来进行。具体的代码实现如下所示
```
public class SnapshotArrayIterator&lt;E&gt; implements Iterator&lt;E&gt; {
private int cursor;
private ArrayList&lt;E&gt; snapshot;
public SnapshotArrayIterator(ArrayList&lt;E&gt; arrayList) {
this.cursor = 0;
this.snapshot = new ArrayList&lt;&gt;();
this.snapshot.addAll(arrayList);
}
@Override
public boolean hasNext() {
return cursor &lt; snapshot.size();
}
@Override
public E next() {
E currentItem = snapshot.get(cursor);
cursor++;
return currentItem;
}
}
```
这个解决方案虽然简单但代价也有点高。每次创建迭代器的时候都要拷贝一份数据到快照中会增加内存的消耗。如果一个容器同时有多个迭代器在遍历元素就会导致数据在内存中重复存储多份。不过庆幸的是Java中的拷贝属于浅拷贝也就是说容器中的对象并非真的拷贝了多份而只是拷贝了对象的引用而已。关于深拷贝、浅拷贝我们在[第47讲](https://time.geekbang.org/column/article/200786)中有详细的讲解,你可以回过头去再看一下。
那有没有什么方法,既可以支持快照,又不需要拷贝容器呢?
## 解决方案二
我们再来看第二种解决方案。
我们可以在容器中为每个元素保存两个时间戳一个是添加时间戳addTimestamp一个是删除时间戳delTimestamp。当元素被加入到集合中的时候我们将addTimestamp设置为当前时间将delTimestamp设置成最大长整型值Long.MAX_VALUE。当元素被删除时我们将delTimestamp更新为当前时间表示已经被删除。
注意,这里只是标记删除,而非真正将它从容器中删除。
同时每个迭代器也保存一个迭代器创建时间戳snapshotTimestamp也就是迭代器对应的快照的创建时间戳。当使用迭代器来遍历容器的时候只有满足addTimestamp&lt;snapshotTimestamp&lt;delTimestamp的元素才是属于这个迭代器的快照。
如果元素的addTimestamp&gt;snapshotTimestamp说明元素在创建了迭代器之后才加入的不属于这个迭代器的快照如果元素的delTimestamp&lt;snapshotTimestamp说明元素在创建迭代器之前就被删除掉了也不属于这个迭代器的快照。
这样就在不拷贝容器的情况下在容器本身上借助时间戳实现了快照功能。具体的代码实现如下所示。注意我们没有考虑ArrayList的扩容问题感兴趣的话你可以自己完善一下。
```
public class ArrayList&lt;E&gt; implements List&lt;E&gt; {
private static final int DEFAULT_CAPACITY = 10;
private int actualSize; //不包含标记删除元素
private int totalSize; //包含标记删除元素
private Object[] elements;
private long[] addTimestamps;
private long[] delTimestamps;
public ArrayList() {
this.elements = new Object[DEFAULT_CAPACITY];
this.addTimestamps = new long[DEFAULT_CAPACITY];
this.delTimestamps = new long[DEFAULT_CAPACITY];
this.totalSize = 0;
this.actualSize = 0;
}
@Override
public void add(E obj) {
elements[totalSize] = obj;
addTimestamps[totalSize] = System.currentTimeMillis();
delTimestamps[totalSize] = Long.MAX_VALUE;
totalSize++;
actualSize++;
}
@Override
public void remove(E obj) {
for (int i = 0; i &lt; totalSize; ++i) {
if (elements[i].equals(obj)) {
delTimestamps[i] = System.currentTimeMillis();
actualSize--;
}
}
}
public int actualSize() {
return this.actualSize;
}
public int totalSize() {
return this.totalSize;
}
public E get(int i) {
if (i &gt;= totalSize) {
throw new IndexOutOfBoundsException();
}
return (E)elements[i];
}
public long getAddTimestamp(int i) {
if (i &gt;= totalSize) {
throw new IndexOutOfBoundsException();
}
return addTimestamps[i];
}
public long getDelTimestamp(int i) {
if (i &gt;= totalSize) {
throw new IndexOutOfBoundsException();
}
return delTimestamps[i];
}
}
public class SnapshotArrayIterator&lt;E&gt; implements Iterator&lt;E&gt; {
private long snapshotTimestamp;
private int cursorInAll; // 在整个容器中的下标,而非快照中的下标
private int leftCount; // 快照中还有几个元素未被遍历
private ArrayList&lt;E&gt; arrayList;
public SnapshotArrayIterator(ArrayList&lt;E&gt; arrayList) {
this.snapshotTimestamp = System.currentTimeMillis();
this.cursorInAll = 0;
this.leftCount = arrayList.actualSize();;
this.arrayList = arrayList;
justNext(); // 先跳到这个迭代器快照的第一个元素
}
@Override
public boolean hasNext() {
return this.leftCount &gt;= 0; // 注意是&gt;=, 而非&gt;
}
@Override
public E next() {
E currentItem = arrayList.get(cursorInAll);
justNext();
return currentItem;
}
private void justNext() {
while (cursorInAll &lt; arrayList.totalSize()) {
long addTimestamp = arrayList.getAddTimestamp(cursorInAll);
long delTimestamp = arrayList.getDelTimestamp(cursorInAll);
if (snapshotTimestamp &gt; addTimestamp &amp;&amp; snapshotTimestamp &lt; delTimestamp) {
leftCount--;
break;
}
cursorInAll++;
}
}
}
```
实际上上面的解决方案相当于解决了一个问题又引入了另外一个问题。ArrayList底层依赖数组这种数据结构原本可以支持快速的随机访问在O(1)时间复杂度内获取下标为i的元素但现在删除数据并非真正的删除只是通过时间戳来标记删除这就导致无法支持按照下标快速随机访问了。如果你对数组随机访问这块知识点不了解可以去看我的《数据结构与算法之美》专栏这里我就不展开讲解了。
现在,我们来看怎么解决这个问题:让容器既支持快照遍历,又支持随机访问?
解决的方法也不难我稍微提示一下。我们可以在ArrayList中存储两个数组。一个支持标记删除的用来实现快照遍历功能一个不支持标记删除的也就是将要删除的数据直接从数组中移除用来支持随机访问。对应的代码我这里就不给出了感兴趣的话你可以自己实现一下。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
今天我们讲了如何实现一个支持“快照”功能的迭代器。其实这个问题本身并不是学习的重点,因为在真实的项目开发中,我们几乎不会遇到这样的需求。所以,基于今天的内容我不想做过多的总结。我想和你说一说,为什么我要来讲今天的内容呢?
实际上学习本节课的内容如果你只是从前往后看一遍看懂就觉得ok了那收获几乎是零。一个好学习方法是把它当作一个思考题或者面试题在看我的讲解之前自己主动思考如何解决并且把解决方案用代码实现一遍然后再来看跟我的讲解有哪些区别。这个过程对你分析问题、解决问题的能力的锻炼代码设计能力、编码能力的锻炼才是最有价值的才是我们这篇文章的意义所在。所谓“知识是死的能力才是活的”就是这个道理。
其实,不仅仅是这一节的内容,整个专栏的学习都是这样的。
在《数据结构与算法之美》专栏中,有同学曾经对我说,他看了很多遍我的专栏,几乎看懂了所有的内容,他觉得都掌握了,但是,在最近第一次面试中,面试官给他出了一个结合实际开发的算法题,他还是没有思路,当时脑子一片放空,问我学完这个专栏之后,要想应付算法面试,还要学哪些东西,有没有推荐的书籍。
我看了他的面试题之后发现,用我专栏里讲的知识是完全可以解决的,而且,专栏里已经讲过类似的问题,只是换了个业务背景而已。之所以他没法回答上来,还是没有将知识转化成解决问题的能力,因为他只是被动地“看”,从来没有主动地“思考”。**只掌握了知识,没锻炼能力,遇到实际的问题还是没法自己去分析、思考、解决**。
我给他的建议是,把专栏里的每个开篇问题都当做面试题,自己去思考一下,然后再看解答。这样整个专栏学下来,对能力的锻炼就多了,再遇到算法面试也就不会一点思路都没有了。同理,学习《设计模式之美》这个专栏也应该如此。
## 课堂讨论
在今天讲的解决方案二中,删除元素只是被标记删除。被删除的元素即便在没有迭代器使用的情况下,也不会从数组中真正移除,这就会导致不必要的内存占用。针对这个问题,你有进一步优化的方法吗?
欢迎留言和我分享你的思考。如果有收获,欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,399 @@
<audio id="audio" title="68 | 访问者模式(上):手把手带你还原访问者模式诞生的思维过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/cd/e7/cd33d13c85802e56daeba1d13dbe6ae7.mp3"></audio>
前面我们讲到大部分设计模式的原理和实现都很简单不过也有例外比如今天要讲的访问者模式。它可以算是23种经典设计模式中最难理解的几个之一。因为它难理解、难实现应用它会导致代码的可读性、可维护性变差所以访问者模式在实际的软件开发中很少被用到在没有特别必要的情况下建议你不要使用访问者模式。
尽管如此,为了让你以后读到应用了访问者模式的代码的时候,能一眼就能看出代码的设计意图,同时为了整个专栏内容的完整性,我觉得还是有必要给你讲一讲这个模式。除此之外,为了最大化学习效果,我今天不只是单纯地讲解原理和实现,更重要的是,我会手把手带你还原访问者模式诞生的思维过程,让你切身感受到创造一种新的设计模式出来并不是件难事。
话不多说,让我们正式开始今天的学习吧!
## 带你“发明”访问者模式
假设我们从网站上爬取了很多资源文件它们的格式有三种PDF、PPT、Word。我们现在要开发一个工具来处理这批资源文件。这个工具的其中一个功能是把这些资源文件中的文本内容抽取出来放到txt文件中。如果让你来实现你会怎么来做呢
实现这个功能并不难不同的人有不同的写法我将其中一种代码实现方式贴在这里。其中ResourceFile是一个抽象类包含一个抽象函数extract2txt()。PdfFile、PPTFile、WordFile都继承ResourceFile类并且重写了extract2txt()函数。在ToolApplication中我们可以利用多态特性根据对象的实际类型来决定执行哪个方法。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
public abstract void extract2txt();
}
public class PPTFile extends ResourceFile {
public PPTFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...省略一大坨从PPT中抽取文本的代码...
//...将抽取出来的文本保存在跟filePath同名的.txt文件中...
System.out.println(&quot;Extract PPT.&quot;);
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
}
public class WordFile extends ResourceFile {
public WordFile(String filePath) {
super(filePath);
}
@Override
public void extract2txt() {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
// 运行结果是:
// Extract PDF.
// Extract WORD.
// Extract PPT.
public class ToolApplication {
public static void main(String[] args) {
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.extract2txt();
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
如果工具的功能不停地扩展,不仅要能抽取文本内容,还要支持压缩、提取文件元信息(文件名、大小、更新时间等等)构建索引等一系列的功能,那如果我们继续按照上面的实现思路,就会存在这样几个问题:
- 违背开闭原则,添加一个新的功能,所有类的代码都要修改;
- 虽然功能增多,每个类的代码都不断膨胀,可读性和可维护性都变差了;
- 把所有比较上层的业务逻辑都耦合到PdfFile、PPTFile、WordFile类中导致这些类的职责不够单一变成了大杂烩。
针对上面的问题,我们常用的解决方法就是拆分解耦,把业务操作跟具体的数据结构解耦,设计成独立的类。这里我们按照访问者模式的演进思路来对上面的代码进行重构。重构之后的代码如下所示。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
//...
}
//...PPTFile、WordFile代码省略...
public class Extractor {
public void extract2txt(PPTFile pptFile) {
//...
System.out.println(&quot;Extract PPT.&quot;);
}
public void extract2txt(PdfFile pdfFile) {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
public void extract2txt(WordFile wordFile) {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
extractor.extract2txt(resourceFile);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
这其中最关键的一点设计是我们把抽取文本内容的操作设计成了三个重载函数。函数重载是Java、C++这类面向对象编程语言中常见的语法机制。所谓重载函数是指,在同一类中函数名相同、参数不同的一组函数。
不过如果你足够细心就会发现上面的代码是编译通过不了的第37行会报错。这是为什么呢
我们知道,多态是一种动态绑定,可以在运行时获取对象的实际类型,来运行实际类型对应的方法。而函数重载是一种静态绑定,在编译时并不能获取对象的实际类型,而是根据声明类型执行声明类型对应的方法。
在上面代码的第3538行中resourceFiles包含的对象的声明类型都是ResourceFile而我们并没有在Extractor类中定义参数类型是ResourceFile的extract2txt()重载函数,所以在编译阶段就通过不了,更别说在运行时根据对象的实际类型执行不同的重载函数了。那如何解决这个问题呢?
解决的办法稍微有点难理解,我们先来看代码,然后我再来给你慢慢解释。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Extractor extractor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Extractor extractor) {
extractor.extract2txt(this);
}
//...
}
//...PPTFile、WordFile跟PdfFile类似这里就省略了...
//...Extractor代码不变...
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
在执行第30行的时候根据多态特性程序会调用实际类型的accept函数比如PdfFile的accept函数也就是第16行代码。而16行代码中的this类型是PdfFile的在编译的时候就确定了所以会调用extractor的extract2txt(PdfFile pdfFile)这个重载函数。这个实现思路是不是很有技巧?这是理解访问者模式的关键所在,也是我之前所说的访问者模式不好理解的原因。
现在如果要继续添加新的功能比如前面提到的压缩功能根据不同的文件类型使用不同的压缩算法来压缩资源文件那我们该如何实现呢我们需要实现一个类似Extractor类的新类Compressor类在其中定义三个重载函数实现对不同类型资源文件的压缩。除此之外我们还要在每个资源文件类中定义新的accept重载函数。具体的代码如下所示
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Extractor extractor);
abstract public void accept(Compressor compressor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Extractor extractor) {
extractor.extract2txt(this);
}
@Override
public void accept(Compressor compressor) {
compressor.compress(this);
}
//...
}
}
//...PPTFile、WordFile跟PdfFile类似这里就省略了...
//...Extractor代码不变
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
Compressor compressor = new Compressor();
for(ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(compressor);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
上面代码还存在一些问题添加一个新的业务还是需要修改每个资源文件类违反了开闭原则。针对这个问题我们抽象出来一个Visitor接口包含是三个命名非常通用的visit()重载函数分别处理三种不同类型的资源文件。具体做什么业务处理由实现这个Visitor接口的具体的类来决定比如Extractor负责抽取文本内容Compressor负责压缩。当我们新添加一个业务功能的时候资源文件类不需要做任何修改只需要修改ToolApplication的代码就可以了。
按照这个思路我们可以对代码进行重构,重构之后的代码如下所示:
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
abstract public void accept(Visitor vistor);
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public void accept(Visitor visitor) {
visitor.visit(this);
}
//...
}
//...PPTFile、WordFile跟PdfFile类似这里就省略了...
public interface Visitor {
void visit(PdfFile pdfFile);
void visit(PPTFile pdfFile);
void visit(WordFile pdfFile);
}
public class Extractor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println(&quot;Extract PPT.&quot;);
}
@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
@Override
public void visit(WordFile wordFile) {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
public class Compressor implements Visitor {
@Override
public void visit(PPTFile pptFile) {
//...
System.out.println(&quot;Compress PPT.&quot;);
}
@Override
public void visit(PdfFile pdfFile) {
//...
System.out.println(&quot;Compress PDF.&quot;);
}
@Override
public void visit(WordFile wordFile) {
//...
System.out.println(&quot;Compress WORD.&quot;);
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(extractor);
}
Compressor compressor = new Compressor();
for(ResourceFile resourceFile : resourceFiles) {
resourceFile.accept(compressor);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
## 重新来看访问者模式
刚刚我带你一步一步还原了访问者模式诞生的思维过程,现在,我们回过头来总结一下,这个模式的原理和代码实现。
访问者者模式的英文翻译是Visitor Design Pattern。在GoF的《设计模式》一书中它是这么定义的
>
Allows for one or more operation to be applied to a set of objects at runtime, decoupling the operations from the object structure.
翻译成中文就是:允许一个或者多个操作应用到一组对象上,解耦操作和对象本身。
定义比较简单,结合前面的例子不难理解,我就不过多解释了。对于访问者模式的代码实现,实际上,在上面例子中,经过层层重构之后的最终代码,就是标准的访问者模式的实现代码。这里,我又总结了一张类图,贴在了下面,你可以对照着前面的例子代码一块儿来看一下。
<img src="https://static001.geekbang.org/resource/image/c4/65/c42c636c5384da5bd5343618305db865.jpg" alt="">
最后,我们再来看下,访问者模式的应用场景。
一般来说访问者模式针对的是一组类型不同的对象PdfFile、PPTFile、WordFile。不过尽管这组对象的类型是不同的但是它们继承相同的父类ResourceFile或者实现相同的接口。在不同的应用场景下我们需要对这组对象进行一系列不相关的业务操作抽取文本、压缩等但为了避免不断添加功能导致类PdfFile、PPTFile、WordFile不断膨胀职责越来越不单一以及避免频繁地添加功能导致的频繁代码修改我们使用访问者模式将对象与操作解耦将这些业务操作抽离出来定义在独立细分的访问者类Extractor、Compressor中。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
访问者模式允许一个或者多个操作应用到一组对象上,设计意图是解耦操作和对象本身,保持类职责单一、满足开闭原则以及应对代码的复杂性。
对于访问者模式,学习的主要难点在代码实现。而代码实现比较复杂的主要原因是,函数重载在大部分面向对象编程语言中是静态绑定的。也就是说,调用类的哪个重载函数,是在编译期间,由参数的声明类型决定的,而非运行时,根据参数的实际类型决定的。
正是因为代码实现难理解,所以,在项目中应用这种模式,会导致代码的可读性比较差。如果你的同事不了解这种设计模式,可能就会读不懂、维护不了你写的代码。所以,除非不得已,不要使用这种模式。
## 课堂讨论
实际上,今天举的例子不用访问者模式也可以搞定,你能够想到其他实现思路吗?
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,248 @@
<audio id="audio" title="69 | 访问者模式(下):为什么支持双分派的语言不需要访问者模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/bc/61ec4e74700e14f9cbd45c8ba308c8bc.mp3"></audio>
上一节课中,我们学习了访问者模式的原理和实现,并且还原了访问者模式诞生的思维过程。总体上来讲,这个模式的代码实现比较难,所以应用场景并不多。从应用开发的角度来说,它的确不是我们学习的重点。
不过,我们前面反复说过,学习我的专栏,并不只是让你掌握知识,更重要的是锻炼你分析、解决问题的能力,锻炼你的逻辑思维能力,所以,今天我们继续把访问者模式作为引子,一块讨论一下这样两个问题,希望能激发你的深度思考:
- 为什么支持双分派的语言不需要访问者模式呢?
- 除了访问者模式,上一节课中的例子还有其他实现方案吗?
话不多说,让我们正式开始今天的学习吧!
## 为什么支持双分派的语言不需要访问者模式?
实际上讲到访问者模式大部分书籍或者资料都会讲到Double Dispatch中文翻译为双分派。虽然学习访问者模式并不用非得理解这个概念我们前面的讲解就没有提到它但是为了让你在查看其它书籍或者资料的时候不会卡在这个概念上我觉得有必要在这里讲一下。
除此之外我觉得学习Double Dispatch还能加深你对访问者模式的理解而且能一并帮你搞清楚今天文章标题中的这个问题为什么支持双分派的语言就不需要访问者模式这个问题在面试中可是会被问到的哦
既然有Double Dispatch对应的就有Single Dispatch。所谓**Single Dispatch**,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的编译时类型来决定。所谓**Double Dispatch**,指的是执行哪个对象的方法,根据对象的运行时类型来决定;执行对象的哪个方法,根据方法参数的运行时类型来决定。
**如何理解“Dispatch”这个单词呢** 在面向对象编程语言中我们可以把方法调用理解为一种消息传递也就是“Dispatch”。一个对象调用另一个对象的方法就相当于给它发送一条消息。这条消息起码要包含对象名、方法名、方法参数。
**如何理解“Single”“Double”这两个单词呢**“Single”“Double”指的是执行哪个对象的哪个方法跟几个因素的运行时类型有关。我们进一步解释一下。Single Dispatch之所以称为“Single”是因为执行哪个对象的哪个方法只跟“对象”的运行时类型有关。Double Dispatch之所以称为“Double”是因为执行哪个对象的哪个方法跟“对象”和“方法参数”两者的运行时类型有关。
具体到编程语言的语法机制Single Dispatch和Double Dispatch跟多态和函数重载直接相关。当前主流的面向对象编程语言比如Java、C++、C#都只支持Single Dispatch不支持Double Dispatch。
接下来我们拿Java语言来举例说明一下。
Java支持多态特性代码可以在运行时获得对象的实际类型也就是前面提到的运行时类型然后根据实际类型决定调用哪个方法。尽管Java支持函数重载但Java设计的函数重载的语法规则是并不是在运行时根据传递进函数的参数的实际类型来决定调用哪个重载函数而是在编译时根据传递进函数的参数的声明类型也就是前面提到的编译时类型来决定调用哪个重载函数。也就是说具体执行哪个对象的哪个方法只跟对象的运行时类型有关跟参数的运行时类型无关。所以Java语言只支持Single Dispatch。
这么说比较抽象,我举个例子来具体说明一下,代码如下所示:
```
public class ParentClass {
public void f() {
System.out.println(&quot;I am ParentClass's f().&quot;);
}
}
public class ChildClass extends ParentClass {
public void f() {
System.out.println(&quot;I am ChildClass's f().&quot;);
}
}
public class SingleDispatchClass {
public void polymorphismFunction(ParentClass p) {
p.f();
}
public void overloadFunction(ParentClass p) {
System.out.println(&quot;I am overloadFunction(ParentClass p).&quot;);
}
public void overloadFunction(ChildClass c) {
System.out.println(&quot;I am overloadFunction(ChildClass c).&quot;);
}
}
public class DemoMain {
public static void main(String[] args) {
SingleDispatchClass demo = new SingleDispatchClass();
ParentClass p = new ChildClass();
demo.polymorphismFunction(p);//执行哪个对象的方法,由对象的实际类型决定
demo.overloadFunction(p);//执行对象的哪个方法,由参数对象的声明类型决定
}
}
//代码执行结果:
I am ChildClass's f().
I am overloadFunction(ParentClass p).
```
在上面的代码中第31行代码的polymorphismFunction()函数执行p的实际类型的f()函数也就是ChildClass的f()函数。第32行代码的overloadFunction()函数匹配的是重载函数中的overloadFunction(ParentClass p)也就是根据p的声明类型来决定匹配哪个重载函数。
假设Java语言支持Double Dispatch那下面的代码摘抄自上节课中第二段代码建议结合上节课的讲解一块理解中的第37行就不会报错。代码会在运行时根据参数resourceFile的实际类型PdfFile、PPTFile、WordFile来决定使用extract2txt的三个重载函数中的哪一个。那下面的代码实现就能正常运行了也就不需要访问者模式了。这也回答了为什么支持Double Dispatch的语言不需要访问者模式。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
//...
}
//...PPTFile、WordFile代码省略...
public class Extractor {
public void extract2txt(PPTFile pptFile) {
//...
System.out.println(&quot;Extract PPT.&quot;);
}
public void extract2txt(PdfFile pdfFile) {
//...
System.out.println(&quot;Extract PDF.&quot;);
}
public void extract2txt(WordFile wordFile) {
//...
System.out.println(&quot;Extract WORD.&quot;);
}
}
public class ToolApplication {
public static void main(String[] args) {
Extractor extractor = new Extractor();
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
extractor.extract2txt(resourceFile);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
## 除了访问者模式,上一节的例子还有其他实现方案吗?
上节课我通过一个例子来给你展示了访问者模式是如何一步一步设计出来的。我们这里再一块回顾一下那个例子。我们从网站上爬取了很多资源文件它们的格式有三种PDF、PPT、Word。我们要开发一个工具来处理这批资源文件这其中就包含抽取文本内容、压缩资源文件、提取文件元信息等。
实际上开发这个工具有很多种代码设计和实现思路。为了讲解访问者模式上节课我们选择了用访问者模式来实现。实际上我们还有其他的实现方法比如我们还可以利用工厂模式来实现定义一个包含extract2txt()接口函数的Extractor接口。PdfExtractor、PPTExtractor、WordExtractor类实现Extractor接口并且在各自的extract2txt()函数中分别实现Pdf、PPT、Word格式文件的文本内容抽取。ExtractorFactory工厂类根据不同的文件类型返回不同的Extractor。
这个实现思路其实更加简单,我们直接看代码。
```
public abstract class ResourceFile {
protected String filePath;
public ResourceFile(String filePath) {
this.filePath = filePath;
}
public abstract ResourceFileType getType();
}
public class PdfFile extends ResourceFile {
public PdfFile(String filePath) {
super(filePath);
}
@Override
public ResourceFileType getType() {
return ResourceFileType.PDF;
}
//...
}
//...PPTFile/WordFile跟PdfFile代码结构类似此处省略...
public interface Extractor {
void extract2txt(ResourceFile resourceFile);
}
public class PdfExtractor implements Extractor {
@Override
public void extract2txt(ResourceFile resourceFile) {
//...
}
}
//...PPTExtractor/WordExtractor跟PdfExtractor代码结构类似此处省略...
public class ExtractorFactory {
private static final Map&lt;ResourceFileType, Extractor&gt; extractors = new HashMap&lt;&gt;();
static {
extractors.put(ResourceFileType.PDF, new PdfExtractor());
extractors.put(ResourceFileType.PPT, new PPTExtractor());
extractors.put(ResourceFileType.WORD, new WordExtractor());
}
public static Extractor getExtractor(ResourceFileType type) {
return extractors.get(type);
}
}
public class ToolApplication {
public static void main(String[] args) {
List&lt;ResourceFile&gt; resourceFiles = listAllResourceFiles(args[0]);
for (ResourceFile resourceFile : resourceFiles) {
Extractor extractor = ExtractorFactory.getExtractor(resourceFile.getType());
extractor.extract2txt(resourceFile);
}
}
private static List&lt;ResourceFile&gt; listAllResourceFiles(String resourceDirectory) {
List&lt;ResourceFile&gt; resourceFiles = new ArrayList&lt;&gt;();
//...根据后缀(pdf/ppt/word)由工厂方法创建不同的类对象(PdfFile/PPTFile/WordFile)
resourceFiles.add(new PdfFile(&quot;a.pdf&quot;));
resourceFiles.add(new WordFile(&quot;b.word&quot;));
resourceFiles.add(new PPTFile(&quot;c.ppt&quot;));
return resourceFiles;
}
}
```
当需要添加新的功能的时候比如压缩资源文件类似抽取文本内容功能的代码实现我们只需要添加一个Compressor接口PdfCompressor、PPTCompressor、WordCompressor三个实现类以及创建它们的CompressorFactory工厂类即可。唯一需要修改的只有最上层的ToolApplication类。基本上符合“对扩展开放、对修改关闭”的设计原则。
对于资源文件处理工具这个例子,如果工具提供的功能并不是非常多,只有几个而已,那我更推荐使用工厂模式的实现方式,毕竟代码更加清晰、易懂。相反,如果工具提供非常多的功能,比如有十几个,那我更推荐使用访问者模式,因为访问者模式需要定义的类要比工厂模式的实现方式少很多,类太多也会影响到代码的可维护性。
## 重点回顾
好了,今天内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
总体上来讲,访问者模式难以理解,应用场景有限,不是特别必需,我不建议在项目中使用它。所以,对于上节课中的处理资源文件的例子,我更推荐使用工厂模式来设计和实现。
除此之外我们今天重点讲解了Double Dispatch。在面向对象编程语言中方法调用可以理解为一种消息传递Dispatch。一个对象调用另一个对象的方法就相当于给它发送一条消息这条消息起码要包含对象名、方法名和方法参数。
所谓Single Dispatch指的是执行哪个对象的方法根据对象的运行时类型来决定执行对象的哪个方法根据方法参数的编译时类型来决定。所谓Double Dispatch指的是执行哪个对象的方法根据对象的运行时类型来决定执行对象的哪个方法根据方法参数的运行时类型来决定。
具体到编程语言的语法机制Single Dispatch和Double Dispatch跟多态和函数重载直接相关。当前主流的面向对象编程语言比如Java、C++、C#都只支持Single Dispatch不支持Double Dispatch。
## 课堂讨论
1. 访问者模式将操作与对象分离,是否违背面向对象设计原则?你怎么看待这个问题呢?
1. 在解释Single Dispatch的代码示例中如果我们把SingleDispatchClass的代码改成下面这样其他代码不变那DemoMain的输出结果会是什么呢为什么会是这样的结果呢
```
public class SingleDispatchClass {
public void polymorphismFunction(ParentClass p) {
p.f();
}
public void overloadFunction(ParentClass p) {
p.f();
}
public void overloadFunction(ChildClass c) {
c.f();
}
}
```
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,202 @@
<audio id="audio" title="70 | 备忘录模式:对于大对象的备份和恢复,如何优化内存和时间的消耗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c5/11/c516c58085171935bcf176471c63b711.mp3"></audio>
上两节课我们学习了访问者模式。在23种设计模式中访问者模式的原理和实现可以说是最难理解的了特别是它的代码实现。其中用Single Dispatch来模拟Double Dispatch的实现思路尤其不好理解。不知道你有没有将它拿下呢如果还没有弄得很清楚那就要多看几遍、多自己动脑经琢磨一下。
今天,我们学习另外一种行为型模式,备忘录模式。这个模式理解、掌握起来不难,代码实现比较灵活,应用场景也比较明确和有限,主要是用来防丢失、撤销、恢复等。所以,相对于上两节课,今天的内容学起来相对会比较轻松些。
话不多说,让我们正式开始今天的学习吧!
## 备忘录模式的原理与实现
备忘录模式也叫快照Snapshot模式英文翻译是Memento Design Pattern。在GoF的《设计模式》一书中备忘录模式是这么定义的
>
Captures and externalizes an objects internal state so that it can be restored later, all without violating encapsulation.
翻译成中文就是:在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。
在我看来,这个模式的定义主要表达了两部分内容。一部分是,存储副本以便后期恢复。这一部分很好理解。另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。这部分不太好理解。接下来,我就结合一个例子来解释一下,特别带你搞清楚这两个问题:
- 为什么存储和恢复副本会违背封装原则?
- 备忘录模式是如何做到不违背封装原则的?
假设有这样一道面试题,希望你编写一个小程序,可以接收命令行的输入。用户输入文本时,程序将其追加存储在内存文本中;用户输入“:list”程序在命令行中输出内存文本的内容用户输入“:undo”程序会撤销上一次输入的文本也就是从内存文本中将上次输入的文本删除掉。
我举了个小例子来解释一下这个需求,如下所示:
```
&gt;hello
&gt;:list
hello
&gt;world
&gt;:list
helloworld
&gt;:undo
&gt;:list
hello
```
怎么来编程实现呢你可以打开IDE自己先试着编写一下然后再看我下面的讲解。整体上来讲这个小程序实现起来并不复杂。我写了一种实现思路如下所示
```
public class InputText {
private StringBuilder text = new StringBuilder();
public String getText() {
return text.toString();
}
public void append(String input) {
text.append(input);
}
public void setText(String text) {
this.text.replace(0, this.text.length(), text);
}
}
public class SnapshotHolder {
private Stack&lt;InputText&gt; snapshots = new Stack&lt;&gt;();
public InputText popSnapshot() {
return snapshots.pop();
}
public void pushSnapshot(InputText inputText) {
InputText deepClonedInputText = new InputText();
deepClonedInputText.setText(inputText.getText());
snapshots.push(deepClonedInputText);
}
}
public class ApplicationMain {
public static void main(String[] args) {
InputText inputText = new InputText();
SnapshotHolder snapshotsHolder = new SnapshotHolder();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals(&quot;:list&quot;)) {
System.out.println(inputText.getText());
} else if (input.equals(&quot;:undo&quot;)) {
InputText snapshot = snapshotsHolder.popSnapshot();
inputText.setText(snapshot.getText());
} else {
snapshotsHolder.pushSnapshot(inputText);
inputText.append(input);
}
}
}
}
```
实际上,备忘录模式的实现很灵活,也没有很固定的实现方式,在不同的业务需求、不同编程语言下,代码实现可能都不大一样。上面的代码基本上已经实现了最基本的备忘录的功能。但是,如果我们深究一下的话,还有一些问题要解决,那就是前面定义中提到的第二点:要在不违背封装原则的前提下,进行对象的备份和恢复。而上面的代码并不满足这一点,主要体现在下面两方面:
- 第一为了能用快照恢复InputText对象我们在InputText类中定义了setText()函数,但这个函数有可能会被其他业务使用,所以,暴露不应该暴露的函数违背了封装原则;
- 第二快照本身是不可变的理论上讲不应该包含任何set()等修改内部状态的函数但在上面的代码实现中“快照“这个业务模型复用了InputText类的定义而InputText类本身有一系列修改内部状态的函数所以用InputText类来表示快照违背了封装原则。
针对以上问题我们对代码做两点修改。其一定义一个独立的类Snapshot类来表示快照而不是复用InputText类。这个类只暴露get()方法没有set()等任何修改内部状态的方法。其二在InputText类中我们把setText()方法重命名为restoreSnapshot()方法,用意更加明确,只用来恢复对象。
按照这个思路,我们对代码进行重构。重构之后的代码如下所示:
```
public class InputText {
private StringBuilder text = new StringBuilder();
public String getText() {
return text.toString();
}
public void append(String input) {
text.append(input);
}
public Snapshot createSnapshot() {
return new Snapshot(text.toString());
}
public void restoreSnapshot(Snapshot snapshot) {
this.text.replace(0, this.text.length(), snapshot.getText());
}
}
public class Snapshot {
private String text;
public Snapshot(String text) {
this.text = text;
}
public String getText() {
return this.text;
}
}
public class SnapshotHolder {
private Stack&lt;Snapshot&gt; snapshots = new Stack&lt;&gt;();
public Snapshot popSnapshot() {
return snapshots.pop();
}
public void pushSnapshot(Snapshot snapshot) {
snapshots.push(snapshot);
}
}
public class ApplicationMain {
public static void main(String[] args) {
InputText inputText = new InputText();
SnapshotHolder snapshotsHolder = new SnapshotHolder();
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
String input = scanner.next();
if (input.equals(&quot;:list&quot;)) {
System.out.println(inputText.toString());
} else if (input.equals(&quot;:undo&quot;)) {
Snapshot snapshot = snapshotsHolder.popSnapshot();
inputText.restoreSnapshot(snapshot);
} else {
snapshotsHolder.pushSnapshot(inputText.createSnapshot());
inputText.append(input);
}
}
}
}
```
实际上上面的代码实现就是典型的备忘录模式的代码实现也是很多书籍包括GoF的《设计模式》中给出的实现方法。
除了备忘录模式,还有一个跟它很类似的概念,“备份”,它在我们平时的开发中更常听到。那备忘录模式跟“备份”有什么区别和联系呢?实际上,这两者的应用场景很类似,都应用在防丢失、恢复、撤销等场景中。它们的区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。这个不难理解,这里我就不多说了。
## 如何优化内存和时间消耗?
前面我们只是简单介绍了备忘录模式的原理和经典实现,现在我们再继续深挖一下。如果要备份的对象数据比较大,备份频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时会比较长。这个问题该如何解决呢?
不同的应用场景下有不同的解决方法。比如我们前面举的那个例子应用场景是利用备忘录来实现撤销操作而且仅仅支持顺序撤销也就是说每次操作只能撤销上一次的输入不能跳过上次输入撤销之前的输入。在具有这样特点的应用场景下为了节省内存我们不需要在快照中存储完整的文本只需要记录少许信息比如在获取快照当下的文本长度用这个值结合InputText类对象存储的文本来做撤销操作。
我们再举一个例子。假设每当有数据改动,我们都需要生成一个备份,以备之后恢复。如果需要备份的数据很大,这样高频率的备份,不管是对存储(内存或者硬盘)的消耗,还是对时间的消耗,都可能是无法接受的。想要解决这个问题,我们一般会采用“低频率全量备份”和“高频率增量备份”相结合的方法。
全量备份就不用讲了,它跟我们上面的例子类似,就是把所有的数据“拍个快照”保存下来。所谓“增量备份”,指的是记录每次操作或数据变动。
当我们需要恢复到某一时间点的备份的时候,如果这一时间点有做全量备份,我们直接拿来恢复就可以了。如果这一时间点没有对应的全量备份,我们就先找到最近的一次全量备份,然后用它来恢复,之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或者数据变动。这样就能减少全量备份的数量和频率,减少对时间、内存的消耗。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
备忘录模式也叫快照模式,具体来说,就是在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象为先前的状态。这个模式的定义表达了两部分内容:一部分是,存储副本以便后期恢复;另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。
备忘录模式的应用场景也比较明确和有限,主要是用来防丢失、撤销、恢复等。它跟平时我们常说的“备份”很相似。两者的主要区别在于,备忘录模式更侧重于代码的设计和实现,备份更侧重架构设计或产品设计。
对于大对象的备份来说,备份占用的存储空间会比较大,备份和恢复的耗时会比较长。针对这个问题,不同的业务场景有不同的处理方式。比如,只备份必要的恢复信息,结合最新的数据来恢复;再比如,全量备份和增量备份相结合,低频全量备份,高频增量备份,两者结合来做恢复。
## 课堂讨论
今天我们讲到备份在架构或产品设计中比较常见比如重启Chrome可以选择恢复之前打开的页面你还能想到其他类似的应用场景吗
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,139 @@
<audio id="audio" title="71 | 命令模式:如何利用命令模式实现一个手游后端架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/bb/9a36afd78f4cada33b95d6c92d9669bb.mp3"></audio>
设计模式模块已经接近尾声了现在我们只剩下3个模式还没有学习它们分别是命令模式、解释器模式、中介模式。这3个模式使用频率低、理解难度大只在非常特定的应用场景下才会用到所以不是我们学习的重点你只需要稍微了解见了能认识就可以了。
今天呢,我们来学习其中的命令模式。在学习这个模式的过程中,你可能会遇到的最大的疑惑是,感觉命令模式没啥用,是一种过度设计,有更加简单的设计思路可以替代。所以,我今天讲解的重点是这个模式的设计意图,带你搞清楚到底什么情况下才真正需要使用它。
话不多说,让我们正式开始今天的学习吧!
## 命令模式的原理解读
命令模式的英文翻译是Command Design Pattern。在GoF的《设计模式》一书中它是这么定义的
>
The command pattern encapsulates a request as an object, thereby letting us parameterize other objects with different requests, queue or log requests, and support undoable operations.
翻译成中文就是下面这样。为了帮助你理解,我对这个翻译稍微做了补充和解释,也一起放在了下面的括号中。
命令模式将请求(命令)封装为一个对象,这样可以使用不同的请求参数化其他对象(将不同请求依赖注入到其他对象),并且能够支持请求(命令)的排队执行、记录日志、撤销等(附加控制)功能。
对于GoF给出的定义我这里再进一步解读一下。
落实到编码实现命令模式用的最核心的实现手段是将函数封装成对象。我们知道C语言支持函数指针我们可以把函数当作变量传递来传递去。但是在大部分编程语言中函数没法儿作为参数传递给其他函数也没法儿赋值给变量。借助命令模式我们可以将函数封装成对象。具体来说就是设计一个包含这个函数的类实例化一个对象传来传去这样就可以实现把函数像对象一样使用。从实现的角度来说它类似我们之前讲过的回调。
当我们把函数封装成对象之后,对象就可以存储下来,方便控制执行。所以,命令模式的主要作用和应用场景,是用来控制命令的执行,比如,异步、延迟、排队执行命令、撤销重做命令、存储命令、给命令记录日志等等,这才是命令模式能发挥独一无二作用的地方。
## 命令模式的实战讲解
上面的讲解比较偏理论,比较不好理解,我这里再结合一个具体的例子来解释一下。
假设我们正在开发一个类似《天天酷跑》或者《QQ卡丁车》这样的手游。这种游戏本身的复杂度集中在客户端。后端基本上只负责数据比如积分、生命值、装备的更新和查询所以后端逻辑相对于客户端来说要简单很多。
考虑到你可能对游戏开发不熟悉,我这里稍微交代一些背景知识。
为了提高性能,我们会把游戏中玩家的信息保存在内存中。在游戏进行的过程中,只更新内存中的数据,游戏结束之后,再将内存中的数据存档,也就是持久化到数据库中。为了降低实现的难度,一般来说,同一个游戏场景里的玩家,会被分配到同一台服务上。这样,一个玩家拉取同一个游戏场景中的其他玩家的信息,就不需要跨服务器去查找了,实现起来就简单了很多。
一般来说游戏客户端和服务器之间的数据交互是比较频繁的所以为了节省网络连接建立的开销客户端和服务器之间一般采用长连接的方式来通信。通信的格式有多种比如Protocol Buffer、JSON、XML甚至可以自定义格式。不管是什么格式客户端发送给服务器的请求一般都包括两部分内容指令和数据。其中指令我们也可以叫作事件数据是执行这个指令所需的数据。
服务器在接收到客户端的请求之后,会解析出指令和数据,并且根据指令的不同,执行不同的处理逻辑。对于这样的一个业务场景,一般有两种架构实现思路。
常用的一种实现思路是利用多线程。一个线程接收请求,接收到请求之后,启动一个新的线程来处理请求。具体点讲,一般是通过一个主线程来接收客户端发来的请求。每当接收到一个请求之后,就从一个专门用来处理请求的线程池中,捞出一个空闲线程来处理。
另一种实现思路是在一个线程内轮询接收请求和处理请求。这种处理方式不太常见。尽管它无法利用多线程多核处理的优势但是对于IO密集型的业务来说它避免了多线程不停切换对性能的损耗并且克服了多线程编程Bug比较难调试的缺点也算是手游后端服务器开发中比较常见的架构模式了。
我们接下来就重点讲一下第二种实现方式。
整个手游后端服务器轮询获取客户端发来的请求,获取到请求之后,借助命令模式,把请求包含的数据和处理逻辑封装为命令对象,并存储在内存队列中。然后,再从队列中取出一定数量的命令来执行。执行完成之后,再重新开始新的一轮轮询。具体的示例代码如下所示,你可以结合着一块看下。
```
public interface Command {
void execute();
}
public class GotDiamondCommand implements Command {
// 省略成员变量
public GotDiamondCommand(/*数据*/) {
//...
}
@Override
public void execute() {
// 执行相应的逻辑
}
}
//GotStartCommand/HitObstacleCommand/ArchiveCommand类省略
public class GameApplication {
private static final int MAX_HANDLED_REQ_COUNT_PER_LOOP = 100;
private Queue&lt;Command&gt; queue = new LinkedList&lt;&gt;();
public void mainloop() {
while (true) {
List&lt;Request&gt; requests = new ArrayList&lt;&gt;();
//省略从epoll或者select中获取数据并封装成Request的逻辑
//注意设置超时时间,如果很长时间没有接收到请求,就继续下面的逻辑处理。
for (Request request : requests) {
Event event = request.getEvent();
Command command = null;
if (event.equals(Event.GOT_DIAMOND)) {
command = new GotDiamondCommand(/*数据*/);
} else if (event.equals(Event.GOT_STAR)) {
command = new GotStartCommand(/*数据*/);
} else if (event.equals(Event.HIT_OBSTACLE)) {
command = new HitObstacleCommand(/*数据*/);
} else if (event.equals(Event.ARCHIVE)) {
command = new ArchiveCommand(/*数据*/);
} // ...一堆else if...
queue.add(command);
}
int handledCount = 0;
while (handledCount &lt; MAX_HANDLED_REQ_COUNT_PER_LOOP) {
if (queue.isEmpty()) {
break;
}
Command command = queue.poll();
command.execute();
}
}
}
}
```
## 命令模式 VS 策略模式
看了刚才的讲解,你可能会觉得,命令模式跟策略模式、工厂模式非常相似啊,那它们的区别在哪里呢?不仅如此,在留言区中我还看到有不止一个同学反映,感觉学过的很多模式都很相似。不知道你有没有类似的感觉呢?
实际上,这个问题我之前简单提到过,可能没有作为重点来说,有些同学印象不是很深刻,这里我就再跟你讲一讲。
实际上,每个设计模式都应该由两部分组成:第一部分是应用场景,即这个模式可以解决哪类问题;第二部分是解决方案,即这个模式的设计思路和具体的代码实现。不过,代码实现并不是模式必须包含的。如果你单纯地只关注解决方案这一部分,甚至只关注代码实现,就会产生大部分模式看起来都很相似的错觉。
实际上,设计模式之间的主要区别还是在于设计意图,也就是应用场景。单纯地看设计思路或者代码实现,有些模式确实很相似,比如策略模式和工厂模式。
之前讲策略模式的时候,我们有讲到,策略模式包含策略的定义、创建和使用三部分,从代码结构上来,它非常像工厂模式。它们的区别在于,策略模式侧重“策略”或“算法”这个特定的应用场景,用来解决根据运行时状态从一组策略中选择不同策略的问题,而工厂模式侧重封装对象的创建过程,这里的对象没有任何业务场景的限定,可以是策略,但也可以是其他东西。从设计意图上来,这两个模式完全是两回事儿。
有了刚刚的铺垫,接下来,我们再来看命令模式跟策略模式的区别。你可能会觉得,命令的执行逻辑也可以看作策略,那它是不是就是策略模式了呢?实际上,这两者有一点细微的区别。
在策略模式中不同的策略具有相同的目的、不同的实现、互相之间可以替换。比如BubbleSort、SelectionSort都是为了实现排序的只不过一个是用冒泡排序算法来实现的另一个是用选择排序算法来实现的。而在命令模式中不同的命令具有不同的目的对应不同的处理逻辑并且互相之间不可替换。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
命令模式在平时工作中并不常用,你稍微了解一下就可以。今天,我重点讲解了它的设计意图,也就是能解决什么问题。
落实到编码实现,命令模式用到最核心的实现手段,就是将函数封装成对象。我们知道,在大部分编程语言中,函数是没法作为参数传递给其他函数的,也没法赋值给变量。借助命令模式,我们将函数封装成对象,这样就可以实现把函数像对象一样使用。
命令模式的主要作用和应用场景,是用来控制命令的执行,比如,异步、延迟、排队执行命令、撤销重做命令、存储命令、给命令记录日志等等,这才是命令模式能发挥独一无二作用的地方。
## 课堂讨论
从我们已经学过的这些设计模式中,找两个代码实现或者设计思路很相似的模式,说一说它们的不同点。
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,360 @@
<audio id="audio" title="72 | 解释器模式:如何设计实现一个自定义接口告警规则功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/04/f9/0413f0c64e70074f61d0e2f2163924f9.mp3"></audio>
上一节课,我们学习了命令模式。命令模式将请求封装成对象,方便作为函数参数传递和赋值给变量。它主要的应用场景是给命令的执行附加功能,换句话说,就是控制命令的执行,比如,排队、异步、延迟执行命令、给命令执行记录日志、撤销重做命令等等。总体上来讲,命令模式的应用范围并不广。
今天,我们来学习解释器模式,它用来描述如何构建一个简单的“语言”解释器。比起命令模式,解释器模式更加小众,只在一些特定的领域会被用到,比如编译器、规则引擎、正则表达式。所以,解释器模式也不是我们学习的重点,你稍微了解一下就可以了。
话不多说,让我们正式开始今天的学习吧!
## 解释器模式的原理和实现
解释器模式的英文翻译是Interpreter Design Pattern。在GoF的《设计模式》一书中它是这样定义的
>
Interpreter pattern is used to defines a grammatical representation for a language and provides an interpreter to deal with this grammar.
翻译成中文就是:解释器模式为某个语言定义它的语法(或者叫文法)表示,并定义一个解释器用来处理这个语法。
看了定义,你估计会一头雾水,因为这里面有很多我们平时开发中很少接触的概念,比如“语言”“语法”“解释器”。实际上,这里的“语言”不仅仅指我们平时说的中、英、日、法等各种语言。从广义上来讲,只要是能承载信息的载体,我们都可以称之为“语言”,比如,古代的结绳记事、盲文、哑语、摩斯密码等。
要想了解“语言”表达的信息,我们就必须定义相应的语法规则。这样,书写者就可以根据语法规则来书写“句子”(专业点的叫法应该是“表达式”),阅读者根据语法规则来阅读“句子”,这样才能做到信息的正确传递。而我们要讲的解释器模式,其实就是用来实现根据语法规则解读“句子”的解释器。
为了让你更好地理解定义,我举一个比较贴近生活的例子来解释一下。
实际上理解这个概念我们可以类比中英文翻译。我们知道把英文翻译成中文是有一定规则的。这个规则就是定义中的“语法”。我们开发一个类似Google Translate这样的翻译器这个翻译器能够根据语法规则将输入的中文翻译成英文。这里的翻译器就是解释器模式定义中的“解释器”。
刚刚翻译器这个例子比较贴近生活,现在,我们再举个更加贴近编程的例子。
假设我们定义了一个新的加减乘除计算“语言”,语法规则如下:
- 运算符只包含加、减、乘、除,并且没有优先级的概念;
- 表达式(也就是前面提到的“句子”)中,先书写数字,后书写运算符,空格隔开;
- 按照先后顺序,取出两个数字和一个运算符计算结果,结果重新放入数字的最头部位置,循环上述过程,直到只剩下一个数字,这个数字就是表达式最终的计算结果。
我们举个例子来解释一下上面的语法规则。
比如“ 8 3 2 4 - + * ”这样一个表达式我们按照上面的语法规则来处理取出数字“8 3”和“-”运算符计算得到5于是表达式就变成了“ 5 2 4 + * ”。然后,我们再取出“ 5 2 ”和“ + ”运算符计算得到7表达式就变成了“ 7 4 * ”。最后,我们取出“ 7 4”和“ * ”运算符最终得到的结果就是28。
看懂了上面的语法规则我们将它用代码实现出来如下所示。代码非常简单用户按照上面的规则书写表达式传递给interpret()函数,就可以得到最终的计算结果。
```
public class ExpressionInterpreter {
private Deque&lt;Long&gt; numbers = new LinkedList&lt;&gt;();
public long interpret(String expression) {
String[] elements = expression.split(&quot; &quot;);
int length = elements.length;
for (int i = 0; i &lt; (length+1)/2; ++i) {
numbers.addLast(Long.parseLong(elements[i]));
}
for (int i = (length+1)/2; i &lt; length; ++i) {
String operator = elements[i];
boolean isValid = &quot;+&quot;.equals(operator) || &quot;-&quot;.equals(operator)
|| &quot;*&quot;.equals(operator) || &quot;/&quot;.equals(operator);
if (!isValid) {
throw new RuntimeException(&quot;Expression is invalid: &quot; + expression);
}
long number1 = numbers.pollFirst();
long number2 = numbers.pollFirst();
long result = 0;
if (operator.equals(&quot;+&quot;)) {
result = number1 + number2;
} else if (operator.equals(&quot;-&quot;)) {
result = number1 - number2;
} else if (operator.equals(&quot;*&quot;)) {
result = number1 * number2;
} else if (operator.equals(&quot;/&quot;)) {
result = number1 / number2;
}
numbers.addFirst(result);
}
if (numbers.size() != 1) {
throw new RuntimeException(&quot;Expression is invalid: &quot; + expression);
}
return numbers.pop();
}
}
```
在上面的代码实现中语法规则的解析逻辑第23、25、27、29行都集中在一个函数中对于简单的语法规则的解析这样的设计就足够了。但是对于复杂的语法规则的解析逻辑复杂代码量多所有的解析逻辑都耦合在一个函数中这样显然是不合适的。这个时候我们就要考虑拆分代码将解析逻辑拆分到独立的小类中。
该怎么拆分呢?我们可以借助解释器模式。
解释器模式的代码实现比较灵活,没有固定的模板。我们前面也说过,应用设计模式主要是应对代码的复杂性,实际上,解释器模式也不例外。它的代码实现的核心思想,就是将语法解析的工作拆分到各个小类中,以此来避免大而全的解析类。一般的做法是,将语法规则拆分成一些小的独立的单元,然后对每个单元进行解析,最终合并为对整个语法规则的解析。
前面定义的语法规则有两类表达式一类是数字一类是运算符运算符又包括加减乘除。利用解释器模式我们把解析的工作拆分到NumberExpression、AdditionExpression、SubstractionExpression、MultiplicationExpression、DivisionExpression这样五个解析类中。
按照这个思路,我们对代码进行重构,重构之后的代码如下所示。当然,因为加减乘除表达式的解析比较简单,利用解释器模式的设计思路,看起来有点过度设计。不过呢,这里我主要是为了解释原理,你明白意思就好,不用过度细究这个例子。
```
public interface Expression {
long interpret();
}
public class NumberExpression implements Expression {
private long number;
public NumberExpression(long number) {
this.number = number;
}
public NumberExpression(String number) {
this.number = Long.parseLong(number);
}
@Override
public long interpret() {
return this.number;
}
}
public class AdditionExpression implements Expression {
private Expression exp1;
private Expression exp2;
public AdditionExpression(Expression exp1, Expression exp2) {
this.exp1 = exp1;
this.exp2 = exp2;
}
@Override
public long interpret() {
return exp1.interpret() + exp2.interpret();
}
}
// SubstractionExpression/MultiplicationExpression/DivisionExpression与AdditionExpression代码结构类似这里就省略了
public class ExpressionInterpreter {
private Deque&lt;Expression&gt; numbers = new LinkedList&lt;&gt;();
public long interpret(String expression) {
String[] elements = expression.split(&quot; &quot;);
int length = elements.length;
for (int i = 0; i &lt; (length+1)/2; ++i) {
numbers.addLast(new NumberExpression(elements[i]));
}
for (int i = (length+1)/2; i &lt; length; ++i) {
String operator = elements[i];
boolean isValid = &quot;+&quot;.equals(operator) || &quot;-&quot;.equals(operator)
|| &quot;*&quot;.equals(operator) || &quot;/&quot;.equals(operator);
if (!isValid) {
throw new RuntimeException(&quot;Expression is invalid: &quot; + expression);
}
Expression exp1 = numbers.pollFirst();
Expression exp2 = numbers.pollFirst();
Expression combinedExp = null;
if (operator.equals(&quot;+&quot;)) {
combinedExp = new AdditionExpression(exp1, exp2);
} else if (operator.equals(&quot;-&quot;)) {
combinedExp = new AdditionExpression(exp1, exp2);
} else if (operator.equals(&quot;*&quot;)) {
combinedExp = new AdditionExpression(exp1, exp2);
} else if (operator.equals(&quot;/&quot;)) {
combinedExp = new AdditionExpression(exp1, exp2);
}
long result = combinedExp.interpret();
numbers.addFirst(new NumberExpression(result));
}
if (numbers.size() != 1) {
throw new RuntimeException(&quot;Expression is invalid: &quot; + expression);
}
return numbers.pop().interpret();
}
}
```
## 解释器模式实战举例
接下来,我们再来看一个更加接近实战的例子,也就是咱们今天标题中的问题:如何实现一个自定义接口告警规则功能?
在我们平时的项目开发中监控系统非常重要它可以时刻监控业务系统的运行情况及时将异常报告给开发者。比如如果每分钟接口出错数超过100监控系统就通过短信、微信、邮件等方式发送告警给开发者。
一般来讲监控系统支持开发者自定义告警规则比如我们可以用下面这样一个表达式来表示一个告警规则它表达的意思是每分钟API总出错数超过100或者每分钟API总调用数超过10000就触发告警。
```
api_error_per_minute &gt; 100 || api_count_per_minute &gt; 10000
```
在监控系统中告警模块只负责根据统计数据和告警规则判断是否触发告警。至于每分钟API接口出错数、每分钟接口调用数等统计数据的计算是由其他模块来负责的。其他模块将统计数据放到一个Map中数据的格式如下所示发送给告警模块。接下来我们只关注告警模块。
```
Map&lt;String, Long&gt; apiStat = new HashMap&lt;&gt;();
apiStat.put(&quot;api_error_per_minute&quot;, 103);
apiStat.put(&quot;api_count_per_minute&quot;, 987);
```
为了简化讲解和代码实现,我们假设自定义的告警规则只包含“||、&amp;&amp;&gt;&lt;、==”这五个运算符,其中,“&gt;&lt;、==”运算符的优先级高于“||、&amp;&amp;”运算符,“&amp;&amp;”运算符优先级高于“||”。在表达式中任意元素之间需要通过空格来分隔。除此之外用户可以自定义要监控的key比如前面的api_error_per_minute、api_count_per_minute。
那如何实现上面的需求呢?我写了一个骨架代码,如下所示,其中的核心的实现我没有给出,你可以当作面试题,自己试着去补全一下,然后再看我的讲解。
```
public class AlertRuleInterpreter {
// key1 &gt; 100 &amp;&amp; key2 &lt; 1000 || key3 == 200
public AlertRuleInterpreter(String ruleExpression) {
//TODO:由你来完善
}
//&lt;String, Long&gt; apiStat = new HashMap&lt;&gt;();
//apiStat.put(&quot;key1&quot;, 103);
//apiStat.put(&quot;key2&quot;, 987);
public boolean interpret(Map&lt;String, Long&gt; stats) {
//TODO:由你来完善
}
}
public class DemoTest {
public static void main(String[] args) {
String rule = &quot;key1 &gt; 100 &amp;&amp; key2 &lt; 30 || key3 &lt; 100 || key4 == 88&quot;;
AlertRuleInterpreter interpreter = new AlertRuleInterpreter(rule);
Map&lt;String, Long&gt; stats = new HashMap&lt;&gt;();
stats.put(&quot;key1&quot;, 101l);
stats.put(&quot;key3&quot;, 121l);
stats.put(&quot;key4&quot;, 88l);
boolean alert = interpreter.interpret(stats);
System.out.println(alert);
}
}
```
实际上,我们可以把自定义的告警规则,看作一种特殊“语言”的语法规则。我们实现一个解释器,能够根据规则,针对用户输入的数据,判断是否触发告警。利用解释器模式,我们把解析表达式的逻辑拆分到各个小类中,避免大而复杂的大类的出现。按照这个实现思路,我把刚刚的代码补全,如下所示,你可以拿你写的代码跟我写的对比一下。
```
public interface Expression {
boolean interpret(Map&lt;String, Long&gt; stats);
}
public class GreaterExpression implements Expression {
private String key;
private long value;
public GreaterExpression(String strExpression) {
String[] elements = strExpression.trim().split(&quot;\\s+&quot;);
if (elements.length != 3 || !elements[1].trim().equals(&quot;&gt;&quot;)) {
throw new RuntimeException(&quot;Expression is invalid: &quot; + strExpression);
}
this.key = elements[0].trim();
this.value = Long.parseLong(elements[2].trim());
}
public GreaterExpression(String key, long value) {
this.key = key;
this.value = value;
}
@Override
public boolean interpret(Map&lt;String, Long&gt; stats) {
if (!stats.containsKey(key)) {
return false;
}
long statValue = stats.get(key);
return statValue &gt; value;
}
}
// LessExpression/EqualExpression跟GreaterExpression代码类似这里就省略了
public class AndExpression implements Expression {
private List&lt;Expression&gt; expressions = new ArrayList&lt;&gt;();
public AndExpression(String strAndExpression) {
String[] strExpressions = strAndExpression.split(&quot;&amp;&amp;&quot;);
for (String strExpr : strExpressions) {
if (strExpr.contains(&quot;&gt;&quot;)) {
expressions.add(new GreaterExpression(strExpr));
} else if (strExpr.contains(&quot;&lt;&quot;)) {
expressions.add(new LessExpression(strExpr));
} else if (strExpr.contains(&quot;==&quot;)) {
expressions.add(new EqualExpression(strExpr));
} else {
throw new RuntimeException(&quot;Expression is invalid: &quot; + strAndExpression);
}
}
}
public AndExpression(List&lt;Expression&gt; expressions) {
this.expressions.addAll(expressions);
}
@Override
public boolean interpret(Map&lt;String, Long&gt; stats) {
for (Expression expr : expressions) {
if (!expr.interpret(stats)) {
return false;
}
}
return true;
}
}
public class OrExpression implements Expression {
private List&lt;Expression&gt; expressions = new ArrayList&lt;&gt;();
public OrExpression(String strOrExpression) {
String[] andExpressions = strOrExpression.split(&quot;\\|\\|&quot;);
for (String andExpr : andExpressions) {
expressions.add(new AndExpression(andExpr));
}
}
public OrExpression(List&lt;Expression&gt; expressions) {
this.expressions.addAll(expressions);
}
@Override
public boolean interpret(Map&lt;String, Long&gt; stats) {
for (Expression expr : expressions) {
if (expr.interpret(stats)) {
return true;
}
}
return false;
}
}
public class AlertRuleInterpreter {
private Expression expression;
public AlertRuleInterpreter(String ruleExpression) {
this.expression = new OrExpression(ruleExpression);
}
public boolean interpret(Map&lt;String, Long&gt; stats) {
return expression.interpret(stats);
}
}
```
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
解释器模式为某个语言定义它的语法(或者叫文法)表示,并定义一个解释器用来处理这个语法。实际上,这里的“语言”不仅仅指我们平时说的中、英、日、法等各种语言。从广义上来讲,只要是能承载信息的载体,我们都可以称之为“语言”,比如,古代的结绳记事、盲文、哑语、摩斯密码等。
要想了解“语言”要表达的信息,我们就必须定义相应的语法规则。这样,书写者就可以根据语法规则来书写“句子”(专业点的叫法应该是“表达式”),阅读者根据语法规则来阅读“句子”,这样才能做到信息的正确传递。而我们要讲的解释器模式,其实就是用来实现根据语法规则解读“句子”的解释器。
解释器模式的代码实现比较灵活,没有固定的模板。我们前面说过,应用设计模式主要是应对代码的复杂性,解释器模式也不例外。它的代码实现的核心思想,就是将语法解析的工作拆分到各个小类中,以此来避免大而全的解析类。一般的做法是,将语法规则拆分一些小的独立的单元,然后对每个单元进行解析,最终合并为对整个语法规则的解析。
## 课堂讨论
1.在你过往的项目经历或阅读源码的时候,有没有用到或者见过解释器模式呢?<br>
2.在告警规则解析的例子中,如果我们要在表达式中支持括号“()”,那如何对代码进行重构呢?你可以把它当作练习,试着编写一下代码。
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。

View File

@@ -0,0 +1,194 @@
<audio id="audio" title="73 | 中介模式:什么时候用中介模式?什么时候用观察者模式?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bd/a2/bd2fb998b91ffd160ff8a9dd810051a2.mp3"></audio>
今天我们来学习23种经典设计模式中的最后一个中介模式。跟前面刚刚讲过的命令模式、解释器模式类似中介模式也属于不怎么常用的模式应用场景比较特殊、有限但是跟它俩不同的是中介模式理解起来并不难代码实现也非常简单学习难度要小很多。
如果你对中介模式有所了解,你可能会知道,中介模式跟之前讲过的观察者模式有点相似,所以,今天我们还会详细讨论下这两种模式的区别。
话不多说,让我们正式开始今天的学习吧!
## 中介模式的原理和实现
中介模式的英文翻译是Mediator Design Pattern。在GoF中的《设计模式》一书中它是这样定义的
>
Mediator pattern defines a separate (mediator) object that encapsulates the interaction between a set of objects and the objects delegate their interaction to a mediator object instead of interacting with each other directly.
翻译成中文就是:中介模式定义了一个单独的(中介)对象,来封装一组对象之间的交互。将这组对象之间的交互委派给与中介对象交互,来避免对象之间的直接交互。
还记得我们在[第30节课](https://time.geekbang.org/column/article/187761)中讲的“如何给代码解耦”吗?其中一个方法就是引入中间层。
实际上中介模式的设计思想跟中间层很像通过引入中介这个中间层将一组对象之间的交互关系或者说依赖关系从多对多网状关系转换为一对多星状关系。原来一个对象要跟n个对象交互现在只需要跟一个中介对象交互从而最小化对象之间的交互关系降低了代码的复杂度提高了代码的可读性和可维护性。
这里我画了一张对象交互关系的对比图。其中,右边的交互图是利用中介模式对左边交互关系优化之后的结果,从图中我们可以很直观地看出,右边的交互关系更加清晰、简洁。
<img src="https://static001.geekbang.org/resource/image/43/9f/4376d541bf17a029f37aa76009ef3a9f.jpg" alt="">
提到中介模式,有一个比较经典的例子不得不说,那就是航空管制。
为了让飞机在飞行的时候互不干扰,每架飞机都需要知道其他飞机每时每刻的位置,这就需要时刻跟其他飞机通信。飞机通信形成的通信网络就会无比复杂。这个时候,我们通过引入“塔台”这样一个中介,让每架飞机只跟塔台来通信,发送自己的位置给塔台,由塔台来负责每架飞机的航线调度。这样就大大简化了通信网络。
刚刚举的是生活中的例子我们再举一个跟编程开发相关的例子。这个例子与UI控件有关算是中介模式比较经典的应用很多书籍在讲到中介模式的时候都会拿它来举例。
假设我们有一个比较复杂的对话框,对话框中有很多控件,比如按钮、文本框、下拉框等。当我们对某个控件进行操作的时候,其他控件会做出相应的反应,比如,我们在下拉框中选择“注册”,注册相关的控件就会显示在对话框中。如果我们在下拉框中选择“登陆”,登陆相关的控件就会显示在对话框中。
按照通常我们习惯的UI界面的开发方式我们将刚刚的需求用代码实现出来就是下面这个样子。在这种实现方式中控件和控件之间互相操作、互相依赖。
```
public class UIControl {
private static final String LOGIN_BTN_ID = &quot;login_btn&quot;;
private static final String REG_BTN_ID = &quot;reg_btn&quot;;
private static final String USERNAME_INPUT_ID = &quot;username_input&quot;;
private static final String PASSWORD_INPUT_ID = &quot;pswd_input&quot;;
private static final String REPEATED_PASSWORD_INPUT_ID = &quot;repeated_pswd_input&quot;;
private static final String HINT_TEXT_ID = &quot;hint_text&quot;;
private static final String SELECTION_ID = &quot;selection&quot;;
public static void main(String[] args) {
Button loginButton = (Button)findViewById(LOGIN_BTN_ID);
Button regButton = (Button)findViewById(REG_BTN_ID);
Input usernameInput = (Input)findViewById(USERNAME_INPUT_ID);
Input passwordInput = (Input)findViewById(PASSWORD_INPUT_ID);
Input repeatedPswdInput = (Input)findViewById(REPEATED_PASSWORD_INPUT_ID);
Text hintText = (Text)findViewById(HINT_TEXT_ID);
Selection selection = (Selection)findViewById(SELECTION_ID);
loginButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
String username = usernameInput.text();
String password = passwordInput.text();
//校验数据...
//做业务处理...
}
});
regButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
//获取usernameInput、passwordInput、repeatedPswdInput数据...
//校验数据...
//做业务处理...
}
});
//...省略selection下拉选择框相关代码....
}
}
```
我们再按照中介模式,将上面的代码重新实现一下。在新的代码实现中,各个控件只跟中介对象交互,中介对象负责所有业务逻辑的处理。
```
public interface Mediator {
void handleEvent(Component component, String event);
}
public class LandingPageDialog implements Mediator {
private Button loginButton;
private Button regButton;
private Selection selection;
private Input usernameInput;
private Input passwordInput;
private Input repeatedPswdInput;
private Text hintText;
@Override
public void handleEvent(Component component, String event) {
if (component.equals(loginButton)) {
String username = usernameInput.text();
String password = passwordInput.text();
//校验数据...
//做业务处理...
} else if (component.equals(regButton)) {
//获取usernameInput、passwordInput、repeatedPswdInput数据...
//校验数据...
//做业务处理...
} else if (component.equals(selection)) {
String selectedItem = selection.select();
if (selectedItem.equals(&quot;login&quot;)) {
usernameInput.show();
passwordInput.show();
repeatedPswdInput.hide();
hintText.hide();
//...省略其他代码
} else if (selectedItem.equals(&quot;register&quot;)) {
//....
}
}
}
}
public class UIControl {
private static final String LOGIN_BTN_ID = &quot;login_btn&quot;;
private static final String REG_BTN_ID = &quot;reg_btn&quot;;
private static final String USERNAME_INPUT_ID = &quot;username_input&quot;;
private static final String PASSWORD_INPUT_ID = &quot;pswd_input&quot;;
private static final String REPEATED_PASSWORD_INPUT_ID = &quot;repeated_pswd_input&quot;;
private static final String HINT_TEXT_ID = &quot;hint_text&quot;;
private static final String SELECTION_ID = &quot;selection&quot;;
public static void main(String[] args) {
Button loginButton = (Button)findViewById(LOGIN_BTN_ID);
Button regButton = (Button)findViewById(REG_BTN_ID);
Input usernameInput = (Input)findViewById(USERNAME_INPUT_ID);
Input passwordInput = (Input)findViewById(PASSWORD_INPUT_ID);
Input repeatedPswdInput = (Input)findViewById(REPEATED_PASSWORD_INPUT_ID);
Text hintText = (Text)findViewById(HINT_TEXT_ID);
Selection selection = (Selection)findViewById(SELECTION_ID);
Mediator dialog = new LandingPageDialog();
dialog.setLoginButton(loginButton);
dialog.setRegButton(regButton);
dialog.setUsernameInput(usernameInput);
dialog.setPasswordInput(passwordInput);
dialog.setRepeatedPswdInput(repeatedPswdInput);
dialog.setHintText(hintText);
dialog.setSelection(selection);
loginButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
dialog.handleEvent(loginButton, &quot;click&quot;);
}
});
regButton.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
dialog.handleEvent(regButton, &quot;click&quot;);
}
});
//....
}
}
```
从代码中我们可以看出原本业务逻辑会分散在各个控件中现在都集中到了中介类中。实际上这样做既有好处也有坏处。好处是简化了控件之间的交互坏处是中介类有可能会变成大而复杂的“上帝类”God Class。所以在使用中介模式的时候我们要根据实际的情况平衡对象之间交互的复杂度和中介类本身的复杂度。
## 中介模式 VS 观察者模式
前面讲观察者模式的时候我们讲到观察者模式有多种实现方式。虽然经典的实现方式没法彻底解耦观察者和被观察者观察者需要注册到被观察者中被观察者状态更新需要调用观察者的update()方法。但是,在跨进程的实现方式中,我们可以利用消息队列实现彻底解耦,观察者和被观察者都只需要跟消息队列交互,观察者完全不知道被观察者的存在,被观察者也完全不知道观察者的存在。
我们前面提到,中介模式也是为了解耦对象之间的交互,所有的参与者都只与中介进行交互。而观察者模式中的消息队列,就有点类似中介模式中的“中介”,观察者模式的中观察者和被观察者,就有点类似中介模式中的“参与者”。那问题来了:中介模式和观察者模式的区别在哪里呢?什么时候选择使用中介模式?什么时候选择使用观察者模式呢?
在观察者模式中,尽管一个参与者既可以是观察者,同时也可以是被观察者,但是,大部分情况下,交互关系往往都是单向的,一个参与者要么是观察者,要么是被观察者,不会兼具两种身份。也就是说,在观察者模式的应用场景中,参与者之间的交互关系比较有条理。
而中介模式正好相反。只有当参与者之间的交互关系错综复杂,维护成本很高的时候,我们才考虑使用中介模式。毕竟,中介模式的应用会带来一定的副作用,前面也讲到,它有可能会产生大而复杂的上帝类。除此之外,如果一个参与者状态的改变,其他参与者执行的操作有一定先后顺序的要求,这个时候,中介模式就可以利用中介类,通过先后调用不同参与者的方法,来实现顺序的控制,而观察者模式是无法实现这样的顺序要求的。
## 重点回顾
好了,今天的内容到此就讲完了。我们一块来总结回顾一下,你需要重点掌握的内容。
中介模式的设计思想跟中间层很像通过引入中介这个中间层将一组对象之间的交互关系或者依赖关系从多对多网状关系转换为一对多星状关系。原来一个对象要跟n个对象交互现在只需要跟一个中介对象交互从而最小化对象之间的交互关系降低了代码的复杂度提高了代码的可读性和可维护性。
观察者模式和中介模式都是为了实现参与者之间的解耦,简化交互关系。两者的不同在于应用场景上。在观察者模式的应用场景中,参与者之间的交互比较有条理,一般都是单向的,一个参与者只有一个身份,要么是观察者,要么是被观察者。而在中介模式的应用场景中,参与者之间的交互关系错综复杂,既可以是消息的发送者、也可以同时是消息的接收者。
## 课堂讨论
在讲观察者模式的时候我们有讲到EventBus框架。当时我们认为它是观察者模式的实现框架。EventBus作为一个事件处理的中心事件的派送、订阅都通过这个中心来完成那是不是更像中介模式的实现框架呢
欢迎留言和我分享你的想法。如果有收获,也欢迎你把这篇文章分享给你的朋友。