Maven 多模块 Spring Boot repackage 让被依赖模块变成 fat jar 的编译失败问题
记录时间:2026-09-02
环境:Jenkins 2.568.1(K8s 动态 Slave,共享库jarHelmDevopsMultByk8s流水线)/ maven 容器gzeport-maven:3.8.6-openjdk-11/ 项目sccg-jar-gzeport-market-services(Spring Boot 2.5.14 多模块,spring-boot-maven-plugin 2.1.1.RELEASE)
一、问题现象
sccg 市场采购的 jar 项目 gzeport-market-services 做服务扩容:原来只部署 gzeport-market-service 一个服务,现在要加 gzeport-market-httpent-service、gzeport-market-httpbank-service 两个新服务。Jenkinsfile 的 GET_PROJECT_NAME 加了逗号分隔的三个服务后,httpbank 连续三轮构建失败(#34/#35/#38),全是同一个错:
[ERROR] package com.gzeport.market.server.service does not exist
[ERROR] /.../PosForeServiceImpl.java:[64,13] cannot find symbol
symbol: class MerchantShRegisterService
报错的全是 httpbank 的 service/impl 实现类,缺的接口(MerchantShRegisterService、ForeignBankQueryService、SettlementQueryService 这些)都在 com.gzeport.market.server.service 包下。httpent 没报错。奇怪的是开发本地 IDE 编译完全正常,一到 CI 就挂。
二、排查过程
2.1 前两轮判断(都错了)
第一轮怀疑版本错位:httpbank 对 gzeport-market-service 的依赖写死了 1.6.01-RELEASE,其他兄弟模块都是 ${gzeport.market.version},我判断 CI 会因此去私服拉旧 jar。业务改成属性引用后重跑(#35),报错不变——翻顶层 pom 才发现属性值本来就是 1.6.01-RELEASE,改前改后是同一个版本,判断依据不成立。
第二轮怀疑 com.gzeport.market.server.service 包在拆分后被挪进了别的模块(workspace 里有 gzeport-market-core 等顶层目录)。grep 业务源码定位,接口和实现全在 httpbank 已依赖的 gzeport-market-service 里,这条也排除了。
教训:看到「写死的版本号与属性值不同」要先核对属性实际值再下结论,我跳过了这步。
2.2 定位真凶:repackage 原地替换
包在依赖模块里、版本也对、reactor 也把 market-service 构建进去了,那问题只剩一个可能:httpbank 编译时拿到的 market-service jar 和源码构建出来的不是同一个东西。
我打开 gzeport-market-service 的 pom 看 build 段,找到了:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.1.1.RELEASE</version>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<includeSystemScope>true</includeSystemScope>
<mainClass>com.gzeport.market.server.MarketServerApplication</mainClass>
</configuration>
</plugin>
repackage 没配 <classifier>。这是 spring-boot 插件的默认行为:package 阶段把 target/gzeport-market-service.jar 原地替换成可执行 fat jar,自身类文件全部挪进 BOOT-INF/classes/,依赖塞进 BOOT-INF/lib/。jar 根目录下不再有 com/ 目录。
整条链对上了:
-am把 market-service 排进 reactor 并构建成功(它是启动模块,fat jar 正是它要的产物)- httpbank 编译时从 reactor 拿到这个 fat jar,jar 根下没有
com/gzeport/market/server/service/,报package does not exist - 全仓库 grep 下来依赖 market-service 的只有 httpbank 一家,所以只有 httpbank 挂
- 本地 IDE 能编过是因为 IDE 直接拿工作区源码解析依赖,不走 Maven 构件,把问题盖住了
对照其他模块,这个解释也自洽:common-service、file-storage-service 是纯库(没有 spring-boot 插件),被谁依赖都没事;httpent/httpbank 自己也配了 repackage,但没有下游消费者,fat jar 正是部署想要的形态。坑只留给被人依赖还配 repackage 的模块。
三、根因分析
gzeport-market-service 是「双身份」模块:既是 httpbank 的依赖库,又是独立部署的启动服务。两种身份对 jar 形态的要求互斥,repackage 没配 classifier 时只能二选一,选了部署形态就伤了依赖方。
修复思路就是让两种形态并存:给 repackage 配 <classifier>exec</classifier>,fat jar 另存为 -exec.jar,原 jar 保持纯库形态留在原位,依赖方和部署方各取所需。
四、解决方案
4.1 业务侧(根因修复)
gzeport-market-server/gzeport-market-service/pom.xml 的 spring-boot 插件配置加一行:
<configuration>
<classifier>exec</classifier>
<includeSystemScope>true</includeSystemScope>
<mainClass>com.gzeport.market.server.MarketServerApplication</mainClass>
</configuration>
注意:这个插件配置只能加在 market-service 自己的 pom 里。顶层聚合 pom 的
<build><plugins>是全局继承的(顶层那块还挂着 flatten 插件),加在那里会让 dao、core 这些纯库模块也执行 repackage,问题直接扩大到所有模块。注意:
<includeSystemScope>true</includeSystemScope>别顺手去掉,market-service 依赖了 system scope 的lib/iTextAsian.jar(PDF 中文字体),去掉后字体丢失。
4.2 流水线侧(连带适配)
CI 镜像阶段的产物准备原来是 mv *.jar app.jar,target 下只能有一个 jar。加 classifier 后 market-service 的 target 会有两个 jar,多源 mv 会报错。我改了共享库 vars/stageBuildAndPushImage.groovy 的产物检查逻辑:存在 *-exec.jar 时先删掉薄库 jar 再把 exec jar 改名为 app.jar;没有 exec jar 走原逻辑。
注意:第一版我写的是
mv *-exec.jar app.jar后跟rm -f ./*.jar,推演时发现app.jar自己也匹配*.jar,会把它一起删掉。改成find . -maxdepth 1 -name "*.jar" ! -name "*-exec.jar" -delete用排除法先清理,再 mv。这类通配符加删除的组合要过一遍展开结果再用。
我还去 plugin.xml 里确认过 spring-boot-maven-plugin 2.1.1 的 classifier 参数不支持 -D 命令行传参(整个插件只有 skip 支持),所以「流水线不动、命令行加参数」这条路不存在,pom 必须改。
五、验证
业务推送 pom 修改后重跑 #40,SUCCESS,3 分 1 秒。镜像阶段日志把两种分支都验证了:
market-service 的 target 出现双 jar,走新的 exec 分支:
-rw-r--r-- 1 root root 178005577 gzeport-market-service-exec.jar
-rw-r--r-- 1 root root 2102276 gzeport-market-service.jar
+ ls gzeport-market-service-exec.jar
+ find . -maxdepth 1 -name '*.jar' '!' -name '*-exec.jar' -delete
+ mv gzeport-market-service-exec.jar app.jar
httpbank/httpent 没有 exec 命名,走原通用分支(ls '*-exec.jar' 无命中后 mv xxx.jar app.jar),行为与之前完全一致,零回归。三个镜像全部推送成功:gzeport-market-service / httpent-service / httpbank-service : dev-20260902-40。
httpbank 编译通过本身就说明纯库形态的 jar 回到了 reactor classpath,整条根因链都对上了。
六、注意事项
- httpent 为什么没事:它的 pom 根本没引 market-service,依赖链上没有任何被 repackage 污染的构件。判断这类问题先画依赖图,谁消费了「被打成 fat jar 的模块」,谁就会炸。
- 这个结构本身就是设计问题:market-service「既被人依赖、又自己部署」是微服务拆分拆了一半的形态。加 classifier 是止血,正解是把接口抽成独立 api 模块(如
gzeport-market-api),httpbank 依赖接口走远程调用,而不是进程内 import 实现。还有个运行期隐患:httpbank 的 fat jar 里现在塞着整个 market-service(178MB 里有它一份),改了 market-service 代码后 httpbank 不重新构建就还在跑旧逻辑。 - 类似项目的提前识别:多模块 jar 项目接入 CI 时,如果某个服务既出现在部署清单里、又出现在其他服务的 pom 依赖里,提前让业务加 classifier,别等编译失败再排查。
七、参考资料
- spring-boot-maven-plugin repackage 参数说明(classifier 语义:不配则原地替换主构件,配了则附加为补充构件):https://docs.spring.io/spring-boot/docs/2.1.1.RELEASE/maven-plugin/repackage-mojo.html
- 验证命令:
mvn -pl <模块> -am clean package后看 target 下 jar 的目录结构(unzip -l xxx.jar | grep BOOT-INF判断 fat jar)