Wujies 无界平台
平台功能(自研)架构设计

插件运行时

这一页解决一个问题:一个插件 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-dirwujies-platform/wujies-admin/src/main/resources/application.ymlplugins-dist
同一目录的启动参数wujies-platform/script/prune/start-host.ps1 拼出的 -Dloader.pathplugins-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 的 PluginClassPathLoaderJVM 启动瞬间扫 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 里没有这个配置项,靠注解默认值兜底。

必须用 JVM 参数切换,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) 在「彻底删除」时擦掉。

2026-10-02 起,两个加载时机都按它过滤(旧结论已不成立)

在此之前只有运行期选版本,JVM 启动瞬间是「目录下所有 jar 全挂上去」。两套判据不一致, 于是不在清单里的残留 jar 也会被打开,形成一条自我循环的锁死链:

① 彻底删除时 jar 删不掉(正被运行期加载器打开)→ 残留留在 plugins-dist → ② 下次启动 → 启动期"全挂" → 残留 jar 被启动期加载器打开 → ③ 重装 → 覆盖同一个文件名(Windows 上覆盖允许、删除被拒)→ 启动期加载器手里的句柄指向被改过的字节 → ④ 卸载只关得掉 runtime 自己的加载器 → 删除再次失败 → 回到 ①。

所以**「带着残留重启会起不来」那个老坑已经消失** —— 两个版本的 jar 不会再被同时挂上。 实测旁证:修之前 mall 的 jar 在宿主运行时独占打开失败、停掉宿主后立刻可以打开, 而它的 active-plugins.txt 记录早已被移除 —— 说明打开它的只能是"启动期全挂"。

三处实现读的是同一个文件名,判据一致:

实现方法读法
PluginRuntimeManagerreadActiveJars()appCode=文件名,跳过空行与 # 注释
PluginClassPathLoaderactiveJarNames(dir)同上;也容忍只有文件名的写法,跳过空行与 #
PluginClassLoaderactiveJars(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  →  mall
启动脚本的工程根推断目前是错的(脚本注释与 AGENTS 的描述不符)

script/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 列表,不因为委托失败而让原本能解析的类解析不了。

为什么必须委托,而不能"各挂各的 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 / task

createContext 里三个顺序不能变

PluginRuntimeManager.createContext(...) 的注释写得很明确:

  1. setClassLoader 必须在 refresh() 之前 —— prepareBeanFactory 会用它设置 beanFactory.setBeanClassLoader(...),而 @ConditionalOnClass 这类条件判断正是通过 BeanFactory 的类加载器去加载注解里的类的。设晚了就永远找不到插件类;
  2. setParent(hostContext) —— 插件 bean 才能注入宿主的 SqlSessionFactory / 平台服务;
  3. 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 条目);与定位器该放哪个模块的关系见跨插件调用。

收进上下文之前先做一次 isLinkable 检查

插件里有些类是可选能力,它们引用的类型在宿主运行期并不存在。典型两个真实例子:

  • 定时任务类用 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 认不认识这些 SQLPluginMapperRegistrar.register(appCode, classLoader, jar)宿主 Configuration
Spring 容器里有没有这个 beanPluginRuntimeManager.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)。

Mapper XML 的注册刻意不用 classpath*:mapper//*Mapper.xml

那个写法是拿宿主类加载器扫 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-coreSPI,PluginRuntimeManager 自己就实现了它;插件未加载或没有该 bean 返回 null
XxxServiceLocator(wujies-common-plugin-sdk)SDK薄包装,返回每次调用都反查的代理

PluginRuntimeManager.isPluginLoaded(appCode) 用来把「没装」与「装了没这个 bean」分开, 才能给出「请到应用市场安装」这种能照着做的提示。PluginClassLoaderProvider.livePluginClassLoaders() 返回每次调用重新取的快照(给 Redis 的 Jackson 按全名加载插件 VO 用)—— 缓存它会让卸载后的类加载器永远回不去。

不要在宿主上下文里注册与插件实现同类型的 bean

哪怕是"转发代理"也不行。Spring 按类型注入走 beanNamesForTypeIncludingAncestors, 会把父上下文的同类型 bean 一起算作候选 → 插件的控制器启动即报 expected single matching bean but found 2。

事件中继:跨上下文的事件是单向的

Spring 的事件传播是单向的:AbstractApplicationContext#publishEvent 会把事件再交给 parent, 所以插件发的事件能到宿主;反过来,宿主(以及兄弟插件)发的事件进不了某个插件子上下文。

后果很具体:mall 抽成插件后,它的 MallPaySuccessListener / MallPayRefundSuccessListener 会静默失效(订单付完停在"待支付",没有任何报错)。所以宿主侧有 PluginEventRelay 把宿主收到的事件转发进各插件子上下文。

三个不能改坏的点:

  1. 转发用 multicastEvent 而不是 publishEvent —— 后者会再向 parent 冒泡一次, 宿主监听器收到两遍。实现是 PluginApplicationContext.multicastLocal(...), 它按 bean 名 applicationEventMulticaster 取本地多播器(getApplicationEventMulticaster() 是 protected);
  2. 必须排除事件的来源上下文 —— 插件 publishEvent 时它自己已经本地多播过一遍, 中继再转发回去,发布插件的监听器就收到两遍。实测后果是支付时 PayOrderListener 建出 两条 pay_notify_task、商户回调被调两次。来源靠 PluginApplicationContext 覆盖两个 publishEvent 重载、由 PluginEventOrigin 记录 —— Spring 是「先本地多播、返回后再冒泡父上下文」,中间没有可插入的钩子,只有在外层入口上记才记得住;
  3. 不中继 org.springframework.* —— 否则每次子上下文 refresh 的 ContextRefreshedEvent 都会灌进每个插件。另有线程内标记 RELAYING 防回环。
跨插件事件的类必须住在 wujies-common-core

事件类型匹配认的是「事件对象的 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。

两处配套,缺一不可:

  1. PluginHandlerMapping.registerController 在注册任何路由之前调 attachHostInterceptorsOnce(), 把宿主 mapping 的 getAdaptedInterceptors() 复制过来 —— 复制的是实例(含 MappedInterceptor 包装),所以 /open/** 这类路径匹配规则与 SaInterceptor 的 exclude 语义原样保留。

    ::: warning 只 setInterceptors 不 initInterceptors 不生效 setInterceptors(...) 只是把原始对象塞进 interceptors,adaptedInterceptors 由 initInterceptors() 构建;本 bean 在容器启动时已经 afterPropertiesSet 过, 所以必须显式再 init 一次。 :::

  2. AllUrlHandler 的登录清单要同时收「宿主 mapping + 插件 mapping」,且 PluginRuntimeManager 在 load / unload 后调 refreshLoginCheckUrls()。 原因是 SaInterceptor 的登录检查是 SaRouter.match(allUrlHandler.getUrls()) —— 清单里没有的路径直接跳过。

验证这类改动时不要再叠 -Dloader.path

那会把插件类同时注册到宿主 mapping 上,卸载摘不掉它们,表现为「停用了接口还能用」。

实测(热加载与启动期加载两条路都验过):

用例结果
插件接口不带 token{"code":401}
插件接口带 token200
插件里 @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。
注销 Mapper 必须排在"关类加载器"之前

宿主的 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 里改"的组合

相关阅读

界面截图

插件运行时拓扑(示意图占位)

插件运行时拓扑(示意图占位)

/images/docs/plugin-runtime.png

可画:宿主 classpath → PluginClassLoader → 子 Spring 上下文 → 路由/菜单。

目录