插件运行时
这一页解决一个问题:一个插件 jar 从磁盘进到 JVM 里,中间发生了什么,以及动这些代码时会坏在哪。
具体覆盖三件事:插件目录与两个加载时机、每插件一个类加载器 + 一个子 Spring 上下文、 路由注册与卸载回收。只讲后端 —— 前端远程应用的装载在前端远程应用。
范例应用 wujies-hello 的四个演示点(调宿主服务 / 跨插件调用 / 开放 /open 接口 / 两种定时任务)
都有真实源码,索引见跟着 hello 读源码。本页只讲机制。
读者定位:先按症状查
| 你要做什么 / 遇到什么 | 直接跳 |
|---|---|
| 想知道"部署完为什么不用重启"、插件目录在哪 | 「插件目录与两个加载时机」一节 |
| 想确认该用哪套加载机制、开关写哪里 | 「两套加载机制不能同时启用」一节 |
两个版本 jar 并存、删不掉、ZipException | 「active-plugins.txt 是选版本的唯一判据」一节 |
跨插件注入报 No qualifying bean / X cannot be cast to X | 「每插件一个类加载器」一节 |
| 插件装了但接口裸奔、卸载后接口还能用 | 「路由注册」一节 |
| 宿主发的事件插件收不到 / 插件收到两遍 | 「事件中继」一节 |
| 每装一次泄漏一个类加载器 | 「卸载顺序不能反」一节 |
插件目录与两个加载时机
| 配置项 | 在哪 | 当前值 |
|---|---|---|
app-market.deploy.plugin-dir | wujies-platform/wujies-admin/src/main/resources/application.yml | plugins-dist |
| 同一目录的启动参数 | wujies-platform/script/prune/start-host.ps1 拼出的 -Dloader.path | plugins-dist |
两者必须指向同一个目录,否则部署落位到一个地方、JVM 去另一个地方找。yml 里那条注释就是这么写的:
「后端插件 jar 落位目录,必须与宿主启动参数 -Dloader.path 一致」。
loader.path 只加载该目录根下的 jar,不递归子目录 —— 所以迁移脚本落位的
<pluginDir>/migrations/<appCode>/ 不会被当成插件(见数据库迁移)。
目录下有两个完全不同的加载时机,必须分开理解,混在一起会把结论下反:
| 时机 | 加载者 | 什么时候 | 能不能热插拔 |
|---|---|---|---|
| JVM 启动瞬间 | PluginClassPathLoader(wujies-platform/wujies-admin/,Spring 之前执行) | WujiesApplication.main 第一行 | ❌ 启动期行为 |
| 启动完成 + 运行期 | PluginRuntimeManager(wujies-app-market/plugin/) | ApplicationReadyEvent 之后 | ✅ 装完即用、卸完即走 |
PluginRuntimeManager 刻意挂 ApplicationReadyEvent 而不是 @PostConstruct:
后者执行时宿主的 HandlerMapping / 监听容器可能还没就绪,插件接口注册进去会被后续初始化覆盖掉。
为什么还需要那个"兼容"的启动期加载器:-Dloader.path 只被打包后的 PropertiesLauncher 识别。
从 IDE 直接跑主类时它会被静默忽略 —— jar 明明躺在目录里、JVM 却没加载。后果是三个都很难查的现象:
- 安装时报「当前 JVM 没有加载 xxx.jar」;
- 插件接口全部落到 fallback,返回「应用未安装或未启用」;
- 部署完不重启就以为生效了。
PluginClassPathLoader 在 SpringApplication 构造之前换掉线程上下文类加载器(TCCL)。
Spring 的组件扫描与 MyBatis 的资源扫描走的是 ClassUtils.getDefaultClassLoader(),它返回的正是 TCCL,
所以换掉 TCCL 就够了,不需要改任何 Spring 配置。父加载器保持为原 TCCL,平台类仍由父加载器提供。
它还会通过 PluginRuntimeProbe.findLoadedJar(fileName) 检测「已由 loader.path 加载」并跳过 ——
两种方式互不冲突也互不重复。
两套加载机制不能同时启用
| 机制 | 实现位置 | 触发 | 类加载器 |
|---|---|---|---|
| 启动期加载器(兼容) | wujies-admin 的 PluginClassPathLoader | JVM 启动瞬间扫 plugins-dist 挂到 TCCL | 一个共享的 |
| 插件运行时(默认) | wujies-app-market 的 PluginRuntimeManager | 启动期 + 运行期 | 每插件独立 |
同时生效 → 插件类被加载两次(一次进宿主上下文、一次进子上下文)→ bean 与接口重复注册。
开关属性名 app-market.plugin-runtime.enabled,代码默认 true:
// PluginRuntimeManager
public static final String ENABLED_PROPERTY = "app-market.plugin-runtime.enabled";
@Value("${app-market.plugin-runtime.enabled:true}")
private boolean enabled;application.yml 里没有这个配置项,靠注解默认值兜底。
启动加载器在 Spring 之前执行,读的是系统属性,读不到 yml:
-Dapp-market.plugin-runtime.enabled=false # 退回旧的 loader.path 行为只在 yml 里改 false 的结果是「启动器让路(它读的是系统属性、默认 true)+ 运行时又没开」
→ 插件完全不加载。PluginRuntimeManager.loadAll() 遇到这种组合会打一条 warn
并把正确做法写出来,但功能是真的不生效。
active-plugins.txt 是选版本的唯一判据
目录里同时留着旧版本是常态:Windows 上运行中的 JVM 不允许删除已被打开的文件(覆盖可以、删除不行)。
app-market.deploy.plugin-dir 下那个 active-plugins.txt 就是「哪个应用当前生效哪个 jar」的权威记录:
<appCode>=<jar 文件名>
exam=wujies-exam-plugin-1.0.1.jar
mall=wujies-mall-plugin-1.0.2.jar它由 AppPackageDeployService.recordActiveJar(pluginRoot, appCode, jarFileName) 在部署时写入
(先丢弃该应用的旧记录与空行/注释,再追加一行),由 forgetActiveJar(appCode) 在「彻底删除」时擦掉。
在此之前只有运行期选版本,JVM 启动瞬间是「目录下所有 jar 全挂上去」。两套判据不一致, 于是不在清单里的残留 jar 也会被打开,形成一条自我循环的锁死链:
① 彻底删除时 jar 删不掉(正被运行期加载器打开)→ 残留留在 plugins-dist →
② 下次启动 → 启动期"全挂" → 残留 jar 被启动期加载器打开 →
③ 重装 → 覆盖同一个文件名(Windows 上覆盖允许、删除被拒)→ 启动期加载器手里的句柄指向被改过的字节 →
④ 卸载只关得掉 runtime 自己的加载器 → 删除再次失败 → 回到 ①。
所以**「带着残留重启会起不来」那个老坑已经消失** —— 两个版本的 jar 不会再被同时挂上。
实测旁证:修之前 mall 的 jar 在宿主运行时独占打开失败、停掉宿主后立刻可以打开,
而它的 active-plugins.txt 记录早已被移除 —— 说明打开它的只能是"启动期全挂"。
三处实现读的是同一个文件名,判据一致:
| 实现 | 方法 | 读法 |
|---|---|---|
PluginRuntimeManager | readActiveJars() | appCode=文件名,跳过空行与 # 注释 |
PluginClassPathLoader | activeJarNames(dir) | 同上;也容忍只有文件名的写法,跳过空行与 # |
PluginClassLoader | activeJars(dir) | 同上,返回绝对路径集合,用于判断兄弟 jar 是否生效 |
读不到清单(文件缺失或为空)时不过滤,退回改造前的「全挂」宽容行为 ——
三处刻意用同一套判据,避免语义漂移。此时两个版本 jar 并存就会撞
Result Maps collection already contains value、服务起不来,
处理办法见故障排查的「启动与加载」一节。
运行期比启动期多一层兜底:PluginRuntimeManager.loadAll() 把不在清单里的 jar 当作
「手工放置的插件」,按文件名约定推导应用编码,若该编码还没有生效版本就照样加载它。
启动期加载器没有这层兜底。清单驱动、手工放置的 jar 一律不碰 ——
script/prune/prune-plugins.ps1 也只删「同一个 appCode 名下、非当前生效版本」的 jar。
应用编码从文件名推导的规则(PluginRuntimeManager.resolveAppCode / PluginClassLoader.appCodeOfJar):
wujies-mall-plugin-1.0.2.jar → mallscript/prune/start-host.ps1 用 Split-Path $PSScriptRoot -Parent 推工程根,
而它和 prune-plugins.ps1 都在 script/prune/ 下 —— 推出来的是 wujies-platform/script,
不是 wujies-platform。要照注释说的「在 Wujies Platform 目录下」用,得显式传参:
powershell -NoProfile -ExecutionPolicy Bypass -File script\prune\start-host.ps1 -PluginDir plugins-dist脚本自己的注释写的是 scripts\start-host.ps1(少一层 prune),也与实际路径不符。
每插件一个类加载器
PluginClassLoader extends URLClassLoader,parent-first,父加载器是宿主。
刻意不做"子优先":平台类(Spring / MyBatis / wujies-common-* / wujies-common-plugin-sdk)
必须由父加载器提供,否则插件里的 IXxxService 会是另一个 Class 对象,注入与 instanceof 判断全部失效。
插件自己的类不在父加载器上,自然会从本 jar 加载,所以并不需要子优先。
URL 列表里只有自己那个 jar
collectUrls(jar) 只返回 {jar}。兄弟插件 jar不再挂进 URL 列表(曾经挂过,当兜底),原因是它会在
Windows 上必然发作:
- Spring 建插件上下文要组件扫描,扫描会枚举加载器的所有 URL → 每个加载器都打开并缓存住
兄弟 jar 的
JarFile句柄; - 于是
AppPurgeService「彻底删除」时删不掉那个 jar:删除后端产物失败: wujies-mall-plugin-1.0.2.jar — 另一个程序正在使用此文件; - 而重装会把同名文件写到同一个位置 → 别人正打开着它的时候覆盖它 → 那些加载器缓存里的句柄
读到的是被改过/截断的字节(早先出现过的
ZipException: invalid LOC header (bad signature)就是这个签名)。
去掉 URL 兜底的代价只有一个、而且语义更正确:兄弟插件没加载时引用它的类解析不到 →
由 isLinkable 守卫跳过并列出「缺 cn.wujies.x.*」。依赖没装就不该能跑。
跨插件的类解析靠「委托给拥有者」
PluginClassLoader.findClass 在 super.findClass 之前先问「这个类归谁」:
cn.wujies.member.mapper.MemberMapper → PluginClassLoader[member]判据在 ownerAppCodeOf(className):cn.wujies. 之后的第一段就是应用码;
平台段(common / appmarket / admin / modules)与自己的段返回 null。
拥有者是活查询(ownerLoaderLookup,由 PluginRuntimeManager 提供),所以后加载的插件也能被找到;
委托失败就退回自己的 URL 列表,不因为委托失败而让原本能解析的类解析不了。
只挂兄弟 jar 的 URL,会让每个插件各自 defineClass 一份兄弟的类,同一个 FQCN 在 JVM 里有两个 Class 对象:
宿主里的 memberMapper bean ← PluginClassLoader[member] 定义的 Class A
mall 构造参数 MemberMapper ← PluginClassLoader[mall] 自己定义的 Class B
→ Spring 按类型找不到候选 → No qualifying bean of type '...MemberMapper'同类现象还有 ClassCastException: X cannot be cast to X。
回归测试 PluginClassLoaderIdentityTest(4 项)的核心断言不是"能解析",而是 assertSame;
PluginClassLoaderSiblingTest.siblingJarsAreNotAttachedAsUrls 是另一道回归闸
(getURLs().length == 1 且那一个就是自己的 jar)。
PluginClassLoader 的构造函数还有一条:按 appCodeOfJar 排除自己的其它版本
(wujies-mall-plugin-1.0.1 与 1.0.2 是同一个应用)。漏了这条的实测后果是组件扫描读到旧 jar 里的类,
直接炸:Failed to read candidate component class: .../wujies-mall-plugin-1.0.1.jar!/...MallCoupon.class。
unload 时 PluginClassLoader.close(),AppPackageDeployService 之后就能删掉那个文件。
子 Spring 上下文
每个插件一个以宿主为 parent 的 PluginApplicationContext(继承 AnnotationConfigApplicationContext)。
宿主 ApplicationContext
└── 插件子上下文 PluginApplicationContext
└── 组件的 controller / service / listener / taskcreateContext 里三个顺序不能变
PluginRuntimeManager.createContext(...) 的注释写得很明确:
setClassLoader必须在refresh()之前 ——prepareBeanFactory会用它设置beanFactory.setBeanClassLoader(...),而@ConditionalOnClass这类条件判断正是通过 BeanFactory 的类加载器去加载注解里的类的。设晚了就永远找不到插件类;setParent(hostContext)—— 插件 bean 才能注入宿主的SqlSessionFactory/ 平台服务;refresh()期间把 TCCL 切成插件加载器,结束后finally还原 —— MyBatis 解析映射文件、静态初始化里读资源都走 TCCL,不还原会污染宿主线程。
ResourceLoader 也显式换成 new DefaultResourceLoader(classLoader)。
组件扫描:枚举 jar 条目,不按包名
scanComponents(classLoader, jar) 直接枚举 jar 条目(跳过 META-INF/、内部类和 interface / abstract),
再按 AnnotatedElementUtils.hasAnnotation(type, Component.class) 过滤 ——
@Service / @Repository / @Controller / @Configuration 都元注解了 @Component,一次判断就够。
为什么不按 basePackage 扫:清单里没有声明包名,"约定包名"会让插件被迫使用某个前缀。
于是它不读 META-INF/spring.factories —— 插件仓库里现在一个都没有(wujies/wujies-plugins/ 下
没有 src/main/resources/META-INF/spring.factories)。把插件的多个 Maven 子模块合并成一个 jar 时,
原来那些 spring.factories 可以直接删掉。
⚠️ 但 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
是另一回事:wujies-exam / wujies-gallery / wujies-mall / wujies-quote 各有一份,
里面一行是那个应用的定位器(如 cn.wujies.mall.fallback.MallServiceLocator)。
它由 Spring Boot 的自动配置机制消费,不是本运行时装配 bean 的依据
(这里只按注解扫 jar 条目);与定位器该放哪个模块的关系见跨插件调用。
插件里有些类是可选能力,它们引用的类型在宿主运行期并不存在。典型两个真实例子:
- 定时任务类用 SnailJob 的
@JobExecutor—— 宿主 fat jar 里没有任何 snail 的 jar; - 消息消费者用
org.springframework.amqp.*—— 宿主没引 spring-rabbit 时同理。
不检查的后果是一个可选功能拖垮整个插件安装:Spring 读这个类的元数据时抛 NoClassDefFoundError,
报成 Failed to parse configuration class [cn.wujies.member.task.MemberPointsExpireTask]。
isLinkable 只读一遍 getDeclaredMethods() / getDeclaredFields() / getDeclaredConstructors()
(不实例化、不初始化,所以不会触发静态块),连不上就跳过并在 WARN 里列出缺什么。
插件自带的 Mapper:注册两次,缺一不可
插件 jar 里的 Mapper 接口与 XML 都要动态注册,而且这是两件不同的事:
| 要解决什么 | 谁做 | 注册到哪 |
|---|---|---|
| MyBatis 认不认识这些 SQL | PluginMapperRegistrar.register(appCode, classLoader, jar) | 宿主 Configuration |
| Spring 容器里有没有这个 bean | PluginRuntimeManager.registerMapperBeans(...) | 宿主 DefaultListableBeanFactory |
两处都在 createContext 之前/之中完成,顺序不能反 —— 插件里的 service 在构造/初始化阶段就可能用到 Mapper。
少了第二处,@RequiredArgsConstructor + private final XxxMapper 的 service 会直接建不出来:
No qualifying bean of type 'cn.wujies.gallery.mapper.GalleryImageAccessLogMapper'。
registerMapperBeans 里那一行属性是跨上下文按类型注入的必要条件:
definition.setAttribute(FactoryBean.OBJECT_TYPE_ATTRIBUTE, mapperType);因为 mapperInterface 是属性而不是构造器参数,Spring 想按类型找候选本来必须先实例化 MapperFactoryBean;
而子上下文向父解析依赖走的是 beanNamesForTypeIncludingAncestors(..., allowEagerInit=false) ——
不允许提前实例化。实测过一次:日志已打出「插件 member 的 9 个 Mapper 已注册为宿主 bean」,
mall 仍注入不到,就是缺这一行。
PluginConverterRegistrar 同理(MapStruct 转换器注册进宿主,且注册前要清 mapstruct-plus 的静态缓存,
否则热加载后报 cannot find converter / X cannot be cast to X)。
那个写法是拿宿主类加载器扫 classpath,会把宿主自己的映射文件也捞一遍,再注册就撞
Result Maps collection already contains value、宿主直接起不来。
PluginMapperRegistrar 只读传入的那个 jar。
定时任务与消息监听的基础设施
registerInfrastructure(context) 直接给子上下文注册两个 BeanPostProcessor 的 bean 定义:
ScheduledAnnotationBeanPostProcessor 与 RabbitListenerAnnotationBeanPostProcessor。
刻意不注册带 @EnableScheduling / @EnableRabbit 的 @Configuration 类:
那种类如果能在宿主包里被扫到,宿主的组件扫描会把它也注册一份,凭空给宿主加一套监听容器注册表。
并且必须先给子上下文一个自己的 RabbitListenerEndpointRegistry(bean 名是 Spring AMQP 约定的
org.springframework.amqp.rabbit.config.internalRabbitListenerEndpointRegistry)——
否则处理器按 bean 名从父上下文解析注册表,插件的监听器注册进宿主的注册表,
撞上自动生成的容器 id RabbitListenerEndpointContainer#0 → 抛
Another endpoint is already registered with id ...#0、插件加载失败(实测踩到)。
父子关系是单向的
插件能注入宿主,宿主不能注入插件。 宿主侧要拿插件服务只有两条路:
| 方式 | 位置 | 特点 |
|---|---|---|
PluginServiceProvider.getPluginBean(appCode, type) | wujies-common-core | SPI,PluginRuntimeManager 自己就实现了它;插件未加载或没有该 bean 返回 null |
XxxServiceLocator(wujies-common-plugin-sdk) | SDK | 薄包装,返回每次调用都反查的代理 |
PluginRuntimeManager.isPluginLoaded(appCode) 用来把「没装」与「装了没这个 bean」分开,
才能给出「请到应用市场安装」这种能照着做的提示。PluginClassLoaderProvider.livePluginClassLoaders()
返回每次调用重新取的快照(给 Redis 的 Jackson 按全名加载插件 VO 用)——
缓存它会让卸载后的类加载器永远回不去。
哪怕是"转发代理"也不行。Spring 按类型注入走 beanNamesForTypeIncludingAncestors,
会把父上下文的同类型 bean 一起算作候选 → 插件的控制器启动即报
expected single matching bean but found 2。
事件中继:跨上下文的事件是单向的
Spring 的事件传播是单向的:AbstractApplicationContext#publishEvent 会把事件再交给 parent,
所以插件发的事件能到宿主;反过来,宿主(以及兄弟插件)发的事件进不了某个插件子上下文。
后果很具体:mall 抽成插件后,它的 MallPaySuccessListener / MallPayRefundSuccessListener
会静默失效(订单付完停在"待支付",没有任何报错)。所以宿主侧有 PluginEventRelay
把宿主收到的事件转发进各插件子上下文。
三个不能改坏的点:
- 转发用
multicastEvent而不是publishEvent—— 后者会再向 parent 冒泡一次, 宿主监听器收到两遍。实现是PluginApplicationContext.multicastLocal(...), 它按 bean 名applicationEventMulticaster取本地多播器(getApplicationEventMulticaster()是 protected); - 必须排除事件的来源上下文 —— 插件
publishEvent时它自己已经本地多播过一遍, 中继再转发回去,发布插件的监听器就收到两遍。实测后果是支付时PayOrderListener建出 两条pay_notify_task、商户回调被调两次。来源靠PluginApplicationContext覆盖两个publishEvent重载、由PluginEventOrigin记录 —— Spring 是「先本地多播、返回后再冒泡父上下文」,中间没有可插入的钩子,只有在外层入口上记才记得住; - 不中继
org.springframework.*—— 否则每次子上下文 refresh 的ContextRefreshedEvent都会灌进每个插件。另有线程内标记RELAYING防回环。
事件类型匹配认的是「事件对象的 Class」。插件各有自己的类加载器,所以订阅方写
@EventListener(PaySuccessEvent.class) 认的是它自己类加载器里那个 Class 对象。
事件类若跟着发布方插件的 jar 走,订阅方就有第二个 Class → 永远不匹配,且不报任何错。
所以事件类必须由宿主类加载器加载,即 wujies-common-core 的
cn.wujies.common.core.domain.event(与 ProcessEvent / UserRegisteredEvent 同一处)。
这条有可执行的回归测试 PluginEventClassLoaderTest(5 项),它用 defineClass 直接造出第二份同名类,
并且先断言隔离真的生效 —— 不然"测试没生效"和"代码正确"长得一模一样。
路由注册:PluginHandlerMapping
为什么不复用宿主那个 HandlerMapping
宿主已经有 requestMappingHandlerMapping(order=0)。往里注册再注销,等于在别人正在用的表上增删 ——
注销时键算错会连宿主接口一起摘掉。
所以插件路由挂在独立的 PluginHandlerMapping(order=10):
- 排在宿主之后 → 插件路由只在宿主路由都没匹配上时才生效;
- 摘除插件只要清空自己的表(
registeredKeys),不可能伤到宿主。
initHandlerMethods() 被故意置空
父类默认实现会把当前上下文里所有 @Controller 扫进来,把宿主控制器重复注册一遍
(两张表都有它们,排查时极具误导性)。本类只接受 registerController(appCode, handler) 的显式注册。
清空的只是"扫描"、不是"初始化":this.config 由
RequestMappingHandlerMapping#afterPropertiesSet() 在调用父类方法之前设好,
所以注册时用到的 getMappingForMethod 依然可用。
PluginRuntimeManager.registerControllers 只从插件子上下文里取 @Controller
(@RestController 元注解了它,getBeansWithAnnotation(Controller.class) 同样收得到)。
拦截器链必须手工挂上,否则接口裸奔
宿主的拦截器是走 WebMvcConfigurer#addInterceptors 注册的,那批拦截器只会被挂到框架自己的
requestMappingHandlerMapping 上。父类只会在上下文里存在 MappedInterceptor bean 时才自动挂,
而宿主没有那种 bean。不管这件事,插件接口就是完全裸奔的。
实测(修复前):不带 token 调 GET /mall/product/list 直接返回真实商品数据,
而同样带 @SaCheckPermission 的宿主接口返回 401。
两处配套,缺一不可:
-
PluginHandlerMapping.registerController在注册任何路由之前调attachHostInterceptorsOnce(), 把宿主 mapping 的getAdaptedInterceptors()复制过来 —— 复制的是实例(含MappedInterceptor包装),所以/open/**这类路径匹配规则与 SaInterceptor 的 exclude 语义原样保留。::: warning 只
setInterceptors不initInterceptors不生效setInterceptors(...)只是把原始对象塞进interceptors,adaptedInterceptors由initInterceptors()构建;本 bean 在容器启动时已经afterPropertiesSet过, 所以必须显式再 init 一次。 ::: -
AllUrlHandler的登录清单要同时收「宿主 mapping + 插件 mapping」,且PluginRuntimeManager在 load / unload 后调refreshLoginCheckUrls()。 原因是 SaInterceptor 的登录检查是SaRouter.match(allUrlHandler.getUrls())—— 清单里没有的路径直接跳过。
那会把插件类同时注册到宿主 mapping 上,卸载摘不掉它们,表现为「停用了接口还能用」。
实测(热加载与启动期加载两条路都验过):
| 用例 | 结果 |
|---|---|
| 插件接口不带 token | {"code":401} |
| 插件接口带 token | 200 |
插件里 @SaIgnore 的接口(如企业库 /portal/enterprise/company/list) | 仍匿名可用 |
| 宿主接口行为 | 不变 |
| 请求日志拦截器 | 开始记录插件路由 |
回归测试 PluginHandlerMappingTest(2 项):pluginRoutesCarryHostInterceptors 与
loginCheckUrlsCoverPluginRoutes。
路由墓碑:卸载后那个 404 该说什么
registerController 在注册时往 retiredPatterns 里记一份「这条路径属于这个应用」,
卸载时不清。appCodeFor(servletPath) 用它反查"这条路径曾经属于谁"
({id} → *、{*path} → ** 之后按 Ant 语义比),配合 PluginRuntimeManager.isLoaded(appCode)
与本租户安装记录,把 404 的措辞分成「已停用 / 未安装或已卸载 / 已启用但产物没加载」,
业务码 4610(AppRouteHintProvider.CODE_APP_UNAVAILABLE)。
⚠️ 墓碑是内存态,只覆盖"本次运行期间卸载/停用";⚠️ 应用正在跑却是 404 时保持原文案 (那是它自己的路径 bug,不能被"应用不可用"盖掉)。
卸载顺序不能反
PluginRuntimeManager.unload(appCode) 的顺序:
① runtime 从 runtimes 里移除
② pluginHandlerMapping.unregisterApp(appCode) → 摘接口
③ refreshLoginCheckUrls() → 登录清单跟着去掉
④ runtime.context().close() → 关子上下文
⑤ pluginMapperRegistrar.unregister(...) → 注销 MyBatis 映射
⑥ unregisterMapperBeans(...) → 摘宿主里的 Mapper bean
⑦ pluginConverterRegistrar.unregister(...) → 摘转换器
⑧ classLoader.close() → 最后关类加载器- 先关上下文:飞行中的请求会打到已销毁的 bean;
- 先关类加载器:上下文销毁阶段加载类会抛
NoClassDefFoundError。
宿主的 MyBatis Configuration 里持有插件的 Mapper 接口类,不摘掉的话那个 PluginClassLoader
会被强引用住、永远回收不了 —— 每装一次泄漏一个加载器及其全部类。
unregisterMapperBeans 还要先 destroySingleton 再 removeBeanDefinition,
否则重装时同名 bean 定义已存在 → BeanDefinitionOverrideException。
load 的对称位置也做了回滚:createContext 抛异常时会把刚注册的 Mapper 与转换器一并注销,
再关类加载器 —— 否则失败一次就留下一个指向废弃类加载器的悬挂映射。
卸载全部插件挂的是 @PreDestroy 的 unloadAll()。
怎么核对:看日志
这一页的每条断言都能在这几行日志里对上(前缀与文本都取自源码):
| 日志 | 出处 | 说明什么 |
|---|---|---|
[plugin-loader] 已启用「每插件独立上下文」运行时,本加载器让路(避免插件类被加载两次) | PluginClassPathLoader.install | 启动期加载器没干活,插件由运行时加载 |
[plugin-loader] 跳过 N 个不在 active-plugins.txt 里的 jar(**刻意不挂载**): [...] | PluginClassPathLoader.listJars | 残留 jar 被启动期跳过 —— 老坑消失的直接证据 |
[plugin-loader] 已挂载 N 个插件 jar(父加载器 ...) | 同上 | 走的是旧路径(运行时被关掉了) |
开始加载 N 个插件(每插件独立类加载器 + 子上下文) / 插件加载完成,已挂载 N 个,接口 N 个 | PluginRuntimeManager.loadAll | 运行期加载的开头与收尾 |
插件目录下有 N 个 jar,按 active-plugins.txt 只加载 N 个(其余是旧版本残留,正常) | 同上 | 数量对不上时先看这条 |
插件 X 已加载:组件 N 个,接口 N 个,Mapper N 个,类加载器 PluginClassLoader[X <- ...] | PluginRuntimeManager.load | 单插件的加载摘要 |
插件 X 的 Mapper 已注册:接口 N 个,XML N 个,语句 N 条 | PluginMapperRegistrar | 自定义 SQL 的注册结果 |
插件 X 的 N 个 Mapper 已注册为宿主 bean(兄弟插件可注入): [...] | PluginRuntimeManager.registerMapperBeans | 跨插件注入的前提;带 bean 名,便于对着 No qualifying bean of type X 核对 |
插件 X 的类加载器只挂自己的 jar(...) / 插件 X 有 N 个 jar **在场但未生效**(不在 active-plugins.txt 里) | PluginClassLoader | 后者正是「报缺 cn.wujies.<x>.*」时该看的那一行 |
插件 X 有 N 个类因依赖的库不在宿主类路径上而**跳过注册**(属可选能力...): [...] | PluginRuntimeManager.scanComponents | 可选能力被 isLinkable 摘掉了,插件本体仍在 |
插件路由已挂上宿主拦截器 N 个(登录 + SaCheckPermission / 开放接口租户 / 请求日志) | PluginHandlerMapping.attachHostInterceptorsOnce | 拦截器链挂上了;打不出这行就是裸奔 |
插件 X 已卸载:摘除接口 N 个,Mapper 与类加载器已回收 | PluginRuntimeManager.unload | 回收完成 |
插件运行时被配置为关闭(app-market.plugin-runtime.enabled = false)... | PluginRuntimeManager.loadAll | 那个"只在 yml 里改"的组合 |
相关阅读
- 必须遵守的不变量 —— 上面这些约束的清单版(权威版本是
wujies-platform/AGENTS.md§2) - 故障排查 —— 按现象查原因
- 应用市场模块 ——
wujies-app-market每个包的职责 - 应用生命周期 —— 落位 / 加载 / 迁移三者的先后顺序
- 跟着 hello 读源码 —— 范例应用的四个演示点
- 后端工程 —— 插件自己的目录结构与
pom.xml写法 - 前端远程应用 —— 前端那一半产物怎么被装载
界面截图

插件运行时拓扑(示意图占位)
/images/docs/plugin-runtime.png可画:宿主 classpath → PluginClassLoader → 子 Spring 上下文 → 路由/菜单。