Maven分离式打包实战:conf、lib、jar拆解与Zip交付方案
1. 项目打包需求与方案选型分析1.1 为什么要把conf、lib、jar拆开打包做过Java后端交付的人应该都遇到过这种场景项目在本地跑得好好的一放到客户服务器上就各种报错排查半天发现是配置文件路径不对或者依赖的某个jar包版本和客户环境里的冲突了。更头疼的是每次改一个数据库连接池大小、调一个日志级别都得重新打包、重新上传整个几十兆甚至上百兆的fat jar运维效率极低。这就是为什么越来越多的团队在Maven构建时选择把项目打成Zip包并且把conf、lib、jar三者分离。具体来说这种结构带来的好处非常直接配置与代码解耦运维人员只需要修改conf目录下的配置文件不需要碰jar包也不需要重新构建。改完重启服务即可生效大幅缩短了配置变更的交付链路。依赖统一管理所有第三方依赖集中在lib目录排查依赖冲突时一目了然也方便做依赖版本的统一升级或替换。主程序包体积小业务代码打成独立的jar通常只有几百KB到几MB传输和部署速度快版本回滚也方便。启动脚本可控通过脚本显式指定classpath避免不同环境下的类加载顺序问题也方便加入JVM参数调优。我见过不少团队一开始图省事直接用maven-shade-plugin打fat jar结果后期维护成本越来越高。尤其是当项目依赖了上百个jar包时每次构建出来的包动辄一两百MBCI/CD流水线的构建时间和存储成本都很可观。所以如果你的项目需要频繁交付到不同环境或者运维和开发是分离的团队这种分离式打包方案几乎是必选项。1.2 三种常见打包方式的对比在Maven生态里打包成可交付产物的方式主要有三种各有适用场景。我整理了一个对比表格方便你根据实际情况做选择打包方式典型插件产物结构优点缺点Fat Jarmaven-shade-plugin / maven-assembly-plugin单一jar包含所有依赖部署简单一个文件搞定体积大配置无法外置依赖冲突难排查目录分离maven-assembly-plugin / maven-dependency-pluginconf lib jar 分目录配置灵活依赖清晰便于运维需要配套启动脚本初次搭建稍复杂容器镜像jib-maven-plugin / docker-maven-plugin镜像内分层环境一致性好适合云原生需要容器环境学习成本较高这篇内容聚焦的是第二种——目录分离式打包。它的核心思路是用maven-jar-plugin打业务jar用maven-dependency-plugin拷贝依赖到lib用maven-resources-plugin或maven-assembly-plugin把配置文件和启动脚本组织到conf和bin目录最后用maven-assembly-plugin整体打成Zip。1.3 整体目录结构设计在动手写配置之前先把最终的产物结构定下来。一个典型的分离式Zip包解压后长这样my-project/ ├── bin/ │ ├── start.sh │ └── stop.sh ├── conf/ │ ├── application.properties │ ├── logback.xml │ └── db.properties ├── lib/ │ ├── spring-core-5.3.20.jar │ ├── spring-context-5.3.20.jar │ └── ...其他依赖 └── my-project-1.0.0.jar这个结构的设计逻辑是bin放启动脚本conf放所有可变的配置lib放不变的依赖根目录放业务jar。运维拿到Zip后解压改conf里的配置执行bin/start.sh就能启动。整个过程中不需要接触lib和业务jar职责边界非常清晰。注意conf目录里的配置文件不要打进业务jar里否则会出现“jar内配置”和“jar外配置”同时存在的情况类加载时到底读哪个取决于classpath顺序极易引发诡异问题。正确的做法是在maven-jar-plugin里通过excludes把配置文件排除掉。2. 核心插件配置与参数详解2.1 maven-jar-plugin业务jar的精细化控制maven-jar-plugin负责把编译后的class文件打成业务jar。在分离式打包场景下它的核心任务是两件事一是排除掉不该进jar的文件二是在MANIFEST.MF里写入正确的classpath和主类信息。先看配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-jar-plugin/artifactId version3.3.0/version configuration excludes exclude**/*.properties/exclude exclude**/*.xml/exclude exclude**/*.yml/exclude exclude**/*.yaml/exclude /excludes archive manifest addClasspathtrue/addClasspath classpathPrefixlib//classpathPrefix mainClasscom.example.MainApplication/mainClass /manifest manifestEntries Class-Pathconf//Class-Path /manifestEntries /archive /configuration /plugin这里有几个关键点需要展开说excludes的取舍把properties、xml、yml、yaml都排除掉是为了让所有配置都走外置conf目录。但要注意如果你的项目里有MyBatis的mapper xml文件这些是必须打进jar的不能一刀切排除。这时候可以精确指定路径比如只排除**/application*.properties和**/logback*.xml保留mapper目录下的xml。classpathPrefix的作用addClasspathtrue会在MANIFEST.MF里生成Class-Path条目classpathPrefixlib/表示所有依赖jar的路径前面加上lib/前缀。这样用java -jar启动时JVM会自动去lib目录找依赖。实测下来这个机制很稳但有个坑Class-Path里的路径是相对于jar所在目录的所以业务jar必须和lib目录在同一级不能挪位置。manifestEntries补充conf路径默认的Class-Path只包含依赖jar不包含conf目录。加上Class-Pathconf//Class-Path后conf目录也会被加入类路径这样application.properties就能被Spring正常加载。多个路径用空格分隔比如conf/ conf/i18n/。2.2 maven-dependency-plugin依赖拷贝的正确姿势依赖拷贝看起来简单但实际操作中有几个细节容易翻车。先看配置plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-dependency-plugin/artifactId version3.6.0/version executions execution idcopy-dependencies/id phasepackage/phase goals goalcopy-dependencies/goal /goals configuration outputDirectory${project.build.directory}/lib/outputDirectory includeScoperuntime/includeScope excludeTransitivefalse/excludeTransitive overWriteReleasesfalse/overWriteReleases overWriteSnapshotstrue/overWriteSnapshots /configuration /execution /executions /pluginincludeScope的选择这里设为runtime表示只拷贝运行时需要的依赖test范围的依赖不会被拷进去。如果你设成compile那test依赖也不会进来但provided范围的依赖同样不会进来。一般来说runtime是最合适的它包含了compile和runtime两种scope的依赖。excludeTransitive的坑这个参数默认是false表示会拷贝传递性依赖。千万别设成true否则你只会得到直接依赖间接依赖全丢了启动时必然报ClassNotFoundException。我见过有人为了减小lib体积把它设成true结果排查了半天才发现问题。overWrite策略overWriteReleasesfalse表示release版本的jar如果已存在就不覆盖overWriteSnapshotstrue表示snapshot版本每次都覆盖。这个组合在本地反复构建时能省不少时间但在CI环境里建议都设成true保证每次构建的产物是干净的。2.3 maven-assembly-pluginZip包的组织者maven-assembly-plugin负责把前面几个插件产出的东西组装成一个Zip。它需要一个assembly描述文件通常放在src/main/assembly/目录下命名为package.xmlassembly xmlnshttp://maven.apache.org/ASSEMBLY/2.1.1 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/ASSEMBLY/2.1.1 http://maven.apache.org/xsd/assembly-2.1.1.xsd idbin/id formats formatzip/format /formats includeBaseDirectorytrue/includeBaseDirectory baseDirectory${project.artifactId}-${project.version}/baseDirectory fileSets fileSet directory${project.build.directory}/directory outputDirectory//outputDirectory includes include*.jar/include /includes /fileSet fileSet directory${project.build.directory}/lib/directory outputDirectory/lib/outputDirectory /fileSet fileSet directorysrc/main/resources/directory outputDirectory/conf/outputDirectory includes include*.properties/include include*.xml/include include*.yml/include /includes /fileSet fileSet directorysrc/main/bin/directory outputDirectory/bin/outputDirectory fileMode0755/fileMode /fileSet /fileSets /assemblyincludeBaseDirectory设为true时Zip解压后会多一层项目名-版本号的目录避免文件散落在当前目录。这个建议开启否则解压时容易和已有文件混在一起。fileModebin目录下的脚本必须设成0755否则解压后没有执行权限还得手动chmod。这个细节很多人会忽略导致运维拿到包后第一件事就是chmod体验很差。然后在pom里绑定assembly插件plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-assembly-plugin/artifactId version3.6.0/version configuration descriptors descriptorsrc/main/assembly/package.xml/descriptor /descriptors finalName${project.artifactId}-${project.version}/finalName appendAssemblyIdfalse/appendAssemblyId /configuration executions execution idmake-assembly/id phasepackage/phase goals goalsingle/goal /goals /execution /executions /pluginappendAssemblyIdfalse表示最终Zip文件名不带assembly的id后缀否则会变成my-project-1.0.0-bin.zip。这个看个人喜好我倾向于设成false文件名更干净。3. 启动脚本编写与实操流程3.1 start.sh的完整实现启动脚本是分离式打包的灵魂它决定了服务能不能正确加载conf和lib。下面是一个经过生产验证的脚本模板#!/bin/bash APP_NAMEmy-project APP_HOME$(cd $(dirname $0)/.. pwd) JAR_NAME${APP_NAME}.jar CONF_DIR${APP_HOME}/conf LIB_DIR${APP_HOME}/lib LOG_DIR${APP_HOME}/logs PID_FILE${APP_HOME}/bin/${APP_NAME}.pid JAVA_OPTS-Xms512m -Xmx1024m -XX:UseG1GC JAVA_OPTS${JAVA_OPTS} -Dfile.encodingUTF-8 JAVA_OPTS${JAVA_OPTS} -Dlogging.config${CONF_DIR}/logback.xml JAVA_OPTS${JAVA_OPTS} -Dspring.config.location${CONF_DIR}/ if [ ! -d ${LOG_DIR} ]; then mkdir -p ${LOG_DIR} fi if [ -f ${PID_FILE} ]; then PID$(cat ${PID_FILE}) if kill -0 ${PID} 2/dev/null; then echo 服务已在运行PID${PID} exit 1 fi fi nohup java ${JAVA_OPTS} \ -classpath ${CONF_DIR}:${LIB_DIR}/*:${APP_HOME}/${JAR_NAME} \ com.example.MainApplication \ ${LOG_DIR}/console.log 21 echo $! ${PID_FILE} echo 服务已启动PID$!这个脚本里有几个设计要点值得说明APP_HOME的推导用cd $(dirname $0)/.. pwd来获取项目根目录这样无论从哪个目录执行脚本路径都是对的。比硬编码路径可靠得多。classpath的拼接${CONF_DIR}:${LIB_DIR}/*:${APP_HOME}/${JAR_NAME}注意lib目录用的是/*通配符这是Java 6以后支持的写法表示加载该目录下所有jar。conf目录放在最前面保证外置配置优先于jar内配置被加载。spring.config.location显式指定Spring Boot的配置目录避免它去jar内部找配置。如果你的项目不是Spring Boot这个参数可以去掉改用-Dconfig.path之类的自定义参数。PID文件管理把PID写到文件里stop.sh就能根据PID优雅停机避免kill -9导致的数据丢失。3.2 stop.sh与重启逻辑#!/bin/bash APP_NAMEmy-project APP_HOME$(cd $(dirname $0)/.. pwd) PID_FILE${APP_HOME}/bin/${APP_NAME}.pid if [ ! -f ${PID_FILE} ]; then echo PID文件不存在服务可能未运行 exit 0 fi PID$(cat ${PID_FILE}) if ! kill -0 ${PID} 2/dev/null; then echo 进程 ${PID} 不存在清理PID文件 rm -f ${PID_FILE} exit 0 fi kill ${PID} for i in $(seq 1 30); do if ! kill -0 ${PID} 2/dev/null; then echo 服务已停止 rm -f ${PID_FILE} exit 0 fi sleep 1 done echo 优雅停机超时强制终止 kill -9 ${PID} rm -f ${PID_FILE}这个stop脚本先发SIGTERM信号给应用30秒时间做优雅停机比如等待正在处理的请求完成、关闭连接池等超时后才用SIGKILL强制终止。这个设计在生产环境非常重要直接kill -9可能导致事务未提交、文件句柄未释放等问题。3.3 完整构建与验证流程配置写好后执行构建mvn clean package -DskipTests构建完成后在target/目录下会看到my-project-1.0.0.jar业务jarlib/依赖目录my-project-1.0.0.zip最终交付包验证Zip包结构是否正确unzip -l target/my-project-1.0.0.zip确认输出里包含bin、conf、lib和业务jar四部分。然后解压到临时目录做一次启动测试mkdir -p /tmp/deploy-test unzip target/my-project-1.0.0.zip -d /tmp/deploy-test cd /tmp/deploy-test/my-project-1.0.0 chmod x bin/*.sh bin/start.sh tail -f logs/console.log看到应用正常启动、端口监听成功后再执行bin/stop.sh确认能正常停止。这一套流程走通说明打包配置没问题。实操心得建议在CI流水线里加一步“解压后启动测试”用脚本自动验证Zip包的完整性。我见过好几次因为assembly描述文件里路径写错导致conf目录没打进去本地没发现到了测试环境才暴露。4. 常见问题排查与避坑指南4.1 配置文件加载不到的问题这是分离式打包最高频的问题表现是应用启动时报FileNotFoundException或者配置项全是默认值。排查思路按以下顺序来第一步确认conf目录是否真的在Zip里。用unzip -l看包内容如果conf目录缺失检查assembly描述文件里的fileSet路径是否正确。常见错误是directorysrc/main/resources/directory写成了src/main/resource少个s。第二步确认classpath里有没有conf。启动脚本里-classpath参数必须包含${CONF_DIR}且要放在lib前面。如果用的是java -jar方式启动那conf目录必须写进MANIFEST.MF的Class-Path里。第三步确认配置文件名和加载逻辑匹配。Spring Boot默认找application.properties或application.yml如果你外置的文件叫app.properties那就得通过spring.config.nameapp显式指定。我踩过的一个坑是conf目录里放了application.properties但jar包里也有一份同名的结果Spring加载了jar内的那份。原因是classpath顺序不对conf没有排在前面。解决办法就是在启动脚本里把conf目录放在classpath最前面同时在maven-jar-plugin里把配置文件排除掉双保险。4.2 依赖冲突与版本不一致lib目录里的依赖多了之后冲突概率会上升。典型表现是NoSuchMethodError、ClassNotFoundException或者LinkageError。排查方法mvn dependency:tree -Dverbose dep-tree.txt打开dep-tree.txt搜索omitted for conflict这些就是被Maven仲裁掉的版本。如果被仲裁掉的版本恰好是运行时需要的就会出问题。解决办法是在pom里用dependencyManagement显式锁定版本或者用exclusions排除掉不需要的传递依赖。另一个常见问题是同一个jar在lib目录里出现了多个版本。这通常是因为不同模块依赖了同一个库的不同版本且scope设置导致都被拷了进来。可以在dependency-plugin里加excludeGroupIds或excludeArtifactIds来排除但更推荐从源头用dependencyManagement统一版本。4.3 启动脚本权限与换行符问题脚本在Windows上编辑后传到Linux经常因为CRLF换行符导致bad interpreter: No such file or directory。解决办法是在项目里加.gitattributes文件*.sh text eollf或者在构建时用dos2unix转换。另外assembly里的fileMode虽然设了0755但有些解压工具不认这个权限位所以start.sh里最好加一句自检if [ ! -x $0 ]; then chmod x $0 fi4.4 常见问题速查表问题现象可能原因排查方法解决方案启动报ClassNotFoundExceptionlib目录缺失或classpath未包含lib检查Zip内是否有lib目录确认dependency-plugin的outputDirectory配置不生效conf未加入classpath或jar内有同名配置检查启动脚本classpath顺序conf放最前jar内排除配置NoSuchMethodError依赖版本冲突mvn dependency:treedependencyManagement锁定版本脚本无法执行权限位丢失或CRLF换行ls -l查看权限file查看换行chmod x.gitattributes设lfZip解压后目录散乱includeBaseDirectory为falseunzip -l查看结构设为true并指定baseDirectory日志文件没生成logback配置路径不对检查-Dlogging.config参数用绝对路径指向conf目录4.5 几个容易被忽略的细节JVM参数外置不要把Xmx、GC参数硬编码在脚本里建议放到conf目录下的jvm.options文件脚本读取该文件拼接参数。这样运维调优时不用改脚本降低出错概率。时区设置容器化部署时经常遇到时区不对导致日志时间差8小时。在启动脚本里加-Duser.timezoneAsia/Shanghai或者在conf里统一配置。依赖jar的签名问题如果lib目录里有签名过的jar比如某些加密库拷贝过程中如果被修改会导致签名校验失败。确保dependency-plugin不做任何jar内容修改只做拷贝。构建可重复性assembly插件打Zip时文件的时间戳会影响Zip的MD5。如果CI需要校验产物一致性可以在assembly配置里加tarLongFileModeposix/tarLongFileMode并配合maven-buildmetadata-plugin固定时间戳。这个属于进阶需求普通交付场景可以忽略。我在实际交付中最大的体会是分离式打包的配置本身不复杂难的是把每一个细节都考虑到尤其是路径、权限、classpath顺序这三块。建议第一次搭建时每配一个插件就构建一次解压看结构确认无误再配下一个。全部配完后写一个自动化验证脚本把解压、启动、健康检查、停止这一套跑通之后每次改配置都跑一遍能省掉大量排查时间。这套方案我们团队用了三年多交付过几十个项目稳定性是经过验证的。