把 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
) {
}
宿主只依赖 DataSourceProvider 和 DataSourceSettings,具体的 MySQL、PostgreSQL 或其他实现放在外部插件中。这样做的关键不是接口数量,而是控制依赖方向:插件依赖稳定的 API,宿主不依赖某个插件的内部实现。
H-Spi:让插件拥有运行时生命周期
E-Spi 解决的是“插件不必和宿主打包在一起”。如果需求进一步变成“服务运行期间更换插件”,就进入 H-Spi 的关注范围。
一个热插拔插件至少要有清晰的生命周期:
- 发现:宿主识别插件包及其元数据。
- 校验:检查版本、依赖、权限和配置。
- 启动:创建插件上下文,注册路由、任务或服务。
- 运行:插件处理自己的请求和资源。
- 停止:取消任务、关闭连接、注销监听器。
- 卸载:释放类加载器和其他引用,避免旧类无法回收。
其中最容易被低估的是“停止”和“卸载”。如果插件启动了线程、定时任务、连接池或事件监听器,却没有在卸载时释放,热插拔只会把问题从“重启服务”变成“运行越久越不稳定”。
可以把插件操作设计成显式命令,而不是让运维直接替换正在使用的 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 的耦合,同时保留单体服务容易部署、容易回滚的优点。