Spring Boot把内嵌Web服务器纳入了Spring的启动流程,但Spring Framework的源码一行都没改。关键就在于Spring预留的一个默认空实现。本文就从这个细节出发,看看开闭原则在真实工程里是什么样的。
开闭原则的简单定义:对修改关闭,对扩展开放。
也就是增加新功能时,尽量不修改已有代码,而是通过扩展现有代码来实现。 下面我们开始来说一下为啥会建议:想给现有代码加功能,尽量不直接改?
先从:谁在依赖这个类说起
想给一个类加功能,最简单粗暴的做法是打开这个类的源码,把新逻辑写进去。大多数时候我们确实也是这么干的,写起来最快。
但是如果从「谁在依赖这个类」的角度来看的话,就没有那么简单了。一个类可能被N多处代码引用到,且每次引用,背后还带着一整条链路,直接改它,多处代码的行为都可能受影响,每一处都得跟着回归验证,成本不低,且依赖越多,成本越高。
那不建议改源码,那新功能怎么加?Spring给出了一种做法,我们具体来看一下。
Spring留的口子
Spring容器启动的时候,会走一套固定的启动流程,这套流程定义在AbstractApplicationContext的refresh()方法里。整个流程十几个步骤,顺序是写死的:先做刷新前的准备,再构建Bean工厂,然后注册各种后置处理器,初始化事件发布器,接着实例化单例Bean,最后发布启动完成的事件。
这套流程如果从头到尾写成一个整体,后来者想往里加东西,就只能改源码。Spring没有这么做,它在流程中间留了几个空着的口子,onRefresh()就是其中一个:
java
protected void onRefresh() throws BeansException {
// 默认什么都不做,留给子类实现
}
方法体是空的,源码里也写了注释:默认什么都不做,留给子类。固定流程执行到这一步,默认等于什么都没发生。
Spring Boot的Servlet Web应用,用的上下文正是AbstractApplicationContext一路继承下来的子类。它要把Tomcat的启动挂进容器启动流程,做法不是去改Spring的启动流程,而是覆写这个onRefresh(),在里面把Web服务器创建出来:
java
@Override
protected void onRefresh() {
super.onRefresh();
createWebServer();
}
异常处理的部分这里略掉了,核心就是createWebServer()这一个调用。
这样做的好处是:Spring的启动流程一个字没变,所有不跑Web服务的Spring项目,走到onRefresh()这一步还是什么都不发生,完全不受影响;Spring Boot的子类走到这一步时,自动把Web服务器启动起来。旧代码对修改关闭,新能力通过扩展挂进来,这就是开闭原则的本意。
为了能更好的理解这个原则,我们用一段代码来简单模拟和演示一下Spring的这个启动过程。
写段demo代码验证一下
我们可以在基类写一个用final修饰的方法,固定住启动流程,中间留一个空的钩子,和Spring的结构完全对应:
java
class AppContext {
public final void start() {
System.out.println("准备环境");
onRefresh();
System.out.println("启动完成");
}
// 默认钩子,什么都不做
protected void onRefresh() {
}
}
子类只覆写钩子,在预留的口子处插入自己的行为:
java
class WebAppContext extends AppContext {
@Override
protected void onRefresh() {
System.out.println("创建Web服务器");
}
}
public static void main(String[] args) {
new AppContext().start();
new WebAppContext().start();
}
这段代码在本机Java 17上运行结果输出如下:
text
基类启动:
准备环境
启动完成
子类启动:
准备环境
创建Web服务器
启动完成
两段输出放在一起对比,子类的「创建Web服务器」准确地插在了「准备环境」和「启动完成」之间。基类的start()流程一个字没动,并用final固定住了流程,子类想改流程顺序都没有办法,它只能在预留的口子处决定做点什么。Spring Boot接入Tomcat,用的是同一套机制,只不过口子处的动作换成了真正启动一个Web服务器。
当然,扩展的需求不一定都要靠继承来支持,在Spring里还有另一种方法
不止继承这一条路
开闭原则要求的是通过泛化的手段做扩展,继承只是其中一种。Spring里还有一条基于接口的方式,BeanPostProcessor是典型例子:想在Bean创建的前后加自定义处理,不用动容器的任何源码,实现这个接口再注册进容器,容器在创建每个Bean时都会回调它。
两条路的差别在于扩展点的位置。钩子方法挂在父类的固定流程里,扩展方必须是子类,一次只能有一个生效,适合流程骨架稳定、只需要在个别步骤上开口子的场景。接口扩展不要求继承关系,实现方想放哪里放哪里,同一类实现还能注册多个同时生效,适合扩展点多、需要自由插拔的场景。
实际选型的时候,可以按下面这张表来判断。
怎么选扩展方式
| 方式 | 做法 | 适合场景 | 主要代价 |
|---|---|---|---|
| 直接改源码 | 打开原类修改 | 代码完全自己掌控,改动一次性的 | 所有依赖方跟着回归;二方包、三方包用不了 |
| 继承覆写 | 子类继承父类,覆写方法或钩子 | 父类有固定流程和预留口子,扩展点单一 | 和父类实现细节耦合,继承层次一深就难维护 |
| 接口扩展 | 实现接口并注册 | 扩展点多,实现方需要自由插拔 | 要提前设计接口,抽象成本高 |
| 组合委托 | 持有现有对象,在外面包一层 | 想增强已有类,又不想和它建立父子关系 | 转发代码多,包一层就要维护一层的接口 |
用的时候可以按一个顺序过滤:能用现成的钩子或接口,就用现成的;没有口子但想增强已有对象,考虑组合;继承用于自己体系内的固定流程扩展;直接改源码,排在所有方式的最后面。
目前看起来,预留扩展点的方式,挺好的哦,没缺点? 那肯定不是,你能确保你留的扩展点是对的吗? 真能应付将来的需求变化?
开闭原则不是免费的
留扩展点不是免费的午餐。一个方法从private改成protected,一个流程被final固定下来骨架,这相当于对所有后来的子类做了一次承诺:以后的扩展都得按这个形状来。如果方向是对的,后来者受益;方向猜错了,这个口子就是个没人用的摆设,删又不敢删,不知道哪天会不会有人用上。
Spring的那些钩子,也不是一开始就全设计好的,很多是在变化真实发生过之后才抽出来的。
更稳妥的节奏是:第一次遇到某个变化,先直接改,把问题解决了;同一类变化第二次出现,再回头把扩展点抽出来。第一次改,第二次抽取,不为还没出现的变化提前买单。
只有当你非常明确了,它是一个稳定的扩展点,你才能预留。
小结
开闭原则真正难的地方,是在判断哪里该留口子。口子留对了,后来的每一次扩展都省力;留错了,要为这个承诺持续付维护成本。所以设计扩展点这件事,考验的不是语法和模式的熟练度,而是对变化方向的预判,这种预判只能来自被业务反复打磨的经验。下次遇到一个总被不同需求反复修改的类,先别急着抱怨代码写得差,看看能不能把下一次变化,从改源码挪到扩展上