尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Maven多模块打包成一个可执行胖JAR实战指南

Maven多模块打包成一个可执行胖JAR实战指南 多模块项目在本地跑得好好的一到打包环节就犯愁——mvn package一下target目录里躺着七八个 JARcommon.jar、dao.jar、service.jar、web.jar各占一个坑。部署的时候你得挨个往服务器上拷启动脚本里拼一长串classpath少写一个路径就报ClassNotFoundException。更头疼的是交给运维或者交付给客户的时候人家只想要一个能直接跑起来的文件而你塞过去一个压缩包还得附一份哪个是主包、哪个是依赖的说明文档。这个场景我在好几个项目里都撞上过尤其是那种 Spring Boot 起步、后来业务膨胀拆成多模块的中后台系统。拆分本身没错模块化让代码边界清晰、编译更快、团队协作不打架但拆分的收益不该以打包部署的复杂度为代价。所以Maven 多模块项目只打成一个 JAR 包这件事本质上是一道权衡题既要保留源码层面的模块划分又要在产物层面收敛成一个可执行的胖 JARfat jar。这篇内容我打算把整条链路讲透——从 Maven 为什么会打出多个包、合并的几种路线怎么选到手把手的插件配置、主类入口怎么写、META-INF里的资源怎么合并再到我实际踩过的签名冲突、Spring 配置文件被覆盖这些坑。适合正在用多模块结构做 Java 后端、被打包部署问题卡住的开发者也适合想搞清楚胖 JAR 到底是怎么来的的入门朋友。读完你能直接照着配置跑通不用再翻一堆零散的帖子。1. 先搞清楚多模块项目为什么会打出多个 JAR1.1 Maven 的默认打包行为拆解要解决问题先得理解问题的成因。Maven 的多模块multi-module本质上是一个聚合 继承的工程结构父工程通常packaging为pom负责统一管理依赖版本、插件版本和编译配置各个子模块通过parent指向父工程各自拥有独立的pom.xml。mvn package执行时Maven 会按照模块间的依赖顺序逐个对每个子模块调用打包生命周期每个packaging为jar的模块都会在自己的target下生成一个独立的 JAR。这就是多模块必出多个 JAR的根本原因——打包动作是按模块维度触发的而不是按整个工程维度。你的common模块编译出一堆工具类和实体打成common-1.0.jardao模块依赖common打成dao-1.0.jarservice依赖dao打成service-1.0.jar。这些包之间是引用关系不是包含关系。service的 JAR 里没有dao的字节码运行时必须把这一串 JAR 都放到classpath上才能跑起来。理解这一点很关键。很多人误以为打包成一个 JAR 是把多个包拼在一起其实更准确的说法是让某一个模块通常是启动模块在打包时把它自己以及它依赖的所有模块、所有第三方库的字节码全部解压后重新组织进一个 JAR 里再指定一个主类入口。这个过程业界叫 fat jar 或者 uber jar。1.2 多 JAR 部署带来的真实麻烦为什么大家不愿意接受多 JAR我总结下来主要是这么几类痛点都是实际项目里真金白银换来的教训。第一类是部署成本。多 JAR 意味着你要维护一份哪些包是运行必需的清单构建产物变了、模块增删了清单就得跟着改。有一次我们上线前临时加了个模块结果运维的部署脚本没更新应用起不来排查了半小时才发现是这个原因。第二类是版本错配。多个 JAR 各自带版本号common-1.0.jar和common-1.1.jar同时出现在服务器上很容易出现更新了 A 包但忘了更新 B 包的情况导致运行时行为诡异。合并成一个包之后版本是一体的冲突的可能性大幅降低。第三类是启动脚本复杂。Windows 和 Linux 的 classpath 分隔符不一样;和:写启动脚本时经常翻车。而一个可执行 JAR 可以简单到java -jar app.jar甚至在MANIFEST.MF里配好Main-Class后双击就能跑。第四类是交付友好。给客户或者给测试环境扔一个文件比扔一个目录树清爽得多。这一点在运维规范比较严格的团队里特别重要。提示合并成单 JAR 并不是所有场景都适用。如果多个模块是需要独立部署的服务比如微服务架构下的不同应用那就应该保持各自独立的包。本文讨论的是逻辑上属于同一个应用、只是源码层面拆了模块的场景。1.3 合并方案选型的整体思路打成一个 JAR听起来是一件事实际上有好几条技术路线选错了后面全是坑。我先把主要的路线摆出来对比一下后面再展开讲推荐方案的细节。方案核心插件适用场景主要缺点Shade 方案maven-shade-plugin需要合并多模块 第三方依赖控制精细配置项较多资源合并需手动处理Assembly 方案maven-assembly-plugin老项目、简单合并资源合并能力弱重复文件处理粗暴Spring Boot 方案spring-boot-maven-pluginSpring Boot 项目强绑定 Spring Boot 结构自定义 jar 插件maven-jar-plugin 依赖拷贝需要外置依赖瘦包不生成胖 JAR仍需多文件这里有个常见的认知误区Shade 和 Assembly 不是一回事。Assembly 更像按一个描述文件把文件收集起来塞进一个压缩包它对类文件的去重、资源文件的合并处理比较粗糙遇到两个 JAR 里有同名文件比如META-INF/MANIFEST.MF时往往直接取第一个容易丢东西。而 Shade 插件在设计上更懂合并这件事它能把多个 JAR 的字节码重新组织并且提供了ResourceTransformer机制来优雅处理资源文件冲突。所以对于多模块 有第三方依赖的中大型应用我基本无脑选 Shade。那 Spring Boot 项目呢Spring Boot 自己有个spring-boot-maven-plugin执行repackage目标也能生成一个可执行的 fat jar。它和 Shade 的区别在于包结构Spring Boot 用的是自定义的嵌套 JAR 结构BOOT-INF/lib/、BOOT-INF/classes/启动时由它自己的JarLauncher引导。这种方式对 Spring Boot 生态兼容性最好但不适合非 Spring Boot 项目。下面我会以 Shade 为主线讲解同时把 Spring Boot 场景的差异点单独拎出来说。2. 用 Shade 插件合并多模块的核心配置2.1 项目结构与职责划分假设我们有这样一个典型的多模块工程结构如下demo-parent ├── pom.xml (packaging: pom父工程) ├── demo-common (工具类、常量、实体) │ └── pom.xml ├── demo-dao (数据访问层) │ └── pom.xml ├── demo-service (业务逻辑层) │ └── pom.xml └── demo-web (控制器、启动类依赖 service) └── pom.xml打包的职责落点很明确只有demo-web这个末端的启动模块需要配置 Shade 插件。其余模块保持packaging: jar不动正常被依赖即可。为什么只在启动模块配因为 Shade 插件的shade目标会把当前模块的字节码 当前模块所有compile/runtime范围的依赖一起打进输出包。demo-web依赖demo-servicedemo-service又依赖demo-dao和demo-common这种传递依赖关系会让 Shade 在打包demo-web时自动把整条依赖链上的模块字节码都收进来最终一个包解决所有问题。这里有个细节要注意模块之间的依赖范围必须是compile默认或者runtime。如果你图省事把某个模块依赖写成了providedShade 就不会把它打进去运行时会报类找不到。我在早期项目里就因为随手给demo-common标了provided而踩过坑当时业务代码没直接引用common里的类通过反射调用编译期不报错运行期才炸。2.2 父 POM 里要统一管理的东西虽然 Shade 配在启动模块但有几个东西放在父 POM 里统一管理会省心很多这也是多模块工程的最佳实践。一是统一版本号。用properties定义revision或者各模块统一版本避免子模块之间出现版本错配。二是统一编译插件确保所有模块用同一个 JDK 版本编译maven-compiler-plugin配好source和target。三是**dependencyManagement**把第三方依赖的版本锁死子模块引用时只写groupId和artifactId不写version。这些做法看起来和打成单 JAR没直接关系但影响很大。合并打包时如果不同模块悄悄引入了同一个库的不同版本Shade 处理重复类时就得做取舍默认取先出现的那个可能埋下运行时行为不一致的雷。统一在父 POM 锁版本能把这个风险压到最低。注意父工程的packaging必须是pom而不是jar。这一点新手经常搞错写成jar的话父工程自己也会参与打包生成一个几乎没用的空 JAR还会给模块解析带来困扰。2.3 Shade 插件的完整配置范例下面是我在项目里用得比较顺手的一份配置放在demo-web的pom.xml的buildplugins里。我把它拆开逐段讲。plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.1/version executions execution phasepackage/phase goals goalshade/goal /goals configuration createDependencyReducedPomfalse/createDependencyReducedPom shadedArtifactAttachedfalse/shadedArtifactAttached transformers !-- 指定主类入口 -- transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.demo.web.DemoApplication/mainClass /transformer !-- 合并 SPI 服务文件 -- transformer implementationorg.apache.maven.plugins.shade.resource.ServicesResourceTransformer/ /transformers filters filter artifact*:*/artifact excludes !-- 排除签名文件避免 SecurityException -- excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters /configuration /execution /executions /plugin先看phasepackage/phase和goalshade/goal这表示绑定在package阶段执行shade目标mvn package时会自动触发你不需要手敲额外的命令。createDependencyReducedPom设为false是我个人的偏好。它的作用原本是在打包后生成一个精简版 POM把已经打进胖 JAR 的依赖从 POM 里移除。但这个东西在多模块工程里经常引起混乱比如它生成的dependency-reduced-pom.xml会让后续模块解析依赖时出现意外。除非你确实需要把瘦身后的 POM 发布到仓库否则关掉更省事。shadedArtifactAttached设为false意思是让 Shade替换掉原来的主产物也就是target下的demo-web-1.0.jar直接就是合并后的胖 JAR。如果设为true它会额外生成一个带-shaded后缀的包原包保留。我喜欢false产物干净运维拿到的就是那一个。ManifestResourceTransformer是最关键的转换器它负责往MANIFEST.MF里写入Main-Class也是让java -jar能跑起来的核心。mainClass要填你自己启动类的全限定名。ServicesResourceTransformer处理的是META-INF/services/下那些 SPI 文件。Java 的 SPI 机制比如 JDBC 驱动、日志实现依赖这些文件如果多个 JAR 里都有同名文件不合并就会互相覆盖导致某种实现莫名失效。filters里的排除项是我强烈建议保留的。下面第 4 章会详细讲为什么。3. 从零走一遍打包流程3.1 依赖范围与打包范围的核对配置写完先别急着打包有个前置检查能帮你省掉很多返工在心里过一遍运行时到底需要哪些东西。Shade 默认打进的是compile和runtime范围的依赖。test范围天然不进包这是对的。重点是那些你希望不打包、由外部环境提供的库——比如容器里已经放好的通用工具包、由应用服务器提供的 API 等这类才应该标provided。除此之外一律不要乱标provided。还有一种情况是多模块工程里把某些模块的依赖声明成了optional。optionaltrue的依赖不会传递给依赖它的模块也就不一定进包。这个特性是给可选功能用的用在正常业务模块上就属于误用了。我一般是打包前跑一次mvn dependency:tree把整个依赖树打出来看一眼重点确认三件事模块之间的依赖是不是都在、有没有意外的版本冲突、有没有多余的传递依赖。多花两分钟比打包后跑起来报错再回头查要划算得多。3.2 执行打包与产物确认前置检查通过后在父工程根目录执行mvn clean package -DskipTests加上-DskipTests跳过测试执行注意不是-Dmaven.test.skiptrue后者连测试代码都不编译有时候会隐藏编译问题我个人更习惯前者。命令跑完后各个模块的target下都会生成 JAR但你要盯住的是demo-web/target/demo-web-1.0.jar。判断它是不是真正的胖 JAR有两个直观办法。一是看体积。如果这个包只有几十 KB那基本可以判定合并失败了很可能依赖没进来。真正的胖 JAR 通常是几十 MB 起步因为把 Spring、数据库驱动这些都塞进去了。二是看内容用unzip -l或者解压看目录结构unzip -l demo-web/target/demo-web-1.0.jar | head -50如果看到com/demo/service/、com/demo/dao/这些其他模块的包路径出现在里面就说明模块字节码成功合并了。再看有没有org/springframework/之类第三方库的路径确认第三方依赖也进去了。实操心得我习惯在打包后专门确认一下其他模块的标志性类。比如demo-common里有个StringUtils那就搜一下com/demo/common/StringUtils.class在不在包里。这个动作比单纯看体积靠谱因为体积大也可能是某个大库单独撑起来的。3.3 跑起来验证主类入口产物没问题了接着验证能不能启动java -jar demo-web/target/demo-web-1.0.jar如果能正常打印启动日志说明Main-Class配置正确、依赖也齐了。如果报no main manifest attribute那就是ManifestResourceTransformer没生效检查一下mainClass的全限定名写对没有。如果报ClassNotFoundException或者NoClassDefFoundError定位思路是看缺失的类是哪个模块或者哪个库提供的再回头确认它的依赖范围和是否被打进了包。这一步排查有个小技巧把缺失类的包名截取前几段基本就能判断出是哪来的比大海捞针快得多。还有一种启动失败是**Invalid signature file digest**这是签名文件冲突导致的属于最高频的坑之一正好接着往下讲。4. 踩过的坑与高频问题排查4.1 签名文件冲突一场典型的 SecurityException这个坑我估计做合并打包的人百分之七八十都遇到过。现象是启动时报错大意是某个类的签名校验失败Invalid signature file digest for Manifest main attributes。原因在于很多第三方 JAR 在发布时是带数字签名的签名信息记录在META-INF/*.SF、*.DSA、*.RSA这些文件里。当你用 Shade 把这些 JAR 解压重组后类的字节码内容变了或者说打包结构变了但签名文件还在于是 JVM 做签名校验时发现对不上直接抛异常拒绝加载。解决方法就是上面配置里的filters把这三类签名文件全部排除掉。合并后的胖 JAR 本身是一个新的、未签名的产物不需要保留原始签名。这个处理是安全的因为这些库的功能依赖的是代码本身不是签名。注意排除签名文件只影响你合并后的包不改变原始仓库里那些 JAR 的完整性。如果你有发布到内部仓库的需求建议把合并后的包单独作为一个新 artifact 管理。4.2 资源文件被覆盖spring.handlers 与 spring.schemas第二个高频坑是资源文件覆盖。最典型的是 Spring 的META-INF/spring.handlers和META-INF/spring.schemas。这两个文件用来映射自定义命名空间的处理器和 XSD 位置多个 Spring 相关的 JAR 里都有同名文件。Shade 处理同名资源时的默认行为是后来者覆盖先到者或者只取一个结果就是某些命名空间的映射丢了启动时解析 XML 配置会报Unable to locate Spring NamespaceHandler for XML schema namespace之类的错。解决办法是用AppendingTransformer把同名文件追加合并而不是覆盖transformer implementationorg.apache.maven.plugins.shade.resource.AppendingTransformer resourceMETA-INF/spring.handlers/resource /transformer transformer implementationorg.apache.maven.plugins.shade.resource.AppendingTransformer resourceMETA-INF/spring.schemas/resource /transformer如果你用的是 Spring Boot它的情况稍微特殊spring.factories这类文件也需要合并通常用 Spring Boot 自己的插件会更省心。但只要走 Shade 路线遇到任何多个 JAR 都有同名配置文件的情况AppendingTransformer基本都是标准答案。4.3 依赖冲突与重复类多模块项目合并打包另一个绕不开的问题是重复类。同一个类在多个 JAR 里出现Shade 默认会保留第一个找到的并在构建日志里输出警告比如[WARNING] foo.bar.Baz discovered in both module-a-1.0.jar and module-b-1.0.jar这种情况下运行时到底用哪个类取决于 JAR 的扫描顺序是不确定的容易造成本地跑得好、线上行为不一致的诡异问题。根源通常是版本没统一或者同一个库被以不同名字引入比如log4j和slf4j-log4j12的关系。处理思路有两条。一是根治回到父 POM用dependencyManagement统一版本把冲突的传递依赖用exclusions排掉。二是兜底如果确实有两个版本需要共存比如某些 SPI 实现可以在 Shade 配置里用relocation把其中一个重命名到新包路径下或者显式指定保留策略。我的经验是尽量走第一条路。合并打包会把所有问题在构建期就暴露出来这其实是好事——与其等上线后踩不如在打包日志里就把冲突看清楚。4.4 高频问题速查表为了方便排查我把常见问题和对应的原因、解决办法整理成一张表现象常见原因解决方向no main manifest attribute未配置 Main-Class检查 ManifestResourceTransformer 的 mainClassInvalid signature file digest打包了带签名的 JAR排除 META-INF 下的.SF/.DSA/*.RSAClassNotFoundException依赖范围不对或未打包检查 provided/optional确认依赖范围Unable to locate NamespaceHandlerspring.handlers 被覆盖用 AppendingTransformer 追加合并某个 SPI 实现失效META-INF/services 被覆盖加 ServicesResourceTransformer运行时行为诡异多版本同一个类共存统一版本排查重复类警告配置文件读不到资源路径在合并后变化改用 classpath 相对路径读取这张表里的每一条背后都是一次真实的排查过程。建议打包后先跑一遍功能自测别等到部署上线才发现问题。5. 进阶玩法瘦身、分层与产物管理5.1 胖 JAR 太大怎么瘦身合并之后最直观的感受就是包变大了。一个普通的 Spring Boot 项目胖 JAR 到五六十 MB 很正常。如果你觉得体积碍眼有几条实用的瘦身思路。一是排除不需要的传递依赖。多模块项目里最容易被忽略的是那些间接依赖比如某个库带了一个只在特定场景用的工具包你其实用不到。用mvn dependency:analyze找出未被使用的声明依赖再用dependency:tree找多余的传递依赖逐步排掉。二是升级压缩级别。Shade 的打包本质是重新组织 ZIP可以让它用更高的压缩比。Maven 层面可以通过配置maven-jar-plugin或者相关参数调整不过效果有限主要还是靠减少依赖。三是考虑分层把不常变的依赖比如各种框架和常变的业务代码分开打包这在容器镜像构建时能显著减小每次构建的传输量。这一块属于更进阶的话题普通项目按前两条做就够了。5.2 多模块结构的可维护性建议最后聊几句结构层面的经验。多模块是把双刃剑拆得合理收益是编译快、边界清晰拆得随意收益就是负的。我的建议是遵循变化频率一致的模块放一起。common里的工具类如果谁都在改那它其实不是一个稳定的底层模块反而会拖慢所有人的编译。相反如果common半年不动一次那它作为独立模块就很有价值——编译一次之后很少重编。另外模块的依赖方向一定要单向。web → service → dao → common这条线上不允许出现反向依赖或者循环依赖。Maven 本身也会阻止循环依赖但设计上如果两个模块互相引用说明它们的边界画错了应该把共同部分抽到更底层。合并打包这件事某种意义上也在强迫你去审视模块边界——因为一旦合并成一个包模块之间的依赖关系就必须是干净的单向链否则打包顺序和类冲突都会出问题。实操心得我一般会在父 POM 里加一个maven-enforcer-plugin配置banDuplicateClasses或者依赖一致性规则把重复类、版本冲突这类问题在构建阶段就卡住而不是等到运行时。多花几分钟配置能省掉无数排查时间。5.3 关于交付产物的命名与版本最后一个容易被忽略的点合并后的 JAR 怎么命名、怎么带版本。默认情况下Maven 产物名是artifactId-version.jar。如果直接交付建议把版本号保留在文件名里比如demo-web-1.0.0.jar而不是重命名成app.jar。原因很简单版本信息在文件上出问题回溯时能一眼看出线上跑的是哪个版本。如果非要固定名字很多运维脚本写死了app.jar那至少在构建时把版本写进MANIFEST.MF通过Implementation-Version之类的字段保留追溯能力。还有一点关于pom.xml里的finalName。它可以让产物名字完全按你的意愿来写起来也简单。但要注意finalName只影响文件名字不改变 Maven 内部的坐标和依赖解析。所以如果多个模块的finalName撞了可能导致构建产物互相覆盖这一点在合并打包时尤其要留意。我个人在实际操作中的体会是合并打包的配置本身不复杂复杂的是它会把多模块工程里那些平时被掩盖的问题一次性翻出来——版本没统一、依赖范围标错、资源文件冲突。所以与其说这是一篇教你打一个包的内容不如说它是一个契机逼你把模块依赖关系彻底梳理清楚。配置照着上面的模板抄基本能跑通剩下的精力建议花在排查构建日志的警告上那些 WARNING 往往才是真正的隐患所在。
返回列表