ARTICLE · 1001383
基于 Solon 插件体系构建系统
1. 微内核、插件化架构
公司的产品是采用微服务架构的,虽然微服务提供了灵活的组装能力,但业务多了之后,一个功能往往涉及多个微服务,已经很难清楚哪个微服务是服务哪个应用的,最终变成还是需要部署所有的微服务,于是资源占用也就上来了,而且我们主要是面向企业级用户,因此也就会疑问哪种构建方式更适合我们的产品?后来为了信创,遇上 Solon,把公司产品从 Spring、Spring Cloud 迁移到 Solon 和 Solon Cloud 上来。在学习 Solon 的时候,官网中介绍插件部分,SPI,E-SPI,H-SPI。如果你验证过 E-SPI,一定会想在 Solon 上构建一个插件化的系统,因为太方便了。那我们的产品是不是采用微内核、插件化的架构会更好呢?于是我在自己的实验项目(Ray)中,逐步验证自己的想法,后来又碰上微前端(MicroApp),我觉得确实可行了 —— 前后端都打包到一个 Jar 里面,一个插件就是一个应用。但是自己前端太菜,花了很多时间去搞微应用的集成。好在 AI 来了,解决了前端集成的问题。最近我让 AI 把我的半成品插件管理改了改,终于实现了我想要的效果 —— 一个主应用,其余的都是插件。
插件 = 一个 jar,一个插件就是一个应用。
top/goldenyear/ray/**META-INF/solon/*.properties | ||
ui/{app}/ | ||
db/migration/{code}/ | ||
plugin-{code}.yml |
内置插件通过 E-SPI 的方式放在 modules 目录下,不需要的插件从目录中删掉就可以了。扩展的/外部的插件的安装、启停、升级、卸载全部经由 infra 插件管理界面完成,server 不重启。

那么今天就来聊聊 Solon 的插件系统。
2. Solon 插件基础:Plugin SPI
2.1 Plugin 接口与发现协议
Solon 的插件是标准 SPI:实现 Plugin 接口,在 META-INF/solon/ 下用一个 properties 文件声明,启动时内核会进行扫描。添加一个依赖(或把 jar 放进扩展目录),插件自动激活—— 没有开关类配置,发现即装载。
public interface Plugin {void start(AppContext context) throws Throwable; // 应用初始化后调用default void prestop() throws Throwable {} // ::stop 之前default void stop() throws Throwable {} // Solon::stop 时}
声明文件 META-INF/solon/{packname}.properties(文件名要求全局唯一,否则可能无法正常加载,如果使用 Gradle 编译,还可能出现文件配置文件覆盖问题):
solon.plugin=top.goldenyear.ray.module.bpm.XPluginImpsolon.plugin.priority=0# 数值越大越先启动,默认 0
Ray 在此协议上追加了一行自己的约定 ——清单指针:
# ray-modules/ray-module-bpm/src/main/resources/META-INF/solon/top.goldenyear.ray.module.bpm.propertiessolon.plugin=top.goldenyear.ray.module.bpm.XPluginImpsolon.plugin.priority=0ray.plugin.descriptor=plugin-bpm.yml# ray 插件运行时凭这一行找到插件清单
最后一行的描述是 ray 插件的入口:插件运行时扫描所有 META-INF/solon/*.properties,凡带 ray.plugin.descriptor 键的,就是一个 ray 插件。复用 Solon 原生协议意味着同一个 jar 既能编译期内置、也能运行期外置加载,两种形态零差异。
2.2 插件在生命周期中的位置
理解插件机制的关键是它在应用生命周期中的确切位置:
flowchart TBsubgraph boot[”启动阶段”]A[”Init lambda”] --> B[”AppInitEndEvent”]B --> C[”Plugin.start”]C --> D[”AppPluginLoadEndEvent”]D --> E[”Bean 扫描 + 注入”]E --> F[”AppBeanLoadEndEvent”]F --> G[”AppContext.start / @Init”]G --> H[”AppLoadEndEvent”]endR((”::Running::”))subgraph shutdown[”停机阶段”]I[”AppPrestopEndEvent”] --> J[”Plugin.prestop”]J --> K[”AppContext.stop / @Destroy”]K --> L[”Plugin.stop”]L --> M[”AppStopEndEvent”]endH --> R --> I
三个对插件开发最重要的时序:
Plugin.start早于 Bean 扫描。插件在 start 里做的事是“装配”:注册拦截器、桥接依赖,而不是使用业务 Bean。ray 的 XPluginImp里context.beanScan(XPluginImp.class)显式声明扫描包,随后才轮到容器注入。AppPluginLoadEndEvent表示所有 Solon 插件已 start 完毕。ray 的插件运行时监听这个事件才开始扫描清单,保证 fat jar 内所有模块的文件都已就位。 AppPrestopEndEvent阶段数据源仍可用, AppStopEndEvent时库已关。在 Ray 的实际测试中踩过坑,插件停机清理必须放在 prestop。
2.3 插件能做什么:四个扩展点
插件不只是“被动启动的模块”,它可以在 start 时通过 AppContext 的四个扩展机制编程式地增强框架:
beanBuilderAdd(anno, handler) | @Controller | |
beanInjectorAdd(anno, handler) | @Inject | |
beanInterceptorAdd(anno, interceptor) | @Transaction | |
beanExtractorAdd(anno, extractor) | @CloudJob |
Solon 生态里的 solon-web、solon-data 等框架件,本质上都是带 META-INF/solon/*.properties 的插件。Ray 的 ray-framework-plugin、ray-framework-flyway、各业务模块同样如此 ——Solon 本身就是微内核、插件化的架构。
3. 装载双轨:E-SPI 与 H-SPI
编译打包到 Jar 能解决确定性的组装(通过 Gradle 参数等),但 Ray 还需要两种运行期形态,对应 Solon 官方的两个扩展装载机制:
solon.extend | org.noear:solon-hotplug) | |
AppClassLoader.addJar | PluginManager | |
PluginClassLoader(父委托到 AppClassLoader) | ||
copyTo 派生) | ||
ray-module-{code}-{version}.jarmodules/目录下 | ray-plugin-{code}-{version}.jarplugins/目录下 |
发布物布局(package.sh 产出):
flowchart TDroot[”dist/ray-{version}-{profile}/”]root --> srv[”ray-server-{version}.jar平台运行时(零业务模块),java -jar 直跑”]root --> cfg[”app-prod.yml外部配置”]root --> webapp[”webapp/特殊站点体外模板根”]root --> modules[”modules/ · E-SPI 扩展目录(solon.extend)”]root --> plugins[”plugins/ · H-SPI 动态插件运行目录”]modules --> m1[”ray-module-core-{version}.jar”]modules --> m2[”ray-module-{bpm,press,dev,ai}-{version}.jarprod 全量;minimal 仅 core”]plugins --> p1[”ray-plugin-home-{version}.jar随包预置,界面安装后生效”]
3.1 E-SPI:官方模块的装载
app.yml 一行配置即启用:
solon:# 官方模块 E-SPI 体外扩展:启动时自动加载 modules/ 下全部 jar# (AppClassLoader.addJar,共享 ClassLoader,重启生效)extend: ”modules”
内核启动时把 modules/ 下所有 jar 经 AppClassLoader.addJar 加入 classpath,后续流程与编译期内嵌完全一致。开发的时候,默认设置为 profileEnv=dev 把模块直接编译进 fat jar(E-SPI 文档明言"分/合自由、加载时机一样"),调试体验与单体无异。
build.properties
profileEnv=debuild.gradle
if (profileEnv == ”dev”) {// dev 全内嵌单体(fat jar 直跑调试):core 与其余模块编译进 jar(02 §5.1)implementation(project(”:ray-modules:ray-module-core”))implementation(project(”:ray-modules:ray-module-bpm”))implementation(project(”:ray-modules:ray-module-press”))implementation(project(”:ray-modules:ray-module-ai”))implementation(project(”:ray-modules:ray-module-dev”))implementation(project(”:ray-plugins:ray-plugin-home”))}
为什么官方模块不走 H-SPI 呢?
自举死锁: infra_plugin注册表由 core 自己的 SQL 版本线建表,而外置插件的启动要靠查这张表 —— "表由 core 建、core 的启动又靠表信息",走不通;宿主启动期注入:ray-server(framework-server)启动即 @Injectcore 的IPermissionService实现,H-SPI 子上下文的 Bean 宿主不可见;
3.2 H-SPI:动态插件的热装载
solon-hotplug 的 PluginManager 全为静态方法、无状态:
PluginManager.add(code, jarFile); // 登记(name → file)PluginPackage pkg = PluginManager.load(code); // 独立 PluginClassLoader,扫描 META-INF/solonPluginManager.start(code); // 子 AppContext 初始化 + Plugin.startPluginManager.stop(code); // Plugin.prestop/stop + context stop/clearPluginManager.unload(code); // classLoader.removeJar + closePluginManager.remove(code); // 移除登记
PluginClassLoader extends AppClassLoader,标准父委托。 PluginPackage.start() 创建子 AppContext,插件的 @Controller/@Component 在子上下文生效、路由注册到全局,可注入宿主 Bean,但宿主默认看不见插件 Bean。
4. Ray 插件运行时:ray-framework-plugin
Solon SPI 解决“代码怎么装进来”,剩下的四个问题由 ray-frameworks/ray-framework-plugin 来处理:清单怎么解析(descriptor/)、插件怎么统一建模(registry/)、前端怎么 serve(ui/)、SQL 怎么迁移(sql/)、安装怎么编排(install/)。
flowchart TDroot[”ray-frameworks/ray-framework-plugin(top.goldenyear.ray.framework.plugin)”]root --> d[”descriptor/RayPluginDescriptor · DescriptorLoader(清单解析+校验)RequiresParser”]root --> r[”registry/PluginRegistry(注册中心) · RayPluginInstancePluginState / PluginType / PluginOrigin”]root --> u[”ui/UiRegistrar · RayUiStaticRepository(StaticRepository SPI)各 UiResourceStore”]root --> s[”sql/FlywayMigrator(迁移门面,委托 ray-framework-flyway)”]root --> i[”install/PluginInstaller(安装编排) · PluginStoreAdminGatewayBridge · EventBusPurge”]root --> g[”integration/RayPluginFrameworkPlugin(Solon 插件入口,priority=5)”]
业务模块不直接依赖这个框架模块—— 它们只提供清单与资源,零感知。
4.1 插件清单:plugin-{code}.yml
# ray-modules/ray-module-bpm/src/main/resources/plugin-bpm.yml(实际文件)code: bpm# 全局唯一编码,^[a-z][a-z0-9-]*$,保留字 corename: 工作流程version: ${version}# 构建期经 processResources 以模块版本展开description: 流程设计、流程审批与流程管理author: rayrequires: ”core>=1.0.0”# 依赖约束,安装时校验apiVersion: ”1”apps:# 前端应用声明,一个插件可携带多个(core 携带 3 个)- code: app-bpmname: 工作流程url: /bpm/# 服务路径 = 静态资源前缀 = micro-app urlicon: mdi:chart-timeline-variantsort: 30parent: root# 应用树父节点
清单文件名带 {code} 而非统一的 plugin.yml,是因为 ray-server 的 fat jar 任务以 zipTree 展开 runtimeClasspath,同名资源在合并时会互相覆盖—— 带上编码即天然唯一。DescriptorLoader 在安装时做全套校验:code 格式、url 必须 ^/[a-z0-9-]+/$、/ 前缀仅 core 可声明、jar 根必须恰有一个清单("一 jar 一插件,一插件一应用")。
4.2 统一模型与状态机
public classRayPluginInstance{String code; // core / bpm / home / ...PluginType type; // CORE(仅 core)/ EXTERNAL(其余全部)PluginOrigin origin; // CLASSPATH(E-SPI)/ PLUGINS(H-SPI)PluginState state; // RESOLVED/INSTALLED/STARTED/STOPPED/UPGRADING/FAILEDRayPluginDescriptor descriptor;ClassLoader classLoader; // E-SPI 为 AppClassLoader;H-SPI 为 PluginClassLoaderList repositories; // 本插件注册的静态仓库(停用时注销)}
type 只有两类 —— core 是平台内核(插件管理、用户、权限、路由都在 core 内,停 core 等于停机,PluginInstaller 对其 stop/uninstall 请求直接拒绝,而非靠前端隐藏按钮);其余全部 EXTERNAL。origin 记录装载来源,决定可执行的操作集:
infra_plugin.status 恢复 | |||
生命周期状态机:
stateDiagram-v2direction TB[*] --> RESOLVED: 上传/内置扫描RESOLVED --> INSTALLED: install(执行 SQL,写 infra_plugin)INSTALLED --> STARTED: startSTARTED --> STOPPED: stopSTOPPED --> STARTED: startSTOPPED --> [*]: uninstall(数据保留/清理)STARTED --> UPGRADING: upgrade(上传新版本)UPGRADING --> STARTED: 成功(vN+1)UPGRADING --> FAILED: 失败FAILED --> STARTED: 修复后手工 startnote right of RESOLVED: 清单校验通过,文件就绪note right of FAILED: 记录原因,人工介入,阻断自动重启
状态落库 infra_plugin(status=期望态/管理员意图,驱动重启后自动恢复;state=运行态,驱动界面显示与 FAILED 告警),磁盘/内存状态由 PluginRegistry 持有并与之对账。
4.3 classpath 插件的注册流程
RayPluginFrameworkPlugin(priority=5)的 start 做三件事:注册自研静态资源处理器(见 §4.5)、订阅宿主 DataSource、监听 AppPluginLoadEndEvent 触发扫描:
flowchart TDA[”Solon 启动”] --> B[”内核 E-SPI:solon.extend 声明的 modules/下全部 jar → AppClassLoader.addJar”]B --> C[”各 Solon 插件 start 完毕触发 AppPluginLoadEndEvent”]C --> D[”scan():ScanUtil 扫描 META-INF/solon/*.properties中带 ray.plugin.descriptor 指针的条目”]D --> E[”加载 plugin-{code}.yml,按 priority 注册到PluginRegistry(core 先,重复注册抛异常)”]E --> F[”数据源就绪(AifeiDbManager.addStartEvent)migrateAndMount():逐插件”]F --> G1[”① Flyway migrate(table=flyway_{code},幂等;core 恒最先)”]G1 --> G2[”② 读 infra_plugin 期望态(status=0 则不挂前端)”]G2 --> G3[”③ UiRegistrar.mount(静态资源注册)→ STARTED”]G1 -.->|迁移失败| X[”插件标 FAILED、前端不可见不阻断 server 与其他插件”]
"SQL 先于静态注册"是有意的:迁移失败的插件整体不可见,而不是带着旧表结构残喘。
4.4 SQL 迁移:一插件一实例一表
sql/FlywayMigrator 委托 ray-framework-flyway 的工厂,为每个插件构造独立 Flyway 实例:
Flyway.configure(cl) // 关键:资源扫描用插件 ClassLoader.dataSource(ds).locations(”classpath:db/migration/” + code) // 插件命名空间,防 fat jar 合并撞名.table(”flyway_” + code) // 独立 history 表:flyway_core/flyway_bpm/....validateOnMigrate(true) // checksum 防篡改.cleanDisabled(true) // 永禁 clean.outOfOrder(false) // 线性升级,乱序报错.load();
版本线互不干扰,installed_by 列区分 system(启动自动)与操作人(安装/升级)。种子数据(账号、字典、应用行)经 R__seed.sql 可重复脚本承载(目前可能还不太合理),INSERT IGNORE 幂等。一个多插件共库的坑:Flyway 的 baselineOnMigrate 按"整库非空"判定存量,core 迁完库就非空,bpm 会被误判存量跳过基线 —— Ray 的解法是整轮首个插件迁移前判定一次“库中已有表且均无 flyway_{code} 版本线”才认定存量库。
4.5 前端统一发布:StaticRepository SPI
前端由后端 serve,落点是 solon-web-staticfiles 的 StaticRepository SPI——StaticMappings 支持运行期增删,天然匹配插件启停:
// 插件启动:UiRegistrar.mount → shell(root)应用挂 ”/”,微应用挂去尾斜杠前缀RayStaticMappings.addUi(”/”, repo);RayStaticMappings.addUi(”/bpm”, repo);// 插件停用StaticMappings.remove(repo);
资源来源以 UiResourceStore 接口屏蔽:ClasspathUiStore 走 AppClassLoader.getResource(E-SPI 模块),HotplugUiStore 走 PluginPackage.getResource(H-SPI 插件,jar 内 URL 直读、天然原子)。SPA fallback 与分级缓存(html no-cache+ETag 协商、hash 资产 immutable 长缓存)在 RayUiStaticRepository.find() 与自研的 RayUiStaticResourceHandler 内实现——原生 StaticResourceHandler 对无后缀路径直接 return,根本无法承载 SPA。
5. 动态插件的安装编排:PluginInstaller
install/PluginInstaller 是静态门面,infra 管理界面直调。核心是两段式安装,防止“一上传就改库”:
sequenceDiagramautonumberactor Admin as 管理员participant Ctrl as 插件管理 APIparticipant Inst as PluginInstallerparticipant Tgt as DB/FS/HSPIAdmin->>Ctrl: 上传 jarCtrl->>Inst: POST /infra/plugin/uploadInst->>Tgt: 落临时文件+解析清单+sha256Inst-->>Admin: {token, 清单摘要}(10 分钟令牌)Admin->>Ctrl: precheck(界面展示待执行 SQL)Ctrl->>Inst: POST /infra/plugin/precheckInst->>Inst: 只读校验 code/版本/requires/app 冲突Inst->>Tgt: Flyway info() 预检(只读)Inst-->>Admin: 待执行脚本列表Admin->>Ctrl: 确认Ctrl->>Inst: POST /infra/plugin/confirmInst->>Inst: 串行锁(code 为键)+ 复查Inst->>Tgt: 落盘 → load → migrate → start → activateInst-->>Admin: 安装结果(成功/失败+阶段)
首装(doFreshInstall)的装载顺序值得注意:
PluginManager.add(code, jar);PluginPackage pkg = PluginManager.load(code); // ① 先 load:Flyway 要经插件FlywayMigrator.migrate(code, pkg.getClassLoader()); // ClassLoader 扫 db/migration/{code}PluginManager.start(code); // ② SQL 成功才启动后端 Beanactivate(...); // ③ 注册中心/网关桥/模型同步/挂前端
SQL 失败即 unload+remove+删 jar,不触达后端 Bean。升级(doInstallOver)先用临时 ClassLoader 跑 migrate —— “SQL 失败旧版本零感知继续服务” —— 然后落盘新 jar、拆旧装新,装新失败用旧 jar 原路恢复回滚。
activate 里两步是 H-SPI 实战出来的:
PluginRegistry.register(EXTERNAL, descriptor, PLUGINS, cl);AdminGatewayBridge.bridge(code); // 子上下文控制器挂进宿主网关AifeiDbManager.syncModels(cl); // ORM 模型映射 + 实例工厂补登记UiRegistrar.mount(descriptor, store); // 静态资源
REST API 面(InfraPluginAdminController,/admin-api/infra/plugin/*):paginate/get/upload/precheck/confirm/start/stop/uninstall/sqlHistory/sqlAcceptChange(=Flyway repair)/listClasspath。
停机链路挂在 AppPrestopEndEvent,重启恢复挂在 AppLoadEndEvent:读 infra_plugin 中 origin=PLUGINS 且 status=1 的行逐件 add/load/migrate/start,FAILED 的不自动重试、单件失败不阻断 server。
6. 实战:写一个动态插件
以仓库里真实的首页插件 ray-plugins/ray-plugin-home 为例,完整走一遍。
6.1 目录结构
flowchart TDroot[”ray-plugins/ray-plugin-home/”]root --> gradle[”build.gradle”]root --> src[”src/main/”]src --> java[”java/top/goldenyear/ray/plugin/home/”]src --> res[”resources/”]java --> j1[”XPluginImp.java(Solon 插件入口)”]java --> j2[”controller/HomeAdminController.java”]java --> j3[”service/HomeService.java”]res --> r1[”META-INF/solon/ray-plugin-home.properties”]res --> r2[”plugin-home.yml(清单,jar 根)”]res --> r3[”db/migration/home/(SQL 版本线,可选)”]
6.2 四个必需文件
插件入口——与官方模块同构,beanScan 声明扫描包即可:
@Slf4jpublic class XPluginImp implements Plugin {@Overridepublic void start(AppContext context) {context.beanScan(XPluginImp.class);}}
如果插件依赖宿主 Bean(内置模块 bpm 依赖宿主的 FlowEngine),需要在 start 里桥接——因为 H-SPI 子上下文的 copyTo 只复制构建器/注入器/拦截器/提取器,不复制宿主 Bean:
// ray-module-bpm 的 XPluginImp(依赖宿主 FlowEngine 的桥接写法)FlowEngine engine = Solon.context().getBean(FlowEngine.class);if (engine != null && appContext.getWrap(FlowEngine.class) == null) {appContext.wrapAndPut(FlowEngine.class, engine); // dev 内嵌形态下为 no-op}
SPI 声明META-INF/solon/ray-plugin-home.properties:
solon.plugin=top.goldenyear.ray.plugin.home.XPluginImpsolon.plugin.priority=0ray.plugin.descriptor=plugin-home.yml
清单plugin-home.yml:
code: homename: 首页version: ${version}description: 首页子应用(app-home)与欢迎页聚合接口author: rayrequires: ”core>=1.0.0”apiVersion: ”1”apps:- code: app-homename: 首页url: /home/icon: material-symbols:dashboard-outlinesort: 1parent: root
构建脚本build.gradle 三个关键任务:
// ① 前端产物装配:从 ray-ui 仓库同步 dist 进构建目录(不污染 src/,不入 git)tasks.register('syncUi', Sync) {from(file(”${rayUiHome}/apps/micro/home/dist”)) { into 'ui/home' }into layout.buildDirectory.dir('generated/plugin-ui')}sourceSets.main.resources.srcDir layout.buildDirectory.dir('generated/plugin-ui')// ② 清单 ${version} 模板以模块版本展开(单一来源)processResources {filesMatching('plugin-*.yml') { expand version: moduleVersion }}// ③ 插件主发布物:classes + 清单 + ui/ + db/migration/ + META-INF/solon/,不带第三方依赖tasks.register('pluginJar', Jar) {archiveBaseName = 'ray-plugin-home'from sourceSets.main.outputmanifest { attributes 'Ray-Plugin-Code': 'home', 'Ray-Plugin-Version': ”${project.version}” }}
6.3 发布与安装
package.sh 按 ray-plugins//build/libs/ray-plugin--{version}.jar 收集进发布物 plugins/ 目录随包预置 ——预置 jar 不自动装载,首次仍经管理界面上传/安装一次(两段式向导:上传自动预检 → 待执行 SQL + 备份必勾 → 执行),重启后按 infra_plugin.status 恢复。新插件加入 settings.gradle 后 ./gradlew :ray-plugins:ray-plugin-home:pluginJar 即得可安装的 jar。
6.4 插件开发规范
不得携带宿主未提供的第三方依赖:PluginClassLoader 父委托共享宿主 classpath,独有依赖会 NoClassDefFoundError;表只操作本插件前缀( {code}_*),脚本可重入(CREATE TABLE IF NOT EXISTS等);全局注册必须可注销:插件 Bean 避免注册全局监听/定时器,或在 Plugin.stop注销 —— 被全局缓存/线程池/静态引用持有的类会阻止 ClassLoader 卸载,造成泄漏;停机清理放 preStop(数据源仍可用),不订阅AppStopEndEvent。
7. 落地踩坑实录
7.1 H-SPI 子上下文的两处"想当然"
①copyTo不复制宿主 Bean。文档语义容易读成"子上下文继承宿主一切",实际只复制构建器/注入器/拦截器/提取器四类扩展点。插件注入宿主 Bean 需模块自行桥接。
② "宿主不可见插件 Bean"对网关不成立 —— 但也不自动可见。ray 的管理端控制器靠 tag=adminApi 打标,由宿主 AdminGateway 启动期扫描自身容器装载;H-SPI 子上下文的控制器根本进不去。框架的 AdminGatewayBridge 在插件 start 后把子上下文带该 tag 的 @Mapping 控制器手工 add 进宿主网关,卸载时 RoutingTable.remove(Class) 反注册 —— 并且要反射清扫 solon 4.0.6 的路由缓存:RoutingTableDefault.remove(Class) 不清缓存,stop 后再 start 同一插件会静默丢路由(200 → 重装 → 部分 404)。
7.2 运行期加载的类,字节码增强会失效
InstanceUtil 用 LambdaMetafactory 为 ORM 模型生成实例工厂,对插件 ClassLoader(以及 E-SPI 经 AppClassLoader.addJar 运行期加入)的类一律抛 Invalid caller——privateLookupIn 跨 unnamed module 丢失 full privilege access。ray 在 AifeiDbManager.syncModels/removeModels 里做了两处运行期补齐:@Table 模型映射在插件 start 后反射补登记;经公开的 setFactory API 为插件模型注册反射工厂(优先于 lambda 工厂,宿主类仍走 JIT 内联)。同理,停用落地为完整卸载链而非 stop 保留,重新启用走全新装载链 —— 既满足类卸载语义,也绕开路由缓存 bug。
7.3 停用语义:内嵌模块与动态插件不同
E-SPI 模块的类在共享 AppClassLoader 里,物理上不可卸—— 停用只能摘前端入口(unmount 静态仓库),后端类与路由仍在;管理界面以"内置模块"徽标明示。H-SPI 插件的停用则是物理停止:unmount → 网关反注册 → PluginManager.stop/unload/remove → EventBus 清扫(EventBusPurge,处理历史插件的订阅残留) → ORM 模型移除,实测启停循环 200→404→200。
7.4 停机时序:prestop 而非 stop
最初的监听器订阅 AppStopEndEvent,那时数据库已关,stopAll() 查 infra_plugin 必抛异常。修正:订阅 AppPrestopEndEvent(容器 preStop、数据源仍可用),且 stop 列表从 PluginRegistry 内存态获取而非查库。
7.5 fat jar 资源合并撞名
zipTree 合并依赖时同名资源互相覆盖、胜出顺序不可控。对策:清单 plugin-{code}.yml、SQL 目录 db/migration/{code}/ 全部带编码命名空间;flyway 的 META-INF/services 注册文件手工做 union,保证 fat jar 内唯一。
7.6 modules / 缺失的行为设计
E-SPI 内置 core 模块缺失时,启动自然终止于宿主注入(如 IPermissionService 找不到) —— Solon 内核打印异常、停机 exit 1,不会半瘫运行。
8. 小结
Solon 提供的是“组装单元”与“两条装载轨道”;ray 做的是把前端产物、SQL 版本线、管理编排装进同一个 jar 里。Solon 的插件机制使得同一个插件 jar,编译期内嵌可调试、E-SPI 预置可分发、H-SPI 上传可热装,三种形态一条注册链。