夜雨聆风学习资料网

ARTICLE · 1001383

基于 Solon 插件体系构建系统

基于 Solon 插件体系构建系统

1. 微内核、插件化架构

公司的产品是采用微服务架构的,虽然微服务提供了灵活的组装能力,但业务多了之后,一个功能往往涉及多个微服务,已经很难清楚哪个微服务是服务哪个应用的,最终变成还是需要部署所有的微服务,于是资源占用也就上来了,而且我们主要是面向企业级用户,因此也就会疑问哪种构建方式更适合我们的产品?后来为了信创,遇上 Solon,把公司产品从 Spring、Spring Cloud 迁移到 Solon 和 Solon Cloud 上来。在学习 Solon 的时候,官网中介绍插件部分,SPI,E-SPI,H-SPI。如果你验证过 E-SPI,一定会想在 Solon 上构建一个插件化的系统,因为太方便了。那我们的产品是不是采用微内核、插件化的架构会更好呢?于是我在自己的实验项目(Ray)中,逐步验证自己的想法,后来又碰上微前端(MicroApp),我觉得确实可行了 —— 前后端都打包到一个 Jar 里面,一个插件就是一个应用。但是自己前端太菜,花了很多时间去搞微应用的集成。好在 AI 来了,解决了前端集成的问题。最近我让 AI 把我的半成品插件管理改了改,终于实现了我想要的效果 —— 一个主应用,其余的都是插件。

插件 =  一个 jar,一个插件就是一个应用。

携带物
jar 内路径
作用
后端代码
top/goldenyear/ray/**
 + META-INF/solon/*.properties
Solon Plugin SPI,控制器/服务/监听器
前端产物
ui/{app}/
微前端 dist,由后端统一 serve
SQL 脚本
db/migration/{code}/
Flyway 版本化迁移,每插件一条版本线
自描述清单
plugin-{code}.yml
(jar 根)
编码/版本/依赖约束/前端应用声明

内置插件通过 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 TB    subgraph 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”]    end    R((”::Running::”))    subgraph shutdown[”停机阶段”]        I[”AppPrestopEndEvent”] --> J[”Plugin.prestop”]        J --> K[”AppContext.stop / @Destroy”]        K --> L[”Plugin.stop”]        L --> M[”AppStopEndEvent”]    end    H --> R --> I

三个对插件开发最重要的时序:

  1. Plugin.start
     早于 Bean 扫描。插件在 start 里做的事是“装配”:注册拦截器、桥接依赖,而不是使用业务 Bean。ray 的 XPluginImp 里 context.beanScan(XPluginImp.class) 显式声明扫描包,随后才轮到容器注入。
  2. AppPluginLoadEndEvent
     表示所有 Solon 插件已 start 完毕。ray 的插件运行时监听这个事件才开始扫描清单,保证 fat jar 内所有模块的文件都已就位。
  3. AppPrestopEndEvent
     阶段数据源仍可用,AppStopEndEvent 时库已关。在 Ray 的实际测试中踩过坑,插件停机清理必须放在 prestop。

2.3 插件能做什么:四个扩展点

插件不只是“被动启动的模块”,它可以在 start 时通过 AppContext 的四个扩展机制编程式地增强框架:

扩展方法
用途
典型例子
beanBuilderAdd(anno, handler)
注册 Bean 构建器
@Controller
 构建器注册路由
beanInjectorAdd(anno, handler)
注册字段注入器
@Inject
 注入器解析 Bean/配置
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 官方的两个扩展装载机制:

E-SPI(体外扩展)
H-SPI(热插拔)
Solon 机制
solon.extend
 配置扩展目录
solon-hotplug(org.noear:solon-hotplug)
装载方式
内核启动时 AppClassLoader.addJar
PluginManager
 静态 API,运行期任意时刻
ClassLoader
共享 AppClassLoader
独享 PluginClassLoader(父委托到 AppClassLoader)
Bean 容器
同一 AppContext
子 AppContext(copyTo 派生)
生效时机
重启生效
即时生效
可卸载
否(类不可卸载)
是(unload 关闭 ClassLoader)
ray 中的角色
内置模块(core/bpm/press/dev/ai)
动态插件(如 ray-plugin-home)
发布物命名
ray-module-{code}-{version}.jar
,在 modules/目录下
ray-plugin-{code}-{version}.jar
,在 plugins/目录下

发布物布局(package.sh 产出):

flowchart TD    root[”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=de

build.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 呢?

  1. 自举死锁:infra_plugin 注册表由 core 自己的 SQL 版本线建表,而外置插件的启动要靠查这张表 —— "表由 core 建、core 的启动又靠表信息",走不通;
  2. 宿主启动期注入:ray-server(framework-server)启动即 @Inject core 的 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 TD    root[”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-bpm    name: 工作流程    url: /bpm/# 服务路径 = 静态资源前缀 = micro-app url    icon: mdi:chart-timeline-variant    sort: 30    parent: 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/FAILED    RayPluginDescriptor descriptor;    ClassLoader classLoader;         // E-SPI 为 AppClassLoader;H-SPI 为 PluginClassLoader    List repositories; // 本插件注册的静态仓库(停用时注销)}

type 只有两类 —— core 是平台内核(插件管理、用户、权限、路由都在 core 内,停 core 等于停机,PluginInstaller 对其 stop/uninstall 请求直接拒绝,而非靠前端隐藏按钮);其余全部 EXTERNAL。origin 记录装载来源,决定可执行的操作集:

能力
core(CORE)
官方模块(E-SPI)
动态插件(H-SPI)
随 server 启动自动注册
✓(必)
✓(在 classpath 即注册)
按 infra_plugin.status 恢复
停用(stop)
✗ 禁止
✓(摘前端入口;类不可卸)
✓(完整:类卸载+路由反注册)
卸载(uninstall)
✗
✗(随发布物替换)
✓
热升级(上传新 jar)
✗
✗(拒绝上传,提示随发布物升级)
✓
SQL 迁移 / 前端注册
✓
✓(同一套机制)
✓

生命周期状态机:

stateDiagram-v2    direction TB    [*] --> RESOLVED: 上传/内置扫描    RESOLVED --> INSTALLED: install(执行 SQL,写 infra_plugin)    INSTALLED --> STARTED: start    STARTED --> STOPPED: stop    STOPPED --> STARTED: start    STOPPED --> [*]: uninstall(数据保留/清理)    STARTED --> UPGRADING: upgrade(上传新版本)    UPGRADING --> STARTED: 成功(vN+1)    UPGRADING --> FAILED: 失败    FAILED --> STARTED: 修复后手工 start    note 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 TD    A[”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 管理界面直调。核心是两段式安装,防止“一上传就改库”:

sequenceDiagram    autonumber    actor Admin as 管理员    participant Ctrl as 插件管理 API    participant Inst as PluginInstaller    participant Tgt as DB/FS/HSPI    Admin->>Ctrl: 上传 jar    Ctrl->>Inst: POST /infra/plugin/upload    Inst->>Tgt: 落临时文件+解析清单+sha256    Inst-->>Admin: {token, 清单摘要}(10 分钟令牌)    Admin->>Ctrl: precheck(界面展示待执行 SQL)    Ctrl->>Inst: POST /infra/plugin/precheck    Inst->>Inst: 只读校验 code/版本/requires/app 冲突    Inst->>Tgt: Flyway info() 预检(只读)    Inst-->>Admin: 待执行脚本列表    Admin->>Ctrl: 确认    Ctrl->>Inst: POST /infra/plugin/confirm    Inst->>Inst: 串行锁(code 为键)+ 复查    Inst->>Tgt: 落盘 → load → migrate → start → activate    Inst-->>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 TD    root[”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 {  @Override  public 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-home    name: 首页    url: /home/    icon: material-symbols:dashboard-outline    sort: 1    parent: 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.output    manifest { 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 上传可热装,三种形态一条注册链。

相关学习资料