新来了个同事,设计模式用的是真优雅呀!代码如诗!!
之前有小伙伴私信我说看源码的时候感觉源码很难,不知道该怎么看,其实这有部分原因是因为没有弄懂一些源码实现的套路,也就是设计模式,所以本文我就总结了 9 种在源码中非常常见的设计模式,并列举了很多源码的实现例子,希望对你看源码和日常工作中有所帮助。
单例模式
单例模式是指一个类在一个进程中只有一个实例对象(但也不一定,比如 Spring 中的 Bean 的单例是指在一个容器中是单例的)
单例模式创建分为饿汉式和懒汉式,总共大概有 8 种写法。但是在开源项目中使用最多的主要有两种写法:
1、静态常量
静态常量方式属于饿汉式,以静态变量的方式声明对象。这种单例模式在 Spring 中使用的比较多,举个例子,在 Spring 中对于 Bean 的名称生成有个类 AnnotationBeanNameGenerator 就是单例的。

2、双重检查机制
除了上面一种,还有一种双重检查机制在开源项目中也使用的比较多,而且在面试中也比较喜欢问。双重检查机制方式属于懒汉式,代码如下:
之所以这种方式叫双重检查机制,主要是在创建对象的时候进行了两次 INSTANCE == null 的判断。
疑问讲解
这里解释一下双重检查机制的三个疑问:
外层判断 null 的作用
内层判断 null 的作用
变量使用 volatile 关键字修饰的作用
外层判断 null 的作用:其实就是为了减少进入同步代码块的次数,提高效率。你想一下,其实去了外层的判断其实是可以的,但是每次获取对象都需要进入同步代码块,实在是没有必要。
内层判断 null 的作用:防止多次创建对象。假设 AB 同时走到同步代码块,A 先抢到锁,进入代码,创建了对象,释放锁,此时 B 进入代码块,如果没有判断 null,那么就会直接再次创建对象,那么就不是单例的了,所以需要进行判断 null,防止重复创建单例对象。
volatile 关键字的作用:防止重排序。因为创建对象的过程不是原子,大概会分为三个步骤
第一步:分配内存空间给 Singleton 这个对象
第二步:初始化对象
第三步:将 INSTANCE 变量指向 Singleton 这个对象内存地址
假设没有使用 volatile 关键字发生了重排序,第二步和第三步执行过程被调换了,也就是先将 INSTANCE 变量指向 Singleton 这个对象内存地址,再初始化对象。这样在发生并发的情况下,另一个线程经过第一个 if 非空判断时,发现已经为不为空,就直接返回了这个对象,但是此时这个对象还未初始化,内部的属性可能都是空值,一旦被使用的话,就很有可能出现空指针这些问题。
双重检查机制在 dubbo 中的应用
在 dubbo 的 spi 机制中获取对象的时候有这样一段代码:

虽然这段代码跟上面的单例的写法有点不同,但是不难看出其实是使用了双重检查机制来创建对象,保证对象单例。
建造者模式
将一个复杂对象的构造与它的表示分离,使同样的构建过程可以创建不同的表示,这样的设计模式被称为建造者模式。它是将一个复杂的对象分解为多个简单的对象,然后一步一步构建而成。
上面的意思看起来很绕,其实在实际开发中,其实建造者模式使用的还是比较多的,比如有时在创建一个 pojo 对象时,就可以使用建造者模式来创建:
上面这段代码就是通过建造者模式构建了一个 PersonDTO 对象,所以建造者模式又被称为 Budiler 模式。
这种模式在创建对象的时候看起来比较优雅,当构造参数比较多的时候,适合使用建造者模式。
接下来就来看看建造者模式在开源项目中是如何运用的
1、在 Spring 中的运用
我们都知道,Spring 在创建 Bean 之前,会将每个 Bean 的声明封装成对应的一个 BeanDefinition,而 BeanDefinition 会封装很多属性,所以 Spring 为了更加优雅地创建 BeanDefinition,就提供了 BeanDefinitionBuilder 这个建造者类。

2、在 Guava 中的运用
在项目中,如果我们需要使用本地缓存,会使用本地缓存的实现的框架来创建一个,比如在使用 Guava 来创建本地缓存时,就会这么写
这其实也就是建造者模式。
建造者模式不仅在开源项目中有所使用,在 JDK 源码中也有使用到,比如 StringBuilder 类。
工厂模式
工厂模式在开源项目中也使用的非常多,具体的实现大概可以细分为三种:
简单工厂模式
工厂方法模式
抽象工厂模式
简单工厂模式
简单工厂模式,就跟名字一样,的确很简单。比如说,现在有个动物接口 Animal,具体的实现有猫 Cat、狗 Dog 等等,而每个具体的动物对象创建过程很复杂,有各种各样地步骤,此时就可以使用简单工厂来封装对象的创建过程,调用者不需要关心对象是如何具体创建的。
当需要使用这些对象,调用者就可以直接通过简单工厂创建就行。
需要注意的是,一般来说如果每个动物对象的创建只需要简单地 new 一下就行了,那么其实就无需使用工厂模式,工厂模式适合对象创建过程复杂的场景。
工厂方法模式
上面说的简单工厂模式看起来没啥问题,但是还是违反了七大设计原则的 OCP 原则,也就是开闭原则。所谓的开闭原则就是对修改关闭,对扩展开放。
什么叫对修改关闭?就是尽可能不修改的意思。就拿上面的例子来说,如果现在新增了一种动物兔子,那么 createAnimal 方法就得修改,增加一种类型的判断,那么就此时就出现了修改代码的行为,也就违反了对修改关闭的原则。
所以解决简单工厂模式违反开闭原则的问题,就可以使用工厂方法模式来解决。
这种方式就是工厂方法模式。他将动物工厂提取成一个接口 AnimalFactory,具体每个动物都各自实现这个接口,每种动物都有各自的创建工厂,如果调用者需要创建动物,就可以通过各自的工厂来实现。
此时假设需要新增一个动物兔子,那么只需要实现 AnimalFactory 接口就行,对于原来的猫和狗的实现,其实代码是不需要修改的,遵守了对修改关闭的原则,同时由于是对扩展开放,实现接口就是扩展的意思,那么也就符合扩展开放的原则。
抽象工厂模式
工厂方法模式其实是创建一个产品的工厂,比如上面的例子中,AnimalFactory 其实只创建动物这一个产品。而抽象工厂模式特点就是创建一系列产品,比如说,不同的动物吃的东西是不一样的,那么就可以加入食物这个产品,通过抽象工厂模式来实现。
在动物工厂中,新增了创建食物的接口,小狗小猫的工厂去实现这个接口,创建狗粮和猫粮,这里就不去写了。
1、工厂模式在 Mybatis 的运用
在 Mybatis 中,当需要调用 Mapper 接口执行 sql 的时候,需要先获取到 SqlSession,通过 SqlSession 再获取到 Mapper 接口的动态代理对象,而 SqlSession 的构造过程比较复杂,所以就提供了 SqlSessionFactory 工厂类来封装 SqlSession 的创建过程。

对于使用者来说,只需要通过 SqlSessionFactory 来获取到 SqlSession,而无需关心 SqlSession 是如何创建的。
2、工厂模式在 Spring 中的运用
我们知道 Spring 中的 Bean 是通过 BeanFactory 创建的。

BeanFactory 就是 Bean 生成的工厂。一个 Spring Bean 在生成过程中会经历复杂的一个生命周期,而这些生命周期对于使用者来说是无需关心的,所以就可以将 Bean 创建过程的逻辑给封装起来,提取出一个 Bean 的工厂。
策略模式
策略模式也比较常见,就比如说在 Spring 源码中就有很多地方都使用到了策略模式。
在讲策略模式是什么之前先来举个例子。
假设现在有一个需求,需要将消息推送到不同的平台。
最简单的做法其实就是使用 if else 来做判断就行了。
根据不同的平台类型进行判断,调用对应的 api 发送消息。
虽然这样能实现功能,但是跟上面的提到的简单工厂的问题是一样的,同样违反了开闭原则。当需要增加一种平台类型,比如邮件通知,那么就得修改 notifyMessage 的方法,再次进行 else if 的判断,然后调用发送邮件的邮件发送消息。
此时就可以使用策略模式来优化了。
首先设计一个策略接口:
短信通知实现:
app 通知实现:
最后 notifyMessage 的实现只需要要循环调用所有的 MessageNotifier 的 support 方法,一旦 support 方法返回 true,说明当前 MessageNotifier 支持该类的消息发送,最后再调用 notify 发送消息就可以了。
那么如果现在需要支持通过邮件通知,只需要实现 MessageNotifier 接口,注入到 Spring 容器就行,其余的代码根本不需要有任何变动。
到这其实可以更好的理解策略模式了。就拿上面举的例子来说,短信通知,app 通知等其实都是发送消息一种策略,而策略模式就是需要将这些策略进行封装,抽取共性,使这些策略之间相互替换。
策略模式在 SpringMVC 中的运用
1、对接口方法参数的处理
比如说,我们经常在写接口的时候,会使用到了 @PathVariable、@RequestParam、@RequestBody 等注解,一旦我们使用了注解,SpringMVC 会处理注解,从请求中获取到参数,然后再调用接口传递过来,而这个过程,就使用到了策略模式。
对于这类参数的解析,SpringMVC 提供了一个策略接口 HandlerMethodArgumentResolver

这个接口的定义就跟我们上面定义的差不多,不同的参数处理只需要实现这个解决就行,比如上面提到的几个注解,都有对应的实现。
比如处理 @RequestParam 注解的 RequestParamMethodArgumentResolver 的实现。

当然还有其它很多的实现,如果想知道各种注解处理的过程,只需要找到对应的实现类就行了。
2、对接口返回值的处理
同样,SpringMVC 对于返回值的处理也是基于策略模式来实现的。

HandlerMethodReturnValueHandler 接口定义跟上面都是同一种套路。
比如说,常见的对于 @ResponseBody 注解处理的实现 RequestResponseBodyMethodProcessor。

同样,HandlerMethodReturnValueHandler 的实现也有很多,这里就不再举例了。
策略模式在 Spring 的运用远不止这两处,对于配置文件的加载 PropertySourceLoader 也是策略模式的运用。
模板方法模式
模板方法模式是指,在父类中定义一个操作中的框架,而操作步骤的具体实现交由子类做。其核心思想就是,对于功能实现的顺序步骤是一定的,但是具体每一步如何实现交由子类决定。
比如说,对于旅游来说,一般有以下几个步骤:
做攻略,选择目的地
收拾行李
乘坐交通工具去目的地
玩耍、拍照
乘坐交通工具去返回
但是对于去哪,收拾什么东西都,乘坐什么交通工具,都是由具体某个旅行来决定。
那么对于旅游这个过程使用模板方法模式翻译成代码如下:
对于某次旅行来说,只需要重写每个步骤该做的事就行,比如说这次可以选择去杭州西湖,下次可以去长城,但是对于旅行过程来说是不变了,对于调用者来说,只需要调用暴露的 travel 方法就行。
可能这说的还是比较抽象,我再举两个模板方法模式在源码中实现的例子。
模板方法模式在源码中的使用
1、模板方法模式在 HashMap 中的使用
HashMap 我们都很熟悉,可以通过 put 方法存元素,并且在元素添加成功之后,会调用一下 afterNodeInsertion 方法。

而 afterNodeInsertion 其实是在 HashMap 中是空实现,什么事都没干。

这其实就是模板方法模式。HashMap 定义了一个流程,那就是当元素成功添加之后会调用 afterNodeInsertion,子类如果需要在元素添加之后做什么事,那么重写 afterNodeInsertion 就行。
正巧,JDK 中的 LinkedHashMap 重写了这个方法。

而这段代码主要干的一件事就是可能会移除最老的元素,至于到底会不会移除,得看 if 是否成立。
添加元素移除最老的元素,基于这种特性其实可以实现 LRU 算法,比如 Mybatis 的 LruCache 就是基于 LinkedHashMap 实现的,有兴趣的可以扒扒源码,这里就不再展开讲了。
2、模板方法模式在 Spring 中的运用
我们都知道,在 Spring 中,ApplicationContext 在使用之前需要调用一下 refresh 方法,而 refresh 方法就定义了整个容器刷新的执行流程代码。

在整个刷新过程有一个 onRefresh 方法

而 onRefresh 方法默认是没有做任何事,并且在注释上有清楚两个单词 Template method,翻译过来就是模板方法的意思,所以 onRefresh 就是一个模板方法,并且方法内部的注释也表明了,这个方法是为了子类提供的。
在 Web 环境下,子类会重写这个方法,然后创建一个 Web 服务器。

3、模板方法模式在 Mybatis 中的使用
在 Mybatis 中,是使用 Executor 执行 Sql 的。

而 Mybatis 一级缓存就在 Executor 的抽象实现中 BaseExecutor 实现的。如图所示,红圈就是一级缓存

比如在查询的时候,如果一级缓存有,那么就处理缓存的数据,没有的话就调用 queryFromDatabase 从数据库查

queryFromDatabase 会调用 doQuery 方法从数据库查数据,然后放入一级缓存中。

而 doQuery 是个抽象方法

所以 doQuery 其实就是一个模板方法,需要子类真正实现从数据库中查询数据,所以这里就使用了模板方法模式。
责任链模式
在责任链模式里,很多对象由每一个对象对其下家的引用而连接起来形成一条链。请求在这个链上传递,由该链上的某一个对象或者某几个对象决定处理此请求,每个对象在整个处理过程中值扮演一个小小的角色。
举个例子,现在有个请假的审批流程,根据请假的人的级别审批到的领导不同,比如有有组长、主管、HR、分管经理等等。
先需要定义一个处理抽象类,抽象类有个下一个处理对象的引用,提供了抽象处理方法,还有一个对下一个处理对象的调用方法。
几种审批人的实现
有了这几个实现之后,接下来就需要对对象进行组装,组成一个链条,比如在 Spring 中就可以这么玩。
之后对于调用方而言,只需要获取到链条,开始处理就行。
一旦后面出现需要增加或者减少审批人,只需要调整链条中的节点就行,对于调用者来说是无感知的。
责任链模式在开源项目中的使用
1、在 SpringMVC 中的使用
在 SpringMVC 中,可以通过使用 HandlerInterceptor 对每个请求进行拦截。

而 HandlerInterceptor 其实就使用到了责任链模式,但是这种责任链模式的写法跟上面举的例子写法不太一样。
对于 HandlerInterceptor 的调用是在 HandlerExecutionChain 中完成的。

比如说,对于请求处理前的拦截,就在是这样调用的。

其实就是循环遍历每个 HandlerInterceptor,调用 preHandle 方法。
2、在 Sentinel 中的使用
Sentinel 是阿里开源的一个流量治理组件,而 Sentinel 核心逻辑的执行其实就是一条责任链。
在 Sentinel 中,有个核心抽象类 AbstractLinkedProcessorSlot

这个组件内部也维护了下一个节点对象,这个类扮演的角色跟例子中的 ApprovalHandler 类是一样的,写法也比较相似。这个组件有很多实现

比如有比较核心的几个实现
DegradeSlot:熔断降级的实现
FlowSlot:流量控制的实现
StatisticSlot:统计的实现,比如统计请求成功的次数、异常次数,为限流提供数据来源
SystemSlot:根据系统规则来进行流量控制
整个链条的组装的实现是由 DefaultSlotChainBuilder 实现的

并且内部是使用了 SPI 机制来加载每个处理节点

所以,如果你想自定一些处理逻辑,就可以基于 SPI 机制来扩展。
除了上面的例子,比如 Gateway 网关、Dubbo、MyBatis 等等框架中都有责任链模式的身影,所以责任链模式使用的还是比较多的。
代理模式
代理模式也是开源项目中很常见的使用的一种设计模式,这种模式可以在不改变原有代码的情况下增加功能。
举个例子,比如现在有个 PersonService 接口和它的实现类 PersonServiceImpl
这个类刚开始运行的好好的,但是突然之前不知道咋回事了,有报错,需要追寻入参,所以此时就可以这么写。
这么写,就修改了代码,万一以后不需要打印日志了呢,岂不是又要修改代码,不符和之前说的开闭原则,那么怎么写呢?可以这么玩。
可以实现一个代理类 PersonServiceProxy,对 PersonServiceImpl 进行代理,这个代理类干的事就是打印日志,最后调用 PersonServiceImpl 进行人员信息的保存,这就是代理模式。
当需要打印日志就使用 PersonServiceProxy,不需要打印日志就使用 PersonServiceImpl,这样就行了,不需要改原有代码的实现。
讲到了代理模式,就不得不提一下 Spring AOP,Spring AOP 其实跟静态代理很像,最终其实也是调用目标对象的方法,只不过是动态生成的,这里就不展开讲解了。
代理模式在 Mybtais 中的使用
前面在说模板方法模式的时候,举了一个 BaseExecutor 使用到了模板方法模式的例子,并且在 BaseExecutor 这里面还完成了一级缓存的操作。
其实不光是一级缓存是通过 Executor 实现的,二级缓存其实也是,只不过不在 BaseExecutor 里面实现,而是在 CachingExecutor 中实现的。

CachingExecutor 中内部有一个 Executor 类型的属性 delegate,delegate 单词的意思就是代理的意思,所以 CachingExecutor 显然就是一个代理类,这里就使用到了代理模式。
CachingExecutor 的实现原理其实很简单,先从二级缓存查,查不到就通过被代理的对象查找数据,而被代理的 Executor 在 Mybatis 中默认使用的是 SimpleExecutor 实现,SimpleExecutor 继承自 BaseExecutor。
这里思考一下二级缓存为什么不像一级缓存一样直接写到 BaseExecutor 中?
这里我猜测一下是为了减少耦合。
我们知道 Mybatis 的一级缓存默认是开启的,一级缓存写在 BaseExecutor 中的话,那么只要是继承了 BaseExecutor,就拥有了一级缓存的能力。
但二级缓存默认是不开启的,如果写在 BaseExecutor 中,讲道理也是可以的,但不符和单一职责的原则,类的功能过多,同时会耦合很多判断代码,比如开启二级缓存走什么逻辑,不开启二级缓存走什么逻辑。而使用代理模式很好的解决了这一问题,只需要在创建的 Executor 的时候判断是否开启二级缓存,开启的话就用 CachingExecutor 代理一下,不开启的话老老实实返回未被代理的对象就行,默认是 SimpleExecutor。
如图所示,是构建 Executor 对象的源码,一旦开启了二级缓存,就会将前面创建的 Executor 进行代理,构建一个 CachingExecutor 返回。

适配器模式
适配器模式使得原本由于接口不兼容而不能一起工作的哪些类可以一起工作,将一个类的接口转换成客户希望的另一个接口。
举个生活中的例子,比如手机充电器接口类型有 USB TypeC 接口和 Micro USB 接口等。现在需要给一个 Micro USB 接口的手机充电,但是现在只有 USB TypeC 接口的充电器,这怎么办呢?
其实一般可以弄个一个 USB TypeC 转 Micro USB 接口的转接头,这样就可以给 Micro USB 接口手机充电了,代码如下
USBTypeC 接口充电
MicroUSB 接口
适配实现,最后是调用 USBTypeC 接口来充电
方然除了上面这种写法,还有一种继承的写法。
这两种写法主要是继承和组合(聚合)的区别。
这样就可以通过适配器(转接头)就可以实现 USBTypeC 给 MicroUSB 接口充电。
适配器模式在日志中的使用
在日常开发中,日志是必不可少的,可以帮助我们快速快速定位问题,但是日志框架比较多,比如 Slf4j、Log4j 等等,一般同一系统都使用一种日志框架。
但是像 Mybatis 这种框架来说,它本身在运行的过程中也需要产生日志,但是 Mybatis 框架在设计的时候,无法知道项目中具体使用的是什么日志框架,所以只能适配各种日志框架,项目中使用什么框架,Mybatis 就使用什么框架。
为此 Mybatis 提供一个 Log 接口

而不同的日志框架,只需要适配这个接口就可以了

就拿 Slf4j 的实现来看,内部依赖了一个 Slf4j 框架中的 Logger 对象,最后所有日志的打印都是通过 Slf4j 框架中的 Logger 对象来实现的。
此外,Mybatis 还提供了如下的一些实现

这样,Mybatis 在需要打印日志的时候,只需要从 Mybatis 自己的 LogFactory 中获取到 Log 对象就行,至于最终获取到的是什么 Log 实现,由最终项目中使用日志框架来决定。

观察者模式
当对象间存在一对多关系时,则使用观察者模式(Observer Pattern)。比如,当一个对象被修改时,则会自动通知依赖它的对象。
这是什么意思呢,举个例子来说,假设发生了火灾,可能需要打 119、救人,那么就可以基于观察者模式来实现,打 119、救人的操作只需要观察火灾的发生,一旦发生,就触发相应的逻辑。

观察者的核心优点就是观察者和被观察者是解耦合的。就拿上面的例子来说,火灾事件(被观察者)根本不关系有几个监听器(观察者),当以后需要有变动,只需要扩展监听器就行,对于事件的发布者和其它监听器是无需做任何改变的。
观察者模式实现起来比较复杂,这里我举一下 Spring 事件的例子来说明一下。
观察者模式在 Spring 事件中的运用
Spring 事件,就是 Spring 基于观察者模式实现的一套 API。
Spring 事件的实现比较简单,其实就是当 Bean 在生成完成之后,会将所有的 ApplicationListener 接口实现(监听器)添加到 ApplicationEventMulticaster 中。
ApplicationEventMulticaster 可以理解为一个调度中心的作用,可以将事件通知给监听器,触发监听器的执行。

retrieverCache 中存储了事件类型和对应监听器的缓存。当发布事件的时候,会通过事件的类型找到对应的监听器,然后循环调用监听器。

所以,Spring 的观察者模式实现的其实也不复杂。
总结
本文通过对设计模式的讲解加源码举例的方式介绍了 9 种在代码设计中常用的设计模式:
单例模式
建造者模式
工厂模式
策略模式
模板方法模式
责任链模式
代理模式
适配器模式
观察者模式
其实这些设计模式不仅在源码中常见在平时工作中也是可以经常使用到的。
设计模式其实还是一种思想,或者是套路性的东西,至于设计模式具体怎么用、如何用、代码如何写还得依靠具体的场景来进行灵活的判断。
评论