本文将系统讲解 Solon 框架的热加载与插件热插拔机制。从开发阶段的 Debug 热更新到生产级的 H-Spi 热插拔,覆盖完整的知识链路。
1. 开篇:为什么需要热加载和热插拔?生产级插件管理的意义
在 Java 后端开发的日常中,有几个场景几乎每个开发者都会反复遭遇:
开发阶段的"改一行等半天"。 调试一个 FreeMarker 模板的样式问题,每改一次就要重启应用------等待容器初始化、等待依赖注入完成、等待数据库连接池建好。真正有效的修改时间可能只有 5 秒,但等待重启却要 30 秒甚至更久。一个下午下来,累计浪费的时间相当可观。
生产环境的"半夜停机更新"。 某个业务模块需要紧急修复一个线上 Bug,但部署方案是整体重新打包、停机、替换 JAR、重启。凌晨两点被叫起来操作不说,关键是服务中断期间的用户体验损失。对于 7x24 运行的在线服务来说,每一秒的停机都在透支信任。
模块化部署的"耦合困境"。 一个中大型系统往往包含用户管理、订单处理、消息通知、报表统计等多个业务模块。当所有模块打包在一个 JAR 里,任何模块的小改动都意味着整体重新发布。模块之间本应独立演进,却因为缺乏运行时的隔离和动态加载能力,被迫绑在一起。
这三个痛点分别指向了三个核心能力需求:开发态的热加载 、运行态的热插拔 、架构态的模块隔离。
Solon 框架在这三个方面提供了原生的、体系化的支持。这不是通过第三方工具拼凑出来的方案,而是从框架内核层面设计的一套完整的插件管理与生命周期管控机制。
具体来说,Solon 提供了以下关键技术能力:
- Debug 模式:面向开发阶段的资源热更新,模板和静态文件修改即时生效
- 启动参数体系:一套统一的参数控制机制,覆盖环境切换、安全停机、扩展目录等
- E-Spi(体外扩展机制):基于共享 ClassLoader 的外部插件加载,解决 fatjar 部署场景下的扩展需求
- H-Spi(热插拔机制):基于隔离 ClassLoader 的运行时插件管理,支持不停机安装、卸载、更新业务模块
- solon-hotplug 插件:为 H-Spi 提供管理能力的基础扩展插件
- 插件开发模板:标准化的插件工程结构,让业务插件的开发有章可循
- 插件间交互:基于 EventBus 的松耦合通信模式,以及父子 ClassLoader 的资源访问规则
- ClassLoader 隔离:每个热插拔插件拥有独立的类加载器,避免类冲突和资源泄漏
- 完整生命周期 :从
start()到stop()的全流程管控,确保资源的注册与清理对称 - AppContext 上下文:每个插件拥有独立的应用上下文,实现真正的运行时隔离
本文将逐一展开这些技术点,从开发调试效率提升到生产级热插拔实战,覆盖完整的知识链路。
2. Debug 模式与资源热更新
Solon 的 Debug 模式是一个面向开发阶段的功能。开启之后,框架会以更高的检测频率监控资源文件的变化,实现模板文件和静态资源的即时刷新,无需重启应用。
2.1 四种启用方式
Debug 模式的启用非常灵活,可以根据使用场景选择最合适的方式:
bash
# 方式一:程序启动参数(最常用)
java -jar demo.jar --debug=1
# 方式二:JVM 系统参数
java -Dsolon.debug=1 -jar demo.jar
除了命令行参数,还有两种更便捷的启用方式:
- solon-test 单元测试自动启用 :引入
solon-test-junit5依赖后,测试运行时 Debug 模式会自动开启,无需额外配置。这意味着在编写单元测试时,所有资源变更都能即时反映。 - IDE 开发工具配置 :在 IntelliJ IDEA 或其他 IDE 的运行配置(Run Configuration)中,添加 VM 参数
-Dsolon.debug=1或程序参数--debug=1即可。配置一次,后续每次运行都生效。
四种方式本质上是同一条配置入口的不同写法,选择哪种取决于使用习惯和场景。
2.2 效果一览
开启 Debug 模式后,不同类型资源的变化会触发不同的行为:
| 资源类型 | 效果 |
|---|---|
| 动态模板文件变更 | 即改即生效(FreeMarker、Thymeleaf、Enjoy 等) |
| 静态资源文件变更 | 即改即生效(CSS、JS、HTML、图片等) |
| Java 类代码 | 不支持,需 JRebel 或 DebugTools 等第三方工具 |
| 属性配置文件 | 打印提示信息,提醒开发者配置已变更 |
其中最实用的就是模板和静态资源的热更新。在前端联调阶段,修改一个 FreeMarker 模板的布局,刷新浏览器即可看到效果,开发效率的提升是实实在在的。
2.3 需要关注的限制
有几个关键点值得深入理解:
Java 类代码不会被自动热加载。 这不是 Solon 的设计缺陷,而是由 JVM 类加载机制本身决定的。一个类一旦被 ClassLoader 加载,在同一个 ClassLoader 内就无法被替换。要实现 Java 代码的热替换,需要借助 JRebel、DebugTools 等在字节码层面工作的第三方工具。
solon-proxy 插件会额外打印代理类信息。 在 Debug 模式下,如果你使用了 solon-proxy 插件(Solon 的 AOP 动态代理实现),框架会打印出动态代理的实现类名。这在排查 AOP 代理链问题时非常有用------你可以清楚地看到某个 Bean 被几层代理包裹,每一层的实现类是什么。
Debug 模式有性能损耗。 更频繁的资源变更检测意味着更多的文件系统 I/O 操作和模板重新解析开销。因此,仅建议在开发环境开启,生产环境务必关闭。这也符合 Debug 模式的设计初衷------它就是一个开发调试辅助工具,不是为生产环境准备的。
在实际开发中,建议将
--debug=1配置在 IDE 的开发运行配置中,而生产部署脚本中明确不传该参数,避免误操作。
3. 启动参数体系
Solon 提供了一套完整的启动参数体系,用于在应用启动阶段控制各种行为。理解这套参数体系,是掌握 Solon 运维能力的基础。
3.1 一个关键前提
启动参数有一个重要特性需要首先明确:所有启动参数在应用启动完成后会被静态化。
这意味着,启动参数在启动时读取一次后就固定下来了,运行期间无法通过任何方式修改。这个设计是为了内部更高效的利用------参数值不需要每次访问都去解析和判断,直接缓存为常量即可。
这也带来一个实际影响:如果你想通过 E-Spi 体外扩展加载外部配置文件来覆盖启动参数,是做不到的。启动参数的优先级高于一切外部配置。
3.2 完整参数表
| 启动参数 | 对应应用配置 | 描述 |
|---|---|---|
--env |
solon.env |
环境(可用于配置切换) |
--debug |
solon.debug |
调试模式(0 或 1) |
--scanning |
--- | 是否扫描(默认 1) |
--setup |
solon.setup |
安装模式(0 或 1) |
--white |
solon.white |
白名单模式(0 或 1) |
--drift |
solon.drift |
漂移模式,部署到 K8s 设为 1 |
--alone |
solon.alone |
单体模式(0 或 1) |
--extend |
solon.extend |
扩展目录路径 |
--locale |
solon.locale |
地域设置 |
--config |
solon.config |
指定外部配置文件路径 |
--config.add |
--- | 追加配置文件 |
--app.name |
solon.app.name |
应用名 |
--app.group |
solon.app.group |
应用分组 |
--stop.safe |
solon.stop.safe |
安全停止(0 或 1) |
--stop.delay |
solon.stop.delay |
安全停止延时秒数(默认 10 秒) |
3.3 三种等价写法
Solon 的启动参数有三种等价的传入方式,以设置运行环境为 dev 为例:
bash
# 写法一:JVM 系统属性
java -Dsolon.env=dev -jar demo.jar
# 写法二:启动参数(带 solon. 前缀)
java -jar demo.jar --solon.env=dev
# 写法三:启动参数(短名称)
java -jar demo.jar --env=dev
三种写法完全等价,最终都会被解析为 solon.env 配置项。写法一通过 JVM 标准的 -D 参数设置系统属性;写法二和写法三通过 Solon 自定义的 -- 参数协议传入,短名称是完整配置名的便捷缩写。
3.4 几个重点参数的深入理解
--env:环境切换的核心开关。 设置 --env=dev 后,Solon 会自动加载 app-dev.yml 作为环境配置,与 app.yml 合并。不同环境使用不同的配置文件,这在多环境部署中几乎是刚需。
--stop.safe 和 --stop.delay:优雅停机的关键配置。 开启安全停止模式后,Solon 在收到停止信号时不会立刻关闭,而是先停止接收新请求,等待已有请求处理完毕(最长等待 stop.delay 秒,默认 10 秒),然后再执行关闭流程。在 Kubernetes 环境中,这个能力至关重要------Pod 滚动更新时,旧 Pod 需要优雅地处理完存量请求再退出。
--drift:为 K8s 量身定制的漂移模式。 当 Pod 在集群中发生迁移(例如节点故障导致的重新调度),某些有状态的服务可能需要感知到这种变化并做出响应。设置 --drift=1 后,框架会在漂移场景下提供相应的状态保持和感知能力。
--extend:E-Spi 体外扩展的入口。 指定一个外部目录路径,Solon 启动时会自动扫描该目录下的 JAR 文件和配置文件并加载。这个参数是下一节要讲的 E-Spi 机制的基础配置。
--scanning:控制 Bean 扫描行为。 默认值为 1,表示正常扫描主类所在包及其子包下的所有组件。设置为 0 则跳过扫描------某些特殊场景下(比如纯插件模式运行,所有 Bean 由插件自行注册),关闭自动扫描可以减少不必要的类路径遍历开销。
3.5 在代码中访问启动参数
由于所有带"."的启动参数同时会成为应用配置,因此可以通过 Solon.cfg() 在代码中随时获取:
java
@Component
public class StartupPrinter {
@Inject("${solon.env}")
String env;
@Init
public void init() {
System.out.println("当前环境: " + env);
System.out.println("应用名称: " + Solon.cfg().appName());
System.out.println("安全停止: " + Solon.cfg().get("solon.stop.safe", "0"));
}
}
需要再次强调的是,Solon.cfg() 返回的配置在启动后是只读的。虽然你可以调用 loadAdd() 方法追加新的配置源,但已经静态化的启动参数值不会被覆盖。
在实际项目中,建议将环境相关的参数(如
--env)通过部署脚本或 K8s ConfigMap 注入,而非硬编码在启动命令中。这样不同环境的部署差异可以通过运维配置来管理,保持应用包的一致性。
4. E-Spi(体外扩展机制)
当我们把一个 Java 应用打包成 fatjar 部署到服务器时,一个很现实的问题浮现出来:如何在不重新打包主程序的前提下,动态添加新的业务模块或修改配置?
传统的 classpath 扩展方式在 fatjar 模式下完全失效------你无法往一个已经打好的 JAR 包里追加 class 文件。E-Spi(External Spi)就是 Solon 内核为应对这个场景而直接提供的体外扩展机制。
4.1 它解决什么问题?
考虑一个典型的生产部署场景:你的主应用是 order-service.jar,业务上需要在不同客户环境中加载不同的扩展模块。有些客户需要短信通知模块,有些需要特定的支付对接模块。你当然可以把所有模块都打进主应用,但这会导致 fatjar 越来越臃肿,而且任何一个小模块的更新都意味着整个应用要重新打包部署。
E-Spi 的思路很直接:把扩展模块和配置文件放在 JAR 包外部的一个目录中,启动时由框架自动扫描加载。
4.2 配置与文件结构
在 app.yml 中声明扩展目录:
yaml
solon.extend: "demo_ext" # 手动创建目录(目录不存在会静默跳过)
solon.extend: "!demo_ext" # 前缀 "!" 自动创建目录
两种方式的区别仅在于目录是否自动创建。带 ! 前缀时,Solon 会在启动时自动创建该目录;不带时,如果目录不存在就安静地忽略------这在某些环境下更安全,因为你可以通过是否创建目录来控制扩展是否生效。
部署后的文件结构如下:
demo.jar
demo_ext/
_db.properties # 外部配置文件(如数据源配置)
demo_user.jar # 外部插件包
demo_order.jar # 外部插件包
启动时,Solon 会自动扫描 demo_ext 目录下所有 .jar、.zip(作为插件包加载)和 .properties、.yml(作为配置文件加载)。整个过程零代码、零配置------放到目录里就行。
4.3 代码方式的灵活加载
如果你的需求更动态,不想依赖固定目录,也可以通过代码手动加载:
java
@SolonMain
public class Application {
public static void main(String[] args) throws Exception {
Solon.start(Application.class, args, app -> {
// 手动加载外部 jar 包
app.classLoader().addJar(new File("/demo.jar"));
// 手动加载外部配置文件
app.cfg().loadAdd(new File("/demo.yml"));
});
}
}
这种方式在 Solon.start() 的初始化回调中执行,灵活性更高------你可以根据命令行参数、环境变量甚至远程配置来决定加载哪些扩展包。
4.4 核心设计:共享而非隔离
E-Spi 最关键的设计决策是 共享 ClassLoader。所有通过 E-Spi 加载的外部插件包和主应用使用同一个 ClassLoader、同一个 AppContext、同一份配置。
这意味着什么?
- 外部插件中注册的
@Component、@Controller等 Bean,在主应用的AppContext中完全可见,可以直接@Inject注入 - 外部插件可以直接引用主应用中的类和接口,无需额外的 RPC 或序列化开销
- 外部配置文件会与主配置合并,就像它们本来就在
app.yml里一样
这种共享模型的代价是:更新任何外部插件或配置后,必须重启主服务。 因为共享 ClassLoader 意味着类一旦加载就无法卸载,热更新在共享模型下是不安全的。
4.5 插件包打包的实践建议
关于插件包如何打包,有两种方案:
方案一:fatjar 打包。 使用 maven-assembly-plugin 将插件包连同所有依赖打成一个完整的 fatjar。简单粗暴,但体积大,且容易出现依赖版本冲突。
方案二(推荐):公共依赖上提。 将公共依赖(如 Solon 核心、日志框架、工具库等)放在主应用的 pom.xml 中,插件包的 pom.xml 将这些依赖标记为 <optional>true</optional>。这样插件包只包含自己的业务代码和私有依赖,体积更小,也从根本上避免了版本冲突。
E-Spi 由 Solon 内核直接支持,无需引入任何额外依赖。如果你的场景不需要热更新,E-Spi 就是成本最低、最直接的体外扩展方案。
5. H-Spi(热插拔机制)
如果说 E-Spi 是"体外扩展的经济型方案",那 H-Spi(Hot-Spi)就是为生产环境热插拔场景量身定制的高级方案。两者的核心区别可以用一个词概括:隔离。
5.1 为什么需要隔离?
E-Spi 的共享 ClassLoader 模型虽然简单,但存在一个根本性的限制:共享意味着耦合。 当所有插件共享同一个 ClassLoader 时,一个插件加载的类可能会影响另一个插件的行为------比如两个插件依赖同一个库的不同版本,或者一个插件注册的 Bean 意外覆盖了另一个插件的同名 Bean。在共享模型下,卸载一个插件并保证不影响其他插件几乎是不可能的。
H-Spi 选择了完全不同的路径:每个插件包独享 ClassLoader、AppContext 和配置,完全隔离。 这种设计牺牲了一些开发便利性,但换来了真正的运行时独立性------你可以随时加载、卸载、更新任意一个插件,而不影响主服务和其他插件的运行。
5.2 与 E-Spi 的核心对比
| 维度 | E-Spi | H-Spi |
|---|---|---|
| ClassLoader | 共享 | 独享(完全隔离) |
| AppContext | 共享 | 独享 |
| 配置 | 共享 | 独享 |
| 更新后是否重启 | 需要 | 不需要 |
| 依赖 | 内核直接支持 | 需引入 solon-hotplug |
| 适用场景 | 简单外部扩展 | 生产热插拔、模块隔离 |
这张表基本决定了你的技术选型:如果你的扩展模块更新不频繁,或者可以接受重启,E-Spi 就够了。如果你需要在线上不停机更新模块------比如一个 SaaS 平台需要在不同租户环境下动态加载业务模块------H-Spi 是唯一的选择。
5.3 ClassLoader 隔离的规则
H-Spi 的隔离遵循双亲委派模型的变体,理解这个规则对正确开发热插拔插件至关重要:
父级到子级: 子级插件可以获取并使用父级 ClassLoader(即主应用)中的类和资源。这是合理的------公共库(如数据库驱动、工具类)放在主应用中,所有插件都能用。但有一个硬性约束:如果子级注册了什么资源到公共空间,必须在插件的 stop() 方法中注销。 否则插件卸载后,这些注册就会成为悬挂引用,造成资源泄漏。
同级之间: 同级插件的 ClassLoader 互相不可见,无法直接访问对方的类和资源。插件之间如果需要通信,必须通过事件总线(EventBus)进行,且交互数据应使用弱类型(如 Map、JsonString),而不是强类型的自定义 DTO------因为你根本无法引用对方定义的类。
这种设计思路可以类比为微服务架构中的服务间通信:每个插件就像一个独立服务,它们之间通过"消息"而非"方法调用"交互。官方也建议结合 DamiBus 来帮助解耦。
5.4 一个必须遵守的开发约束
H-Spi 插件的 stop() 方法不是可选的------它是插件生命周期中最关键的一环 。每个在 start() 中注册到公共空间的资源,都必须在 stop() 中精确移除:路由规则、定时任务、事件订阅、静态文件映射,一个都不能遗漏。否则所谓的"热插拔"就变成了"热泄漏"。
6. solon-hotplug 插件
solon-hotplug 是 H-Spi 机制的具体实现插件,提供了从底层热插拔到上层管理的完整能力。理解它的 API 分层设计,有助于在实际项目中做出正确的技术选择。
6.1 依赖引入
xml
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-hotplug</artifactId>
</dependency>
6.2 两层 API 设计
solon-hotplug 提供了两层 API,面向不同的使用场景:
底层接口:PluginPackage
这是最基础的原子操作接口,直接操作单个 JAR 包的加载与卸载。一般不直接使用,但理解它有助于掌握整个机制的运作方式:
java
// 加载 jar 包并返回插件包对象
PluginPackage jarPlugin = PluginPackage.loadJar(new File("/xxx/xxx.jar"));
// 启动插件(执行插件生命周期的 start)
jarPlugin.start();
// 卸载插件(执行插件生命周期的 stop,并释放 ClassLoader)
PluginPackage.unloadJar(jarPlugin);
PluginPackage 是对单个插件包的完整抽象,包含其独立的 ClassLoader、AppContext 和配置。loadJar 负责创建隔离环境并加载类,start 触发插件生命周期,unloadJar 则执行清理并释放资源。
管理接口:PluginManager(推荐使用)
PluginManager 在 PluginPackage 之上封装了"注册-管理-调度"的能力,是日常开发中推荐使用的接口:
java
PluginManager.add("add1", "/x/x/x.jar"); // 注册插件(声明名称和路径)
PluginManager.remove("add1"); // 移除注册
PluginManager.load("add1"); // 加载插件
PluginManager.start("add1"); // 启动插件(未加载则自动加载)
PluginManager.stop("add1"); // 停止插件
PluginManager.unload("add1"); // 卸载插件(未停止则自动停止)
注意到两个关键的自动化行为:start(name) 时如果插件尚未加载会自动执行加载;unload(name) 时如果插件尚未停止会自动执行停止。这种防御性设计避免了因操作顺序不当导致的异常。
6.3 热管理的两种方式
配置文件声明式:
在 app.yml 中声明待管理的插件:
yaml
solon.hotplug:
add1: "/x/x/x.jar" # 格式:name: jarfile
add2: "/x/x/x2.jar"
启动时 solon-hotplug 会读取配置,但不会自动加载和启动这些插件------它们只是"注册"了,等待你通过代码按需启动。
HTTP 接口动态管理:
一个更实用的做法是通过 HTTP 接口暴露插件管理能力,实现运行时的动态控制:
java
@SolonMain
public class App {
public static void main(String[] args) {
Solon.start(App.class, args, app -> {
// 启动插件的 HTTP 接口
app.router().get("/plugin/start", ctx -> {
String name = ctx.param("name");
PluginManager.start(name);
ctx.output("OK");
});
// 停止插件的 HTTP 接口
app.router().get("/plugin/stop", ctx -> {
String name = ctx.param("name");
PluginManager.stop(name);
ctx.output("OK");
});
});
}
}
调用 GET /plugin/start?name=add1 即可在线加载并启动插件,调用 GET /plugin/stop?name=add1 即可停止并卸载------全程无需重启主服务。
6.4 从管理到平台化
PluginManager.add() 和 PluginManager.remove() 不限于配置文件,也可以通过代码随时调用。这意味着插件信息完全可以存储在数据库中,通过管理界面动态维护。
更进一步,你可以构建一个完整的插件管理平台:数据库存储插件元数据(名称、版本、JAR 路径、状态),管理后台提供上传、启停、版本回滚等操作,运行时通过 PluginManager 的 API 执行实际的加载卸载。solon-hotplug 提供了构建这样一个平台所需的全部底层能力。
6.5 一个容易被忽略的细节
H-Spi 插件开发中,最容易被忽略的是 ClassLoader 对框架行为的影响。比如在使用后端模板引擎(如 Freemarker、Thymeleaf)时,模板文件的查找依赖于 ClassLoader 的资源加载。如果你的插件使用了独立 ClassLoader,模板引擎默认会从主应用的 ClassLoader 中查找模板------自然找不到。
正确的做法是在创建模板渲染器时显式传入插件自身的 ClassLoader:
java
// 使用插件自身的 ClassLoader 创建渲染器
static final FreemarkerRender viewRender =
new FreemarkerRender(BaseController.class.getClassLoader());
这类细节在 H-Spi 开发中比比皆是。凡是涉及资源加载、类查找、反射实例化的地方,都要问自己一句:当前用的是哪个 ClassLoader? 这也是 H-Spi 相比 E-Spi 开发成本更高的地方------但当你需要不停机更新线上模块时,这些成本是值得的。
7. 插件开发模板
在前面的章节里,我们理解了 H-Spi 的加载机制和 ClassLoader 隔离原理。现在进入实战环节------如何写一个规范的热插拔插件。
7.1 start() 与 stop() 的对称哲学
热插拔插件的核心接口是 Plugin,只有两个方法:start() 和 stop()。start() 负责注册一切资源,stop() 负责移除一切资源。这两个方法必须形成严格的对称关系------start 中注册了什么,stop 中就必须对应移除什么。任何遗漏都会导致插件卸载后产生"幽灵资源"(残留的路由、悬挂的定时任务、无法回收的监听器),进而引发内存泄漏或逻辑错误。
7.2 start() 方法的标准操作
一个完整的 start() 方法通常需要完成四件事:
java
public class Plugin1Impl implements Plugin {
AppContext context;
StaticRepository staticRepository;
@Override
public void start(AppContext context) {
this.context = context;
// 1. 加载插件专属配置文件
context.cfg().loadAdd("demo1011.plugin1.yml");
// 2. 扫描插件自身的 Bean
context.beanScan(Plugin1Impl.class);
// 3. 注册静态文件仓库(传入插件的 ClassLoader)
staticRepository = new ClassPathStaticRepository(
context.getClassLoader(), "plugin1_static");
StaticMappings.add("/", staticRepository);
}
}
逐行拆解:
- 保存 AppContext 引用:后续 stop() 中需要通过它遍历插件上下文的 Bean,所以必须保存为成员变量。
context.cfg().loadAdd():加载插件自己的配置文件。注意这里用的是loadAdd(追加),不是覆盖。每个热插拔插件拥有独立的AppContext,配置也是插件独享的,不会污染主应用或其他插件的配置空间。context.beanScan(Plugin1Impl.class):参数传入插件实现类本身,Solon 会以此为基点扫描该类所在包及其所有子包,将其中的@Component、@Controller等 Bean 注册到插件的独立上下文中。ClassPathStaticRepository:创建时传入了context.getClassLoader(),这一点非常关键。因为插件的静态文件在插件自身的 jar 中,只有用插件的 ClassLoader 才能找到。如果用默认的 ClassLoader,会去主应用的 classpath 里找,自然一无所获。
7.3 stop() 的清理清单
stop() 方法是热插拔开发中最关键的环节。以下是需要移除的四类资源:
java
@Override
public void stop() throws Throwable {
// 1. 移除 HTTP 路由(建议使用统一前缀,方便批量移除)
Solon.app().router().remove("/user");
// 2. 移除定时任务(按名称逐个移除)
JobManager.remove("job1");
// 3. 移除事件订阅(遍历插件上下文中所有 Bean)
context.beanForeach(bw -> {
if (bw.raw() instanceof EventListener) {
EventBus.unsubscribe(bw.raw());
}
});
// 4. 移除静态文件仓库
StaticMappings.remove(staticRepository);
}
几点实践建议:
- 路由使用统一前缀 :比如插件的所有接口都以
/user开头,卸载时router().remove("/user")一行就能批量清理。如果路由分散在各处,卸载时很容易遗漏。 - 定时任务必须按名称移除 :
JobManager.remove("job1")要求你在注册定时任务时就给它一个明确的名称,而不是使用默认生成的名称。 - 事件监听器的清理需要遍历 :因为事件订阅发生在各个 Bean 中,没有集中注册点,所以需要通过
context.beanForeach()遍历插件上下文中的所有 Bean,找到实现了EventListener接口的实例逐一移除。 - 静态文件仓库直接移除引用 :因为 start() 中保存了
staticRepository引用,stop() 中直接移除即可。
7.4 插件声明文件
完成插件实现类后,还需要在 resources 目录下创建 Solon 的 SPI 声明文件,路径为:
META-INF/solon/{packageName}.properties
文件内容:
properties
solon.plugin=org.example.plugin1.Plugin1Impl
solon.plugin.priority=1
其中 solon.plugin 指向插件实现类的全限定名,solon.plugin.priority 控制插件加载优先级(数字越大越先加载,默认为 0)。这个声明文件是 Solon 自定义的 SPI 发现机制------框架启动时会扫描所有 jar 包中 META-INF/solon/ 目录下的 .properties 文件,根据声明实例化并调用 Plugin。对于热插拔插件而言,这个声明文件由 PluginManager 在运行时动态读取,完成插件的加载和注册。
8. 插件间交互建议
H-Spi 的 ClassLoader 隔离机制确保了插件的独立性,但也带来了一个直接约束:插件 A 的类对插件 B 不可见 。插件 A 中定义的 UserDTO,在插件 B 的 ClassLoader 中根本不存在,直接引用会导致 ClassNotFoundException。这要求我们在设计插件间交互时,必须采用与普通单体应用不同的策略。
8.1 策略一:事件总线解耦
最推荐的方式是通过 Solon 内置的 EventBus 进行通信:
java
// 插件 A:发布事件
Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("action", "created");
EventBus.publish("userCreated", data);
// 插件 B:订阅事件
EventBus.subscribe("userCreated", (EventListener<Map>) data -> {
Long userId = (Long) data.get("userId");
// 处理逻辑...
});
这里的关键在于:使用 Map 或基础类型作为事件载荷,而非自定义 DTO。事件主题用字符串标识,数据用 Map 承载,完全规避了类依赖问题。
如果确实想用强类型事件对象,那事件类必须放在父级 ClassLoader(即主应用)中,所有子级插件都能访问。但这会增加主应用的依赖,需要权衡。
8.2 策略二:弱类型数据传递
当插件间需要通过共享服务传递结构化数据时,使用框架内置的基础类型或 JSON 字符串:
java
// 插件 A:将数据序列化为 JSON 字符串
String userJson = ONode.stringify(userMap);
EventBus.publish("userData", userJson);
// 插件 B:反序列化为自己的内部 DTO
EventBus.subscribe("userData", (EventListener<String>) json -> {
// 反序列化为插件 B 自己的 UserVO
// UserVO user = ONode.deserialize(json, UserVO.class);
});
这种方式虽然不够优雅,但在 ClassLoader 隔离的环境下是最安全的做法。每个插件维护自己的内部模型,对外只交换基础类型数据,本质上是一种防腐层思想的应用。
8.3 策略三:父级 ClassLoader 放置共享接口
如果插件间的交互比较频繁且需要强类型约束,可以将共享的接口和实体类提取到主应用模块中:
主应用 (parent ClassLoader)
├── shared-api.jar ← 共享接口和 DTO
├── plugin1.jar ← 依赖 shared-api
└── plugin2.jar ← 依赖 shared-api
插件 A 通过接口调用插件 B 的服务,接口定义在主应用中,两个插件都能访问。这种方式的代价是增加了主应用的依赖管理复杂度------每次新增共享类型都需要修改主应用模块,一定程度上削弱了插件的独立性。因此建议只将真正全局通用的接口(如用户查询、权限校验)放到主应用中。
8.4 策略四:结合 DamiBus
Solon 生态提供了 DamiBus 作为插件间通信的专用工具。DamiBus 是一个基于主题(Topic)的本地事件总线,支持同步和异步两种分发模式,专为未知模块和隔离模块之间的解耦而设计。
java
// 插件 A:发送主题消息
DamiBus.strBus().send("user.topic.created", userId);
// 插件 B:监听主题
DamiBus.strBus().listen("user.topic.created", (topic, payload) -> {
String userId = (String) payload;
// 处理逻辑...
});
DamiBus 与 Solon EventBus 的区别在于:EventBus 是 Solon 框架内置的事件机制,更适合应用内部的事件驱动;而 DamiBus 的设计更偏向于模块间的 RPC 风格通信,支持请求-响应模式,且与 Solon 的 ClassLoader 隔离机制天然兼容(主题和载荷都是基础类型)。
实际项目中,推荐优先使用 EventBus + 弱类型数据 处理简单的事件通知,复杂交互场景引入 DamiBus。
9. ClassLoader 隔离下的注意事项
H-Spi 的 ClassLoader 隔离是双刃剑------它在保证插件独立性的同时,也在很多不起眼的地方埋下了陷阱。以下四个场景是开发中最容易踩坑的。
9.1 模板渲染的 ClassLoader 问题
当插件使用后端模板引擎(Freemarker、Thymeleaf、Enjoy 等)时,模板引擎默认使用 Thread.currentThread().getContextClassLoader() 来查找模板文件。在热插拔环境下,这个 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);
}
}
}
BaseController.class.getClassLoader() 返回的是加载该类的插件 ClassLoader,模板引擎会从正确的 jar 包中查找模板文件。这个问题不仅存在于 Freemarker,任何需要从 classpath 加载资源的场景(如读取 i18n 资源文件、加载 XML 配置等)都需要注意 ClassLoader 的正确使用。
9.2 包名独立性要求
每个热插拔插件的包名必须保持独立,不能有交叉。原因是 context.beanScan() 以插件实现类为基点,扫描其所在包及子包下的所有类。如果两个插件的包名存在父子关系或重合,就会发生"扫描越界"------插件 A 扫到了插件 B 的类,但由于 ClassLoader 隔离,这些类实际上是不同的类,导致注册混乱。
推荐的包名结构:
主程序包: com.example.app ← 独立顶层包
插件1包: com.example.plugin.user ← 独立子包
插件2包: com.example.plugin.order ← 独立子包
9.3 依赖放置策略
ClassLoader 隔离意味着每个插件都有自己的一套依赖。如果不加控制,会导致依赖重复加载、内存浪费、甚至版本冲突。合理的做法是分层放置:
| 依赖类型 | 放置位置 | 说明 |
|---|---|---|
| Solon 核心框架 | 主应用 | 所有插件都需要,放在父级 ClassLoader 中共享 |
| 日志框架(slf4j 等) | 主应用 | 全局统一日志输出 |
| 数据库驱动 | 主应用 | 连接池通常由主应用管理 |
| 业务专用库 | 插件包 | 只在某个插件中使用的依赖 |
| 插件 pom.xml 中的公共依赖 | 标记 <optional>true</optional> |
避免打包时带入主应用已有的依赖 |
xml
<!-- 插件的 pom.xml -->
<dependency>
<groupId>org.noear</groupId>
<artifactId>solon-boot</artifactId>
<version>${solon.version}</version>
<optional>true</optional>
</dependency>
9.4 访问主程序资源
虽然子级 ClassLoader 可以访问父级,但在实际编码中需要注意获取方式。插件需要主程序的服务或配置时,有两种标准途径:
java
// 获取主程序的 Bean(通过全局上下文)
UserService userService = Solon.context().getBean(UserService.class);
// 获取主程序的配置
String dbUrl = Solon.cfg().get("datasource.url");
Solon.context() 返回的是主应用的全局 AppContext,而插件中的 context 是插件自己的独立上下文。两者不可混淆------从插件自身的 context 中是找不到主程序注册的 Bean 的。
以上这些注意事项本质上都指向同一个原则:在 ClassLoader 隔离环境下,始终清楚当前代码运行在哪个 ClassLoader 中,需要访问的资源又属于哪个 ClassLoader。 建立了这种认知后,大部分问题都能在编码阶段提前规避。
10. 应用生命周期与 @Init 加载时序
理解 Solon 的应用生命周期,是开发热插拔插件的前提。SolonApp 从 Solon.start() 到最终 Solon.stop(),经历了一个初始化函数时机点、六个应用事件时机点、三个插件生命时机点、两个容器生命时机点------它们串行排列,构成一条完整的执行链路。
10.1 完整时序图
[Init lambda] // Solon.start() 的第三个参数 lambda
→ AppInitEndEvent // 应用初始化完成
→ [Plugin::start] // 所有 SPI 插件依次启动
→ AppPluginLoadEndEvent // 插件加载完成
→ [Bean 扫描 + 注入] // @Component 扫描、@Inject 注入
→ AppBeanLoadEndEvent // Bean 加载(扫描)完成
→ [AppContext::start / @Init] // 容器启动,执行初始化
→ AppLoadEndEvent // 应用加载完成(即启动完成)
→ ::运行阶段::
→ AppPrestopEndEvent // 预停止
→ [Plugin::prestop] // 插件预停止
→ [AppContext::stop / @Destroy] // 容器停止
→ [Plugin::stop] // 插件停止
→ AppStopEndEvent // 应用停止完成
一个核心认知:启动过程必须完整走完后,应用才能正常服务请求。不要在任何启动环节阻塞线程。
10.2 六个应用事件时机点
| 序号 | 事件 | 说明 | 订阅方式 |
|---|---|---|---|
| 1 | AppInitEndEvent |
应用初始化完成 | 手动订阅 (Solon.start lambda 中) |
| 2 | AppPluginLoadEndEvent |
插件加载完成 | 手动订阅 |
| 3 | AppBeanLoadEndEvent |
Bean 扫描完成 | 注解 / 手动均可 |
| 4 | AppLoadEndEvent |
应用启动完成 | 注解 / 手动均可 |
| 5 | AppPrestopEndEvent |
应用预停止 | 注解 / 手动均可 |
| 6 | AppStopEndEvent |
应用停止完成 | 注解 / 手动均可 |
关键规则 :AppBeanLoadEndEvent 之前的事件(即 AppInitEndEvent、AppPluginLoadEndEvent)发生在 Bean 扫描之前,此时 @Component 组件尚未被容器发现,因此必须在 Solon.start() 的 lambda 中手动订阅,否则会错过时机。
java
// 手动订阅(用于早期事件)
Solon.start(App.class, args, app -> {
app.onEvent(AppInitEndEvent.class, e -> {
System.out.println("初始化完成");
});
app.onEvent(AppPluginLoadEndEvent.class, e -> {
System.out.println("插件加载完成");
});
});
// 注解订阅(用于 Bean 扫描之后的事件)
@Component
public class StartupListener implements EventListener<AppLoadEndEvent> {
@Override
public void onEvent(AppLoadEndEvent event) throws Throwable {
System.out.println("应用启动完成,所有 Bean 已就绪");
}
}
10.3 LifecycleBean 自动排序机制(v2.2.8+)
LifecycleBean 接口绑定在 AppContext 的启动与停止阶段,只对单例有效。当多个 LifecycleBean 存在依赖关系时,Solon 从 v2.2.8 起支持基于 @Inject 依赖的自动排序 ------如果 Bean2 通过 @Inject 注入了 Bean1,则 Bean1 的 start() 必然先于 Bean2 执行。
| 接口方法 | 对应注解 | 执行时机 | 说明 |
|---|---|---|---|
LifecycleBean::start |
@Init |
AppContext::start() |
Bean 扫描完成后执行 |
LifecycleBean::postStart |
--- | 同上(后半段) | v2.9+;适合启动网络监听等 |
LifecycleBean::preStop |
--- | AppContext::preStop() |
注销远程服务 |
LifecycleBean::stop |
@Destroy |
AppContext::stop() |
释放本地资源 |
java
// 自动排序示例:Bean1 的 start() 一定先于 Bean2 执行
@Component
public class Bean1 implements LifecycleBean {
@Override
public void start() {
// 数据库连接池初始化 ...
}
}
@Component
public class Bean2 implements LifecycleBean {
@Inject
Bean1 bean1; // 通过注入形成依赖关系,自动排序
@Override
public void start() {
bean1.func1(); // 此时 bean1 已完成 start()
}
}
10.4 循环依赖处理
自动排序基于 @Inject 依赖关系构建。当两个 LifecycleBean 相互注入时,容器无法确定执行顺序,会抛出异常。解决方案有两种:
方案一:解除双向依赖,仅保留单向注入。
方案二:手动指定顺序位 ,通过 @Component(index = N) 显式排列:
java
@Component(index = 1) // index 越小,越先执行
public class Bean1 implements LifecycleBean {
@Inject
Bean2 bean2;
@Override
public void start() { /* 先执行 */ }
}
@Component(index = 2)
public class Bean2 implements LifecycleBean {
@Inject
Bean1 bean1;
@Override
public void start() { /* 后执行 */ }
}
11. AppContext 核心
AppContext 是 Solon 框架 IoC/AOP 特性的实现载体,也是热插拔特性的实现基础。它的核心职责是管理托管对象的生命周期和注解处理。在全局模式下,Solon.context() 返回唯一的全局上下文;而在热插拔(H-SPI)模式下,每个插件会获得独立的 AppContext 实例,形成 ClassLoader + AppContext 的双重隔离。
11.1 三种获取方式
| 方式 | 代码示例 | 适用场景 |
|---|---|---|
| 全局上下文 | Solon.context() |
普通组件中直接获取全局容器 |
| 注入获取 | @Inject AppContext context |
在 @Component 组件中注入当前所属上下文 |
| 插件生命周期参数 | Plugin.start(AppContext context) |
在插件开发中,获取插件自身的独立上下文 |
java
// 方式一:全局上下文
Solon.context().getBeanAsync(UserService.class, bean -> {
// 异步获取 Bean
});
// 方式二:注入当前上下文
@Component
public class OrderService {
@Inject
AppContext context; // 拿到的是当前组件所属的上下文
}
// 方式三:插件参数
public class OrderPlugin implements Plugin {
@Override
public void start(AppContext context) {
// 这是 OrderPlugin 的独立上下文(H-SPI 模式)
context.beanScan(OrderPlugin.class);
}
}
11.2 核心接口分类
AppContext 提供的 API 可按职责分为六大类:
| 分类 | 核心方法 | 说明 |
|---|---|---|
| 注解处理 | beanBuilderAdd()、beanInjectorAdd()、beanInterceptorAdd()、beanExtractorAdd() |
注册自定义注解的构建器、注入器、拦截器、提取器 |
| 自动装配 | beanScan(source)、beanScan(basePackage)、beanMake(clz) |
扫描指定包下的 Bean 并触发注解处理 |
| 手动注入 | beanInject(bean) |
对任意对象执行字段注入 |
| 手动注册 | wrap()、wrapAndPut()、putWrap()、beanRegister() |
手动包装并注册 Bean 到容器 |
| 获取与订阅 | getBean()、getBeanAsync()、getBeansOfType()、subBeansOfType() |
同步/异步获取 Bean,或订阅特定类型的 Bean |
| 生命周期绑定 | lifecycle(lifecycleBean)、lifecycle(index, lifecycleBean) |
将 LifecycleBean 绑定到该上下文的启停 |
11.3 插件 context 与 Solon.context() 的区别
这是理解热插拔架构的关键:
| 维度 | Solon.context()(全局) |
插件 AppContext(独立) |
|---|---|---|
| 作用域 | 整个应用共享 | 仅限当前插件内部 |
| ClassLoader | 应用主 ClassLoader | 插件私有 ClassLoader |
| Bean 可见性 | 主程序 + E-SPI 插件的所有 Bean | 仅插件自身扫描到的 Bean |
| 生命周期 | 随应用启停 | 随插件的 start() / stop() |
| 访问主程序资源 | 直接访问 | 需通过 Solon.context().getBean() 间接获取 |
| 配置 | Solon.cfg() 共享配置 |
context.cfg() 插件私有配置 |
在 H-SPI 热插拔插件中,如果需要访问主程序的 Bean 或配置,应使用 Solon.context() 和 Solon.cfg();如果需要管理插件自身的组件,则使用插件参数传入的 context。两者的区别决定了 Bean 的可见范围和注入方向。
12. 实战案例:一个主程序 + 两个热插拔插件
本节通过一个电商系统场景,演示如何基于 Solon H-SPI 构建热插拔架构。主程序提供用户服务,订单插件和积分插件作为两个独立的热插拔模块,通过 EventBus 实现跨插件事件驱动。
12.1 包结构设计
shop-system/ # 根项目
├── shop-common/ # 公共模块(主程序 + 插件共享)
│ └── src/main/java/com/shop/common/
│ ├── event/OrderCreatedEvent.java
│ └── model/User.java
│
├── shop-main/ # 主程序
│ └── src/main/java/com/shop/main/
│ ├── ShopApp.java # 启动入口
│ ├── controller/UserController.java
│ └── service/UserService.java
│ └── src/main/resources/
│ └── app.yml
│
├── shop-plugin-order/ # 订单插件
│ └── src/main/java/com/shop/plugin/order/
│ ├── OrderPlugin.java # 插件入口
│ └── controller/OrderController.java
│ └── src/main/resources/
│ └── META-INF/solon/
│ └── com.shop.plugin.order.properties
│
└── shop-plugin-points/ # 积分插件
└── src/main/java/com/shop/plugin/points/
├── PointsPlugin.java # 插件入口
└── controller/PointsController.java
└── src/main/resources/
└── META-INF/solon/
└── com.shop.plugin.points.properties
12.2 公共模块:事件与模型
java
// com.shop.common.event.OrderCreatedEvent
package com.shop.common.event;
import lombok.Getter;
@Getter
public class OrderCreatedEvent {
private final Long orderId;
private final Long userId;
private final double amount;
public OrderCreatedEvent(Long orderId, Long userId, double amount) {
this.orderId = orderId;
this.userId = userId;
this.amount = amount;
}
}
java
// com.shop.common.model.User
package com.shop.common.model;
import lombok.Data;
@Data
public class User {
private Long id;
private String name;
private int points;
}
12.3 主程序
java
// com.shop.main.ShopApp
package com.shop.main;
import org.noear.solon.Solon;
import org.noear.solon.annotation.SolonMain;
import org.noear.solon.hotplug.PluginManager;
@SolonMain
public class ShopApp {
public static void main(String[] args) {
Solon.start(ShopApp.class, args, app -> {
// 插件热管理 HTTP 接口
app.router().get("/plugin/start", ctx -> {
String name = ctx.param("name");
PluginManager.start(name);
ctx.output("插件 " + name + " 已启动");
});
app.router().get("/plugin/stop", ctx -> {
String name = ctx.param("name");
PluginManager.stop(name);
ctx.output("插件 " + name + " 已停止");
});
});
}
}
java
// com.shop.main.service.UserService
package com.shop.main.service;
import com.shop.common.model.User;
import org.noear.solon.annotation.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class UserService {
private final Map<Long, User> userStore = new ConcurrentHashMap<>();
public UserService() {
User user = new User();
user.setId(1L);
user.setName("张三");
user.setPoints(100);
userStore.put(1L, user);
}
public User getUser(Long id) {
return userStore.get(id);
}
public void addPoints(Long userId, int points) {
User user = userStore.get(userId);
if (user != null) {
user.setPoints(user.getPoints() + points);
}
}
}
java
// com.shop.main.controller.UserController
package com.shop.main.controller;
import com.shop.common.model.User;
import com.shop.main.service.UserService;
import org.noear.solon.annotation.*;
@Controller
@Mapping("/user")
public class UserController {
@Inject
private UserService userService;
@Get
@Mapping("/{id}")
public User getUser(@Path long id) {
return userService.getUser(id);
}
}
12.4 主程序 app.yml 配置
yaml
server.port: 8080
solon.app.name: "shop-system"
solon.app.group: "com.shop"
# 热插拔插件管理配置
solon.hotplug:
order: "plugins/shop-plugin-order.jar"
points: "plugins/shop-plugin-points.jar"
12.5 订单插件
SPI 声明文件 META-INF/solon/com.shop.plugin.order.properties:
properties
solon.plugin=com.shop.plugin.order.OrderPlugin
solon.plugin.priority=0
插件入口:
java
// com.shop.plugin.order.OrderPlugin
package com.shop.plugin.order;
import org.noear.solon.Solon;
import org.noear.solon.core.AppContext;
import org.noear.solon.core.Plugin;
import org.noear.solon.core.event.EventBus;
public class OrderPlugin implements Plugin {
private AppContext context;
@Override
public void start(AppContext context) {
this.context = context;
context.beanScan(OrderPlugin.class);
System.out.println("[OrderPlugin] 订单插件已加载");
}
@Override
public void stop() throws Throwable {
Solon.app().router().remove("/order");
context.beanForeach(bw -> {
if (bw.raw() instanceof org.noear.solon.core.event.EventListener) {
EventBus.unsubscribe(bw.raw());
}
});
System.out.println("[OrderPlugin] 订单插件已卸载");
}
}
java
// com.shop.plugin.order.controller.OrderController
package com.shop.plugin.order.controller;
import com.shop.common.event.OrderCreatedEvent;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Get;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
import org.noear.solon.core.event.EventBus;
import java.util.concurrent.atomic.AtomicLong;
@Controller
@Mapping("/order")
public class OrderController {
private final AtomicLong orderIdSeq = new AtomicLong(1000L);
@Get
@Mapping("/create")
public String createOrder(@Param Long userId, @Param double amount) {
Long orderId = orderIdSeq.getAndIncrement();
// 发布订单创建事件(通过全局 EventBus)
EventBus.publish(new OrderCreatedEvent(orderId, userId, amount));
return "订单创建成功,订单号:" + orderId + ",金额:" + amount;
}
}
12.6 积分插件
SPI 声明文件 META-INF/solon/com.shop.plugin.points.properties:
properties
solon.plugin=com.shop.plugin.points.PointsPlugin
solon.plugin.priority=1
插件入口:
java
// com.shop.plugin.points.PointsPlugin
package com.shop.plugin.points;
import org.noear.solon.Solon;
import org.noear.solon.core.AppContext;
import org.noear.solon.core.Plugin;
import org.noear.solon.core.event.EventBus;
public class PointsPlugin implements Plugin {
private AppContext context;
@Override
public void start(AppContext context) {
this.context = context;
context.beanScan(PointsPlugin.class);
System.out.println("[PointsPlugin] 积分插件已加载");
}
@Override
public void stop() throws Throwable {
Solon.app().router().remove("/points");
context.beanForeach(bw -> {
if (bw.raw() instanceof org.noear.solon.core.event.EventListener) {
EventBus.unsubscribe(bw.raw());
}
});
System.out.println("[PointsPlugin] 积分插件已卸载");
}
}
java
// com.shop.plugin.points.listener.OrderCreatedListener
package com.shop.plugin.points.listener;
import com.shop.common.event.OrderCreatedEvent;
import com.shop.common.model.User;
import com.shop.main.service.UserService;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Component;
import org.noear.solon.core.event.EventBus;
import org.noear.solon.core.event.EventListener;
@Component
public class OrderCreatedListener implements EventListener<OrderCreatedEvent> {
@Override
public void onEvent(OrderCreatedEvent event) throws Throwable {
// 通过全局上下文获取主程序的 UserService
UserService userService = Solon.context().getBean(UserService.class);
if (userService != null) {
int earnedPoints = (int) (event.getAmount() * 10);
userService.addPoints(event.getUserId(), earnedPoints);
System.out.println("[积分] 用户 " + event.getUserId()
+ " 获得积分:" + earnedPoints
+ "(来源订单:" + event.getOrderId() + ")");
}
}
}
java
// com.shop.plugin.points.controller.PointsController
package com.shop.plugin.points.controller;
import com.shop.common.model.User;
import com.shop.main.service.UserService;
import org.noear.solon.Solon;
import org.noear.solon.annotation.Controller;
import org.noear.solon.annotation.Get;
import org.noear.solon.annotation.Mapping;
import org.noear.solon.annotation.Param;
@Controller
@Mapping("/points")
public class PointsController {
@Get
@Mapping("/query")
public String queryPoints(@Param Long userId) {
UserService userService = Solon.context().getBean(UserService.class);
if (userService != null) {
User user = userService.getUser(userId);
if (user != null) {
return "用户 " + user.getName() + " 当前积分:" + user.getPoints();
}
}
return "未找到用户";
}
}
12.7 运行流程说明
- 主程序
ShopApp启动,注册插件管理 HTTP 接口 - 主程序扫描
com.shop.main包,注册UserService、UserController PluginManager根据app.yml配置注册shop-plugin-order.jar和shop-plugin-points.jar- 调用
GET /plugin/start?name=order加载并启动订单插件 - 订单插件获得独立的 ClassLoader 和 AppContext,执行
start()方法,扫描注册OrderController - 调用
GET /plugin/start?name=points加载并启动积分插件 - 积分插件同样获得独立的 ClassLoader 和 AppContext,扫描注册
PointsController和OrderCreatedListener - 用户调用
GET /order/create?userId=1&amount=99.9,OrderController发布OrderCreatedEvent OrderCreatedListener订阅该事件,通过Solon.context()获取主程序UserService并增加积分- 调用
GET /plugin/stop?name=points,积分插件执行stop(),移除路由和事件订阅
13. 总结与最佳实践
13.1 选型决策表
| 场景需求 | 推荐方案 | 说明 |
|---|---|---|
| 普通单体应用 | 标准 SPI 插件 | Maven 依赖即激活,无额外复杂度 |
| 需要外部扩展包,可接受重启 | E-SPI | 核心支持,ClassLoader 共享,零额外依赖 |
| 运行时热更新,模块隔离 | H-SPI(solon-hotplug) |
每个插件独立 ClassLoader + AppContext |
| 跨插件通信 | EventBus + 弱类型数据 | 或引入 DamiBus 做主题式解耦 |
| 需要完整生命周期控制 | 实现 LifecycleBean 接口 |
支持 start / postStart / preStop / stop 四阶段 |
| 仅需初始化回调 | @Init 注解 |
简洁够用 |
13.2 十条最佳实践清单
-
包名严格隔离 :主程序与每个热插拔插件使用独立包前缀(如
com.shop.main、com.shop.plugin.order),避免扫描越界。 -
公共依赖上移 :将插件间共享的模型、事件类放到公共模块,在主程序打包,插件标记为
<optional>。 -
stop() 必须逆操作 :在
start()中注册的路由、事件订阅、定时任务、静态资源,必须在stop()中逐一移除------这是热插拔的资源契约。 -
跨插件通信使用弱类型:EventBus 传递事件时,建议使用 Map 或 JSON 字符串等弱类型载体;如需强类型,事件类必须放在公共模块。
-
早期事件手动订阅 :
AppBeanLoadEndEvent之前的事件(AppInitEndEvent、AppPluginLoadEndEvent)必须在Solon.start()lambda 中手动订阅。 -
善用
@Component(index = N):当LifecycleBean存在循环依赖风险时,通过 index 显式指定执行顺序。 -
插件内通过
Solon.context()访问主程序 :插件自身的context只能看到自己的 Bean;需要主程序服务时,一律走Solon.context().getBean()。 -
配置隔离 :插件使用
context.cfg().loadAdd("plugin.yml")加载私有配置,避免与主程序app.yml互相污染。 -
启动过程不阻塞 :所有启动环节(包括
@Init、Plugin.start())必须快速返回,耗时操作应使用异步线程。 -
先停后卸 :使用
PluginManager管理插件时,遵循stop → unload顺序;unload内部会自动触发stop,但显式调用更清晰。
13.3 注意事项汇总
- LifecycleBean 只对单例有效:非单例 Bean 不受容器生命周期管理
- AppBeanLoadEndEvent 之前的事件需手动订阅:否则会错过时机
- 启动参数启动后静态化:运行期间无法修改
- E-Spi 更新扩展包后需要重启主服务:共享 ClassLoader 无法卸载已加载的类
- H-Spi 的 stop() 方法必须完整清理:遗漏任何注册资源都会导致泄漏
- 模板渲染显式指定 ClassLoader:否则会从错误的 ClassLoader 中查找模板
- EventBus 同步特性 :内置 EventBus 是同步分发的,异常会向上传播;如需异步使用
EventBus.publishAsync() - 安全停止顺序 :启用
--stop.safe时,prestop 后会等待--stop.delay秒再执行 stop,给存量请求留出完成时间