target 目录里那个 80MB 的 jar,我一般第一眼就不太舒服。
业务代码改了一行,发版却要重新上传一坨第三方依赖。Spring、MyBatis、Jackson、数据库驱动,全跟着业务 jar 一起滚。部署机网速稍微差点,传包比修 bug 还久。
这种 Maven 打包方式,开发环境没感觉,到了生产环境就开始烦人。
我更喜欢把包拆成这样:
app/
├── bin/
│ └── start.sh
├── lib/
│ ├── spring-core-xxx.jar
│ ├── mybatis-xxx.jar
│ └── mysql-connector-j-xxx.jar
├── conf/
│ └── application.yml
└── app-service.jar
app-service.jar只放业务代码。
第三方依赖全部丢到lib。
下次只改业务逻辑,直接替换一个几百 KB 或几 MB 的 jar 就行,不用把依赖重新打包一遍。
这事靠 Maven 做,不复杂,但有几个地方容易配歪。
先看pom.xml,我一般这么写:
<finalName>app-service</finalName>
<plugins>
<!-- 只打业务代码 jar,不把依赖塞进去 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<mainClass>com.dong.order.Bootstrap</mainClass>
<addClasspath>true</addClasspath>
<classpathPrefix>lib/</classpathPrefix>
</manifest>
<manifestEntries>
<Build-Time>${maven.build.timestamp}</Build-Time>
</manifestEntries>
</archive>
</configuration>
</plugin>
<!-- 把第三方依赖复制到 target/lib -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.6.1</version>
<executions>
<execution>
<id>copy-runtime-jars</id>
<phase>package</phase>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
<includeScope>runtime</includeScope>
<excludeTransitive>false</excludeTransitive>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
这里我比较在意两个点。
maven-jar-plugin负责打业务 jar,它会在MANIFEST.MF里写启动类和 classpath。
打完之后可以直接看:
jar xf target/app-service.jar META-INF/MANIFEST.MF
cat META-INF/MANIFEST.MF
正常应该能看到类似内容:
Main-Class: com.dong.order.Bootstrap
Class-Path: lib/jackson-databind-2.15.3.jar lib/mybatis-3.5.13.jar
如果这里没有Class-Path,启动时报ClassNotFoundException,别急着怀疑代码,先看这个文件。
maven-dependency-plugin负责把依赖复制出来。注意我这里用的是runtime,不是compile。
有些依赖编译时需要,运行时不一定要。反过来也有,比如数据库驱动,运行时才暴露问题。这个范围别乱写,不然后面启动时少 jar,日志会很直接:
Exception in thread "main" java.lang.NoClassDefFoundError:
com/mysql/cj/jdbc/Driver
业务启动类没必要写得很花。
package com.dong.order;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
public class Bootstrap {
public static void main(String[] args) throws Exception {
Path config = Path.of("conf", "application.yml");
if (!Files.exists(config)) {
throw new IllegalStateException("配置文件不存在: " + config.toAbsolutePath());
}
System.out.println("order service starting, time=" + LocalDateTime.now());
System.out.println("config loaded from " + config.toAbsolutePath());
OrderServer server = new OrderServer(config);
server.start();
}
}
我会刻意让程序从外部conf目录读配置。
不要什么都塞进 jar。配置跟业务代码绑在一起,后面改个地址、改个开关,还要重新打包,这种设计迟早会把人恶心到。
打包命令很普通:
mvn clean package -DskipTests
执行完看target:
target/
├── app-service.jar
└── lib/
├── jackson-core-xxx.jar
├── jackson-databind-xxx.jar
└── ...
如果要整理成部署目录,可以再加一个简单的脚本。这个不用搞太复杂,能看懂最重要。
#!/bin/bash
set -e
rm -rf target/release
mkdir -p target/release/bin
mkdir -p target/release/lib
mkdir -p target/release/conf
cp target/app-service.jar target/release/
cp target/lib/*.jar target/release/lib/
cp src/main/resources/application.yml target/release/conf/
cat > target/release/bin/start.sh <<'EOF'
#!/bin/bash
APP_HOME=$(cd "$(dirname "$0")/.." && pwd)
cd "$APP_HOME"
java \
-Xms512m \
-Xmx512m \
-Dfile.encoding=UTF-8 \
-jar app-service.jar
EOF
chmod +x target/release/bin/start.sh
这里有个坑。
很多人以为java -jar app-service.jar会自动加载旁边的lib目录。不会。
它认的是 jar 包里MANIFEST.MF的Class-Path。所以前面maven-jar-plugin那段配置不能漏。
还有一种启动方式是不用-jar,直接手写 classpath:
java -cp "app-service.jar:lib/*" com.dong.order.Bootstrap
Linux 下这么写没问题,Windows 要把冒号换成分号:
app-service.jar;lib/*
我平时更偏向第二种,直白。
尤其排查线上问题时,一眼就知道 JVM 到底加载了哪些目录。MANIFEST.MF那套也能用,但藏在 jar 里面,排查时要多拆一层。
最后再补一个我经常放进去的小检查,启动时把关键 class 从哪里加载的打印出来:
package com.dong.order.check;
public final class JarTrace {
private JarTrace() {
}
public static void printLocation(Class<?> type) {
String from = type.getProtectionDomain()
.getCodeSource()
.getLocation()
.toExternalForm();
System.out.println("[jar-trace] " + type.getName() + " -> " + from);
}
}
启动时加两行:
JarTrace.printLocation(com.fasterxml.jackson.databind.ObjectMapper.class);
JarTrace.printLocation(com.dong.order.Bootstrap.class);
看到这种输出,心里就有底:
[jar-trace] com.fasterxml.jackson.databind.ObjectMapper -> file:/app/lib/jackson-databind-2.15.3.jar
[jar-trace] com.dong.order.Bootstrap -> file:/app/app-service.jar
这比猜半天强。
业务代码和第三方依赖分离,真正解决的不是“包大小”这一个问题。
它解决的是发版边界。
业务 jar 归业务,依赖 jar 归依赖,配置归配置。哪块变了动哪块。线上出了ClassNotFoundException、版本冲突、配置没生效,也知道先从哪里下手。
别把所有东西都揉成一个胖 jar,看着省事,排查的时候它会把账慢慢找回来。