ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Maven可执行JAR与仓库机制深度解析

Maven可执行JAR与仓库机制深度解析

简介:本资源是一份面向Java开发者与Maven初学者的实战型学习资料,聚焦Maven核心机制中的仓库管理、本地依赖引入及可执行JAR包构建三大痛点问题。内容覆盖本地/远程/中央仓库的定位与协同逻辑,手把手演示本地JAR包手动安装与POM声明方法,并对比详解maven-jar-plugin、maven-assembly-plugin和maven-shade-plugin三种打包方案的适用场景与配置要点。压缩包共6个文件(5KB),含2个Eclipse偏好设置文件(.prefs)用于IDE环境适配、1个关键pom.xml配置模板、1个示例Java主类、1个.classpath工程路径配置及1个.project项目元信息文件,结构精简,开箱即用。目前已有2772人学习下载,适合需要快速理解Maven底层依赖流转、解决本地包引用异常或生成独立运行jar的中初级Java工程师。

1. Maven 可执行 JAR:为什么mvn package出来的 jar 双击就报“no main manifest attribute”?

你写完一个 Spring Boot 之外的纯 Java CLI 工具,mvn clean package一跑,target 目录下赫然躺着my-tool-1.0.0.jar——双击?闪退。命令行敲java -jar my-tool-1.0.0.jar?控制台冷冰冰甩出一句:no main manifest attribute, in my-tool-1.0.0.jar。这不是配置漏了,也不是 IDE 抽风,而是 Maven 默认压根不往 MANIFEST.MF 里写 Main-Class。它把「可执行」这件事,交给了你——但不是靠手改 MANIFEST,而是靠理解 Maven 的坐标体系、依赖解析机制、以及三种打包策略背后的设计契约。这份资源不是教你怎么点几下鼠标生成 jar,而是带你钻进maven-jar-plugin的<archive>配置、maven-shade-plugin的类重定位逻辑、甚至spring-boot-maven-plugin的LaunchScript注入时机。搞懂它,你才能在 CI 流水线里稳稳输出带完整依赖+入口点的 fat jar,而不是每次上线前临时jar -uf手动补 MANIFEST——那才是真正的玄学运维。


2. Maven 仓库的本质:不是文件夹,而是坐标寻址协议

Maven 仓库常被误认为是“一堆 jar 包堆成的本地文件夹”,但这种理解会直接导致mvn dependency:copy-dependencies失败时抓瞎。真实情况是:Maven 仓库是一套基于 GAV 坐标(GroupId:ArtifactId:Version)的 HTTP 可寻址命名空间。它不关心物理路径,只认坐标;而本地.m2/repository只是这套协议的一个缓存实现。

2.1 坐标如何映射到文件路径?——从org.springframework:spring-core:5.3.31到~/.m2/repository/org/springframework/spring-core/5.3.31/spring-core-5.3.31.jar

Maven 将 GAV 坐标按规则转换为路径,这是所有插件行为的基础:

GroupId: org.springframework → 路径分段:org/springframework ArtifactId: spring-core → 路径分段:spring-core Version: 5.3.31 → 路径分段:5.3.31 最终 jar 名:spring-core-5.3.31.jar

这个映射不是约定俗成,而是硬编码在org.apache.maven.artifact.repository.layout.DefaultRepositoryLayout类中。你可以验证:删掉本地~/.m2/repository/org/springframework/spring-core/5.3.31/,执行mvn compile,Maven 会自动从中央仓库(或你配置的镜像)下载,并严格按此路径结构存放。关键点在于:Maven 从不扫描文件夹找 jar,它永远先拼路径,再检查该路径下是否存在*.jar和*.pom。

2.2 本地包引入的三种合法姿势:为什么system范围是反模式?

当你有一个公司内部未发布到 Nexus 的legacy-util-2.1.jar,常见错误是写:

<dependency> <groupId>com.example</groupId> <artifactId>legacy-util</artifactId> <version>2.1</version> <scope>system</scope> <systemPath>${project.basedir}/lib/legacy-util-2.1.jar</systemPath> </dependency>

注意:systemscope 是 Maven 设计上明确标记为「不推荐用于生产」的机制。它绕过整个仓库解析链,不参与依赖传递,且mvn deploy时完全忽略——CI 构建必然失败。

真正合规的引入方式只有三种:

✅ 方式一:安装到本地仓库(适合单机开发验证)
mvn install:install-file \ -Dfile=legacy-util-2.1.jar \ -DgroupId=com.example \ -DartifactId=legacy-util \ -Dversion=2.1 \ -Dpackaging=jar

执行后,jar 被按 GAV 规则写入~/.m2/repository/com/example/legacy-util/2.1/,后续mvn compile会像引用任何远程依赖一样解析它。

✅ 方式二:部署到私有仓库(Nexus/Artifactory,团队标准流程)
mvn deploy:deploy-file \ -Dfile=legacy-util-2.1.jar \ -DgroupId=com.example \ -DartifactId=legacy-util \ -Dversion=2.1 \ -Dpackaging=jar \ -DrepositoryId=nexus-releases \ -Durl=http://nexus.internal/repository/maven-releases/

这要求settings.xml中已配置nexus-releases对应的认证信息。部署后,所有团队成员只需声明坐标即可,无需共享 jar 文件。

✅ 方式三:使用maven-install-plugin在构建生命周期内安装(适合多模块项目中跨模块依赖)

在父 POM 的<build><plugins>中添加:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-install-plugin</artifactId> <version>3.1.1</version> <executions> <execution> <id>install-legacy-jar</id> <phase>initialize</phase> <goals><goal>install-file</goal></goals> <configuration> <file>${project.basedir}/lib/legacy-util-2.1.jar</file> <groupId>com.example</groupId> <artifactId>legacy-util</artifactId> <version>2.1</version> <packaging>jar</packaging> </configuration> </execution> </executions> </plugin>

这样,每次mvn clean compile都会先将本地 jar 安装到本地仓库,再进入编译阶段。它比手动install:install-file更可靠,且可被 CI 复现。

2.3 仓库镜像与settings.xml的优先级链:为什么改了pom.xml的<repositories>还连不上私有源?

Maven 解析依赖时,遵循严格的仓库优先级顺序:

  1. 项目 POM 中<repositories>声明的仓库(仅对当前项目生效)
  2. settings.xml中<profiles>激活的<repositories>(用户级,影响所有项目)
  3. settings.xml中<mirrors>配置的镜像(覆盖所有仓库请求,最高优先级)
  4. Maven 内置中央仓库(Central)(兜底)

最典型的翻车场景:你在pom.xml里加了公司 Nexus 地址:

<repositories> <repository> <id>company-nexus</id> <url>http://nexus.internal/repository/maven-public/</url> </repository> </repositories>

但settings.xml里同时存在:

<mirrors> <mirror> <id>nexus-mirror</id> <mirrorOf>*</mirrorOf> <url>https://repo1.maven.org/maven2/</url> </mirror> </mirrors>

此时,<mirrorOf>*</mirrorOf>表示「所有仓库请求都走这个镜像」,你的company-nexus请求会被强制重定向到repo1.maven.org,自然 404。解决方法只有两个:要么删掉mirrorOf=*,改为精确匹配(如<mirrorOf>company-nexus</mirrorOf>);要么在settings.xml的 profile 中用<repositories>显式声明公司仓库,并通过<activeProfiles>激活它——镜像不会覆盖 profile 中声明的仓库。


3. 打可执行 JAR 的三种正统方案:从 MANIFEST 到 ClassLoader 重写

Maven 默认jar插件只打「原始 jar」:仅含编译后的 class 文件和MANIFEST.MF(无 Main-Class)。要让它可执行,必须注入启动元数据或合并依赖。主流方案有且仅有三个,各自解决不同场景,混用必踩坑。

3.1maven-jar-plugin+maven-dependency-plugin:分离式可执行(适合依赖稳定、环境可控)

这是最轻量、最符合 Unix 哲学的方案:主 jar 不含依赖,依赖单独解压到lib/目录,通过Class-Path指向它们。

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> <mainClass>com.example.Main</mainClass> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <version>3.6.0</version> <executions> <execution> <id>copy-dependencies</id> <phase>package</phase> <goals><goal>copy-dependencies</goal></goals> <configuration> <outputDirectory>${project.build.directory}/lib</outputDirectory> <overWriteReleases>false</overWriteReleases> <overWriteSnapshots>false</overWriteSnapshots> </configuration> </execution> </executions> </plugin>

执行mvn package后,target/下生成:

  • my-app-1.0.0.jar(含MANIFEST.MF,其中Class-Path: lib/guava-32.1.3-jre.jar lib/logback-classic-1.4.14.jar ...)
  • target/lib/目录(含所有 runtime 依赖 jar)

机制本质:JVM 启动时读取MANIFEST.MF的Class-Path,将指定路径下的 jar 全部加入 classpath。maven-jar-plugin的<addClasspath>true</addClasspath>会自动扫描target/lib/下的文件名并写入Class-Path行(注意:它不校验文件是否存在,只拼字符串)。

提示:<classpathPrefix>必须与maven-dependency-plugin的<outputDirectory>相对路径一致。若你把依赖拷到target/deps/,这里就必须写deps/,否则运行时java -jar找不到依赖。

3.2maven-shade-plugin:Fat Jar(全量合并,适合分发给终端用户)

当用户环境无法保证依赖 jar 存在(比如发给客户一个独立工具),必须把所有 class 打进一个 jar。shade插件不是简单复制,而是重写字节码中的类引用路径,解决类冲突。

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.4.1</version> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.Main</mainClass> </transformer> </transformers> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters> </configuration> </execution> </executions> </plugin>

关键机制在<transformers>:ManifestResourceTransformer会在最终 jar 的META-INF/MANIFEST.MF中写入Main-Class;而filters排除签名文件,避免SecurityException。

但更深层的是shade的类重定位(Relocation)能力。假设你依赖的guava和netty都用了com.google.common.base.Preconditions,但版本不兼容。你可以将其中一个 relocate:

<relocations> <relocation> <pattern>com.google.common</pattern> <shadedPattern>shaded.com.google.common</shadedPattern> </relocation> </relocations>

此时shade会:

  • 将guava-*.jar中所有com.google.common.*类复制到shaded/com/google/common/路径下;
  • 重写所有引用这些类的字节码(包括你自己的 class 和其他依赖的 class),把new com.google.common.base.Preconditions()改成new shaded.com.google.common.base.Preconditions()。

这就是为什么shade比assembly更安全——它不是文件级拼接,而是 JVM 字节码级的语义重写。

3.3spring-boot-maven-plugin:Spring Boot 专用 Launcher(适合 Spring 生态)

如果你的项目是 Spring Boot,绝不要用shade或assembly打可执行 jar。Boot 的Launcher是一套完整的类加载隔离机制:

<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>3.2.0</version> <executions> <execution> <goals><goal>repackage</goal></goals> </execution> </executions> </plugin>

它生成的 jar 结构特殊:

my-boot-app-1.0.0.jar ├── BOOT-INF/ │ ├── classes/ ← 你的代码 │ └── lib/ ← 所有依赖 jar(不展开) ├── META-INF/ │ └── MANIFEST.MF ← Main-Class: org.springframework.boot.loader.JarLauncher └── org/ └── springframework/ └── boot/ └── loader/ ← Boot 自己的类加载器实现

JarLauncher的核心逻辑是:不使用 JVM 默认的AppClassLoader,而是用LaunchedURLClassLoader加载BOOT-INF/classes和BOOT-INF/lib/*.jar。它通过MANIFEST.MF中的Start-Class(你自己的主类)触发启动,并确保 Spring 的ApplicationContext能正确扫描BOOT-INF/classes下的@Component。

血泪经验:若你强行用shade打 Spring Boot 项目,@SpringBootApplication会找不到spring.factories,因为shade默认不合并同名资源文件。必须显式配置ServicesResourceTransformer,否则启动直接ClassNotFoundException。


4. 避坑:五条真实踩过的 Maven 打包雷区

这些不是文档里的警告,而是某开发者在凌晨三点排查 CI 失败时记下的日志。

4.1 现象:mvn package成功,但java -jar target/app.jar报ClassNotFoundException: com.google.gson.Gson

原因:你用了maven-jar-plugin的addClasspath,但maven-dependency-plugin拷贝依赖的 phase 是package,而maven-jar-plugin的jargoal 默认绑定在packagephase 的更早时刻(Maven 生命周期中jar在package的早期执行,dependency:copy-dependencies在晚期)。结果MANIFEST.MF生成时target/lib/还是空的,Class-Path里什么都没写。
解决:显式将maven-jar-plugin绑定到prepare-packagephase,确保它在dependency:copy-dependencies之后执行:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <executions> <execution> <phase>prepare-package</phase> <!-- 关键! --> <goals><goal>jar</goal></goals> <configuration>...</configuration> </execution> </executions> </plugin>

4.2 现象:shade打出的 jar 运行时报NoSuchMethodError: org.slf4j.LoggerFactory.getLogger

原因:slf4j-api和slf4j-simple版本不匹配,且shade默认不处理META-INF/services/org.slf4j.spi.SLF4JServiceProvider这类服务发现文件。多个依赖 jar 中的同名services文件被覆盖,导致 SLF4J 找不到实现类。
解决:添加ServicesResourceTransformer,它会合并所有META-INF/services/下的同名文件:

<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>

4.3 现象:本地mvn clean package正常,Jenkins 上构建失败,报Could not find artifact com.example:legacy-util:jar:2.1

原因:你用了systemscope 引入本地 jar,而 Jenkins slave 机器上根本不存在${project.basedir}/lib/legacy-util-2.1.jar。system依赖不参与远程仓库解析,也不上传到 Nexus。
解决:立即废除systemscope,改用maven-install-plugin在构建时安装(见 2.2 方式三),或推动该 jar 发布到私有仓库。

4.4 现象:spring-boot-maven-plugin打出的 jar,用java -cp "target/app.jar" com.example.Main启动失败

原因:-cp强制使用 JVM 默认AppClassLoader,它无法识别BOOT-INF/目录结构。Spring Boot 的 jar只能用java -jar启动,因为-jar会触发java.util.jar.JarFile的特殊解析逻辑,找到MANIFEST.MF中的Launcher类。
解决:永远用java -jar target/app.jar,不要试图用-cp绕过 Launcher。

4.5 现象:maven-shade-plugin重定位后,反射调用Class.forName("com.google.common.base.Preconditions")仍失败

原因:shade只重写编译期已知的字节码引用,而Class.forName()是运行时动态加载,字符串"com.google.common.base.Preconditions"不会被重写。shade无法修改字符串字面量。
解决:避免在重定位范围内使用硬编码类名的反射;或改用ClassUtils.resolveClassName()(Spring 提供),它支持别名映射;最彻底的是——不要重定位 JDK 或主流库,只 relocate 冲突的第三方小众库。


5. 设计取舍:为什么 Maven 不默认打可执行 jar?——从单一职责到构建可重现性

Maven 的设计哲学第一条就是:每个插件只做一件事,并做到极致。maven-jar-plugin的唯一职责是「将编译输出打包成标准 jar 格式」,而「可执行」涉及三个正交问题:入口点声明(MANIFEST)、依赖管理(classpath)、类加载策略(fat vs thin)。把这些塞进一个插件,只会让jargoal 变成黑匣子,失去可组合性。

我们来对比三种方案的取舍表:

维度maven-jar-plugin+dependencymaven-shade-pluginspring-boot-maven-plugin
产物大小小(主 jar < 1MB,依赖独立)大(所有 class 合并,常 > 50MB)大(含 Boot Loader,+2MB)
启动速度快(JVM 原生 classpath 加载)慢(需解压/内存映射所有 class)中(Launcher 需解析 BOOT-INF)
调试友好性高(可单独替换target/lib/中某个 jar)低(所有 class 混在一起,无法 hotswap)中(可 debugBOOT-INF/classes)
依赖冲突解决能力无(靠 Maven 依赖调解,失败则报错)强(relocation + resource 合并)强(Boot 自定义 ClassLoader)
适用场景企业内网服务(依赖由运维统一部署)独立 CLI 工具(发给客户一个 zip)Spring Web 应用(云原生部署)

我曾经负责一个跨部门的 SDK 项目,初期用shade打包,结果下游团队反馈:“你们的 jar 里log4j-core版本把我们的log4j-api覆盖了,日志全没了”。我们花了两天才定位到是shade的 relocation 规则没写全。从那以后我每次设计 SDK 发布流程,都强制走三步:

  1. 用maven-jar-plugin打标准 jar(确保符合 Java SE 规范);
  2. 用maven-source-plugin和maven-javadoc-plugin生成 source/javadoc jar(让下游能 attach 源码);
  3. 用maven-deploy-plugin发布到 Nexus(所有坐标可追溯,mvn dependency:tree能精准定位冲突)。

可执行 jar?那是下游应用的事。SDK 只提供原子能力,不越界承担执行环境责任。Maven 的克制,恰恰是它能在二十年后依然支撑百万级 Java 项目的底层原因——它不帮你做决定,只给你清晰的契约和可验证的机制。

希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进