从宿主模块抽成应用(SOP)
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页解决一个问题:已经在宿主里写好的业务模块,怎么搬成插件应用。
比从零写一个应用多两件事:改宿主消费方(宿主不能再编译期引用它)与存量迁移 SQL(已经装了这套系统的客户,菜单和数据要继续可用)。
范围先说清楚:抽模块不等于拆契约。当前架构下应用是自包含工程,不存在"先拆一个契约模块"这一步。看到"契约放 *-api""XML 必须放 *-api"的说法都是历史写法(2026-09-30 前的结构),见必须遵守的不变量第 2 条。
wujies/wujies-plugins/wujies-hello/ 是可运行的范例应用(后端 + frontend/hello-app/),四个演示点——调用宿主系统服务、跨插件调用、从插件开放 /open 接口、两种定时任务——都写成了能跑的代码。照着读一遍比看十页文档快,见跟着 hello 读源码。
现状:八个模块已完成
| 模块 | 应用编码 | 版本 | 备注 |
|---|---|---|---|
| 报价管理 | quote | 1.0.1 | 第一个走完全程的模块,步骤可直接复用 |
| 企业数据 | enterprise | 1.0.1 | 1.0.0 漏搬前端,1.0.1 才补回 |
| 会员管理 | member | 1.0.1 | 菜单 68 行,必须生成而不是手写 |
| 支付管理 | pay | 1.0.0 | 唯一带第三方 SDK 的模块 |
| 考试系统 | exam | 1.0.1 | |
| 即时通讯 | im | 1.0.0 | 同一 perms 在多行菜单重复出现,key 要父键限定 |
| 社交互动 | social | 1.0.1 | 菜单要整棵树取,中间目录没有应用前缀 |
| 商城管理 | mall | 1.0.1 | 原本 6 个 Maven 子模块,合并成一个 jar |
另有纯前端应用 finance(财务管理):清单只写 assets.frontend,包里没有 jar,读的是 mall / member 现成的接口。
动手前的三点共识
- 一个应用 = 一个自包含工程:实体 / BO / VO / Mapper 接口与 Mapper XML / Service 接口与实现 / controller / listener / task /
app.json/migrations/全在wujies/wujies-plugins/wujies-<code>/下。 - 平台对
cn.wujies.<app>.*的 Java 引用为 0。宿主消费方要拿插件服务,只能走定位器或事件,不能import。 - 平台依赖一律
provided:由宿主类加载器在运行期提供。父 POMwujies-plugins-parent已在dependencyManagement里统一成provided,插件自己的pom.xml里通常不必再写(wujies-hello/pom.xml就是这种写法)。
十个步骤
| # | 步骤 | 关键点 |
|---|---|---|
| 1 | 契约直接写进插件 | 实体 / BO / VO / Mapper 接口 / Service 接口 / Mapper XML 全在 wujies-plugins/wujies-<code>/src/ 下。没有"先拆 *-api 进 reactor"这一步 |
| 2 | 实现在同一个模块 | controller + service.impl + listener + task 与契约同模块;实测 jar 里只有实现类 |
| 3 | 改宿主消费方 | 删掉根 pom dependencyManagement、wujies-modules、wujies-admin 里对该模块构件的引用;调用点改成定位器 / 事件(见下节) |
| 4 | 写清单 app.json | 菜单树含 F 按钮,perms 与库里既有行逐条对齐(别自己发明);C 菜单 component 写 remote:<code>/<视图键>。字段含义见应用清单 app.json |
| 5 | 建表脚本 migrations/01-*.sql | 从库里 SHOW CREATE TABLE 导出 → 改成 CREATE TABLE IF NOT EXISTS。它同时是安装建表与彻底删除时删表的唯一依据 |
| 6 | 前端 wujies-plugins/frontend/<code>-app | 调 frontend/build/vite.ts 的 defineRemoteApp({...});src/index.ts 返回 {manifest, views}。见前端远程应用 |
| 7 | 打包签名 | 在插件仓库根:.\package.ps1 -Plugin wujies-<code>(本机有 keys/private.pem 时自动签名)。见打包构建 |
| 8 | 上架 | POST /app/package/parse(multipart,落 OSS 并回填校验和)→ POST /app/version → POST /app/version/{id}/publish |
| 9 | 存量迁移 SQL | wujies-platform/script/sql/update/app_market_<m>_migrate.sql:应用登记改 plugin + 上架、菜单归属 seed、存量租户回填 enabled 安装实例。见下节 |
| 10 | 验证 | 见下节 |
三条边界:搬实现时哪些必须留在宿主侧
这是本页最容易做错的部分。"留宿主"的判据只有一条:这份东西的消费方可能是另一个插件或宿主本身,而它们看不见插件。
| 东西 | 放哪 | 为什么 |
|---|---|---|
| 实体 / BO / VO / Mapper 接口 与 Mapper XML / Service 接口与实现 | 插件自己目录里 | 应用自包含;平台对 cn.wujies.<app>.* 的引用为 0。XML 不再需要放宿主 —— 由 PluginMapperRegistrar 在插件加载时注册进宿主 SqlSessionFactory |
| 平台 SPI 的实现 | 宿主侧(wujies-common-*) | 消费方可能是另一个插件。插件的 parent 是宿主,看不见兄弟插件。样板:契约 MenuOwnershipProvider 在 wujies-common-core,实现 AppMenuOwnershipProvider 在 wujies-app-market |
| 跨插件事件类 | 宿主侧(wujies-common-core 的 cn.wujies.common.core.domain.event) | 订阅方按自己类加载器里那个 Class 对象匹配。事件类跟着发布方插件走 → 第二个 Class → 永远不匹配,且不报任何错 |
| 端点 | 按归属拆:应用专属的进插件,平台泛化的留宿主 | 见下 |
边界一:XML 随插件 jar 走,别绕回 mapperLocations
PluginMapperRegistrar 只读传入的那个 jar,刻意不用 classpath*:mapper/**/*Mapper.xml —— 那个写法是拿宿主类加载器扫 classpath,会把宿主的映射文件也捞一遍,再注册就撞 Result Maps collection already contains value,宿主直接起不来。
卸载时还要把 MyBatis StrictMap 里的短名一起摘掉,否则 MappedStatement 持有接口 Class、Class 持有类加载器,每装一次插件泄漏一个类加载器。这两条有回归测试 PluginMapperRegistrarTest 盯着。
边界二:跨插件事件类放 wujies-common-core
- 插件
publishEvent会冒泡到宿主的监听器,这是 Spring 自带行为; - 但反向不成立:宿主或兄弟插件发的事件不会自动进入某个插件子上下文。商城抽成插件时,
MallPaySuccessListener会静默失效(订单付完停在"待支付")——是PluginEventRelay才把宿主事件转发进各子上下文; - 事件类必须由宿主类加载器加载,现在的位置是
wujies-common-core的cn.wujies.common.core.domain.event(PaySuccessEvent/PayRefundSuccessEvent/UserRegisteredEvent/ProcessEvent都在这里); - 回归测试
PluginEventClassLoaderTest用两个真正独立的类加载器钉住这条:事件类一旦被挪回插件模块,测试就失败; - ⚠️ 事件里不要塞继承
TenantEntity的实体(wujies-common-core不能依赖wujies-common-tenant,成环)。正确形态是扁平 POJO 只带标量,需要实体的一方自己按 ID / 单号重查。
边界三:端点的归属
宿主的 wujies-portal / wujies-openapi 已经被移除(归档在 wujies-platform/docs/removed/),端点现在是按归属分开放的:
| 类型 | 放哪 | 当前实例(/portal/** 与 /open/** 的全部归属) |
|---|---|---|
| 单个应用专属的端点 | 搬进插件,插件内直接注入自己的服务(少一跳定位器);插件没装时这些路由根本不存在,不会留下"能路由、一调就报没装"的空壳 | 商城 PortalMallController(/portal/mall)、会员 PortalMemberController(/portal/member) 与 PortalMemberAddressController(/portal/member/address)、报价 PortalQuoteController(/portal/quote)、社交 PortalFollowController(/portal/follow)、企业 PortalEnterpriseCompanyController(/portal/enterprise/company)、考试 OpenExamController(/open/exam)、图床三个 Gallery*OpenController(/open/gallery/*)、商城两个 OpenMall*Controller(/open/mall*) |
| 平台泛化的端点 | 留在宿主(读的是平台表、没有任何应用业务逻辑,搬进 N 个插件就是把同一段代码抄 N 遍) | OpenWebsiteConfigController(/open/{code}/config)、PortalUserProfileController(/portal/user)、OpenMessageController(/open/system/message),都在 wujies-modules/wujies-system |
| 应用自己的公开配置模板 | 插件提供 sys_config 行(config_category = 应用编码),端点仍是上面那个泛化模板 | 新应用接进来不用改平台代码,见种子数据与站点配置 |
搬走的规矩:
- 被搬的 DTO / 模块内服务要一起进插件(报价那轮搬了 4 个 DTO、
MemberInfoVo、FollowUserVo、MobileMemberService等),否则会逼出"插件反向依赖宿主业务包",那是最坏的方向; - 端点路径一个字节都不变,前端与第三方零改动;
- 搬完必须确认
/open/**的租户拦截器仍生效 —— 插件控制器挂在独立的PluginHandlerMapping上,只有挂上宿主拦截器链之后才拦得到;@SaIgnore(开放接口)与@SaCheckLogin也要在挂上链之后复核一遍; - 凡是宿主侧替插件兜底 / 代理的代码,都要先问一句"插件装了没"。样板:社交被彻底删除会 DROP
social_follow,此时再去查表就报"表不存在",表现为图床报 500。做法是先问PluginServiceProvider.isPluginLoaded("<code>"),没装就返回空集。把"没装"当成一种正常状态,而不是异常。 - ⚠️ 平台 SPI 的实现仍然必须在宿主侧(本页"三条边界"第二行)。当前仓库里有一个反例:
FollowRelationApi的契约在wujies-common-plugin-sdk,而实现FollowRelationApiAdapter却在wujies-social插件里,消费方是图床(GalleryImageServiceImpl用ObjectProvider<FollowRelationApi>)。按"插件只看得见父上下文"的不变量,图床在宿主里找不到这个 bean,拿到 null 后静默降级。搬模块时别照抄它:SPI 实现放宿主侧才是与不变量一致的做法。PluginServiceProvider在wujies-common-core(wujies-common-core/.../core/plugin/),isPluginLoaded(appCode)与getPluginBean(appCode, type)都在那里。
第 3 步的细节:改宿主消费方
先删 *-api 引用,再谈调用点
当前架构里宿主的 dependencyManagement、wujies-modules、wujies-admin 本来就不该有 wujies-<m>-api 这一行。如果你在改造中途看到它,说明这份代码还是旧结构,直接删。
宿主拿插件服务的三种场景
| 场景 | 做法 |
|---|---|
| 宿主代码要调插件 Service | cn.wujies.common.core.plugin.PluginServiceLocator 的 proxy(...)(返回每次调用都反查的代理),或 PluginServiceProvider.getPluginBean(appCode, type) |
| 宿主只想"通知",不想编译期持有插件类型 | 走事件:宿主发事件,PluginEventRelay 转发进插件子上下文。会员注册就是这么改的(UserRegisteredEvent) |
| 插件内部(定时任务、controller 搬进来之后) | 直接构造注入,不必再绕定位器。定位器存在的理由是"宿主注入不到插件 bean",插件里没这个问题 |
⚠️ 绝对不要在宿主上下文里注册与插件实现同类型的 bean(哪怕是"转发代理"):Spring 按类型注入会把父上下文的同类型 bean 一起算作候选 → 插件的控制器启动即报 expected single matching bean but found 2。
Mapper 不用改,Service 才要
Mapper 接口与 XML 都在插件里,但接口是注册进宿主 bean factory 的,所以插件之间可以直接注入兄弟的 Mapper(HelloDemoCrossPluginController 就注入了 MemberMapper)。只有 Service 接口要走定位器。
⚠️ 代价要清楚:直接用兄弟的 Mapper 等于隐式依赖对方的数据表,表结构变了你先坏。只在确实要联合查询时才这么干。
排查注入点用对正则
查注入点用 I?Xxx\w*Service,别用 \w+ —— IMemberService 中间没有词,IMember\w+Service 会把它整个漏掉。漏掉的表现是运行期"找不到 bean",编译期不报。
定位器别抄
PluginServiceLocator 在 wujies-common-core;跨插件的定位器(MemberServiceLocator / PayServiceLocator / ImServiceLocator)在 wujies-common-plugin-sdk,插件不需要额外依赖就能用。各应用只写二十来行的薄包装。
第 4 / 5 步:清单与迁移脚本
- 菜单多的时候要"生成"而不是手写(会员 68 行、商城 176 行)。
app.json与 seed SQL 的menu_key必须由同一份数据生成,手写两份必然漂移,而漂移的后果是升级时建出一套重复菜单。写法见菜单与权限。 menu_key只能是小写 kebab:perms 里的 camelCase(member:member:adjustPoints)直接转 key 会在上传时被拒。做法是驼峰前插-再整体小写。- 同一个
perms可能在多行菜单重复出现(im 那轮 19 个 perms 重复 2~3 次)。F 行 key 要用父键限定(im-workbench-<尾>/im-conversation-<尾>),并断言同版本内 key 唯一。 - 菜单要"从根整棵取",别按
perms LIKE '<code>:%'+path过滤:社交在根目录和页面之间有两个中间目录,既没有应用前缀的 perms、path 里也不含social,过滤会把它们连同下面 11 个页面一起丢掉。用递归 CTE 从根往下取。 - 建表脚本只做
up、必须幂等、执行过就不能改(改了会被 checksum 拒绝执行),四条硬规矩与文件命名见数据库迁移;字典 / 站点配置随包走的写法见种子数据与站点配置。 - 库里存在、但代码里根本没有的菜单不要收进清单 —— 收了等于让全新安装也建出一堆点不开的菜单。
- C 菜单可以指向宿主页面(
component写dashboard/overview/index而不是remote:<code>/...)。这不是漏洞,是刻意的:那个页面本来就住在宿主。见应用清单 app.json。
第 9 步的细节:存量迁移 SQL
脚本模板:wujies-platform/script/sql/update/app_market_<m>_migrate.sql(enterprise / exam / gallery / im / mall / member / pay / quote / social 各一份,含回滚段与校验查询)。四件事:
- 应用登记改
plugin+ 上架:app_type必须是plugin。module类型的安装器只管注册菜单、不会加载 jar,那样"装上了但接口全是 404"。 - 菜单归属 seed:把清单里的
menu_key映射到库里既有的menu_id。不先种进去,同步会给这些菜单再建一套重复的。 - 存量租户回填
enabled安装实例。 - 校验查询:脚本末尾的校验段要留,尤其"有映射但没有已启用安装实例的租户应为空"这一条。
菜单可见性的判据是「有归属映射,但没有已启用的安装实例 → 隐藏」。只写映射不写安装实例,菜单会立刻消失。
存量部署上「安装」走不通
迁移脚本回填的状态是 enabled,而状态机里 enabled → installing 非法(实测报「非法的应用状态迁移」);升级到同一版本也会被拒(「当前已是该版本」)。所以落地只有两条路:
- 在上架端点**「部署」**(落位 + 热加载);
- 发布一个更高版本后升级。
给客户交付时建议直接发布 1.0.1,让"升级"成为自然路径。
菜单来自数据库,路由来自 jar。本机 plugin-dir 里没有那个 jar 时,前端点进去就是整片 404(实测:plugins-dist 里只有 gallery,/member/member/detail/user/1 报"请求地址不存在",而 /gallery/image/list 正常)。
补产物走「部署」;对已经在运行的实例它是热加载,不用重启。
回填的部署从没跑过菜单同步,于是抽离时改过的 component(宿主路径 → remote:<code>/<视图键>)会永远停在库里,前端表现为 [Vue Router warn]: Record with path "..." is either missing a "component(s)"。
补法:跑一条幂等的修正 SQL,或让该应用升级一次。
安装只把菜单授给安装者所属角色。原本"谁有底层数据权限谁就能看到页面"的设计要靠补一条授权 SQL 还原(样板:script/sql/update/finance_menu_grants.sql,幂等,按 menu_key 定位菜单)。
第 10 步:验证什么
- 部署报告里
hotLoaded=true,hotLoadNote里有组件与接口数(实测形如「插件 quote 已加载:组件 4 个,接口 16 个」) - 插件自己的接口能返回 200 且有真实数据
- 宿主消费方也要打一次 —— 找那个走定位器代理的 portal / 业务接口直接调,拿到真实数据说明注入点真的通了;若报「应用『xxx』未安装或未启用」,说明定位器或插件加载有问题
-
sys_menu里这个应用的菜单行数不变(没有重复建)。升级路径的菜单同步是幂等的:新版本映射与旧版本应逐条同menu_id - 插件接口不带 token 要 401,带 token 才是 200(证明拦截器链挂上了,见必须遵守的不变量第 7 条)
- 被搬进插件的
/open/**端点匿名仍是 200,租户拦截器日志照旧打印 - 前端页面能打开、样式正常、
K*组件不是"只剩文字" - 在宿主前端仓库跑
npm run audit:menus,没有新增死菜单 - 改过
.vue的话跑npm run check:globals(自动导入宇宙的守门脚本) - 只验证到编译通过时,要说清"只验证了编译"
六个真实踩过的坑
1. 前端要么跟着搬,要么把菜单一起删掉
留一条指向"本分支根本没有的页面"的菜单,是最难发现的一类坏数据 —— 它由应用清单声明,所以全新安装也会照建。
实测:enterprise 被当成"纯后端应用"(清单里没有 assets.frontend,菜单里那行 C 的 component 还是宿主路径),那个菜单永远点不开,控制台报「菜单的 component 解析不出来,已跳过该路由」。1.0.1 才补回前端并走升级修正。
纯前端应用是合法形态:finance 清单只写 assets.frontend、不写 assets.backend,package.ps1 会跳过后端构建与 jar 组装(不需要 JDK / Maven,但签名仍要 JDK,签名工具是 Java 写的)。
2. "多子模块模块"必须合并成一个 jar
一个应用 = 一个插件 jar = 一个子上下文。商城原本是 6 个 Maven 子模块,合并成一个 wujies-plugins/wujies-mall 单 jar;子模块之间的调用在插件内就是普通注入。原来那 5 个 META-INF/spring.factories 随之删掉 —— 插件运行时是按注解扫 jar 条目注册 bean,不看 spring.factories。
3. 插件的 RabbitMQ 队列声明要写在宿主
子上下文里声明的 Queue / Exchange / Binding,宿主的 RabbitAdmin 看不见、不会声明到 broker,表现是"插件加载成功、消息发不出去 / 消费者起不来"。图床、社交、商城的队列声明当初就是这么搬进宿主的 wujies-common-rabbitmq(.../rabbitmq/config/RabbitMQConfig.java),插件侧只留 @RabbitListener。判定标准:队列 / 交换机 / 绑定关系写宿主,消费逻辑写插件。
反过来,插件的 @RabbitListener / @Scheduled 本身没问题:运行时会给子上下文补上必要的基础设施(注册的是 BeanPostProcessor 本身,所以插件里不需要写 @EnableScheduling)。wujies-hello 的 HelloDemoScheduledTask 是可直接对照的最小例子。
4. 同一个应用别留下两个版本的 jar
留下的旧版本删不掉时,下次重启会因重复 mapper XML 直接起不来。本机已有旧版本的(例如库里 1.0.5、本机 1.0.3)要么先停宿主再部署,要么部署后删旧 jar 再重启。wujies-platform/script/prune/start-host.ps1 会先跑同目录的清理脚本,并用 active-plugins.txt 记录运行期真正加载的那一个。
5. 控制台「部署」不改安装记录的 version_id
「部署」只做落位 + 热加载。落位新版本时会清掉旧版本的 jar,于是「部署 1.0.1 → 停用 → 启用」会报「插件 jar 不存在: …-1.0.0.jar」(安装记录还指向 1.0.0)。要动安装记录就走升级:POST /app/install/{installId}/upgrade?versionId=…。
6. 改后端代码后必须先重建宿主 jar 再验证
只跑 mvn compile(哪怕成功)不会更新 wujies-admin.jar,你测的是旧代码,很容易得出"功能没生效"的错误结论然后去改本来是对的实现。另外不要在别人的 JVM 活着时跑 mvn install(会清空输出目录,正在运行的 JVM 抛 NoClassDefFoundError);新增测试必须带 @Tag,否则根 pom 的 surefire <groups> 会让它根本不执行,症状是看着全绿、总数不变。
相关阅读
- 必须遵守的不变量 —— 改代码前先扫这一页
- 跟着 hello 读源码 —— 四个演示点的真实代码
- 应用清单 app.json
- 后端工程
- 前端远程应用
- 菜单与权限
- 数据库迁移
- 种子数据与站点配置