设计模式自从推出就一直很火,个人的体验是,模式运用存乎于心,理解最重要。重点是几个理念,从理念出发去理解模式;面向接口编程、消除重复、职责单一、接口隔离、开放-封闭等。而不是死记硬背和硬套各种模式。
本文从一个简单场景,结合理念,引出一些常用模式。
1、一个需求引发的模式大战
场景:设计一个文件读功能的模块
// 符合面向接口原则
设计一个 读接口 Reader;
interface Reader{
public void read(byte[] data);
}
// 普通流读写
class IOReader implements Reader{
}
// NIO通道读写
class NIOReader implements Reader{
}
// AIO异步通道读写
class NIOReader implements Reader{
}
产生策略模式 Strategy
对于一个操作,实现不同的策略读写
Client 应用,只需要持有一个 Reader的读写句柄(引用)即可。
编码后发现,读的步骤都差不多,打开文件、获取输入句柄、读取块、关闭文件,这些重复需要消除,进行重构
abstract class CommonReader implements Reader{
abstract void openHandle();
abstract void readSimple(byte[] data);
abstract void close();
void read(char[] data){
openHandle();
readSimple(data);
close();
}
}
class IOReader extends CommonReader{
}
这次重构产生了 Template Method 模板模式
基础抽象类,实现了算法逻辑
继承子类,实现算法过程的抽象
功能设计好后,应用开始使用
Reader myReader = new XXXReader();
此处 违背了开放-封闭、接口隔离原则,对于应用而言并不需要知道具体实现类,实现类的变化与应用无关,应用只需关注接口的功能实现。
Reader myReader = Factory.getReader();
产生 Factory 工厂模式/Abstact Factory 抽象工厂模式
由工厂提供具体的实现
最简单,工厂里面根据情况直接new 对象
稍微深入一点,通过反射 依赖查找,实现动态可配置
最终就是Ioc的概念,依赖注入;你看,依赖注入其实并不复杂。
另外,从这里可以认识,接口和抽象类的本质区别了吧。
新增需求一,能够读写 hdfs 集群上的文件,hdfs上的文件系统是单独构建的文件系统,和普通的文件系统API不同
class HdfsReader implements Reader{
HdfsFileSystem system
void read(char[] data){
system.operationXXX();
}
}
产生 adapter 适配器模式
两个系统融合,或者扩展两个接口,或者持有另一个系统的对象, 把相关操作委托给这个系统对象
因为读写集群连接比较耗资源,HdfsFileSystem 只需要一个实例,并维护。
class HdfsFileSystem{
private HdfsFileSystem();
}
产生 Singleton 单例模式
- 单例模式有很多变化
饿汉:一开始就提供实例化的对象
懒汉:用户第一次使用时就提供对象
枚举:因为在多线程情况下的问题(未实例化就被引用),强烈推荐 枚举方式的单例
新增需求二,现在每次读取先需要记录日志
首先想到的是修改 CommonReader,HdfsReader。
但,代码貌似重复了,更重要的是 职责不单一了,读 Reader 不应该关注其他功能。
class LoggerReader implements Reader{
Reader reader
void read(char[] data){
logOperationXXX;
reader.read(data);
}
}
产生 proxy 代理模式
把对一个对象的调用 代理/委托给 另一个对象
屏蔽隔离一个对象的直接调用
典型应用,java Collections 的只读、线程安全包装;java的动态代理
新增需求三,读取需要记录线程号,要判断是否有读取权限......
这些功能可以任意组合,如果扩展子类,则不符合 职责单一;更容易产生重复
ThreadReader implements Reader
SecurityReader implements Reader
产生 Decorator 装饰者模式
不同功能的实现,可以不断叠加和组合在基础功能之上。
典型应用 Java IO 流 zip(buffer(file))
adapter VS proxy VS Decorator
三种模式看起来类似,区别在于使用的目的和用途不同
adapter - 两套异构系统对接时,对上层应用统一接口,在adapter类里面实现差异转换
proxy - 隔离、拦截 对目标对象的访问,经典的AOP模式就来源于此
Decorator - 同源,差异的功能叠加和组合
新增需求四,监控凌晨3:00 - 5:00 读取文件的发消息通知告警
按照职责单一告警当然是一个独立的模块,假设告警接口
interface Alarm{
void alarm(String msg);
}
怎么通知呢,当前对象肯定要知道被通知的对象,也就是说需要持有被通知对象的句柄(引用)
class AlarmReader implements Reader{
Reader reader;
Alarm alarm;
public void setAlarm(Alarm alarm){
this.alarm = alarm;
}
// 是否凌晨
boolean isNight();
void sendMessage(String msg){
alarm.alarm(msg);
}
void read(char[] data){
if(isNight)
sendMessage
}
}
AlarmA implements Alarm{
AlarmReader reader;
public void register(){
reader.setAlarm(this);
}
}
如果有多个告警组件的话
classAlarmReader implements Reader{
Reader reader;
List alarmList;
public void add(Alarm alarm){
alarmList.add(alarm);
}
void sendMessage(String msg){
for(Alarm alram: alarmList)
alarm.alarm(msg);
}
void read(char[] data){
if(isNight)
sendMessage
}
}
产生Observer 观察者模式
一个对象关注另一个对象的状态。通过向这个对象注册,在对象状态发生变化时,通知关注的对象;观察者模式,在事件处理中非常多
观察者模式有很多演进
一、发送变化,有 推模式 - 即把数据发给 关注对象; 拉模式 - 即只发通知给 关注对象, 由关注对象自己取数据。
事实上,所有消息系统都有这两种模式,常见的 ActiveMQ/Kafka 都有这种设计。
二、现在的关注者 和 被关注者,紧密耦合,可以进一步拆分,由一个中介模块来实现注册和通知,这样关注者和被关注者互相之间不需要知道
进一步的强化了职责单一、开放-封闭原则
class AlarmReader implements Reader{
Reader reader;
Mediator mediator;
void setMediator(Mediator mediator){
this.mediator = mediator;
}
// 是否凌晨
boolean isNight();
void sendMessage(){
mediator.alarm();
}
void read(char[] data){
if(isNight)
sendMessage
}
}
class Mediator{
void add(Alarm alarm);
void register(AlarmReader reader);
void sendMessage(String msg){
for( XXX )
alarm.alarm(msg)
}
}
产生Mediator 中介模式
如前所述,中介者模式,就是把两个模块通讯和交互,集中到中介者这个处理模块。中介者模块是解开 循环引用,复杂性的一个重要设计模式。像观察者这种模式,两个模块互相引用,如果设计不好耦合过多,后面就很难维护。所以,观察者模式应该尽量抽象向中介者模式靠齐。
新增需求五 读操作需要支持多线程并发读
多线程执行框架,考虑IO阻塞,把 调用 和 执行分离开来。
产生 Command 模式
把调用和执行分开来,最典型运用,就是 java并发的 Executors 执行框架。可以参照本博的《java 并发编程精华一页纸》,把可能耗时的部分,都封装在 Comand中,提交给执行框架执行。
产生 Active Object 模式(非 23 种模式)
ExecutorService ,持有线程句柄,执行自己管理自己的状态
新增需求六 假设读数据只是流程的第一步,读完以后,需要把数据发送给 消息系统,最后还要插入数据库
此时对同一批数据的操作,形成了类似流水线的用途
怎么设计?
有几个点:第一、每一步完成与否,怎么感知;第二、下一步的流程要能灵活配置
首先想到的是 中介模式
每个步骤持有一个中介,处理完成以后,继续下一步
中介持有所有的 流程对象
-- 这种场景下,使用中介模式的缺点,很快中介就会成为 热点代码。而且 任何步骤的变化,中介模式改动都比较多。
如果,让每个步骤自己持有下一步的操作呢? 就像 链表一样,很容易加入或者去掉 任何一个节点。
iterface Operate{
void operate(String msg);
}
abstract AbstractOperate implements Operate{
Operate next;
void operate(String msg);
public void setNext(Operate next){
this.next = next;
}
}
Class XXXOperate{
void operate(String msg){
xxx
next.operate(xxx);
}
}
产生 COR 职责链模式
如同链表一样,每个对象持有一个同样接口的 引用,调用引用的对象方法,像一个链表一样到最后。
职责链也是用的非常多的一个模式。典型应用 tomcat的 pipeline 流水线; struts,Spring AOP 的各种拦截链
新增需求N ...
好了,再搞下去,估计要疯了。可是,大家看到,一个小的需求衍生下去,有十几个模式都覆盖了。只要大家记住敏捷的那几个原则,模式自然就来了,我把他简单总结几句话
一次只做一件事
代码不能有重复
要接口不要实现
不关注的要隔离
2、正式的模式讨论
I、创建型 - 关注于调用者和被调用者的隔离
工厂 Factory /抽象工厂 Abstract Factory/单例 Singleton / 原型 Prototype /建设者 Builder
工厂/抽象工厂/建设者 符合 开发封闭原则,对象的过程是变化的,而获取对象后的操作是固定的。
单例是个特殊的模型(懒汉,恶汉,枚举) ,对client来说也符合这个原则
原型模式,其实是一个 对象的副本拷贝
II、结构型 - 关注于对象的组成和调用方式
适配 Adapter / 代理 Proxy /桥接 Bridge / 组合 Composite /装饰 Decorator/外观 Facade /享元 Flyweight
适配:用在两个系统的融合。当前系统持有另一个系统的引用,通过委托引用,隔离对上层Client的变化。
代理:把对一个对象的调用 代理/委托给 另一个对象。隔离、保护、拦截对象。
桥接:把对象的抽象和具体行为分离出来。把各自的变化分离出来,把不变的组合封闭,组合不变;每个部分都功能单一
组合:模式比较简单,就是一个类似于链表和树的数据结构来组织对象层次关系
装饰:为功能包装新的功能。每个类职责单一
外观:对外提供统一的访问接口。封闭内部实现,提供统一的访问接口;这个模式的特点是 不同【类型】 的操作最终合并在一起,不像前面其他的模式都具有一定的相似和关联
几乎所有的框架都提供了统一访问的接口,隔离内部实现,简化用户使用;比如iBatis的 Client;Jedis的 Jedis
III、行为型 - 关注对象内部的执行过程
职责链 COR /命令 Comand /解析 Interpreter /迭代器 Iterator /中介 Mediator /备忘录 Memento /观察者 Observer /状态 state /策略 Strategy /模版模式 TEMPLATE METHOD /访问者 Visitor
职责链:同样接口下的,不同处理模块通过职责链链接起来,每个模块都知道自己的下一步,像一个链表一样。PipeLine(tomcat),拦截器链(struts2) 都使用这种模式
命令:封装成命令接口,把功能提交给Command 框架去执行。隔离调用和执行
解释器:语法解析接口
迭代器:提供一个访问内部数据的接口;当前对象需要实现这个访问接口,通过这个接口可以遍历内部数据
中介:可以认为 任务消息队列 / 企业ESB 就是一种广义的中介方式;应用向中介注册,通过中介向其他模块发送消息;或者中介主动持有两个变量进行操作,典型的就是Spring的注入。
备忘录:对象持有一个 备忘录对象,保存自己的状态,可以通过备忘录对象恢复
观察者:最常见的模式,几乎所有的事件都采用这种方式。Observer观察者需要实现一个接口,观察者向 被观察的对象 Observlable 注册,被观察对象 发生事件时,遍历所有注册的观察者,调用其接口实现
状态模式:不同的状态间切换
策略模式:把行为抽象出来,分为不同的实现
模版模式:其实就是抽象类,把一些公共操作整合到一起固化流程
访问Visit模式:Visitable 被访问的对象,需要实现一个接口,accept 方法里面接受外面的访问器; Visitor 访问器提供访问各种对象的方法;所有 实现 Vistable的接口 把 自身 提供给 visitor的 具体某一个访问方法; 通常组合 Compsite 使用
3、模式大比拼
I、Adapter 适配 VS Bridge 桥接
两者非常类似的模式。长得也非常像。
几点区别:
目的/用途: Adapter - 包装一个异构系统到本系统来。 Bridge - 解耦本系统的 静态属性和 动态行为。
使用的阶段:Adapter - 已有系统的包装,属于事后弥补性质 Bridge - 架构设计时,设计好的层次,有点事前规划的感觉
实现的差异:Adapter - 一般是 一个系统持有另一个系统的对象 Bridge - 一般是 持有一个行为的接口
总之,Adapte是对象的包装,Bridge 是属性和行为的组合
举一个例子,来做区分
设计一个带报警功能的门
abstract class Door{
Alarm alarm;
public doorAlram(){
alarm.alarm();
}
}
门 可以扩展;告警 也可以扩展 木门+音乐报警器 , 铁门+ 警铃, 可以任意组合。
此为 桥接 Bridge 模式
如果此时,需要把一个门,比如是厨房的,移到房间,发现型号不能匹配,小了一点。
class CookRoomDoorAdapter implments BedRoom{
XXX 增加适配的边框门条
}
此为 适配 Adapter 模式
II、Strategy 策略 VS TEMPLATE METHOD 模板
TEMPLATE METHOD 和 STRATEGY 模式都是解决类似问题,比如分离通用算法和具体应用上下文;区别是TEMPLATE METHOD用的是继承的方式,STRATEGY用的是委托的方式。
TEMPlATE METHOD 相对简单点,但由于在设计抽象基础类时,就固化了算法,扩展性受到很大限制;而STRATEGY 接口只抽象了每个实现细节,具体实现类也只是实现了细节,对逻辑组合并不了解,这样可以面对多种算法,可以有多个委托对象放来调用,扩展性非常好,
Template method 模板
abstract class TemplateMethod{
abstract void a();
abstract void b();
abstract void c();
public void operate(){
a();
xxx
b();
c();
}
}
Strategy 策略
interface Strategy{
void do();
}
class Context{
Strategy a;
}
Spring 中的策略模式,比如 Ioc,不同类型的加载;资源不同类型资源的读取等等。
III、Strategy 策略 VS Bridge 桥接
两者都是通过持有 引用 委托给另一个来实现功能。
差别在于 Strategy 除抽象行为外的部分是固定的,也就是说封闭的,没有演化和变化;而Bridge 是各自都需要演化的。比如上文的 Door 本身也是要演化的,而Context就是直接的使用者,没有演化。
IV、Visitor 访问 VS Iterator 迭代
Visitor 双向持有,双向分发;iterator 单向持有
所有实现Visitable的 对象,因为把自己暴露给 访问者,在调用visit时,可以通过访问者增加功能
iterator,封装了内部数据实现,提供了唯一的访问接口
V、State 状态 VS Strategy 策略
两者也是非常接近,从委托的方式也是一致的,类图模型图差不多
主要还是使用方式的差异, State 模式 动态变化的 ; Strategy 是静态的,一般在配置、加载时,就默认指定了一种策略。
VI、 XXX
可以看到相似的模式应用何其多,变化和差异也很多。所以回答最开始的问题,不用硬背模式,记住 编码原则和规范,模式就应运而生了。