简介:

java 设计模式是开发者在从事大型系统开发时推荐参考的模式,本质上一类拥有松耦合,高拓展性,便于维护等优点的设计方案的抽象总结,同时这些模板方案也可以更方便后续的开发者理解和维护已有的代码。

这些设计模式都是长期以来大家对大型项目开发的通用经验总结,来源于实践,所以也要回到实践中因地制宜地选择设计模式。每一种设计模式都是双刃剑,盲目选择不合适地设计模式反而会带来过度设计的危害,这种无脑过度设计带来的危害甚至会适得其反地增加代码维护的困难,所以设计模式并非一种万金油的答案,只是一种可以参考的手段,具体情况需要具体分析。

设计准则(尽量遵循)

在维护和开发大型复杂系统的时候,实事求是地遵守以下准则往往可以使得我们设计的系统更加通用,更加便于维护,更加利于拓展和迭代。

  • 开闭原则:对拓展开放,对修改封闭

    • 比如一个很简单的习惯,所有的实体类的内部参数都推荐使用无参数构造器和 GET/SET 方法来完成。
  • 里氏换代原则:任何基类可以出现的地方,子类一定可以出现

    • Java 中的多态的设计
    • Spring 中涉及到热插拔的 Bean 的声明类为其基类,通过 ApplicationContext 来动态装载和删除
  • 依赖倒转原则:针对接口编程,而不是具体的类

    • 对系统的抽象和设计的规范,Interface 还是很多人用
  • 接口隔离:接口之间最好是松耦合的,不要相互依赖

  • 迪米特法则:在业务层上对类的原子化松耦合,一个实体类尽量避免和太多的的其他类相互纠葛

  • 合成复用:推荐使用组合实现的方式而不是继承的方式整合接口和服务

暂时就这么多,看起来挺吓人的,名字很乱,但是理解起来很简单,实际上养成良好的编程习惯之后,尝试对系统进行抽象的思考时,这些设计上的推荐准则都会成为自己的 DNA 记忆,一句话总结:

适当抽象逻辑,尽力高聚松耦!

名词解释:高质量代码的评判标准

  • 可维护性(maintainability): 新功能的增加和旧功能点的废弃不需要破坏原有的代码设计,不会带来极大的引入 bug 的风险,不会带来大篇幅的对代码文件的修改和删除,发生bug的时候容易定位。
  • 可读性(readability): 简单理解就是人容不容易看懂,变量命名,代码逻辑的抽象层次,是否高内聚低耦合。
  • 可扩展性(extensibility): 代码预留扩展点,一般可以以接口加集合的形式完成。
  • 灵活性(flexibility): 有没有留下新功能的扩展点,接口的灵活程度,模板的灵活复用。
  • 简洁性(simplicity) 逻辑简洁,适当抽象解耦,让每一步逻辑都清晰明了。
  • 可重复性(reusability) 解耦,高内聚,木块话,多态,尽量减少编写重复的代码和逻辑。
  • 可测试性(testability) 日志写好,容易单元测试,功能测试尽量细粒度一点

关于设计模式的思考:一种思想而非模板

纸上得来终觉浅,在实际的应用中,不同设计模式的使用往往没有那么死板,学习优秀的代码是如何设计的,让自己的开发也更高效优雅。


构造模式:类的创生

构造模式提供了类的实例化构造逻辑模板,目的在于解决类如何实例化构造问题。

工厂模式:从简单的选择题到包揽全局的升维

工厂模式主要用来创造不同但是相关类型的类,比如实现同一个接口的类,同一个基类的子类,或者通过包装复杂的构造函数来对外暴露比较友好的构造接口,避免实例化时需要开发者手动计算和设计复杂的成员变量。

工厂模式常常用于同继承的子类实例的创建,最典型的案例就是配置解析器 Praser,对于不同的配置文件类型(xml,yaml,json,properties)去实现不同的配置解析类,然后以多态的方式返回对应的解析器,这是比较基础的逻辑,用对一个工厂的依赖代替一堆子类的依赖,达到简化依赖和解耦的效果(比如后面要更新解析类的版本了,只需要在工厂类里面改一改就行)。

工厂模式的另一个比较典型的应用就是 Spring 框架的 DI 容器,通过 BeanFactory 为 Spring 框架提供 Bean 实例并且管理 Bean 的生命周期,不过在 Spring 中,DI 容器的创建 Bean 实例的方式并非通过多态实现,而是用到了另一种技术:反射,反射是一种运行时解析和操作java实例和类的方法,也正是由于反射需要在运行时对实例进行十分耗时的解析操作,导致了 Spring 框架的启动时间十分一言难尽。

单例模式:往往不那么值得推崇的懒加载

虽然说饿汉模式在启动时提前加载类会很费事,有人会推荐懒加载,但是结合实际情况考虑,这种所谓的提高启动效率的说法也往往站不住脚:首先,出于早发现早暴露的原则,过晚的初始化大对象然后导致OOM是一个很不安全的设计方;并且,考虑到单例模式的实例一旦创造,生命周期就和整个JVM进程同步,晚创造带来的时间效率的提升微乎其微;最后,等到用户使用时再初始化也不是一个高性能的方案,让第一个访问的用户承担初始化的时间成本,显然不太合适,况且,如果这个单例的第一次被使用的时候是一个峰值流量,系统的并发性能立刻会大打折扣,多个线程都会因为这个单例堵塞,更容易引发OOM。

所以,最好的懒加载方式竟然是 private static final Singleton INSTANCE = new Singleton();相信 ClassLoader 的智慧,也算是大道至简。

建造者模式:构造函数尽量为必填参数的集合

构造函数对于一个实例来说还是很重要的,大量的可选参数参与构造函数会对用户实例化对象产生很大困扰,所以采用建造者模式,把可选参数用流试处理的设计,代码会优雅很多。

// 令人困惑的构造方法:
Config myConfig = new Config(required1,required2,required3,optional1,null,option3,null,null,optional6,...); // null 作为默认值
// 流式处理的 Builder
ConfigBuilder myBuilder = new ConfigBuilder(required1,required2,required3); // 必选参数交给 Builder
Config myConfig = myBuilder.setOpParam2(optional2)
                        .setOpParam5(optional5)
                        .setOpParam3(optional3)
                        .build(); // 可选参数流式处理
// 因为这种设计的 builder 是和实例一对一强绑定(耦合)的,所以为了避免一些奇怪的引用上的复用冲突,还可以这样
Config myConfig = new ConfigBuilder(required1,required2,required3)
                        .setOpParam2(optional2)
                        .setOpParam5(optional5)
                        .setOpParam3(optional3)
                        .build();

当然也可以把必写参数作为参数校验置入 build() 方法,避免 Builder 的构造函数的强耦合,典型的例子就是 Spring 中 Redisson 的构造方式(Config)和 TaskThreadExcutor 的构造方式(Builder)。

工厂模式和建造起模式两个都是参数化建造实例的模式,对于类本身来说,会存在有无状态的区别,工厂模式的类是一个无状态的类,返回的实例之和这一次的设计参数有关,不会受到上一个实例的影响;建造者本身是为定制化创建实例,建造者实例和被建造的实例是一对一高度相关的,每个建造者都保留其建造实例的状态。

原型模式:减少不必要的开销

原型模式解决的一个关键痛点在于一些新的实例在构造时的巨大开销,这些开销其实并非来自内存分配的开销,而大多来自复杂的计算(比如Hash计算),以及频繁的网络 I/O 带来的时间上的开销,比如复制表的时候应当订阅 binlog 去增量复制,而不是重新获取一整个表(尤其是以HashMap 的形式存储的)。

另一个问题其实在于版本一致性的问题:对于一部分会被不定期更新的数据,我们去做更新时难免会引入中间状态,但是我们又不希望其他线程观察到中间状态,这个时候有一个很熟悉的机制可以解决这种中间状态的问题:copy-on-write 机制,其实本质上也是一种原型模式。


结构模式:类的静态关系

结构模式提供了类和类之间的组合关联的模板,目的在于解决类和类之间如何组合在一起的问题。

代理模式:给新业务安排一个代理人

代理模式旨在尽量保证不侵入原始类的代码的同时给原始类添加新的功能(尽量不侵入,方法上加个注解其实可以接受),一般来说,这些功能都是和业务逻辑联系不太紧密的通用化功能(日志,幂等,鉴权,缓存埋点统计,性能计算…),将这些功能代码强耦合进方法里面还是有点不太合适,为了解决这种尴尬的情况,我们可以在原来的业务类上继承一个子类,并且在子类的方法重载里面加入这些功能,这就是最常用的静态代理。

public class Service{
	public Response doAction(Request req){...}
}
//==>代理之后
public class ServiceProxy extends Service{
	@Override
	public Response doAction(Request req){
		try{
			log.info("日志记录")
			idempotentCheck(req);
			authCheck(req);
			Response res = super.doAction(req);
			sendMQ(res);
		}catch(RuntimeException e){
			transaction.rollback();
		}......

静态代理类需要我们对每个类创造代理类,这样的做法明显效率不高还会带来类爆炸增长的问题,为了解决这些问题,便有了动态代理的方案。Java本身也提供了动态代理的一套方案,那就是Java中的反射机制,反射机制通过在运行时对类进行动态解析(包括类的成员变量,类的方法),就可以在方法执行的前后节点里灵活地完成新功能点的动态代理。

当代理模式走向动态代理的时候,一个我们十分熟悉的概念也慢慢初现端倪,那就是 AOP 的编程理念,动态代理本质是一种面向服务的编程思想,将通用服务作为切面织入已有的业务服务中,以提高代码的灵活性和设计的可复用性。

当然,运行时通过反射动态代理也不是特别好的方案,反射会消耗大量的时间和性能,如果对一个十分复杂的大类反射代理就只是为了在方法调用前加一个调用日志记录,那还不如直接去方法里面写一个。
Spring 自己也供了反射的 AOP 框架,只能在方法级进行织入,采用的也是运行时织入的逻辑,AspectJ 提供了编译时织入的方案,性能上有更好的提升(但是不支持lombok 也算一个坑点)

除开添加新功能而使用的代理模式,还有一种我们常用但是没有在意的代理模式设计,那就是 RPC-client,RPC 的调用中涉及到网络IO,链路追踪,服务鉴权等一系列复杂的功能逻辑,但是RPC本意确实希望我们能够像本地调用一样无感地调用,这个时候,RPC-client 作为代理类封装了这些复杂的内部逻辑,给开发者提供丝滑无感的本地调用体验。

装饰器:战术外骨骼

原始类套壳来增强功能,本质可以理解成给原始类套上外挂。一般来说需要和原始类继承同一个基类或者实现同一个接口来便于多态复用,Java 的 InputStream 就是一个经典的各种装饰器的实现集合。

// 一般来说,装饰器类都会有如下特征
public class EnhancedService implements Service{
	// 内置组合被增强的对象
	private Service originalService;
	public EnhancedService(Service service){
		originalService = service;
	}
	// 增强方法
	@Override
	public void doAction(){
		//do something to enhance the method with originalService
	}
}

装饰器和代理模式有点相似,但是内核思想却有很大不同:

  • 装饰器基于组合的思想,将需要增强的类组合进自己类部,不同于静态代理只能通过继承代理特定的类,而装饰器可以组合接口的不同的实现通用地增强其功能。
  • 装饰器是为了增强功能,而代理类是为添加功能。代理类可以发展到 AOP 去给各种不同的服务添加上通用的切面,比如声明式日志和声明式事务,而装饰器只能给特定接口的服务增强功能,这些功能一般是和业务息息相关的,比如加上适配业务的缓存,存储业务特定的快照。

桥接模式:实际一直在无意识使用

桥接模式最重要的一点,抽象和实现分离,是“组合优于继承”的一种优雅实现。抽象层只是骨架逻辑,属于最上层的设计,并不考虑逻辑里面每一个模块如何实现。实现层是用来实现具体功能的实际代码逻辑。在具体的业务中,我们将具体的实现注册到抽象层的骨架中,就组合完成了一个具体的业务逻辑。

最经典的比如 JDBC 的设计:Class.forName("com.mysql.jdbc.Driver"); 在静态代码中将 mysql 实现的驱动加载到 DriverManager 中,在链接数据库时 DriverManager 本身只是调用 Driver 接口(抽象层)的方法,进而调用到我们注册的mysql驱动(实现层)。

适配器:落后 type-c 手机不会遇上先进 3.5mm 接口

适配器中包含对旧的接口的调用和对新的接口的实现与封装。一方面是一种对老旧的不可维护但是还在使用的代码的补救措施,还有的时候是为了兼容不同接口和格式的数据,统一外部接口,减少侵入地弃用旧的外部依赖换上新的外部依赖。

适配器一般就两种:

//基于继承的类适配器
public class Adaptor extends AdapteeService implements TargetInterface{
	@Override
	...
}
//基于组合的对象适配器
public class Adaptor implements TargetInterface{
	private AdapteeService adaptee;
}

如何选择实现方案主要看被适配服务的接口和新接口究竟有多不适配,也就是接口能直接复用的还有多少。如果有大量可以被复用的接口,类适配器可以省去很多无用的方法重写,如果旧服务的接口和新的接口基本方枘圆凿,那还是基于组合的对象适配器更好,大多时候组合是优于继承的。

虽然不知道这个观念对不对,感觉DSL是适配器模式的最抽象也是最通用的解决方案了

外观模式(facade):封装整合,聚散为整

外观模式,最多用于复杂系统暴露给外部的最表面一层的封装接口,是一种接口组合设计的思想,解决的是一个服务需要调用多个子系统的功能时,我们都希望用户只需要调用一个接口就可以完成整个功能,这个整合的接口就是我们的 Facade 。

外观模式非常常见,比如 Linux 的系统调用函数,封装一个服务里对多个不同数据库的接口以便于分布式事务AOP织入,封装多个不同服务的 Controller 层,RPC 封装好自己服务的 api 层,用于减少网络调用的损耗。

组合&享元模式(不常用)

组合模式本质是树形数据结构,因为树形结构在大多数系统中十分常见,所以推荐有这种结构的系统里面直接抽象到树这一层更好。

实际中树形的业务结构不多见。

享元只是一种优化,把不同的类重复使用的相同内存抽取出来,反而提高了耦合性,所以享元模式有一个我们更熟悉的名字:常量池

设计模式——行为模式

行为模式规定了类的行为模板,主要为了解决类和类之间如何进行交互的问题,更重要的是为了解耦,行为上的设计稍不注意就容易高度耦合。

观察者模式:一对多的订阅发布

这个模式也经常被称为订阅发布模式,一个发布者对应多个订阅者,是一种广播模式。

Java 1.0 就有自己的官方观察者方案了,分别是 Observer 接口和 Observable 被观察类,在 Observable 的 javadoc 里面清晰定义了观察者模式的设计思路:实现一对多的行为关系,具体怎么用可以去看 java.utils 的源码。

具体需要注意的是,观察者模式是一对多响应而不是一对多做选择,在 Observable 代码里是这个样子的:

public void notifyObservers(Object arg) {  
/*  
* a temporary array buffer, used as a snapshot of the state of  
* current Observers.  
*/  
Object[] arrLocal;  
  
synchronized (this) {  
//...// 因为 update 是回调方法,避免死锁
	if (!changed)  
		return;  
	arrLocal = obs.toArray();  
	clearChanged();  
}

for (int i = arrLocal.length-1; i>=0; i--)  
	((Observer)arrLocal[i]).update(this, arg);  
}

可以看到每个 Observer 都要同步阻塞地调用 update,所以不需要做出回应的 Observer 一定要尽早返回,可以异步调用的就不要等同步了,不然会导致整个 notifyObservers 响应特别慢。

不过也有一个升级的观察者框架:Google Guava EventBus,事件总线框架。本质也是一个观察者模式的设计,不过更加灵活,拓展性也更好。

模板模式:只暴露扩展点的复用逻辑

Java 中的模板模式设计就是 Abstract 类的设计,所以在 Java 中模板模式一定是和 Abstract 类紧密相关,在 Abstract 类中写好可以复用的大业务逻辑框架,然后暴露扩展点,也就是待完善的抽象方法,交给子类去做具体的业务扩展,模板模式最经典的设计就是 Java 中的 Servlet :

public class MyServlet extends HttpServlet {
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {...}
  @Override
  protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {...}
}

实际上整个 Http 的流程是封装在 Servlet 的 service() 方法里面的,直接被复用来。

策略模式:代替复杂冗余的判断分支找到正确的算法

策略模式最常见的应用就是用于避免在业务中写下太多冗余的条件判断,在很多情况下,分支判断中的代码都是对某一个大场景根据具体细节的分化处理,也被称为算法族,比如快排,冒泡排序,桶排序,MapReduce 排序,猴子排序都属于排序算法族。

策略模式是基于接口的设计,因为本身是为了算法族而设计,那么设计的粒度也应当在方法粒度,同一个算法族是对同一个接口的实现,而策略器会通过一个map或者对条件的分支判断返回对应的实现,让避免分支判断里面大量的无用逻辑进入业务代码。

责任链模式:链表结构的行为模式

责任链模式是一个很好理解的模式,只要知道链表就可以完整理解这个设计思路,过滤拦截器就是一个典型的责任链模式的实现。

责任链模式的要点在于服务的顺序和链路的终止,对于一些简单的链路来说,通过递归调用就可以方便地实现这两个要求,但显然这是有OOM的风险的,所以又有优化后的递归调用,比如 servlet 中的 filter,用 FilterChain 来调度链路的递归和终止。

如果在整个链路的调用过程中加入更多的条件,并且引入链路的状态(比如上一个节点的结果作为下一个节点的输入),单一的调用链递归的设计就有点力不从心了,当设计越来越复杂的,需要引入外置调度器,考虑异步和休眠唤醒,责任链模式就慢慢进化成了流程引擎。

状态模式:从有限状态机 FSM 理论说起

有限状态机是程序设计里面一个很常见的模式,一般的数学表达为:

000 --- 10/11 ---> 010
当前状态 - In/Out -> 输入后状态

状态机是一个系统层级的行为设计思路,对于一个有状态的系统,会接受哪些输入,存在几种状态,不同状态时系统的输入输出和转移策略,这些问题构成了 FSM 的设计。这种设计在一些嵌入式开发设计和游戏引擎上十分多见,在游戏引擎上,FSM 的输入变成了事件event,通过不同的事件触发更改角色和场景状态。

在复杂的有状态系统里,通过000 --- 10/11 ---> 010来完成复杂的分支走向可比设计令人恼火的分支判断逻辑要有效的多,同时我们还可以对整个状态系统的状态进行简并,嵌套,设计合适的状态结构,实现更加复杂的状态系统。

顺带一提,马尔可夫信道其实也算从信源状态到信宿状态的有限状态机。

迭代器模式:简化集合的遍历

迭代器模式目的就在于简化集合的遍历,在 java8 中,迭代器可以直接用 foreach 遍历调用了,这个设计模式是一个数据结构层级的行为模式,而不是业务层级的,所以大多时候也不需要我们自己去设计。

为什么需要迭代器?其实迭代器的产生和集合的遍历问题息息相关,数组,链表,树,二叉树,霍夫曼树,红黑树,B+tree,集合,队列,各种图,还有bfs,dfs各种遍历方法,错综复杂,对于开发者来说,有一个接口可以通过集合希望的方式遍历这个集合是很有必要的。

使用迭代器的时候需要注意什么?首先是迭代器应当绝对避免对集合内部有增删操作,因为对于一部分集合来说,增删操作会改变集合的结构,很容易导致集合的遍历失效,比如红黑树的旋转,B+树叶节点下推。所以要么避免删改操作,要么去采用有快照的迭代器。

访问者:需要对一组很少修改的类长期拓展非常多的功能

访问者模式的应用场景在于,一组继承自同一个基类的对象,本身是没有留下公用的扩展点的,但是又有长期需要扩展发展业务的需求,这时我们所有业务都抽象成一个 Visitor 接口,来解耦对象和业务,并且留下扩展点。

下面的是一种很旧的废弃设计思路,业务功能需要对象自己调用回调函数太傻缺了

// 这是不同文件格式的内容提取器(一种业务)
public class Extractor implements Visitor{...}
// Vistor 用来表示一对多种文件方案的具体方案
public interface Vistor{
	void visit(PdfFile file);
	void visit(DocFile file);
	...
}
// 这是被拜访者的模板
public abstract class Visitable{
	// 实际是一个回调函数
	public void accept(Visitor visitor){visitor.visit(this);} 
}
// 这是继承自 Vistable 的 PdfFile 使用内容提取器的方法
Extractor ext = new Extractor();
pdfFile.accept(ext); // 表面上是 pdf 调用 ext,实际上是 ext 回调了 pdf

在这个设计中功能是扩展的,对象是对修改封闭的,暴露的扩展点为 visitor 接口协议,也就是只要是实现了这个接口的就可以被对象调用回调函数,但是出于扩展性的考虑,接口的方法都是无返回值的,所以结果会保存在 visitor 的具体实现里。

这种设计有这样几个缺陷,并且不是很符合现在的开发习惯:

  • 调用方式是被拜访者的回调方法,绕的要死。
  • 被处理的对象是方法调用方,结果却要保存在入参里面,太不符合直觉了。
    Visitable pdfFIle = new PdfFile();
    Visitor extractor = new Extractor();
    pdfFile.accept(extractor);
    Output output = extractor.getOutput(); // 结果在入参里,太反直觉了。
    
  • 回调函数最大的风险:无法保证有没有产生对原对象的修改! 这个是绝对无法接受的,万一哪个 visitor 里出了个老六给把 visitable 里面参数改了,都不知道是谁干的。
  • visitor 采用回调的方式,目的只是来让泛型适配重载的参数签名,比如:
    两个泛型声明
    Visitable pdfFIle = new PdfFile();
    Visitorextractor = new Extractor(); 
    直接传泛型会报错
    extractor.visit(pdfFile);  
    因为这个时候传入的是声明类型Vistable,重载里没有
    pdfFile.accept(extractor);
    实际在实例内部调用 visitor.visit(this)
    这时传入的是 this,JVM 会传入这个实例的实例化类型,也就是 new 的类型,就可以重载了 
    
    属于是为了碟醋才包了顿饺子

了解到这么多,所以现在常常采用的可读性高访问者模式是什么样的:

// 首先是已经有一堆已经用了很久的
public class PdfFile,DocFile,TextFile,... extends BaseFile{}
// 这个时候突然想对这一堆类长期拓展一堆业务,就需要一个统一的拓展点,也就是 Visitor
public abstract class Visitor(){

	protected Object output;  // 结果还是保存在 Visitor 里面
	public Object getOutput(){return this.output;}
	
	public void visit(BaseFile file){
		if(file instanceof PdfFile){
			actualVisit((PdfFIle)file);
			return;
		}
		if(file instanceof DocFile){
			actualVisit((DocFile)file);
			return;
		}
		if(file instanceof TextFIle){
			actualVisit((TextFIle)file);
			return;
		}
		return;
	}
	abstract protected void actualVisit(PdfFIle file);
	abstract protected void actualVisit(DocFIle file);
	abstract protected void actualVisit(TextFIle file);
}

// 这个时候调用方法就会很符合逻辑了
Visitor counter = new Counter();
BaseFile file = new DocFile();
counter.visit(file);
Object output = counter.getOutput(); // 拜访后去拿到结果,就符合直觉了

备忘录模式:用于备份和恢复的快照(Snapshot)

备忘录模式又称为快照模式,实际作用就是就是存储快照并且备份恢复,理解上很简单,这种设计模式现在有非常多的实现方式啦。比如全量备份,增量备份,不细说。

命令模式:将指令封装成对象

命令模式最核心的思路在于把指令封装成对象,记录指令,重做,回滚,可以依赖指令对象更加轻松地完成异步,延迟,指令重排,排队指令等功能,命令模式对待请求和指令的态度更像是操作日志,在指令生效前后,命令模式会有更多的操作空间。

命令模式更多出现在游戏服务器后端的设计中,因为玩家和服务器的交互基本就是不同的指令的交互。

中介模式:对象与对象之间的路由器和交换机

在复杂的系统中,往往对象之间有复杂的行为交互网络,这些复杂的关系往往会对后续的维护造成极大困扰,而中介模式就是为了解决这些擅自交互的问题,通过引入一个中间层,将对象需要交互的具体对象隐藏在中间层之后,这样,在后续功能升级和模块更新的时候,中间层可以作为管理方调度这些变化,避免引起系统混乱。