跳出 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 文件作为插件包加载

第一步 ------ 声明扩展目录

yml 复制代码
# 扩展目录为 demo_ext(不存在也不会报错)
solon.extend: "demo_ext"

给值加上 ! 前缀,Solon 会帮你把目录建出来:

yml 复制代码
# 扩展目录为 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。

第三步(可选)------ 用代码加载

如果你更愿意在代码里加载这些额外内容,内核直接暴露了接口:

java 复制代码
@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 里必须移除它注册过的每一个资源,否则卸载时就会泄漏:

java 复制代码
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 上:

java 复制代码
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 最需要先甩掉的是什么:配置,还是整个模块?

相关推荐
宁之明起(B服)1 小时前
Java 反序列化漏洞-xmldecoder/SnakeYAM
java·开发语言
禾高网络1 小时前
开启环保新潮流:回收小程序系统功能介绍
java·大数据·小程序
deviant-ART1 小时前
java stream 的 findFirst 及 findAny 方法遇到 null 元素时会出现 NullPointerException
java·开发语言
jjjava2.02 小时前
MyBatis动态if/trim/where/set标签详解
java·开发语言·数据库
gugucoding2 小时前
34.【Java】I/O流(下):NIO与文件操作
java·python·nio
tianyu2342 小时前
双指针循环匹配金额——打款记录与账单的自动对账算法
java·算法·双指针循环·匹配金额
YDS8293 小时前
大营销平台 —— 架构解析和抽奖流程串联
java·springboot·ddd
小贤plus3 小时前
MyBatis‑Plus IPage 分页踩坑实战:分页与全量查询兼容方案
java
Java内核笔记3 小时前
BeanRegistrar:Spring Boot 4 最被低估的新特性,彻底改变 Bean 注册方式
java·后端