简介:面向Java开发者与Maven初学者的一套轻量示例工程配置包,聚焦依赖仓库认知、本地JAR包引入以及可执行JAR的多种打包方式,适合在项目搭建、依赖排查和构建方案选型时对照参考。压缩包共6个文件,约5KB,主要由xml构建配置、java示例源码、classpath与project工程描述文件及prefs偏好设置组成,覆盖Eclipse工程导入、Maven项目结构识别和基础构建配置等用途,体积虽小但结构完整。已有2770人学习下载,说明其作为入门对照材料具有一定参考度。读者可从中获得pom配置思路、本地包依赖声明方式,以及jar、assembly、shade等打包方案的配置参考,便于理解不同打包插件对依赖与主类清单的处理差异,并快速迁移到自己的Maven项目中。
1. 从一次依赖冲突说起:Maven 仓库、本地包和可执行 jar 怎么串起来
Maven 仓库、本地包、可执行 jar 这三件事,在同一个工程里经常是连着的。某次把工程迁到离线环境,编译时提示依赖找不到,补了一个本地 jar 后又发现打出来的包没有主清单,java -jar直接报no main manifest attribute。这类问题表面看是三件事,根子都在 Maven 的坐标、作用域和打包插件配置上。这篇笔记围绕 Maven 仓库理解、本地包引入、多种方式打可执行 jar 展开,适合正在维护多模块工程、需要离线构建、或者被 fat jar 和依赖冲突折腾过的读者。新手可以按命令逐步复现,熟手可以直接看参数边界和排错现场。
2. Maven 仓库与依赖解析:本地缓存、远程仓库和坐标的优先级
2.1 本地仓库、远程仓库和团队私服的层次
Maven 找依赖不是“先联网搜一下”,而是按固定顺序查。默认本地仓库在用户目录下的.m2/repository,settings.xml里的<localRepository>可以改位置;远程仓库来自pom.xml的<repositories>、settings.xml的<profiles>,以及镜像<mirrors>。团队里常见的做法是配一个内部仓库代理,把中央仓库和内部构件统一到一个地址,构建机只认这个地址,减少外网波动带来的不确定性。
先看清当前环境到底用了哪个本地仓库、哪个镜像,不要凭记忆改配置。下面两条命令一条输出生效后的 settings,一条直接取本地仓库路径:
# 查看合并后的 settings,重点看 localRepository 和 mirrors mvn help:effective-settings # 只取本地仓库路径,适合写进脚本做检查 mvn help:evaluate -Dexpression=settings.localRepository -q -DforceStdout逻辑说明:help:effective-settings会把全局 settings、用户 settings 和命令行参数合并后的结果打印出来,排查“我明明改了 settings 为什么不生效”时最有用。help:evaluate只取一个表达式,-q压掉多余日志,-DforceStdout保证结果直接输出到标准输出,适合在 CI 里做路径断言。参数上,-Dexpression可以换成settings.mirrors之类的表达式,但输出结构复杂时还是用 effective-settings 更直观。
提示:本地仓库不是越干净越好。直接删
.m2/repository会把所有历史依赖清空,下一次构建会重新下载;在离线环境里这等于把自己逼进死角。先确认哪些目录是损坏的,再定点清理。
镜像配置常见的坑是mirrorOf写得太宽。<mirrorOf>*</mirrorOf>会把所有仓库请求都转到同一个地址,如果内部仓库没有代理某个第三方仓库,依赖就会 404。更稳的写法是让内部仓库代理 central,同时保留必要的仓库声明,或者用external:*排除本地文件仓库。下面是一个示例结构,域名用保留示例域名,实际替换成团队仓库地址:
<settings> <localRepository>${user.home}/.m2/repository</localRepository> <mirrors> <mirror> <id>internal-repo</id> <name>internal repository mirror</name> <url>https://repo.example.com/repository/maven-public/</url> <!-- 只镜像 central,避免把内部仓库自己也镜像进去 --> <mirrorOf>central</mirrorOf> </mirror> </mirrors> </settings>这段配置的关键在mirrorOf。写central表示只拦截中央仓库请求,内部仓库地址仍然按pom.xml或 profile 里的声明走;写*则表示所有仓库都走这个镜像,容易出现“内部仓库自己镜像自己”的循环或缺失代理。localRepository用${user.home}是为了让配置在不同机器上都能落地,构建机可以改成固定挂载盘,但要在 CI 脚本里保证目录存在且可写。
2.2 坐标、作用域与依赖调解:为什么同一个包会拉错版本
Maven 坐标由groupId、artifactId、version组成,必要时还有classifier和type。依赖调解有两条常见规则:路径最短优先,路径一样长时先声明优先。很多“本地跑得好、构建机报NoSuchMethodError”的现场,不是代码问题,而是依赖树里同一个库出现了两个版本,打包时选了其中一个。作用域则决定依赖在哪些阶段可见:compile默认参与编译、测试和运行;provided参与编译和测试,但不进运行时;runtime跳过编译但参与运行;test只在测试阶段;system类似provided,但路径由本地文件指定,不推荐作为常规方案。
看依赖树不要只看顶层,-Dverbose会把被省略的冲突路径也列出来:
# 查看完整依赖树,定位同一坐标的多个版本 mvn dependency:tree -Dverbose # 只看某个 groupId 下的依赖,输出更聚焦 mvn dependency:tree -Dverbose -Dincludes=com.example:* # 只看运行时依赖,接近最终可执行 jar 的视角 mvn dependency:tree -Dscope=runtime-Dverbose会显示omitted for conflict之类的标记,顺着它能看到哪个版本被调解掉了。-Dincludes支持groupId:artifactId:version的通配写法,排查单个库时比翻全量树快。-Dscope=runtime很关键:有些依赖在编译期存在,但运行时被排除,最终可执行 jar 启动时才暴露ClassNotFoundException。如果项目是多模块,建议在根目录跑一次全量树,再到出问题的子模块跑一次,比较两边的差异。
注意:不要用“排除依赖”当万能药。排除一个传递依赖后,可能把另一个库需要的类也排掉,最后变成运行时缺类。先确认冲突版本的方法签名差异,再决定是统一版本、排除旧路径,还是用 shade 重定位。
2.3 本地缓存来源与强制更新
本地仓库里的每个构件目录通常带一个_remote.repositories文件,记录这个构件是从哪个仓库来的。排查“为什么本地有包但构建机没有”时,这个文件比猜有用。另一个常见动作是-U强制更新快照,但它只对SNAPSHOT和元数据生效,不是所有依赖都会重新拉。清理本地缓存要定点,不要一把梭:
# 查看某个构件目录下的来源记录 find ~/.m2/repository/com/example -name "_remote.repositories" -maxdepth 4 # 强制更新快照依赖,适合内部 SNAPSHOT 频繁变更的场景 mvn -U clean package # 只清理当前项目相关依赖,交互确认后再删 mvn dependency:purge-local-repository -DreResolve=falsefind命令里的-maxdepth 4是防止在超大本地仓库里扫太久,实际路径按你的 groupId 调整。-U放在clean package前面,表示本次构建检查远程元数据;如果内部仓库本身没更新,-U也变不出新包。purge-local-repository默认会重新解析,-DreResolve=false只清理不重拉,适合先清掉损坏缓存再手动构建。血泪经验是:清理前先备份整个.m2/repository不现实,至少把出问题的groupId目录复制一份,出问题还能回滚。
3. 引入本地包的三条路:system 作用域、install-file 与离线仓库
3.1 system 作用域:能编译,但别把它当长期方案
手里有一个第三方 jar,仓库里没有,最直接的想法是放工程目录里,用system作用域引。它能解决编译期可见性,但边界很清楚:依赖不可传递,默认不会打进 fat jar,换一台机器路径可能对不上。下面是最小写法:
<dependency> <groupId>com.example.local</groupId> <artifactId>legacy-sdk</artifactId> <version>1.0.0</version> <scope>system</scope> <!-- 路径基于项目根目录,避免写死绝对路径 --> <systemPath>${project.basedir}/lib/legacy-sdk-1.0.0.jar</systemPath> </dependency>systemPath必须指向真实存在的文件,Maven 不会去仓库找它。${project.basedir}是项目根目录,这样换机器只要目录结构一致就能编译。问题在打包:maven-assembly-plugin和maven-shade-plugin默认不处理system作用域依赖,spring-boot-maven-plugin需要显式开includeSystemScope。如果团队只是临时联调,用完就撤可以;如果这个包会长期存在,建议走下一节的install-file。
提示:
system作用域在较新的 Maven 版本里并不鼓励使用,部分插件对它的支持也不一致。把它当成临时桥接,不要写进公共父 pom。
3.2 install-file:把本地 jar 装进本地仓库
更稳的做法是把 jar 安装到本地仓库,让它拥有正式坐标,后续依赖声明和普通依赖完全一样。命令不复杂,关键是groupId、artifactId、version要和后面pom.xml里写的完全一致:
mvn install:install-file \ -Dfile=./lib/legacy-sdk-1.0.0.jar \ -DgroupId=com.example.local \ -DartifactId=legacy-sdk \ -Dversion=1.0.0 \ -Dpackaging=jar \ -DgeneratePom=true-Dfile是 jar 路径,可以用相对路径;-DgroupId、-DartifactId、-Dversion是安装后的坐标;-Dpackaging=jar告诉 Maven 构件类型;-DgeneratePom=true会生成一个最小 pom,省去手写。安装完成后,依赖声明里不再需要systemPath:
<dependency> <groupId>com.example.local</groupId> <artifactId>legacy-sdk</artifactId> <version>1.0.0</version> </dependency>这段依赖和普通仓库依赖没有区别,compile作用域默认生效,打包插件也会按常规依赖处理。参数上最容易翻车的是版本不一致:安装时写1.0.0,pom 里写1.0.0-SNAPSHOT,Maven 会认为这是两个构件,然后报找不到。另一个坑是只装到了当前用户的本地仓库,换构建机或 CI 节点就没有。团队协作时要把这个 jar 发布到内部仓库,常用做法是mvn deploy:deploy-file,参数和install-file类似,多一个仓库地址和认证配置。
如果 jar 自带 pom,或者你想指定依赖关系,可以加-DpomFile=./lib/legacy-sdk-1.0.0.pom。没有 pom 时generatePom=true生成的最小 pom 不包含传递依赖,意味着这个本地包依赖的库要由主工程自己声明。别忽略这一点,否则编译过了、运行时报NoClassDefFoundError。
3.3 多模块与离线构建:本地仓库路径和 includeSystemScope
多模块工程里,模块之间用mvn install装进本地仓库,子模块就能按坐标引用。离线构建的前提是本地仓库已经具备全部依赖,包括插件。只写mvn -o不够,先跑一次在线构建把依赖和插件拉全,再用dependency:go-offline做一次预下载:
# 在线阶段:把依赖、插件和报告插件尽量拉全 mvn dependency:go-offline # 离线阶段:只读本地仓库,不再访问远程 mvn -o clean package # 指定本地仓库路径构建,适合构建机挂载独立缓存盘 mvn -Dmaven.repo.local=/data/m2-cache clean packagedependency:go-offline不是绝对完整,某些动态解析的插件仍可能在离线时缺失,所以离线构建前最好完整跑一遍clean package。-Dmaven.repo.local是命令行级别的本地仓库覆盖,优先级高于settings.xml,适合 CI 缓存目录。-o是离线开关,一旦打开,所有远程请求都会失败,包括快照更新。
如果确实还在用system作用域,并且用 Spring Boot 插件打包,需要在插件配置里显式打开:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <!-- 让 repackage 把 system 作用域依赖也放进可执行 jar --> <includeSystemScope>true</includeSystemScope> </configuration> </plugin>includeSystemScope只影响 Spring Boot 的 repackage 过程,不会改变依赖调解,也不会让system依赖变得可传递。换句话说,它只是把文件塞进BOOT-INF/lib,依赖关系仍然要靠主工程自己维护。离线仓库方案更适合长期维护:把本地包发布到内部仓库,构建机只读仓库,不依赖某台开发机的.m2目录。
4. 打可执行 jar 的四种方式:薄 jar、assembly、shade 与 Spring Boot 插件
4.1 maven-jar-plugin + maven-dependency-plugin:薄 jar 加 lib 目录
第一种方式不把依赖塞进主 jar,而是打一个“薄 jar”,旁边放lib目录,启动时通过Class-Path找依赖。它适合依赖体积大、希望多个应用共享 lib 的场景。核心是maven-jar-plugin写主类,maven-dependency-plugin把运行时依赖复制到target/lib:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>${maven-jar-plugin.version}</version> <configuration> <archive> <manifest> <mainClass>com.example.app.Main</mainClass> <!-- 把 lib 目录写进 Class-Path --> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-dependency-plugin</artifactId> <version>${maven-dependency-plugin.version}</version> <executions> <execution> <id>copy-runtime-dependencies</id> <phase>package</phase> <goals> <goal>copy-dependencies</goal> </goals> <configuration> <!-- 只复制运行时依赖,避免测试依赖混入 --> <includeScope>runtime</includeScope> <outputDirectory>${project.build.directory}/lib</outputDirectory> </configuration> </execution> </executions> </plugin>mainClass是全限定类名,必须是包含main方法的类。addClasspath和classpathPrefix一起用,Maven 会把依赖的文件名写进MANIFEST.MF的Class-Path,前缀是lib/。includeScope=runtime排除了provided和test,但system作用域是否被复制要看插件行为,通常不建议依赖这个组合。启动方式是java -jar target/app.jar,前提是target/lib和 jar 在同一目录层级。发布时要把 jar 和 lib 一起拷贝,少一个目录就启动失败。
注意:
Class-Path里的路径是相对 jar 所在目录的,不是相对工作目录。用脚本启动时先cd到 jar 所在目录,或者写绝对路径,别在别的目录直接java -jar /path/app.jar然后怪依赖找不到。
4.2 maven-assembly-plugin:jar-with-dependencies 的 fat jar
第二种方式是把依赖解压后合并进一个 jar,也就是常说的 fat jar。maven-assembly-plugin用内置描述符jar-with-dependencies就能做,配置量小:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>${maven-assembly-plugin.version}</version> <configuration> <descriptorRefs> <descriptorRef>jar-with-dependencies</descriptorRef> </descriptorRefs> <archive> <manifest> <mainClass>com.example.app.Main</mainClass> </manifest> </archive> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin>descriptorRef指定内置描述符,生成的文件名通常带jar-with-dependencies后缀。mainClass写主类,manifest 会带上Main-Class。它的优点是配置简单,适合内部工具和临时交付;缺点是合并策略比较粗,多个依赖里同路径的META-INF/services文件可能互相覆盖,Spring 的spring.factories这类资源也容易出问题。另一个限制是它不处理依赖冲突,同一个类出现在两个 jar 里时,先解压的版本可能胜出,运行时报NoSuchMethodError时很难从 jar 结构上看出来。
如果项目里有system作用域依赖,assembly 默认不会打进去,需要改用install-file或显式配置归档。打包后先看 jar 里有没有主类:
unzip -p target/app-jar-with-dependencies.jar META-INF/MANIFEST.MFunzip -p把指定文件输出到标准输出,不落地临时文件。看到Main-Class再启动,比直接java -jar报错再回头查快。
4.3 maven-shade-plugin:合并资源、重定位与冲突处理
第三种方式也是 fat jar,但maven-shade-plugin对资源合并和包重定位的支持更细。它适合依赖里有大量META-INF/services、或者需要把某个库的包名挪开避免冲突的场景:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>${maven-shade-plugin.version}</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> <configuration> <createDependencyReducedPom>false</createDependencyReducedPom> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>com.example.app.Main</mainClass> </transformer> <!-- 合并 META-INF/services,避免服务发现文件互相覆盖 --> <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/> </transformers> <relocations> <relocation> <pattern>com.example.legacy</pattern> <shadedPattern>com.example.shaded.legacy</shadedPattern> </relocation> </relocations> </configuration> </execution> </executions> </plugin>ManifestResourceTransformer负责写主类,ServicesResourceTransformer负责合并META-INF/services下的服务声明。relocations把指定包名重定位到新包名,适合解决同一个库不同版本的冲突,但重定位不是银弹:反射、字符串写死的类名、资源路径都可能失效。createDependencyReducedPom=false表示不生成精简后的 pom,避免多模块构建时依赖关系被改乱。shade 之后仍然要跑一次java -jar,并且重点看启动日志里有没有ClassNotFoundException或服务加载失败。
4.4 spring-boot-maven-plugin:可执行 jar 的层次结构
如果工程本身就是 Spring Boot 项目,直接用spring-boot-maven-plugin的repackage更省事。它生成的可执行 jar 不是把所有类平铺在根目录,而是把应用类放BOOT-INF/classes,依赖放BOOT-INF/lib,启动器是org.springframework.boot.loader.JarLauncher:
<plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <version>${spring-boot.version}</version> <configuration> <mainClass>com.example.app.Main</mainClass> <!-- 只有确实存在 system 作用域依赖时才打开 --> <includeSystemScope>false</includeSystemScope> </configuration> <executions> <execution> <goals> <goal>repackage</goal> </goals> </execution> </executions> </plugin>repackage会把普通 jar 重新包装成可执行 jar,mainClass不写时插件会尝试自动找,但多主类项目最好显式指定。includeSystemScope=false是默认值,只有本地 jar 还在用system作用域时才考虑打开;长期方案还是把本地包装进仓库。Spring Boot 的 jar 不能简单用unzip后直接java -cp启动,因为类加载器结构不同,验证时用java -jar最直接。
四种方式放在一起对比,选择时看交付形态和依赖复杂度:
| 方式 | 产物形态 | 优点 | 主要边界 |
|---|---|---|---|
| jar-plugin + dependency-plugin | 薄 jar + lib 目录 | 依赖共享、主 jar 小 | 发布要带 lib 目录,路径敏感 |
| assembly | 单 fat jar | 配置简单 | 资源合并粗,冲突处理弱 |
| shade | 单 fat jar | 支持服务合并和重定位 | 重定位可能破坏反射和资源路径 |
| spring-boot-maven-plugin | 层次化可执行 jar | Spring Boot 项目开箱即用 | 非 Spring 项目不适用,结构特殊 |
5. 避坑与排查:可执行 jar 启动失败的 5 个现场
5.1 启动期与清单类问题
现场一:no main manifest attribute。现象是java -jar直接拒绝启动,提示 jar 没有主清单属性。原因通常是maven-jar-plugin没配mainClass,或者 assembly/shade 的 manifest 转换器没生效,也可能是多插件同时写 manifest,后写的覆盖了前一个。解决时先看 manifest:
unzip -p target/app.jar META-INF/MANIFEST.MF确认有Main-Class且类名拼写正确。如果是 shade,检查ManifestResourceTransformer是否在transformers里;如果是 assembly,检查<archive><manifest><mainClass>是否写在正确插件下。多模块项目还要确认打包的是最终模块,不是只打了依赖模块。
现场二:UnsupportedClassVersionError。现象是启动时报类版本不支持。原因一般是编译用的 JDK 主版本高于运行环境,或者某个依赖是用更高版本编译的。解决时不要只看JAVA_HOME,还要看 Maven 编译插件配置里的<source>、<target>、<release>,以及 CI 镜像里的 JDK。用javap -verbose看主类版本,和运行环境对齐。血泪经验是:构建机和运行机的 JDK 大版本不一致时,先在构建机跑一次java -jar,别等发布后才暴露。
现场三:本地 jar 没进 fat jar,运行时报ClassNotFoundException。现象是编译通过,启动时找不到本地包里的类。原因多半是依赖声明用了system作用域,而打包插件默认不包含它;或者install-file安装的坐标和 pom 里的坐标不一致,Maven 实际引用了另一个版本。解决时先看依赖树里有没有目标坐标,再看 jar 里有没有对应类:
mvn dependency:tree -Dincludes=com.example.local:legacy-sdk jar tf target/app.jar | grep legacy-sdk依赖树里没有,说明坐标或作用域有问题;依赖树里有但 jar 里没有,说明打包插件排除了它。把system改成install-file后的普通依赖,或者给 Spring Boot 插件开includeSystemScope,再重新打包验证。
5.2 依赖与仓库类问题
现场四:开发机mvn install后能跑,CI 拉不到包。现象是本地构建成功,流水线报依赖找不到。原因是本地 jar 只装进了开发机的.m2/repository,没有发布到团队仓库;CI 节点用的是自己的本地仓库,自然没有这个构件。解决方式是把本地包deploy到内部仓库,或者在 CI 里显式执行install-file,但后者要求 jar 文件在流水线工作区内。更稳的做法是让仓库管理员统一托管第三方 jar,构建脚本只写普通依赖。别把“我本机能跑”当成依赖已经交付。
现场五:NoSuchMethodError或重复资源导致启动失败。现象是编译没问题,启动时某个类的方法签名对不上,或者META-INF/services里的实现被覆盖。原因是依赖树里同一个库有多个版本,fat jar 合并时选了不兼容的版本;或者 assembly 粗暴合并资源,把服务声明覆盖了。解决时先用mvn dependency:tree -Dverbose找冲突路径,统一版本或排除旧路径;资源合并问题改用 shade 的ServicesResourceTransformer,必要时用relocations隔离包名。改完不要只看编译结果,必须跑一次java -jar并检查启动日志里的服务加载记录。
6. 发布前自检:三条命令验证可执行 jar 与依赖完整性
打可执行 jar 最怕“构建成功但启动失败”。我现在的习惯是发布前固定跑三条检查:先看 manifest,再看 jar 里有没有主类和依赖,最后看运行时依赖树有没有缺口。命令不多,但能把大部分低级问题挡在交付前:
# 1. 看主清单,确认 Main-Class 和 Class-Path unzip -p target/app.jar META-INF/MANIFEST.MF # 2. 看 jar 顶层结构,确认依赖在根目录还是 BOOT-INF/lib jar tf target/app.jar | head -n 40 # 3. 看运行时依赖树,确认没有 provided/test 混入 mvn dependency:tree -Dscope=runtimeunzip -p适合普通 jar 和 Spring Boot jar,Spring Boot 的 manifest 里主类通常是JarLauncher,不是业务主类,这是正常的。jar tf看结构最直观:普通 fat jar 的依赖类在根目录,Spring Boot jar 的依赖在BOOT-INF/lib。-Dscope=runtime用来对齐最终运行视角,如果某个依赖在provided里但运行环境没有提供,这里就能提前发现。三条命令跑完,再执行一次java -jar target/app.jar,基本能覆盖清单、依赖和启动三层问题。
从那以后我每次打可执行 jar 都强制走一遍这三条命令,尤其是换 JDK、换打包插件、引入本地包之后,不再靠“上次能跑”来判断。希望帮到你。
本文还有配套的精品资源,点击获取