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

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

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

小结

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

相关推荐
IT_陈寒23 分钟前
Redis的Lua脚本居然把我的集群搞崩了!
前端·人工智能·后端
SimonKing25 分钟前
开源神器 Navop:数据库+SSH+终端+AI,一个应用全搞定
java·后端·程序员
爱码猿29 分钟前
SpringBoot+MybatisPlus动态数据源
java·spring boot·后端
小义_29 分钟前
JDK 深度解析
java·linux·开发语言·python·面试
奈斯先生Vector1 小时前
从“能调用”到“可运营”:AI Agent 进入多模型时代后的架构升级
java·javascript·数据库·人工智能·算法·架构·aigc
Json____1 小时前
宿舍安全卫生检查系统:Node 全栈开发实战
java·前端·数据库·毕业设计·课程设计·毕设·wwwoop.com
阿里嘎多学长1 小时前
2026-08-30 GitHub 热点项目精选
开发语言·程序员·github·代码托管
pnoker1 小时前
36 个驱动模块:应对协议碎片化
java·物联网·modbus·工业互联网·opc ua
码上有光1 小时前
Linux:进程控制和进程替换
java·linux·服务器·进程控制·进程替换