跳出 Fatjar 的束缚:Solon 插件的体外扩展(E-Spi)与热插拔(H-Spi)
你把一个 Java 服务打成了单个 fatjar 发布出去。然后现实来了:运维想把数据源指向另一台主机,却不想让你重新构建;业务团队想在凌晨两点把某个模块下线,又不想重启整个进程。如果你唯一的答案是"重新打包、整包重发",那每一次都能感觉到那股摩擦。
Solon 恰好为这道缝隙准备了两套机制:E-Spi(体外扩展)和 H-Spi(热插拔)。它们落在同一条谱系的不同位置上,选错了,代价要么是灵活性、要么是稳定性。下面讲清楚各自怎么用、什么时候用哪个。
共同的痛点:fatjar 是密封的
fatjar 部署起来方便,改起来难受。配置文件、业务模块,全都烤进了包里。E-Spi 和 H-Spi 都能撬开这层密封,但契约截然不同:
- E-Spi 让你把配置文件和插件 jar 放在 fatjar 外面,启动时加载进同一个运行时。简单、不需要额外依赖,但变更要重启。
- H-Spi 给每个插件独立的 ClassLoader,让你在服务持续运行时启停模块。能力更强,责任也更大。
E-Spi:把配置和模块放在 jar 旁边
E-Spi(体外扩展)直接瞄准 fatjar 部署这个场景。你指定一个扩展目录,启动时 Solon 扫描它并加载发现的内容:
.properties/.yml文件作为扩展配置加载.jar/.zip文件作为插件包加载
第一步 —— 声明扩展目录
# 扩展目录为 demo_ext(不存在也不会报错)
solon.extend: "demo_ext"
给值加上 ! 前缀,Solon 会帮你把目录建出来:
# 扩展目录为 demo_ext(! 表示自动创建)
solon.extend: "!demo_ext"
第二步 —— 把文件放到 jar 旁边
demo.jar
demo_ext/_db.properties
demo_ext/demo_user.jar
demo_ext/demo_order.jar
现在数据源配置落在 jar 外的 _db.properties 里,两个业务模块作为独立的插件 jar 一起随行。运维可以直接改那个 properties 文件,你完全不用碰 fatjar。
第三步(可选)—— 用代码加载
如果你更愿意在代码里加载这些额外内容,内核直接暴露了接口:
@SolonMain
public class Application {
public static void main(String[] args) throws Exception {
Solon.start(Application.class, args, app -> {
// 加载一个包文件
app.classLoader().addJar(new File("/demo.jar"));
// 加载一个配置文件
app.cfg().loadAdd(new File("/demo.yml"));
});
}
}
底层其实就是 AppClassLoader.addJar(URL | File)。这一个细节就解释了 E-Spi 的全部性格:
- 一切共享 —— 所有插件包共享一个 ClassLoader、一个 AppContext、一棵配置树
- 拆或合,随你 —— 可以体外打包,也可以和主应用打在一起;加载时机是一样的
- 更新要重启 —— 因为都挂在启动时加载的那一个 ClassLoader 上,换 jar 或改配置都要重启主服务后才生效
- 无额外依赖 —— 内核直接提供 E-Spi
一个打包上的提醒:插件 jar 要么自身打成 fatjar,要么把依赖折进主应用(尤其是公共依赖应放在主应用的构建里,插件自己的 pom 把它们标为 optional)。
官方示例:demo2002-external_ext(在 solon-examples 仓库的 2.Solon_Advanced 下)。
H-Spi:隔离并在不重启的前提下热替换
H-Spi(热插拔)是更重的工具。你把一个业务模块开发成自包含的插件包,运行中的服务可以实时加载和卸载它。与 E-Spi 最本质的区别是隔离:
- 每个插件拥有自己的 ClassLoader、AppContext 和配置 —— 完全隔离
- 需要主应用的全局资源?通过
Solon.app()、Solon.cfg()、Solon.context()显式获取 - 更新一个插件包不需要重启主服务
- 主应用需要引入 solon-hotplug 依赖来管理业务插件包
ClassLoader 契约
隔离就是这套机制的全部意义,所以类的可见性规则很关键:
- 父 ClassLoader(把公共资源放这里):子级能看到并使用它的类和资源 —— 但子级注册的任何东西,都必须在它的
stop事件里注销 - 兄弟 ClassLoader 之间:无法使用彼此的类和资源。不要在兄弟之间连显式的类型交互;改用事件总线沟通,用父级实体类或弱类型 JSON 传数据 —— 把它当成调用远程 API 来对待
写好 start(),也要老实写 stop()
一个可热插拔的插件实现 Plugin 接口。start 里注册模块需要的东西;stop 里必须移除它注册过的每一个资源,否则卸载时就会泄漏:
public class Plugin1Impl implements Plugin {
AppContext context;
StaticRepository staticRepository;
@Override
public void start(AppContext context) {
this.context = context;
// 添加自己的配置文件
context.cfg().loadAdd("demo1011.plugin1.yml");
// 扫描自己的 bean
context.beanScan(Plugin1Impl.class);
// 添加自己的静态文件仓库(注册 classloader)
staticRepository = new ClassPathStaticRepository(context.getClassLoader(), "plugin1_static");
StaticMappings.add("/html/", staticRepository);
}
@Override
public void stop() throws Throwable {
// 移除 http 处理器(用前缀便于移除)
Solon.app().router().remove("/user");
// 移除定时任务(选一个支持手动移除的 job 实现)
JobManager.getInstance().jobRemove("job1");
// 移除事件订阅
context.beanForeach(bw -> {
if (bw.raw() instanceof EventListener) {
EventBus.unsubscribe(bw.raw());
}
});
// 移除静态文件仓库
StaticMappings.remove(staticRepository);
}
}
那个 stop 方法就是热插拔要交的税。路由、任务、事件订阅、静态仓库 —— start 加了什么,stop 就得移除什么。漏一行,卸载后就留下幽灵路由或泄漏的监听器。
模板渲染还有一个 ClassLoader 上的坑 —— 渲染器必须钉在正确的 ClassLoader 上:
public class BaseController implements Render {
// 要考虑模板所在的 classloader
static final FreemarkerRender viewRender = new FreemarkerRender(BaseController.class.getClassLoader());
@Override
public void render(Object data, Context ctx) throws Throwable {
if (data instanceof Throwable) {
throw (Throwable) data;
}
if (data instanceof ModelAndView) {
viewRender.render(data, ctx);
} else {
ctx.render(data);
}
}
}
跨模块通讯,靠事件总线配弱类型载荷(Map / JSON 字符串);DamiBus 在这里做解耦很搭。官方示例包:demo1011。插件管理还可以借助 solon-hotplug 进一步推向仓库或平台。
并排对比
| 维度 | E-Spi | H-Spi |
|---|---|---|
| ClassLoader / AppContext / 配置 | 共享 | 隔离(完全) |
| 更新后是否需重启 | 是 | 否(热更新) |
| 额外依赖 | 无(内核内置) | solon-hotplug |
| 侧重点 | 简单体外扩展 / 改配置 | 隔离 + 热插拔 + 管理 |
| 资源移除 | 无需特殊处理 | 必须在 stop 里手动移除所有注册的资源 |
| 跨模块通讯 | 直接共享 | 事件总线 / 弱类型数据 |
| 底层机制 | AppClassLoader.addJar |
隔离 ClassLoader + Plugin.start/stop |
到底该用哪个
从这个问题开始想:"这东西必须在不重启的情况下变更吗?"
- 不用,有个重启窗口就行。 用 E-Spi。把数据源配置外置、业务模块作为兄弟 jar 随行,覆盖了绝大多数"我不想重打 fatjar"的场景,零额外依赖,也没有生命周期的记账负担。
- 要,模块来来去去时服务必须一直在线。 用 H-Spi。你得到真正的隔离和实时替换,作为交换,你要接受
stop清理的纪律和"只走事件总线"的跨模块契约。
一个好用的心智模型:E-Spi 把 文件 挪到 jar 外面,H-Spi 把 模块 搬进各自的运行时气泡里。一个关乎部署便利,一个关乎运维隔离。不少团队两个都用 —— E-Spi 管外置配置,H-Spi 管那一两个真正需要热替换的模块。
如果你正在为一个要长期演进的 Solon 服务做结构设计,值得把两篇官方文档从头到尾读一遍再定 —— 尤其是 ClassLoader 那套规则,认真读第一遍很值。
你的 fatjar 最需要先甩掉的是什么:配置,还是整个模块?

浙公网安备 33010602011771号