☰
Maven分离式打包实战:conf、lib、jar拆解与Zip交付方案
2026/10/19 9:05:00 网站建设 项目流程

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包时,每次构建出来的包动辄一两百MB,CI/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> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.3.0</version> <configuration> <excludes> <exclude>**/*.properties</exclude> <exclude>**/*.xml</exclude> <exclude>**/*.yml</exclude> <exclude>**/*.yaml</exclude> </excludes> <archive> <manifest> <addClasspath>true</addClasspath> <classpathPrefix>lib/</classpathPrefix> <mainClass>com.example.MainApplication</mainClass> </manifest> <manifestEntries> <Class-Path>conf/</Class-Path> </manifestEntries> </archive> </configuration> </plugin>

这里有几个关键点需要展开说:

excludes的取舍:把properties、xml、yml、yaml都排除掉,是为了让所有配置都走外置conf目录。但要注意,如果你的项目里有MyBatis的mapper xml文件,这些是必须打进jar的,不能一刀切排除。这时候可以精确指定路径,比如只排除**/application*.properties和**/logback*.xml,保留mapper目录下的xml。

classpathPrefix的作用:addClasspath=true会在MANIFEST.MF里生成Class-Path条目,classpathPrefix=lib/表示所有依赖jar的路径前面加上lib/前缀。这样用java -jar启动时,JVM会自动去lib目录找依赖。实测下来这个机制很稳,但有个坑:Class-Path里的路径是相对于jar所在目录的,所以业务jar必须和lib目录在同一级,不能挪位置。

manifestEntries补充conf路径:默认的Class-Path只包含依赖jar,不包含conf目录。加上<Class-Path>conf/</Class-Path>后,conf目录也会被加入类路径,这样application.properties就能被Spring正常加载。多个路径用空格分隔,比如conf/ conf/i18n/。

2.2 maven-dependency-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> <includeScope>runtime</includeScope> <excludeTransitive>false</excludeTransitive> <overWriteReleases>false</overWriteReleases> <overWriteSnapshots>true</overWriteSnapshots> </configuration> </execution> </executions> </plugin>

includeScope的选择:这里设为runtime,表示只拷贝运行时需要的依赖,test范围的依赖不会被拷进去。如果你设成compile,那test依赖也不会进来,但provided范围的依赖同样不会进来。一般来说runtime是最合适的,它包含了compile和runtime两种scope的依赖。

excludeTransitive的坑:这个参数默认是false,表示会拷贝传递性依赖。千万别设成true,否则你只会得到直接依赖,间接依赖全丢了,启动时必然报ClassNotFoundException。我见过有人为了减小lib体积把它设成true,结果排查了半天才发现问题。

overWrite策略:overWriteReleases=false表示release版本的jar如果已存在就不覆盖,overWriteSnapshots=true表示snapshot版本每次都覆盖。这个组合在本地反复构建时能省不少时间,但在CI环境里建议都设成true,保证每次构建的产物是干净的。

2.3 maven-assembly-plugin:Zip包的组织者

maven-assembly-plugin负责把前面几个插件产出的东西组装成一个Zip。它需要一个assembly描述文件,通常放在src/main/assembly/目录下,命名为package.xml:

<assembly xmlns="http://maven.apache.org/ASSEMBLY/2.1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/ASSEMBLY/2.1.1 http://maven.apache.org/xsd/assembly-2.1.1.xsd"> <id>bin</id> <formats> <format>zip</format> </formats> <includeBaseDirectory>true</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> <directory>src/main/resources</directory> <outputDirectory>/conf</outputDirectory> <includes> <include>*.properties</include> <include>*.xml</include> <include>*.yml</include> </includes> </fileSet> <fileSet> <directory>src/main/bin</directory> <outputDirectory>/bin</outputDirectory> <fileMode>0755</fileMode> </fileSet> </fileSets> </assembly>

includeBaseDirectory:设为true时,Zip解压后会多一层项目名-版本号的目录,避免文件散落在当前目录。这个建议开启,否则解压时容易和已有文件混在一起。

fileMode:bin目录下的脚本必须设成0755,否则解压后没有执行权限,还得手动chmod。这个细节很多人会忽略,导致运维拿到包后第一件事就是chmod,体验很差。

然后在pom里绑定assembly插件:

<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-assembly-plugin</artifactId> <version>3.6.0</version> <configuration> <descriptors> <descriptor>src/main/assembly/package.xml</descriptor> </descriptors> <finalName>${project.artifactId}-${project.version}</finalName> <appendAssemblyId>false</appendAssemblyId> </configuration> <executions> <execution> <id>make-assembly</id> <phase>package</phase> <goals> <goal>single</goal> </goals> </execution> </executions> </plugin>

appendAssemblyId=false表示最终Zip文件名不带assembly的id后缀,否则会变成my-project-1.0.0-bin.zip。这个看个人喜好,我倾向于设成false,文件名更干净。

3. 启动脚本编写与实操流程

3.1 start.sh的完整实现

启动脚本是分离式打包的灵魂,它决定了服务能不能正确加载conf和lib。下面是一个经过生产验证的脚本模板:

#!/bin/bash APP_NAME="my-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.encoding=UTF-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" 2>&1 & 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_NAME="my-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(业务jar)
  • lib/(依赖目录)
  • 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路径是否正确。常见错误是<directory>src/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.name=app显式指定。

我踩过的一个坑是: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 eol=lf

或者在构建时用dos2unix转换。另外,assembly里的fileMode虽然设了0755,但有些解压工具不认这个权限位,所以start.sh里最好加一句自检:

if [ ! -x "$0" ]; then chmod +x "$0" fi

4.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设lf
Zip解压后目录散乱includeBaseDirectory为falseunzip -l查看结构设为true并指定baseDirectory
日志文件没生成logback配置路径不对检查-Dlogging.config参数用绝对路径指向conf目录

4.5 几个容易被忽略的细节

JVM参数外置:不要把Xmx、GC参数硬编码在脚本里,建议放到conf目录下的jvm.options文件,脚本读取该文件拼接参数。这样运维调优时不用改脚本,降低出错概率。

时区设置:容器化部署时经常遇到时区不对导致日志时间差8小时。在启动脚本里加-Duser.timezone=Asia/Shanghai,或者在conf里统一配置。

依赖jar的签名问题:如果lib目录里有签名过的jar(比如某些加密库),拷贝过程中如果被修改会导致签名校验失败。确保dependency-plugin不做任何jar内容修改,只做拷贝。

构建可重复性:assembly插件打Zip时,文件的时间戳会影响Zip的MD5。如果CI需要校验产物一致性,可以在assembly配置里加<tarLongFileMode>posix</tarLongFileMode>并配合maven-buildmetadata-plugin固定时间戳。这个属于进阶需求,普通交付场景可以忽略。

我在实际交付中最大的体会是:分离式打包的配置本身不复杂,难的是把每一个细节都考虑到,尤其是路径、权限、classpath顺序这三块。建议第一次搭建时,每配一个插件就构建一次,解压看结构,确认无误再配下一个。全部配完后,写一个自动化验证脚本,把解压、启动、健康检查、停止这一套跑通,之后每次改配置都跑一遍,能省掉大量排查时间。这套方案我们团队用了三年多,交付过几十个项目,稳定性是经过验证的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询