HTTP 接口动态管理:
一个更实用的做法是通过 HTTP 接口暴露插件管理能力实现运行时的动态控制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?nameadd1即可在线加载并启动插件调用GET /plugin/stop?nameadd1即可停止并卸载——全程无需重启主服务。6.4 从管理到平台化PluginManager.add()和PluginManager.remove()不限于配置文件也可以通过代码随时调用。这意味着插件信息完全可以存储在数据库中通过管理界面动态维护。更进一步你可以构建一个完整的插件管理平台数据库存储插件元数据名称、版本、JAR 路径、状态管理后台提供上传、启停、版本回滚等操作运行时通过PluginManager的 API 执行实际的加载卸载。solon-hotplug 提供了构建这样一个平台所需的全部底层能力。6.5 一个容易被忽略的细节H-Spi 插件开发中最容易被忽略的是ClassLoader 对框架行为的影响。比如在使用后端模板引擎如 Freemarker、Thymeleaf时模板文件的查找依赖于 ClassLoader 的资源加载。如果你的插件使用了独立 ClassLoader模板引擎默认会从主应用的 ClassLoader 中查找模板——自然找不到。正确的做法是在创建模板渲染器时显式传入插件自身的 ClassLoader// 使用插件自身的 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()方法通常需要完成四件事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() 方法是热插拔开发中最关键的环节。以下是需要移除的四类资源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文件内容solon.pluginorg.example.plugin1.Plugin1Impl solon.plugin.priority1其中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进行通信// 插件 A发布事件 MapString, Object data new HashMap(); data.put(userId, userId); data.put(action, created); EventBus.publish(userCreated, data); // 插件 B订阅事件 EventBus.subscribe(userCreated, (EventListenerMap) data - { Long userId (Long) data.get(userId); // 处理逻辑... });这里的关键在于使用Map或基础类型作为事件载荷而非自定义 DTO。事件主题用字符串标识数据用 Map 承载完全规避了类依赖问题。如果确实想用强类型事件对象那事件类必须放在父级 ClassLoader即主应用中所有子级插件都能访问。但这会增加主应用的依赖需要权衡。8.2 策略二弱类型数据传递当插件间需要通过共享服务传递结构化数据时使用框架内置的基础类型或 JSON 字符串// 插件 A将数据序列化为 JSON 字符串 String userJson ONode.stringify(userMap); EventBus.publish(userData, userJson); // 插件 B反序列化为自己的内部 DTO EventBus.subscribe(userData, (EventListenerString) 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 策略四结合 DamiBusSolon 生态提供了 DamiBus 作为插件间通信的专用工具。DamiBus 是一个基于主题Topic的本地事件总线支持同步和异步两种分发模式专为未知模块和隔离模块之间的解耦而设计。// 插件 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 可能是主应用的导致模板文件找不到。解决方案是在插件中显式传入插件自身的 ClassLoaderpublic 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 中的公共依赖标记optionaltrue/optional避免打包时带入主应用已有的依赖!-- 插件的 pom.xml -- dependency groupIdorg.noear/groupId artifactIdsolon-boot/artifactId version${solon.version}/version optionaltrue/optional /dependency9.4 访问主程序资源虽然子级 ClassLoader 可以访问父级但在实际编码中需要注意获取方式。插件需要主程序的服务或配置时有两种标准途径// 获取主程序的 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 六个应用事件时机点序号事件说明订阅方式1AppInitEndEvent应用初始化完成手动订阅Solon.startlambda 中2AppPluginLoadEndEvent插件加载完成手动订阅3AppBeanLoadEndEventBean 扫描完成注解 / 手动均可4AppLoadEndEvent应用启动完成注解 / 手动均可5AppPrestopEndEvent应用预停止注解 / 手动均可6AppStopEndEvent应用停止完成注解 / 手动均可关键规则AppBeanLoadEndEvent之前的事件即AppInitEndEvent、AppPluginLoadEndEvent发生在 Bean 扫描之前此时Component组件尚未被容器发现因此必须在Solon.start()的 lambda 中手动订阅否则会错过时机。// 手动订阅用于早期事件 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 EventListenerAppLoadEndEvent { Override public void onEvent(AppLoadEndEvent event) throws Throwable { System.out.println(应用启动完成所有 Bean 已就绪); } }10.3 LifecycleBean 自动排序机制v2.2.8LifecycleBean接口绑定在AppContext的启动与停止阶段只对单例有效。当多个LifecycleBean存在依赖关系时Solon 从 v2.2.8 起支持基于Inject依赖的自动排序——如果 Bean2 通过Inject注入了 Bean1则 Bean1 的start()必然先于 Bean2 执行。接口方法对应注解执行时机说明LifecycleBean::startInitAppContext::start()Bean 扫描完成后执行LifecycleBean::postStart—同上后半段v2.9适合启动网络监听等LifecycleBean::preStop—AppContext::preStop()注销远程服务LifecycleBean::stopDestroyAppContext::stop()释放本地资源// 自动排序示例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)显式排列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)在插件开发中获取插件自身的独立上下文// 方式一全局上下文 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插件私有 ClassLoaderBean 可见性主程序 E-SPI 插件的所有 Bean仅插件自身扫描到的 Bean生命周期随应用启停随插件的start()