聊聊开闭原则,以及它在Spring里的样子

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的那些钩子,也不是一开始就全设计好的,很多是在变化真实发生过之后才抽出来的。

更稳妥的节奏是:第一次遇到某个变化,先直接改,把问题解决了;同一类变化第二次出现,再回头把扩展点抽出来。第一次改,第二次抽取,不为还没出现的变化提前买单。

只有当你非常明确了,它是一个稳定的扩展点,你才能预留。

小结

开闭原则真正难的地方,是在判断哪里该留口子。口子留对了,后来的每一次扩展都省力;留错了,要为这个承诺持续付维护成本。所以设计扩展点这件事,考验的不是语法和模式的熟练度,而是对变化方向的预判,这种预判只能来自被业务反复打磨的经验。下次遇到一个总被不同需求反复修改的类,先别急着抱怨代码写得差,看看能不能把下一次变化,从改源码挪到扩展上

相关推荐
小白男神3 分钟前
MySQL进阶学习三(视图)
后端·mysql
猿人谷4 分钟前
Jev:当 AI 不再生成 Token,而是直接做决策
后端·langchain·aigc
苍何6 分钟前
WorkBuddy + 腾讯乐享,原来知识库还能这么用
后端
站大爷IP8 分钟前
Python的生成器把我坑惨了,原来yield和return的区别这么大
后端
苍何9 分钟前
一份来自大厂内部的《AI 原生实践避坑指南》
后端
光依旧30 分钟前
MCP实战手记系列(五):让工具返回一个能点的界面(文末附github源码链接)
java·spring ai·前端集成·mcp·mcp apps
codigger32 分钟前
服务器又卡了?一篇讲透 Linux 性能排查(基础四件套 + perf/strace/火焰图)
linux·后端·性能优化
APIshop35 分钟前
淘宝选品接口实战:从召回、详情、佣金到转链的 Java 接入笔记
java
+VX:Fegn089543 分钟前
计算机毕业设计|基于java+ vue共享单车信息系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
苏渡苇1 小时前
Spring Insight 里如何把 Span 画成瀑布时间线
后端·spring·spring cloud·springboot·监控·apm