5931 字
约 19 分钟
4
如何设计一个“运行时可扩展”的 Java 系统

如何设计一个“运行时可扩展”的 Java 系统

很多 Java 项目都会遇到一种很尴尬的情况:

业务本身没有大改,只是想在某个已有流程上加一点小功能。

比如:

用户注册成功之后
├─ 发通知
├─ 送积分
├─ 自动关注
├─ 发优惠券
└─ 初始化用户等级

每次新增的逻辑可能只有几行。

但传统项目的发布流程通常还是:

修改源码
  ↓
重新编译
  ↓
重新打包
  ↓
重新部署
  ↓
应用重新加载新代码

如果项目很大,那么为了增加一个非常小的附加行为,也要重新走完整发布流程,成本就会越来越明显。

于是就会自然产生一个问题:

能不能把“容易变化的小部分逻辑”从主工程中剥离出去,让它们在系统运行期间动态加入,而不是每次都重新发布整个应用?

这类问题的核心并不是“怎么让 Maven 编译更快”。

真正的思路是:

把一部分原本在构建期确定的逻辑,延迟到运行期再确定。

这就是运行时扩展、插件化、动态代码加载这一类设计的出发点。


一、什么时候值得考虑这种设计

并不是所有项目都应该做运行时动态扩展。

它比较适合一种非常典型的场景:

主流程长期稳定,但某些附加逻辑经常变化。

比如用户注册:

核心流程:

校验用户
   ↓
保存用户
   ↓
注册成功

这部分通常不会频繁改。

但注册成功以后要做什么,却可能不断增加:

发送通知
赠送积分
发送优惠券
自动关注
记录行为
调用第三方系统

这时候就非常适合把:

注册成功以后

看成一个可扩展位置。

类似的场景还有很多。

订单支付成功以后:

支付成功
   ↓
积分
短信
优惠券
统计
消息推送
第三方通知

文件上传成功以后:

上传完成
   ↓
图片压缩
病毒扫描
OCR
内容审核
元数据提取

数据处理系统:

数据进入
   ↓
校验
清洗
转换
过滤
聚合
输出

工作流系统:

任务 A
 ↓
任务 B
 ↓
任务 C

甚至 IDE、网关、规则引擎、低代码平台,本质上都有类似思想。

所以判断是否值得采用这套设计,可以先问自己三个问题:

这个位置以后会不会经常增加新逻辑?

核心流程是不是比较稳定?

新增逻辑是不是可以相对独立?

如果三个答案基本都是“是”,那就很适合做扩展点。


二、第一步不是热加载,而是先找到“变化点”

很多人一想到动态加载,就直接钻进:

ClassLoader
JavaCompiler
反射

其实顺序反了。

真正第一步应该是:

先找到系统里哪些地方最容易变化。

假设原来的代码逻辑是:

用户注册
   ↓
保存用户
   ↓
发送通知
   ↓
送积分
   ↓
自动关注

这时候我们应该把它重新思考为:

用户注册
   ↓
保存用户
   ↓
【注册完成扩展点】

至于扩展点后面挂什么:

通知
积分
关注
优惠券

不应该再写死在核心流程里。

这一步非常关键,因为它实际上是在做:

稳定部分与变化部分分离。

稳定部分:

用户注册核心逻辑

变化部分:

注册成功后的附加逻辑

这才是整个设计的根基。


三、什么叫“扩展点”

所谓扩展点,可以把它理解成:

系统提前留下的一个“插口”。

比如:

注册成功以后

就是一个业务位置。

系统只规定:

如果你想在这里增加功能,
必须遵守统一规范。

那么以后不管新增的是:

送积分
发短信
发优惠券
调用第三方

都按照同一种规范接入。

可以把它想象成:

核心系统
   │
   │ 提供标准插口
   ▼
扩展点
   │
   ├─ 插件 A
   ├─ 插件 B
   ├─ 插件 C
   └─ 插件 D

扩展点真正解决的是:

以后增加功能时,不要修改核心流程,而是新增一个扩展实现。

这其实就是开闭原则:

对扩展开放
对修改关闭

四、做到扩展点以后,还不等于动态更新

做到这里,系统只是从:

写死业务

变成了:

面向扩展接口

例如原来:

注册
 ↓
送积分

现在变成:

注册
 ↓
扩展点
 ↓
积分插件

但如果这个“积分插件”还是写在主工程里,那新增插件之后仍然需要:

重新编译
重新打包
重新发布

所以到这里,我们只是解决了:

代码结构上的解耦。

还没有解决:

运行时加载。

如果继续往前走,就要问:

能不能让扩展实现根本不参与主项目的构建,而是在程序运行起来以后再加入?

这时才真正进入动态加载。


五、动态加载的本质是什么

假设系统已经运行。

现在外部传进来一段新的 Java 代码。

系统面对的其实只是:

一段字符串

而 Java 真正能够运行的不是字符串。

它必须经历一个完整转换:

Java 源码
   ↓
编译
   ↓
字节码
   ↓
类加载
   ↓
Class
   ↓
对象
   ↓
执行

如果把整个过程再压缩一下:

Source
 ↓
Bytecode
 ↓
Class
 ↓
Object

这其实就是运行时 Java 扩展系统最核心的一条链。

理解了这条链,整个项目基本就通了。


六、为什么需要“统一规范”

假设系统允许外部动态加载任意类。

有人写:

hello()

有人写:

run()

有人写:

execute()

那么加载以后系统会遇到一个问题:

我到底应该调用哪个方法?

所以动态扩展系统一定要有一个统一协议。

也就是说:

所有动态代码必须遵守同一种接口规范

比如规定:

每个扩展都必须有一个统一执行入口

那么系统就不需要知道:

这是积分插件
这是短信插件
这是优惠券插件

它只需要知道:

这是一个合法扩展
我可以执行它

这一步本质上是在做:

框架和扩展之间的契约。

没有这个契约,插件体系会非常混乱。


七、动态代码进入系统之前为什么要校验

如果允许用户提交 Java 源码,那么系统不能收到什么就执行什么。

至少要确认它是不是符合框架协议。

例如:

是否标记为扩展
是否实现指定接口
是否存在指定执行方法

于是就会有校验模块。

逻辑可以理解为:

用户源码
   ↓
结构校验
   ↓
是否合法?
  /   \
否     是
↓       ↓
拒绝   继续编译

这一层的作用不是执行代码。

而是:

先筛掉明显不符合约定的扩展。

实际生产系统里,如果允许不可信用户提交源码,还远远不止结构校验。

还需要考虑:

恶意代码
死循环
线程滥用
文件系统访问
网络访问
内存耗尽
反射攻击
System.exit

所以“动态执行用户 Java 代码”本身属于高风险能力。

课程项目通常更侧重架构思想,而不是完整的安全沙箱。


八、编译器解决的是什么问题

校验通过以后,源码还是源码。

JVM 真正执行的是字节码。

所以必须:

Java Source
   ↓
Compiler
   ↓
Bytecode

传统 Java 项目中,这一步通常由:

Maven
Gradle
javac

在应用启动之前完成。

而运行时扩展系统的特殊之处在于:

应用已经启动了,现在才收到新源码。

因此需要在:

Runtime

执行编译。

这就是为什么动态编译器是整个系统的核心模块之一。

它不是为了:

提升 Maven 编译速度

而是为了:

让一小段新代码绕开整个项目重新构建

这两个概念差别很大。


九、为什么很多实现会采用“内存编译”

最简单的动态编译可以:

生成 .java 文件
 ↓
编译成 .class 文件
 ↓
再读取 .class

但这样涉及大量临时文件。

更优雅的一种方式是:

源码在内存
   ↓
编译
   ↓
字节码也留在内存

也就是:

String
 ↓
byte[]

这样下一步 ClassLoader 可以直接加载字节码。

整个流程会非常紧凑:

Java Source
   ↓
Memory Compiler
   ↓
byte[]
   ↓
ClassLoader

所以会看到各种:

MemoryJavaFileObject
JavaFileManager
byte[]

本质上都是为了:

不必依赖磁盘文件完成编译和加载。


十、ClassLoader 为什么是这套设计真正的灵魂

编译结束以后得到的是:

字节码

但字节码还没有真正成为 JVM 里的类。

必须经过:

ClassLoader

完成:

byte[]
 ↓
Class

这一步看起来只是“加载类”。

实际上它还解决了动态更新里最麻烦的问题:

同一个类名怎么加载新版本?

比如第一次:

GivePoints V1

第二次更新以后还是:

GivePoints

类名没变。

但代码变了。

如果总用同一个类加载器,通常不能随意再次定义同名类。

所以常见思路是:

V1
 ↓
ClassLoader A
 ↓
GivePoints

更新以后:

V2
 ↓
ClassLoader B
 ↓
GivePoints

虽然类名相同,但加载它们的 ClassLoader 不同。

在 JVM 里,它们可以被视为不同的类型身份。

所以动态扩展系统能够实现:

相同类名
不同版本
并存或替换

这就是:

类隔离。

因此,ClassLoader 不只是“把 class 加载进来”。

它还是:

版本隔离、插件隔离、生命周期隔离的重要边界。


十一、为什么加载完以后还需要 Manager

假设动态代码已经变成对象。

如果每次业务调用都重新:

源码校验
 ↓
编译
 ↓
加载
 ↓
创建对象
 ↓
执行

显然成本非常高。

正确思路应该是:

发布阶段
和
执行阶段
分离

发布阶段:

新源码
 ↓
校验
 ↓
编译
 ↓
加载
 ↓
创建对象
 ↓
保存

执行阶段:

业务请求
 ↓
找到已有对象
 ↓
直接执行

所以一定会出现一个 Manager。

Manager 可以理解成:

动态扩展注册中心。

它负责:

保存
查询
替换
删除

比如:

givePoints → V2
sendMessage → V1
followUser → V3

以后业务调用只需要:

根据名字找到扩展
 ↓
执行

而不是再次编译。


十二、Executor 的作用其实最简单

当系统已经有了:

合法的扩展对象

执行器只需要做一件事:

调用统一的执行入口。

所以整套系统从使用者视角看,往往非常简单。

逻辑上可能只有:

add
exec
delete

含义分别是:

add
→ 安装 / 更新扩展

exec
→ 执行扩展

delete
→ 卸载扩展

完整一点还可以增加:

enable
disable
version
rollback
query

这时候它就逐渐变成真正的插件平台。


十三、为什么要用责任链来编排这些模块

现在系统已经有:

校验器
编译器
类加载器
管理器
执行器

但还缺一件事:

谁负责告诉它们按什么顺序工作?

最直接的写法:

先校验
再编译
再加载
再管理

全部写死在一个方法里。

这种方式能跑。

但如果以后要增加:

安全扫描
依赖检查
日志
版本检查
监控
缓存

就会不断修改那个大方法。

于是整个框架自己的扩展性反而很差。

所以这里适合使用:

责任链 / Pipeline 思维。

把每一步拆成一个独立任务:

Validator
   ↓
SecurityCheck
   ↓
Compiler
   ↓
ClassLoader
   ↓
Manager

以后:

想增加任务
→ 插进去

想删除任务
→ 拿掉

想改变顺序
→ 重新编排

这就是责任链在这里真正解决的问题:

模块编排。

它不是为了炫技用设计模式。

而是因为整个动态加载流程本身就是一个天然的 Pipeline。


十四、为什么模板模式不是最理想

另一种办法是模板模式。

比如:

beforeValidate
validate
afterValidate

beforeCompile
compile
afterCompile

最开始看起来不错。

但模块越来越多以后,很容易出现:

before
after
before
after
before
after

如果系统有 5 个处理步骤,前后都预留扩展,就会出现很多扩展点。

而且如果希望动态调整整个任务顺序,模板模式也比较僵硬。

所以对于:

A → B → C → D

这种可以插拔、重排的流水线,责任链一般更自然。


十五、为什么做完 Java 动态加载以后,还要接 Spring

到这里,纯 Java 版本已经能做到:

运行时加载新代码

但是如果这个对象是框架自己创建的,就会遇到一个现实问题:

它不是 Spring Bean。

于是动态扩展里面如果依赖:

Service
Mapper
Repository
Redis
配置
其他 Bean

就很麻烦。

比如扩展希望:

注册送积分

真正业务一般不是:

System.out.println()

而是要调用:

PointService

PointService 是 Spring Bean。

所以高级版就需要解决:

如何把动态创建出来的对象接入 Spring IOC。


十六、接入 Spring 的本质是什么

普通 Spring Bean 是这样来的:

Spring 发现 Bean 定义
   ↓
Spring 创建对象
   ↓
依赖注入
   ↓
生命周期处理
   ↓
放进 IOC

而动态对象是:

ClassLoader 动态加载
   ↓
框架自己创建对象

Spring 压根没参与前面的过程。

所以必须显式告诉 Spring:

这个对象虽然不是你创建的,但请帮我对它执行依赖注入。

于是动态插件才可以使用:

@Autowired
@Resource
其他 Spring Bean

整个过程就升级为:

Java Source
 ↓
Compiler
 ↓
ClassLoader
 ↓
Object
 ↓
Spring 接管部分生命周期
 ↓
依赖注入
 ↓
Manager

这样动态代码才真正进入业务体系。


十七、为什么还要做成 Spring Boot Starter

如果最终的框架只能:

复制一堆源码

那复用性就很差。

所以更成熟的做法是:

把整个能力封装成 Starter。

用户只需要:

引入依赖

Spring Boot 就自动创建:

Validator
Compiler
ClassLoader
Manager
Executor
责任链

这就是自动装配的意义。

它解决的是:

框架如何低成本接入不同项目。


十八、为什么 @ConditionalOnMissingBean 很关键

一个好的框架不能只做到:

开箱即用

还要做到:

允许替换

比如框架提供默认 Manager:

Memory Manager

但用户可能想:

Spring Manager

或者:

DB Manager

那么框架就应该遵循:

如果用户没提供
→ 使用默认实现

如果用户提供了
→ 使用用户实现

这就是 @ConditionalOnMissingBean 这一类设计背后的思想。

它其实非常符合整个项目的哲学:

框架本身也应该拥有扩展点。

也就是说,不仅“业务插件”可以扩展。

连:

Validator
Compiler
ClassLoader
Manager

都应该允许用户替换。

这才是真正完整的可扩展架构。


十九、如果自己从零复现,应该按什么顺序做

如果你想自己复现这个项目,我非常建议不要一上来全部做。

按照下面顺序最清晰。

第一阶段,只解决一个问题:

String Java Source
 ↓
编译
 ↓
Class
 ↓
执行

先证明:

运行中的 JVM 可以执行后来才出现的一段 Java 源码。

做到这里就够了。

第二阶段,加入统一接口:

所有动态代码
 ↓
必须遵守统一执行规范

解决:

框架怎么统一调用不同扩展。

第三阶段,加入 ClassLoader 隔离:

V1
 ↓
Loader A

V2
 ↓
Loader B

解决:

同名类怎么更新。

第四阶段,加入 Manager:

加载一次
 ↓
保存

以后调用
 ↓
直接查询执行

解决:

不要每次都重新编译。

第五阶段,加入 Validator:

源码
 ↓
合法性检查
 ↓
再编译

解决:

输入规范问题。

第六阶段,把:

校验
编译
加载
管理

拆成独立 Handler。

然后责任链编排:

A → B → C → D

解决:

框架内部流程扩展性。

第七阶段,再接 Spring:

动态对象
 ↓
Spring 依赖注入

解决:

动态代码如何调用现有业务 Bean。

第八阶段,最后再做 Starter:

自动装配
默认实现
组件替换

解决:

怎么把它真正包装成可复用组件。

如果按照这个顺序做,每一步都只解决一个明确问题,不容易乱。


二十、这套设计最适合哪些真实场景

它比较适合:

规则变化频繁
但主程序不想频繁发布

比如动态规则:

风控规则
营销规则
路由规则
转换规则
校验规则

插件化产品:

IDE
平台型系统
SaaS
低代码平台
开发者平台

可配置业务:

不同客户有不同流程
不同租户有不同逻辑

实验性功能:

临时业务逻辑
灰度规则
A/B Experiment

动态处理链:

ETL
网关
过滤器
消息处理
工作流

二十一、什么时候反而不应该使用

这点也很重要。

如果你的项目:

业务非常稳定
发布非常简单
系统规模不大

那么直接:

改代码
重新部署

往往更简单。

如果只是为了:

显得架构高级

就引入:

动态编译
ClassLoader
插件生命周期
版本管理
类卸载
安全问题
Spring 动态注册

复杂度反而会暴涨。

所以它并不是 Maven 重打包的万能替代品。

它真正适合的是:

变化频率真的高,并且这些变化可以被明确隔离成扩展单元。


二十二、这套设计还有哪些坑

第一个坑是安全。

动态执行 Java 代码非常危险。

如果源码来源不可完全信任,就必须考虑沙箱和权限控制。

第二个坑是类卸载。

Java 类不是说:

Manager.remove()

之后就一定彻底消失。

只有相关:

Class
Object
ClassLoader

都不再被引用,ClassLoader 才有机会被 GC。

所以热更新系统很容易产生:

ClassLoader 泄漏

第三个坑是线程。

动态代码如果自己创建线程、ThreadLocal、线程池,很容易导致旧 ClassLoader 无法回收。

第四个坑是 Spring。

动态注册 Bean 和正常启动时注册 Bean 并不完全一样。

涉及:

生命周期
AOP
BeanPostProcessor
事务代理
销毁

都会复杂很多。

第五个坑是版本兼容。

插件依赖的接口一旦变化,比如:

Chameleon V1

升级到:

Chameleon V2

旧插件可能全部无法使用。

所以扩展 API 本身必须非常稳定。


二十三、这套设计真正值得学习的,其实不是“热更新”

如果只把它理解成:

不重启更新代码。

其实理解得还比较浅。

更重要的是它背后的几个架构思想。

第一:

找到变化点。

第二:

稳定代码与变化代码分离。

第三:

用接口建立边界。

第四:

核心系统依赖抽象,不依赖具体插件。

第五:

复杂流程拆成模块,再做编排。

第六:

框架提供默认能力,同时允许用户替换。

第七:

把构建期决定的部分逻辑延迟到运行期决定。

这些思想甚至比 ClassLoader API 本身更有价值。


总结

如果把整套思路压缩成一张图,就是:

                稳定核心业务
                     │
                     ▼
                 扩展点
                     │
        ┌────────────┼────────────┐
        ▼            ▼            ▼
      插件A         插件B         插件C


              插件如何进入系统?

Java Source
     ↓
 Validator
     ↓
 Compiler
     ↓
 Bytecode
     ↓
 ClassLoader
     ↓
   Class
     ↓
   Object
     ↓
Spring / Manager
     ↓
 Executor

而整个项目真正解决的问题可以概括成:

当系统中存在一些高频变化、又能够被独立抽离的业务逻辑时,通过扩展点、动态编译、ClassLoader 和统一管理机制,把这些逻辑从主工程构建流程中解耦出来,让它们可以在运行时动态加载和替换。

如果你自己想复现,最正确的学习顺序不是一上来就做完整框架,而是:

先让一段字符串代码跑起来
        ↓
再解决统一协议
        ↓
再解决重复加载
        ↓
再解决管理
        ↓
再解决流程编排
        ↓
最后接 Spring 和 Starter

这样你最终学到的就不只是一个“热加载 Demo”,而是完整理解:

一个可扩展框架,是怎么从需求变化一步一步设计出来的。

如何设计一个“运行时可扩展”的 Java 系统
http://www.clxhxhhr.top/posts/409/
作者
clxstart
发布于
2026-09-02
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。