资讯详情

Maven单插件打出Fat JAR?Shade插件与默认生命周期揭秘

📅 2026/10/17 12:57:45 | 华诺云谱 👁 阅读
Maven单插件打出Fat JAR?Shade插件与默认生命周期揭秘
这段时间在交流群里看到一位同学问了个很有意思的问题某个项目的pom.xml里只声明了maven-shade-plugin没有配maven-jar-plugin但mvn package之后却打出了一个带全部依赖、能直接java -jar跑起来的 Fat JAR。他百思不解Fat JAR 不是通常要maven-jar-plugin配 manifest、再用maven-dependency-plugin复制 classpath 才能做出来吗怎么一个插件就搞定了答案其实不复杂但背后牵扯到 Maven 生命周期、插件绑定关系、以及“默认隐藏插件”这几个容易被忽略的机制。这篇内容不聊花哨的进阶玩法就是想把“单个插件出 Fat JAR”这件事的来龙去脉讲透帮助刚开始接触 Maven 打包的人建立正确的心智模型。看完你能理解普通 JAR 与 Fat JAR 差在哪、shade/assembly/spring-boot 这些插件之间到底怎么协作以后看别人的 pom 也不会再被“只看到一个插件”的表象搞晕。1. 你看到的“一个插件”其实背后还有两个隐藏参与者1.1 phase、goal、binding 这三件事必须先分开在 Maven 里构建过程不是一个插件从头干到尾而是把不同插件的不同“目标goal”按顺序挂到“生命周期阶段phase”上面。validate、compile、test、package、verify、install、deploy这些是阶段阶段只是时间节点jar:jar、shade:shade、dependency:copy-dependencies这些才是具体动作。你可以把 phase 理解成流水线上的某个工位goal 是工位上执行的操作。一个工位可以站好几个操作员也就是说同一个 phase 下可以挂多个插件的多个 goal。所以你在 pom 里写plugin并不是“我这次构建只用这一个插件”而是“我再往流水线对应工位加一个操作员”。一旦建立起这个认知“为什么只有一个插件也能打出 Fat JAR”这个问题就已经有一半答案了因为 pom 中显式声明的插件和 Maven 核心生命周期默认绑定的插件是两套来源。前者看得见后者经常被忽略。1.2 packagingjar 的时候jar 插件一直都在只要项目的packaging是jarMaven 默认就会在package阶段绑定maven-jar-plugin的default-jar执行。这个绑定属于 Maven 核心生命周期映射不需要你写进 pom也不受你是否引入其他插件影响。想验证很简单执行mvn help:effective-pom你会看到大量 pom 里根本没写却真实存在的配置或者执行mvn package -X日志里一定会出现类似[INFO] --- maven-jar-plugin:3.3.0:jar (default-jar) demo --- [INFO] Building jar: /path/to/target/demo.jar这就说明你所谓“只用一个插件”实际构建过程中至少有两个插件参与一个是不请自来的maven-jar-plugin另一个才是你写的 shade 插件。只是前者默认执行、不需要显式声明所以 pom 里看不到。提示很多人以为给mainClass配置maven-jar-plugin是“额外加了一个打包步骤”其实不是。你改的只是这个本来就存在、默认绑定好的插件参数而已。1.3 默认 jar 到底打了什么为什么它不算 Fat JAR把 target 下的普通 jar 解压开看里面基本只有三样东西项目自己编译出来的 class、resources 目录下的资源文件、以及META-INF/MANIFEST.MF。第三方依赖的 class 并不在里面它们还躺在本地仓库~/.m2/repository里。如果你只配了 manifest 的Main-Class然后在没有依赖目录的情况下直接java -jar大概率是这样主类能被 JVM 找到因为它在 jar 里但主类里引用的第三方类一个都找不到立刻抛NoClassDefFoundError。普通 jar 和 Fat JAR 的根本差异就一句话普通 jar 只包含本项目自己的类Fat JAR 把运行时需要的第三方依赖类也装进同一个归档文件里。Maven 默认的 jar 插件不会做后者所以才需要其他插件来补上这个缺口。2. Shade 插件不是“凭空打 Fat JAR”而是“接管产物再加工”2.1 Shade 的实际执行链路先让 jar 插件干活再自己重做一遍maven-shade-plugin的shadegoal 默认绑定在package阶段但它几乎总是在maven-jar-plugin的default-jar之后执行。因此真正的执行顺序是maven-jar-plugin先把 target/classes 编译结果打成普通demo.jarshadegoal 读取这个demo.jar再读取项目的所有依赖 jarshade 把所有依赖 jar 解压开把其中的 class 与demo.jar里的 class 合并根据配置过滤掉签名文件、重复文件执行 relocation 重定位最后生成一个新的demo.jar并把原有普通 jar 重命名为original-demo.jar或类似名字。所以你最后在 target 下看到的那个“巨大”的demo.jar已经不是 jar 插件打出来的原始产物而是 shade 插件在 jar 插件产物的基础上重新合成出来的成品。一个插件操作完另一个插件命令覆盖了结果这就是“单插件出 Fat JAR”最核心的机制。2.2 Shade 内部的关键动作合并、过滤、改写合并步骤没那么简单不是把所有 jar 解压后堆一起就行。真实场景里坑很多shade 插件正是为了处理这些坑而存在的过滤签名文件很多依赖 jar 的META-INF下有.SF、.RSA、.DSA签名文件。如果原样合进 Fat JAR运行时 JVM 可能认为 jar 被篡改并抛出SecurityException。所以默认建议过滤。合并服务文件JDK 的 SPI 机制依赖META-INF/services/xxx这类文件。两个 jar 里如果有同名文件直接合并会把内容覆盖掉服务就失效了。shade 需要把内容做拼接而不是覆盖。处理 MANIFEST.MF要在 fat jar 里正常java -jarMain-Class必须写进去。这个工作由 shade 的ManifestResourceTransformer完成。包重定位把依赖里的com.google.common改成shadow.com.google.common这类操作能避免 fat jar 被集成到其他应用时与外部 Guava 发生冲突。这个能力是 shade 相对其他 Fat JAR 插件的独有优势。这些步骤本来零散分布在多个插件里shade 把它们全部集中到shade这一个 goal 内完成自然就显得“一个插件就够”。2.3 为什么传统做法要两三个插件shade 却只要一个传统可执行 Fat JAR 的做法大概是这样的用maven-jar-plugin配置 manifest 的Class-Path再用maven-dependency-plugin的copy-dependencies把依赖复制到lib目录最后让运行时去找lib下的依赖。这里至少涉及 jar 插件和 dependency 插件两个参与者有时还要额外配置maven-antrun-plugin做收尾。而 shade 的路线是“不要 classpath 了直接把依赖塞进同一个 jar”。它把复制依赖、解压、合并、生成清单的工作全部收敛到一个插件内部。所以“一个插件 vs 多个插件”的对比本质上是两种 Fat JAR 实现路线的对比而不是 Maven 某个神秘机制在起作用。3. 插件关系地图谁在 package 阶段接管主产物3.1 常见 Fat JAR 相关插件的身份对比用一个表格看这些插件的角色会更清楚插件默认绑定目标是否接管主 artifact产物特征典型使用场景maven-jar-pluginpackage 阶段的 default-jar是产出普通主 jarxxx.jar所有 jar 项目默认存在maven-shade-pluginpackage 阶段的 shade默认接管替换主 artifactxxx.jaroriginal-xxx.jar合并依赖、包重定位、可执行 fat jarmaven-assembly-pluginpackage 阶段的 single不接管追加附件xxx-jar-with-dependencies.jar生成附带依赖的大包spring-boot-maven-pluginpackage 阶段的 repackage接管重打包主 artifactxxx.jarxxx.jar.originalSpring Boot 可执行 jarmaven-dependency-plugin按配置绑定不接管复制依赖到 lib 目录传统外部 classpath 方案这张表你看一眼就能抓住重点shade 和 spring-boot 是“直接改写最终主 jar”的接管型插件assembly 是“额外再生成一个胖包”的附加型插件。都是 Fat JAR出来的文件形态和部署行为完全不同。3.2 同阶段多个插件的执行顺序决定了谁笑到最后同一个package阶段可以有多个插件执行。默认生命周期映射里的default-jar绑定在前用户自定义的插件执行通常排在后。所以 shade 和 spring-boot 才有机会在 jar 插件已经生成普通 jar 之后把它替换掉。实际判断谁是最终产物最简单的办法是看 target 目录看到xxx.jar和original-xxx.jar说明 shade 接管了看到xxx.jar和xxx.jar.original说明 spring-boot 接管了看到xxx.jar和xxx-jar-with-dependencies.jar说明 assembly 是作为附件追加的。这三种情况的 pom 可能都“只显式写了一个插件”但背后都有默认 jar 插件在打底。这个顺序关系就是“插件关系”里的关键一环。3.3 别忽略父 POM 里的 pluginManagement还有一种常见的“只看到一个插件”场景来自父 POM。比如引用了 Spring Boot 的 parent父 pom 的pluginManagement里早就预置了maven-jar-plugin、maven-surefire-plugin、maven-failsafe-plugin等一堆构建插件的默认配置。子模块的 pom 里只需要加一个spring-boot-maven-plugin的引用就能打出可执行 Fat JAR。所以排查任何构建问题第一步都建议看 effective pom而不是只看当前模块的 pom。很多“为什么没配置它却执行了”的疑惑都是因为父级把底座铺好了。4. 三种“最少配置”打出 Fat JAR 的典型落地 pom4.1 方案一只用 maven-shade-plugin标准接管式这是最常见、最符合“一个插件出 Fat JAR”说法的配置。下面是一个带Main-Class、过滤签名文件、合并 SPI 文件的完整例子plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-shade-plugin/artifactId version3.5.2/version executions execution phasepackage/phase goals goalshade/goal /goals configuration transformers transformer implementationorg.apache.maven.plugins.shade.resource.ManifestResourceTransformer mainClasscom.example.MainKt/mainClass /transformer transformer implementationorg.apache.maven.plugins.shade.resource.ServicesResourceTransformer/ /transformers filters filter artifact*:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters /configuration /execution /executions /plugin执行mvn clean package后你会发现 target 里有demo.jar original-demo.jardemo.jar是最终 Fat JAR里面已经包含所有依赖 classoriginal-demo.jar是 jar 插件原始产物方便你有需要时对比。这个方案适合绝大多数普通 Java/Kotlin 命令行应用。4.2 方案二spring-boot-maven-plugin优雅的重新打包Spring Boot 项目通常长这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build这个 pom 里显式的构建插件确实只有一个。但它的底下一层是 Spring Boot parent 里大段的 pluginManagement上面又叠了一层 Maven 默认生命周期绑定。spring-boot-maven-plugin的repackage目标会在 package 阶段对 jar 插件产出的普通 jar 做二次打包把所有依赖放进BOOT-INF/lib再用 Spring Boot 自己的 Launcher 作为入口。要注意Spring Boot Fat JAR 的内部结构和 shade 完全不同它用的是嵌套目录加载机制而不是把依赖 class 平铺进 jar 根目录。所以这两个插件之间没有真正的替代关系只是“都能产出可执行 Fat JAR”而已。4.3 方案三maven-assembly-plugin追加式胖包assembly 也是单插件配置但产出行为和前两者有微妙差别plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-assembly-plugin/artifactId version3.7.1/version configuration descriptorRefs descriptorRefjar-with-dependencies/descriptorRef /descriptorRefs archive manifest mainClasscom.example.MainKt/mainClass /manifest /archive /configuration executions execution idmake-assembly/id phasepackage/phase goals goalsingle/goal /goals /execution /executions /plugin执行后 target 里会出现普通demo.jar和demo-jar-with-dependencies.jar。前者是主 artifact后者是带依赖的附件。也就是说“一个插件打 Fat JAR”确实成立但最终 fat 包并没有成为主 artifact。如果你接着执行mvn install本地仓库里也会同时安装这两个文件。这种方式适合希望在保留给依赖库使用者普通 jar 的同时额外提供一个完整可执行包的项目。4.4 三种方案怎么选对比项shadespring-bootassembly是否替换主 artifact替换替换不替换胖包文件名demo.jardemo.jardemo-jar-with-dependencies.jar依赖存放形式解压后平铺进 jar放进BOOT-INF/lib解压后平铺进 jar是否支持重定位支持不支持不支持原 jar 保留方式original-demo.jardemo.jar.original原样保留demo.jar典型项目普通 Java/Kotlin 应用Spring Boot 应用需要双产物的工具包选哪个主要看项目类型。如果用的是 Spring Boot自然选 spring-boot 插件如果只是普通可执行程序shade 功能最全尤其是重定位能力是其他插件替代不了的如果团队里有人需要普通 jar 作为库依赖、有人需要 fat jar 直接运行assembly 这种“主产物 附件”的模式最省事。5. 如果“一个插件”没按你想的产出 Fat JAR检查这些点5.1 可执行 jar 报 no main manifest attribute最容易踩的坑就是 shade 只配了插件没配ManifestResourceTransformer或者忘了写mainClass。此时java -jar会报no main manifest attribute, in demo.jar修复方式就是在 shade 的 transformers 里补上主类配置。注意如果你项目里还有其他 transformerManifestResourceTransformer最好保留不要删。5.2 启动后有类但报 ServiceConfigurationError 或策略失效这通常是因为META-INF/services下的文件在合并时被覆盖了。多个依赖 jar 里如果有同名服务描述文件不做处理的话后合并的那个会把前面的内容整个盖掉。用ServicesResourceTransformer可以把多个文件内容按行合并。凡是做 SPI 的依赖比如各种javax.xml.parsers.SAXParserFactory、数据库驱动、日志实现都需要这个 transformer否则 fat jar 运行时功能会莫名消失。5.3 涉及签名 jar 时出现 SecurityException某些依赖 jar 在META-INF下带了签名文件。shade 合并后JVM 验签可能会认为包被篡改抛SecurityException。统一过滤掉签名文件是最直接的解决办法filters filter artifact*:*/artifact excludes excludeMETA-INF/*.SF/exclude excludeMETA-INF/*.DSA/exclude excludeMETA-INF/*.RSA/exclude /excludes /filter /filters如果依赖里还有module-info.class这类 JPMS 文件也可能导致 duplicate class 报错可以根据实际情况在 filter 里排除或者开启插件的多版本 jar 处理开关。5.4 胖包到底是主 artifact 还是附件直接决定安装行为如果你用 shade 但设置了shadedArtifactAttachedtrue/shadedArtifactAttached行为会发生大变化shade 不再替换主 artifact而是生成一个带shadedclassifier 的附加 jar。也就是说你会同时得到普通demo.jar和demo-shaded.jarinstall 时两个都会进本地仓库。很多人对“只用一个插件为什么能打出 Fat JAR”这个问题的困惑可能就来自某一方用了shadedArtifactAttachedfalse默认另一方用了true。两者在 pom 里看起来都只是 shade 插件但最终的 artifact 结构和部署方式完全不同。所以判断项目构建行为时不能只看插件名还要看它的配置参数。5.5 快速自检清单以后遇到“单插件打胖包”的可疑构建按这个顺序排查跑mvn help:effective-pom确认还有哪些插件被父 POM 或默认生命周期注入跑mvn package -X看 package 阶段的执行顺序和日志看 target 目录里文件名判断是替换式还是附加式解压 final jar看META-INF/MANIFEST.MF和依赖 class 的位置如果java -jar失败优先检查 transformer 和 filter 配置。这几步走完绝大多数“为什么只有一个插件”的疑惑都能落地。我个人在实际排查中的体会是Maven 的 pom 从来不是“全部真相”它只是显式那部分。生命周期默认绑定、父级 pluginManagement、插件在同阶段的前后顺序才是真正决定产物的三块拼图。把 effective-pom 当作第一参考资料之后很多奇怪的构建问题都能快速找到答案比对着 pom 猜半天有效得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑