跳出 FatJar:用 Solon 的 E-Spi 与 H-Spi 做插件扩展和热插拔

2026-08-06 38 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

把 Java 服务打成一个 FatJar,发布时确实省事:依赖、配置和业务代码都在一个包里。但运行一段时间后,FatJar 也会暴露出边界:运维想切换数据源地址,业务团队想临时下线某个模块,结果都可能变成“重新构建、重新发布、重启服务”。

Solon 为这类场景准备了两套插件机制:E-Spi(体外扩展)和 H-Spi(热插拔)。前者把一部分能力从主程序包中移到外部,后者进一步关注插件的动态加载与卸载。它们的价值不在于让所有代码都可以随时替换,而在于把适合独立演进的边界从 FatJar 中拆出来。

FatJar 的方便与摩擦

FatJar 适合部署单元清晰、版本整体发布的服务。构建产物只有一个,启动命令简单,回滚也容易。不过,它把几个本来可以独立变化的维度绑定在了一起:

  • 数据源、消息中间件或第三方连接器的实现与主程序一起发布。
  • 某个业务模块的启停依赖整个应用生命周期。
  • 一处小改动可能触发完整构建、镜像制作和发布流程。
  • 运行中的 JVM 通常只能通过重启来获得新的实现。

E-Spi 的思路是把扩展放到应用包之外,由主程序在启动阶段发现并加载。这样,主程序保持相对稳定,插件可以单独替换。H-Spi 则适合更动态的场景:插件能够在运行过程中加入或移除,应用不必因为一个模块的生命周期变化而整体重启。

这并不意味着插件机制可以绕过所有发布流程。插件仍然需要编译、测试、签名或审核,也必须与宿主程序约定稳定的接口。它改变的是发布粒度和运行时边界,而不是取消工程治理。

E-Spi:把扩展移到应用之外

E-Spi 可以理解为“宿主程序 + 外部插件目录”的部署模型。宿主负责启动基础能力,插件负责可替换的实现。一个适合实践的目录可以这样组织:

service/
├── service.jar
├── conf/
│   └── application.yml
└── plugins/
    ├── datasource-mysql.jar
    └── datasource-postgresql.jar

启动脚本不再把所有业务实现硬编码到单个 FatJar 中,而是为外部插件保留明确入口。下面是一个可以改造到部署脚本中的示例;具体的 Solon 插件目录名、启动参数和插件发现规则,应以项目使用的 Solon 版本为准:

#!/usr/bin/env bash
set -Eeuo pipefail

APP_HOME="$(cd "$(dirname "$0")" && pwd)"
APP_JAR="$APP_HOME/service.jar"
PLUGIN_DIR="$APP_HOME/plugins"
CONFIG_FILE="$APP_HOME/conf/application.yml"

mkdir -p "$PLUGIN_DIR" "$(dirname "$CONFIG_FILE")"

# 只加载经过发布流程放入 plugins 目录的扩展包。
# 这里的参数名是部署示例,接入项目时替换为实际的 Solon 插件加载配置。
exec java \
  -Dsolon.plugin.dir="$PLUGIN_DIR" \
  -Dsolon.config.file="$CONFIG_FILE" \
  -jar "$APP_JAR"

外部扩展特别适合以下变化:

  • 同一套服务需要连接不同类型的数据源。
  • 不同客户需要不同的认证、存储或消息适配器。
  • 主程序的核心流程稳定,但外围集成经常变化。
  • 希望先替换一个连接器,而不是重新打包全部业务代码。

设计接口时,建议把插件契约放进独立的 API 模块,而不是让插件直接依赖宿主内部类。一个简化的接口可以这样写:

package example.spi;

public interface DataSourceProvider {
    String name();

    void start(DataSourceSettings settings);

    void stop();
}
package example.spi;

public record DataSourceSettings(
        String host,
        int port,
        String database,
        String username
) {
}

宿主只依赖 DataSourceProviderDataSourceSettings,具体的 MySQL、PostgreSQL 或其他实现放在外部插件中。这样做的关键不是接口数量,而是控制依赖方向:插件依赖稳定的 API,宿主不依赖某个插件的内部实现。

H-Spi:让插件拥有运行时生命周期

E-Spi 解决的是“插件不必和宿主打包在一起”。如果需求进一步变成“服务运行期间更换插件”,就进入 H-Spi 的关注范围。

一个热插拔插件至少要有清晰的生命周期:

  1. 发现:宿主识别插件包及其元数据。
  2. 校验:检查版本、依赖、权限和配置。
  3. 启动:创建插件上下文,注册路由、任务或服务。
  4. 运行:插件处理自己的请求和资源。
  5. 停止:取消任务、关闭连接、注销监听器。
  6. 卸载:释放类加载器和其他引用,避免旧类无法回收。

其中最容易被低估的是“停止”和“卸载”。如果插件启动了线程、定时任务、连接池或事件监听器,却没有在卸载时释放,热插拔只会把问题从“重启服务”变成“运行越久越不稳定”。

可以把插件操作设计成显式命令,而不是让运维直接替换正在使用的 JAR 文件。例如,下面是一个最小的 HTTP 管理接口约定,接口名仅作为可改造示例:

POST /admin/plugins/payment-v2/load
Content-Type: application/json

{
  "path": "/opt/service/plugins/payment-v2.jar",
  "sha256": "replace-with-published-checksum"
}
POST /admin/plugins/payment-v2/unload
Authorization: Bearer ${ADMIN_TOKEN}

实际接入时,管理接口应放在内网或独立管理端口,并增加认证、审计、并发控制和失败回滚。不要把任意路径加载 JAR 的能力暴露到公网。

一个可落地的插件切换流程

假设服务有一个支付适配器,当前版本为 payment-v1.jar,准备上线 payment-v2.jar。可以按下面的流程组织发布:

set -Eeuo pipefail

PLUGIN_DIR=/opt/service/plugins
NEW_PLUGIN=/tmp/payment-v2.jar
PLUGIN_NAME=payment-v2

# 1. 发布前校验文件
sha256sum "$NEW_PLUGIN"

# 2. 原子地放入插件目录,避免宿主读到半截文件
install -m 0644 "$NEW_PLUGIN" "$PLUGIN_DIR/.${PLUGIN_NAME}.jar.tmp"
mv "$PLUGIN_DIR/.${PLUGIN_NAME}.jar.tmp" "$PLUGIN_DIR/${PLUGIN_NAME}.jar"

# 3. 通过管理 API 请求宿主加载插件
curl --fail-with-body \
  -X POST \
  -H "Authorization: Bearer ${ADMIN_TOKEN:?ADMIN_TOKEN is required}" \
  -H 'Content-Type: application/json' \
  "http://127.0.0.1:9090/admin/plugins/${PLUGIN_NAME}/load" \
  -d "{\"path\":\"$PLUGIN_DIR/${PLUGIN_NAME}.jar\"}"

# 4. 检查插件状态和健康指标
curl --fail-with-body \
  -H "Authorization: Bearer ${ADMIN_TOKEN:?ADMIN_TOKEN is required}" \
  "http://127.0.0.1:9090/admin/plugins/${PLUGIN_NAME}/status"

这里的文件替换和 HTTP 路径是部署层示例,不是对所有 Solon 项目的固定 API 承诺。真正实现时,需要把它们接到项目的 E-Spi 或 H-Spi 管理能力上。流程中的几个原则值得保留:先校验,再原子发布,显式加载,最后观察状态。

对于有状态插件,建议使用“新插件先启动、旧插件后停止”的切换顺序,并为请求设置短暂的排空时间。对于不能同时存在两个版本的资源,例如固定端口或独占文件锁,则需要由宿主协调停机顺序,不能简单依赖类加载器替换。

采用前要检查什么

E-Spi 和 H-Spi 更适合边界明确、资源生命周期可控的模块。把一个强耦合的核心模块强行变成热插拔插件,通常会增加复杂度:版本兼容、类加载隔离、事务边界和故障排查都会变得更难。

可以在引入前检查以下事项:

  • 插件 API 是否足够稳定,是否独立于宿主内部实现。
  • 插件是否有明确的启动、停止和失败恢复语义。
  • 插件依赖的线程、连接池、定时器和监听器能否全部释放。
  • 插件包是否经过版本校验、来源校验和权限审核。
  • 插件加载失败时,宿主能否继续提供核心服务。
  • 运维是否有状态查询、审计日志和回滚路径。
  • 是否真的需要运行时热插拔,还是 E-Spi 的独立发布已经足够。

一个稳妥的迁移顺序是:先把低风险适配器做成 E-Spi 外部扩展,再补齐插件状态和生命周期管理,最后才对确实需要不停机变更的模块引入 H-Spi。这样可以逐步降低 FatJar 的耦合,同时保留单体服务容易部署、容易回滚的优点。


相关推荐