1. 为什么要把一个Java项目拆成conf、lib、jar三块再打包
很多人第一次接触"打包"这件事,脑子里只有一个念头:mvn package跑完,target目录下拿到那个jar,扔到服务器上java -jar一跑,收工。这种思路在单体小工具上没问题,但只要项目稍微上点规模,尤其是需要交付给运维、需要频繁改配置、需要多环境部署的时候,把所有东西塞进一个jar里就会变成一场灾难。
我最早踩这个坑是在一个后台服务项目上。当时所有配置都写在src/main/resources里,打出来的jar有六十多兆,里面塞了Spring、数据库驱动、日志框架、各种工具库。测试环境要改一个数据库连接地址,流程是这样的:本地改配置文件,重新mvn package,把新jar传上去,重启服务。改一个字符要等一次完整构建,构建一次两三分钟,一天下来光等构建就能耗掉一两个小时。更麻烦的是,如果运维同学手上有多个环境要同步,每个人手里的jar版本还不一样,出了问题根本说不清是代码问题还是配置问题。
后来我把结构改成了三块分离:conf放配置文件,lib放所有第三方依赖jar,主程序jar只保留自己的class。改配置直接编辑conf目录下的文件,重启即可,不用重新构建;升级依赖只动lib;业务代码变更才重新打主jar。这三块各司其职,交付的时候打成一个zip,解压即用,运维同学再也不用碰Maven。
这个思路本质上是在做关注点分离。配置是会变的,依赖是相对稳定的,业务代码是经常变的,三者的变更频率完全不同。把它们混在一起,就等于用最高的变更频率去绑架最低的变更频率,每次改动都要付出全量构建的代价。拆开之后,每一块独立演进,构建产物也可以分层缓存,效率提升非常明显。
从交付角度看,zip包还有一个隐性好处:它是一个自包含的、跨平台的归档格式。运维不需要知道Maven是什么,不需要装JDK之外的任何东西,解压、改配置、执行启动脚本,三步搞定。这对降低交付沟通成本极其关键。我见过太多项目因为交付物不清晰,运维和开发来回扯皮,最后靠一份手写的部署文档勉强维持,人一换就全乱套。
所以这篇内容适合两类人看:一类是正在做Java服务端项目、准备把构建产物规范化交付的开发者;另一类是接手了老项目、发现打包方式混乱、想重构交付流程的维护者。下面我会把整个方案的原理、配置、踩坑点、验证方法完整讲一遍,配置可以直接抄,但更重要的是理解每一步为什么这么设计。
2. 三块分离的目录结构与Maven插件分工
2.1 最终zip包长什么样
先看目标产物,心里有个图,后面配置才有方向。一个典型的三块分离zip解压后是这样:
myapp/ ├── bin/ │ ├── start.sh │ └── stop.sh ├── conf/ │ ├── application.properties │ ├── logback.xml │ └── db.properties ├── lib/ │ ├── spring-core-5.3.x.jar │ ├── spring-context-5.3.x.jar │ ├── mysql-connector-java-8.x.jar │ └── ...(所有第三方依赖) ├── myapp.jar # 主程序jar,只含业务class └── README.md这里有几个关键点需要说清楚。myapp.jar里不应该包含任何第三方依赖,也不应该包含application.properties这类配置文件。它只装自己写的class和少量必须内置的资源(比如MyBatis的mapper xml,如果不想外置的话)。lib目录里是mvn dependency:copy-dependencies拷出来的全部依赖。conf目录是构建时从src/main/resources里挑出来的配置文件。
启动的时候,classpath需要同时包含主jar和lib下所有jar。这就是为什么需要bin/start.sh,因为java -jar这种启动方式默认只认主jar的MANIFEST,不会自动加载lib目录。这一点是新手最容易翻车的地方,后面会专门讲。
2.2 三个Maven插件各管一段
实现这个结构,核心靠三个插件,各管一段,职责不能混:
| 插件 | 职责 | 关键配置 |
|---|---|---|
| maven-jar-plugin | 打主jar,指定Main-Class和Class-Path | <manifest>里的mainClass和addClasspath |
| maven-dependency-plugin | 把依赖拷到lib目录 | copy-dependenciesgoal,outputDirectory |
| maven-assembly-plugin | 把conf、lib、jar、bin组装成zip | descriptor或fileSets |
很多人会想用maven-shade-plugin打一个fat jar,然后外面再套zip。我不推荐这么做,原因有两个:一是fat jar把依赖全塞进去,等于又回到了"改配置要重新构建"的老路;二是shade插件在处理META-INF/services合并时经常出问题,尤其是用了SPI机制(比如JDBC驱动、日志实现)的项目,很容易出现"本地能跑、打包后找不到实现类"的诡异现象。三块分离天然规避了这些问题。
maven-jar-plugin的addClasspath配置是关键。它会在MANIFEST.MF里生成Class-Path条目,把lib下的jar都列进去。但这里有个坑:Class-Path里的路径是相对于主jar所在目录的,所以如果主jar和lib不在同一层,路径就会错。我们的结构里主jar在根目录,lib在lib/子目录,所以Class-Path里要写成lib/xxx.jar的形式。addClasspath配合classpathPrefix就能自动生成这个前缀。
2.3 为什么不用spring-boot-maven-plugin
如果项目是Spring Boot,很多人第一反应是用spring-boot-maven-plugin的repackage。它确实能打出一个可执行jar,但那个jar本质是fat jar,依赖全在里面。Spring Boot官方其实也支持"分离"模式,通过layout配置成ZIP或者用spring-boot-thin-launcher,但配置起来比直接用上面三个插件复杂,而且和Spring Boot版本强绑定,升级时容易出兼容问题。
我的经验是:如果交付形态要求conf/lib/jar分离,就别用spring-boot-maven-plugin的默认repackage,老老实实用原生插件组合。这样构建逻辑透明,出问题好排查,也不受框架版本约束。Spring Boot的启动类照样能跑,只要classpath对,它不关心依赖是塞在jar里还是放在lib目录。
3. pom.xml完整配置与逐段拆解
3.1 主jar打包配置
先看maven-jar-plugin这一段:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <archive> <manifest> <mainClass>com.example.myapp.Bootstrap</mainClass> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> </manifest> <manifestEntries> <Class-Path>conf/</Class-Path> </manifestEntries> </archive> <excludes> <exclude>**/*.properties</exclude> <exclude>**/*.xml</exclude> <exclude>**/*.yml</exclude> </excludes> </configuration> </plugin>逐条解释。mainClass指定入口类,这个不用多说。addClasspath=true让插件自动扫描项目依赖并写入MANIFEST的Class-Path。classpathPrefix=lib/给每个依赖路径加上lib/前缀,这样运行时JVM会去lib/目录找jar。
manifestEntries里手动加了Class-Path: conf/,这一步是为了让conf目录也进入classpath。注意,conf/后面必须带斜杠,表示目录,否则JVM会当成一个文件去找。这个细节我踩过坑:不带斜杠时,配置文件死活加载不到,排查了半天才发现是MANIFEST里路径写法的问题。
excludes把properties、xml、yml都排除掉,确保配置文件不会被打进主jar。这里要小心,如果你的项目里有MyBatis的mapper xml,且不想外置,就不能排除xml,得改成只排除application*.properties这类。排除规则要按项目实际情况调整,不能无脑抄。
3.2 依赖拷贝配置
<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> <overWriteIfNewer>true</overWriteIfNewer> <includeScope>runtime</includeScope> </configuration> </execution> </executions> </plugin>outputDirectory指向target/lib,构建完依赖就躺在这里。includeScope=runtime只拷运行时需要的依赖,把provided和test范围的排除掉。这一点很重要:比如servlet-api通常是provided,由容器提供,拷进去反而可能引起冲突。overWriteIfNewer=true表示只有依赖更新了才覆盖,能加快重复构建速度。
有个细节值得说:copy-dependencies默认会把依赖的传递依赖也拷进来,这正是我们想要的。但如果你项目里有依赖冲突(比如两个版本的同一个库),Maven的依赖调解机制会选一个,拷出来的就是调解后的结果。所以打包前最好跑一次mvn dependency:tree看看有没有意外的版本,避免线上出现"类找不到"或"方法不存在"。
3.3 assembly组装zip
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.6.0</version> <executions> <execution> <id>make-zip</id> <phase>package</phase> <goals> <goal>single</goal> </goals> <configuration> <descriptors> <descriptor>src/assembly/package.xml</descriptor> </descriptors> </configuration> </execution> </executions> </plugin>assembly插件用外部descriptor文件来描述zip结构,比内联配置清晰。descriptor放在src/assembly/package.xml:
<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.0"> <id>dist</id> <formats> <format>zip</format> </formats> <includeBaseDirectory>true</includeBaseDirectory> <baseDirectory>${project.artifactId}</baseDirectory> <fileSets> <fileSet> <directory>${project.build.directory}/lib</directory> <outputDirectory>lib</outputDirectory> </fileSet> <fileSet> <directory>src/main/resources</directory> <outputDirectory>conf</outputDirectory> <includes> <include>*.properties</include> <include>*.xml</include> <include>*.yml</include> </includes> </fileSet> <fileSet> <directory>src/bin</directory> <outputDirectory>bin</outputDirectory> <fileMode>0755</fileMode> </fileSet> </fileSets> <files> <file> <source>${project.build.directory}/${project.build.finalName}.jar</source> <outputDirectory>/</outputDirectory> </file> </files> </assembly>includeBaseDirectory=true让zip解压后有一个顶层目录,避免文件散落在当前目录。baseDirectory用artifactId命名,解压出来就是myapp/。fileMode=0755给bin下的脚本加执行权限,这个在Linux上很关键,否则解压后脚本没有执行权限,还得手动chmod。
files段把主jar放到zip根目录。注意这里用的是${project.build.finalName}.jar,它对应maven-jar-plugin打出来的jar名。如果项目里改了finalName,这里会自动跟着变,不用手动同步。
4. 启动脚本与classpath的坑
4.1 start.sh怎么写才稳
#!/bin/bash APP_HOME=$(cd "$(dirname "$0")/.." && pwd) APP_NAME=$(basename "$APP_HOME") MAIN_CLASS=com.example.myapp.Bootstrap CLASSPATH="$APP_HOME/conf" CLASSPATH="$CLASSPATH:$APP_HOME/$APP_NAME.jar" for jar in "$APP_HOME"/lib/*.jar; do CLASSPATH="$CLASSPATH:$jar" done JAVA_OPTS="-Xms512m -Xmx1024m -Dfile.encoding=UTF-8" nohup java $JAVA_OPTS -classpath "$CLASSPATH" $MAIN_CLASS > /dev/null 2>&1 & echo $! > "$APP_HOME/bin/app.pid"这段脚本有几个设计点。第一,用dirname "$0"定位脚本所在目录,再往上退一级拿到APP_HOME,这样无论从哪里调用脚本都能正确定位。第二,classpath手动拼接,conf在最前,主jar其次,lib最后。conf放最前是为了让外置配置优先于jar内可能残留的配置。第三,用-classpath而不是-jar,因为-jar会忽略-classpath参数,这是JVM的硬性规定,很多人在这里翻车。
注意:
java -jar和java -classpath是互斥的。一旦用了-jar,JVM只认MANIFEST里的Class-Path,命令行给的-classpath会被忽略。所以三块分离方案必须用-classpath加主类名的方式启动。
4.2 为什么MANIFEST的Class-Path和脚本里的classpath要双保险
你可能会问:MANIFEST里已经写了Class-Path,脚本里又拼一遍,是不是重复了?确实重复,但这是有意的双保险。MANIFEST里的Class-Path在java -jar时生效,脚本里的classpath在java -classpath时生效。实际启动用的是后者,所以MANIFEST里的配置主要是给那些习惯用java -jar的人一个兜底。但要注意,如果lib目录里的jar有增减,MANIFEST不会自动更新(除非重新构建),而脚本里的通配符lib/*.jar是动态的,永远和实际文件一致。所以脚本方式更可靠。
还有一个隐藏问题:lib/*.jar这种通配符在classpath里,JVM是支持的,但仅限于*匹配整个目录下的jar,不能写成lib/*.jar之外的模式。而且通配符不会递归子目录。如果你的lib下有子目录,得单独处理。我一般保持lib扁平,不放子目录。
4.3 配置文件外置后的加载顺序
配置文件从jar里挪到conf目录后,加载方式要跟着调整。如果用的是Spring Boot,默认会从classpath根目录找application.properties,而conf已经在classpath里了,所以能直接找到。但如果你有多个配置文件,或者用了profile机制,就要注意优先级。
我的做法是在启动脚本里显式指定配置路径:
JAVA_OPTS="$JAVA_OPTS -Dspring.config.location=$APP_HOME/conf/"这样Spring Boot会去conf目录找配置,而不是去jar里找。对于非Spring Boot项目,如果用的是ClassLoader.getResourceAsStream("db.properties"),因为conf在classpath里,也能正常加载。关键是确保conf目录在classpath中,且优先级足够高。
5. 实测中遇到的五个典型问题与排查过程
5.1 问题一:启动报ClassNotFoundException,但lib里明明有那个jar
这个问题的排查链路我完整走一遍。第一步,确认lib目录下确实有目标jar,ls lib/ | grep 目标。第二步,确认脚本里的classpath拼接正确,在脚本里加一行echo $CLASSPATH打印出来看。第三步,如果classpath看着没问题,用java -verbose:class -classpath ... 主类启动,观察JVM实际加载了哪些类,看目标类有没有被加载。
我遇到的一次真实原因是:lib目录下的jar文件名里带了空格或者特殊字符,导致classpath拼接时被截断。比如某个依赖的artifactId里有空格(极少见但存在),for jar in lib/*.jar循环时没加引号,$jar展开后就被shell按空格拆成了多个参数。解决办法是循环里用"$jar"加引号。这个坑很隐蔽,因为文件名带空格的情况不常见,一旦遇到很难想到。
5.2 问题二:配置文件改了不生效
排查思路:先确认conf目录在classpath里,用java -verbose:class看不到配置文件的加载,得用代码打印getClass().getResource("/application.properties")的路径,看实际加载的是哪个文件。如果加载的是jar里的,说明conf没进classpath,或者优先级不够。
我遇到的原因是MANIFEST里写了Class-Path: conf(没带斜杠),JVM把它当成一个名为conf的文件去找,自然找不到。加上斜杠后解决。另一个原因是启动脚本里classpath拼接时conf放在了主jar后面,而主jar里残留了同名配置文件,导致jar内的优先。把conf挪到最前面就好了。
5.3 问题三:zip解压后脚本没有执行权限
这个前面提过,fileMode=0755能解决。但要注意,fileMode只在Unix-like系统上生效,Windows上打zip时权限信息可能丢失。如果交付环境是Windows,脚本得用.bat,权限问题不存在。如果交付环境是Linux但构建在Windows上,建议在CI流水线里用Linux构建,或者在部署文档里明确写一句"解压后执行chmod +x bin/*.sh"。
5.4 问题四:依赖冲突导致运行时NoSuchMethodError
打包时Maven会做依赖调解,但调解结果不一定符合预期。比如项目直接依赖了A库1.0,间接依赖了B库,B库又依赖A库2.0,Maven默认选路径最短的,也就是1.0。如果B库的某个方法在2.0才有,运行时就会报NoSuchMethodError。
排查方法:mvn dependency:tree -Dverbose看完整的依赖树,找到冲突的库,用<exclusions>排除掉旧版本,或者用<dependencyManagement>强制指定版本。打包前跑一次依赖树分析,能避免大部分运行时问题。
5.5 问题五:zip包体积过大
lib目录把所有依赖都拷进去,体积可能上百兆。如果交付带宽有限,可以考虑几个优化:一是用maven-dependency-plugin的excludeGroupIds排除掉明显不需要的依赖(比如某些库的文档、源码包);二是对lib目录做压缩,但zip本身已经压缩了,效果有限;三是把不常变的依赖单独打一个基础包,业务包只带增量,但这会增加部署复杂度,一般项目不值得。
我的经验是,除非体积真的成了瓶颈,否则不要过度优化。一个上百兆的zip,传输和存储成本在现代环境下可以接受,为了省几十兆引入复杂的部署逻辑,得不偿失。
6. 构建产物验证与交付清单
6.1 本地验证三步走
构建完成后,别急着交付,本地先验证三件事。第一步,解压zip,检查目录结构是否符合预期,conf、lib、bin、主jar都在。第二步,直接运行bin/start.sh,看服务能否正常启动,日志有没有报错。第三步,改一下conf里的某个配置(比如日志级别),重启,确认改动生效。这三步走完,基本能排除90%的打包问题。
我还会额外做一件事:把zip解压到一个全新的目录,模拟运维的视角,不看任何文档,只凭目录结构尝试启动。如果我自己都启动不起来,说明交付物不够自解释,得补充README或者调整结构。
6.2 交付清单模板
一份清晰的交付清单能省掉大量沟通。我通常会在zip根目录放一个README.md,内容包含:
| 项目 | 说明 |
|---|---|
| 启动命令 | bin/start.sh |
| 停止命令 | bin/stop.sh |
| 配置文件位置 | conf/目录,修改后重启生效 |
| 日志位置 | logs/目录(如果配置了外置日志) |
| JDK要求 | JDK 1.8+ |
| 端口 | 默认8080,可在conf中修改 |
这个清单不用长,但要把运维最关心的信息放进去。我见过太多项目交付时只给一个zip,运维拿到手一脸懵,然后开发被拉去开会解释半小时。一份README能把这半小时省下来。
6.3 版本号与构建信息写入
建议在MANIFEST里写入构建版本和构建时间,方便线上排查时确认部署的是哪个版本:
<manifestEntries> <Implementation-Version>${project.version}</Implementation-Version> <Build-Time>${maven.build.timestamp}</Build-Time> </manifestEntries>maven.build.timestamp需要在properties里定义格式:<maven.build.timestamp.format>yyyy-MM-dd HH:mm:ss</maven.build.timestamp.format>。这样每次构建的jar里都带着时间戳,线上出问题时一看就知道是什么时候构建的,比翻构建记录快得多。
7. 一些个人实践中的取舍与建议
关于conf目录要不要放日志配置,我的做法是放。logback.xml或者log4j2.xml放在conf里,运维可以随时调整日志级别,不用重新构建。但要注意,日志文件输出路径最好用相对路径或者通过启动参数指定,别写死在配置里,否则换环境又要改。
关于lib目录要不要按功能分子目录,我倾向于不分子目录。扁平结构简单,classpath通配符也好写。分子目录虽然看起来整齐,但classpath要递归处理,脚本复杂度上升,收益不明显。
关于assembly和shade的选择,我再强调一次:只要交付形态要求分离,就别用shade。shade适合打fat jar做独立工具,不适合做分层交付。assembly虽然配置啰嗦一点,但结构透明,可控性强。
最后说一个我踩过的坑:有次项目里用了maven-resources-plugin做资源过滤,把配置文件里的占位符替换成实际值。结果打包时conf目录里的文件是过滤后的,但lib里的依赖jar里也有同名配置文件,运行时加载了lib里的旧配置,导致过滤没生效。排查了很久才发现是classpath顺序问题。解决办法是把conf放在classpath最前面,并且确保lib里的依赖不包含同名配置。这个坑提醒我,classpath顺序不是小事,配置文件的加载优先级一定要心里有数。
整套方案跑通之后,构建一次大概几十秒,改配置重启几秒钟,交付物清晰,运维友好。这套结构我用了好几个项目,稳定性没问题,配置可以直接拿去改改就用。唯一需要根据项目调整的是排除规则和启动脚本里的主类名,其他部分基本通用。