你把一个 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 最需要先甩掉的是什么:配置,还是整个模块?


原文地址: https://www.cveoy.top/t/topic/qHg7 著作权归作者所有。请勿转载和采集!

免费AI点我,无需注册和登录