本节目标:让
libraryd的构建结果可以按 commit 复算——插件版本写死,jar 内时间戳固定,同一份源码连续构建两次得到相同的 SHA-256。
适用版本:JDK 25.0.5、Maven 3.9.12、maven-jar-plugin 3.4.2、maven-compiler-plugin 3.16.0。
承接的问题
前两节解决了「模块怎么拆」和「依赖从哪来」,但构建本身仍然依赖很多隐式输入:Maven 安装包自带的默认插件版本、编译时的当前时间、文件系统的遍历顺序、本机 locale。它们共同导致一个常见现象:同一个 commit,在 CI 上构建出的 jar 和本地构建出的 jar,校验和不一样。
对 libraryd 来说,这会带来三个实际麻烦:
- 发布物无法核对。 镜像里的 jar 和本地重新构建的 jar 哈希不同,无法判断线上跑的到底是哪份代码。
- 缓存命中率低。 Docker 层、Maven 本地仓库、CI 缓存都按内容寻址;构建结果每次不同,缓存就失去意义。
- 回滚缺少证据。 出问题时重新构建上一版 tag,得到的字节和当初发布的不一致,排查会多一层不确定性。
可复现构建不是「构建两次恰好一样」,而是把构建输入显式列出来:插件版本、依赖版本、源码、时间戳、编码。本节只处理构建工具这一半;依赖版本在 1.2 已经收敛,源码由 git 固定。
插件锁定:生命周期插件也是构建输入
很多人只在 <build><plugins> 里写自己显式调用的插件,却忽略了 clean、resources、compiler、surefire、jar、install、deploy 这些生命周期插件。它们由 Maven 的 super POM 绑定,版本随 Maven 安装包变化。Maven 3.9.12 和 3.9.13 的默认 jar 插件版本可能不同,构建结果就可能不同。
libraryd 在父 POM 的 pluginManagement 里把生命周期插件全部写死:
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-clean-plugin</artifactId>
<version>3.2.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
<configuration>
<release>${maven.compiler.release}</release>
<parameters>true</parameters>
<compilerArgs>
<arg>-Xlint:all,-processing</arg>
<arg>-Werror</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-install-plugin</artifactId>
<version>3.1.4</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-deploy-plugin</artifactId>
<version>3.1.4</version>
</plugin>
</plugins>
</pluginManagement>
pluginManagement 不会执行插件,只提供版本和默认配置。生命周期插件本来就由 Maven 执行,所以这里写版本就够了;像 Enforcer 这种不属于默认生命周期的插件,还要另外写进 <build><plugins>。
mvn help:effective-pom 能看到最终生效的版本。下面是本机输出的节选:
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
<configuration>
<release>25</release>
<parameters>true</parameters>
<showWarnings>true</showWarnings>
<compilerArgs>
<arg>-Xlint:all,-processing</arg>
<arg>-Werror</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.1</version>
</plugin>
<plugin>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
</plugin>
<plugin>
<artifactId>maven-resources-plugin</artifactId>
<version>3.3.1</version>
</plugin>
<plugin>
<artifactId>maven-install-plugin</artifactId>
<version>3.1.4</version>
</plugin>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<version>3.1.4</version>
</plugin>
这是 mvn help:effective-pom -Doutput=target/effective-pom.xml 生成的文件节选,不是终端直接打印。注意 help:effective-pom 加 -q 在本机不会输出到 stdout,和 1.2 的 dependency:tree 是同一个原因。
插件版本锁定还有一个容易忽略的收益:升级 Maven 时,构建行为不会顺带改变。升级 Maven 只影响 Maven 本身的生命周期实现,不会悄悄把你的 jar 插件从 3.4.2 换成别的版本。
时间戳:project.build.outputTimestamp
插件版本固定后,jar 仍然每次不同。原因是 jar 里的每个条目都带修改时间,clean 之后重新编译,.class 文件的 mtime 就是新的。maven-jar-plugin 默认使用文件时间;设置 project.build.outputTimestamp 后,所有条目改用这个固定时间。
<properties>
<project.build.outputTimestamp>2026-10-16T00:00:00Z</project.build.outputTimestamp>
</properties>
这个属性会被 maven-jar-plugin 等基于 maven-archiver 的打包插件读取,用来覆盖 jar 条目的时间。值可以是 ISO-8601 时间,也可以是 Unix 秒;libraryd 用 ISO 形式,便于人读。
验证属性确实生效:
$ mvn -q help:evaluate -Dexpression=project.build.outputTimestamp -DforceStdout
2026-10-16T00:00:00Z
本机输出。再看 jar 内部条目:
$ unzip -l libraryd-domain/target/libraryd-domain-1.0.0-SNAPSHOT.jar
Length Date Time Name
--------- ---------- ----- ----
0 10-16-2026 00:00 META-INF/
81 10-16-2026 00:00 META-INF/MANIFEST.MF
0 10-16-2026 00:00 com/
0 10-16-2026 00:00 com/plume/
0 10-16-2026 00:00 com/plume/library/
0 10-16-2026 00:00 com/plume/library/domain/
本机输出。所有条目都是 10-16-2026 00:00,与属性值一致。unzip 按本地时区显示,机器时区不同可能显示成别的钟点,但 jar 里存的是同一个 epoch 秒。
这个时间应该取什么?推荐取「源码对应 commit 的时间」,而不是「这次构建的时间」。取构建时间等于把构建时刻变成输入,两次构建自然不同。CI 可以用 -Dproject.build.outputTimestamp=... 从 git 取 commit 时间;本机验证过命令行覆盖会改变 jar 条目时间:
$ mvn -q clean package -DskipTests -Dproject.build.outputTimestamp=2026-10-15T00:00:00Z
$ unzip -l libraryd-domain/target/libraryd-domain-1.0.0-SNAPSHOT.jar | sed -n '4p'
0 10-15-2026 00:00 META-INF/
本机输出。命令行 -D 覆盖了 POM 里的属性,这条路径适合在 CI 中按 commit 注入。
验证可重复性:两次 clean package 比较哈希
判断是否可复现,不看时间戳,看字节。做法是同一个工作区连续两次 clean package,比较产物哈希:
for run in 1 2; do
mvn -q clean package -DskipTests
shasum -a 256 \
libraryd-domain/target/libraryd-domain-1.0.0-SNAPSHOT.jar \
libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar \
> "hash-$run.txt"
done
diff hash-1.txt hash-2.txt && echo IDENTICAL
本机输出:
02ee75c43eaa263f1309d530d3e590d7344d63e9cfb89afa38cdd570068926c4 libraryd-domain/target/libraryd-domain-1.0.0-SNAPSHOT.jar
8a32cad3ad9dd24b8dc139af805e280ce32cbe70298a9e24f8522c4aba1db8f2 libraryd-app/target/libraryd-app-1.0.0-SNAPSHOT.jar
第二次构建的哈希与第一次完全相同,diff 没有输出,脚本打印 IDENTICAL。这是本机真实结果。
把 project.build.outputTimestamp 删掉,再做同样两次构建,结果就不同:
a9baf6e43392682beaccabe302b6a11b35ffd85aa1070ca6890ba5483a935bda libraryd-domain/target/libraryd-domain-1.0.0-SNAPSHOT.jar
188990f50c4bf3a1392a6ab2dd374e1e3e72b290476ec3bc7d1de0a0a2efb561 libraryd-domain/target/libraryd-domain-1.0.0-SNAPSHOT.jar
本机输出,脚本判定 DIFFERENT。两次都是 clean package,源码和依赖都没有变,唯一变量是 jar 条目时间。
这个对比说明一件重要的事:可复现性不是靠「不动代码」自然得到的,而是靠显式固定时间戳。只锁定插件版本不够,只固定时间戳也不够;两者要一起做。
在 libraryd 里落地
把两件事合起来,父 POM 的相关片段如下:
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<project.build.outputTimestamp>2026-10-16T00:00:00Z</project.build.outputTimestamp>
</properties>
编码属性也是可复现性的一部分。源码里有中文注释和字符串常量,如果 CI 的默认平台编码不是 UTF-8,编译出的常量池字节可能不同。显式写 UTF-8 比依赖机器 locale 稳。
构建命令本身不需要特殊参数:
$ mvn -q -DskipTests package
$ echo $?
0
本机输出,-q 下没有构建日志,退出码 0。CI 里通常用非 quiet 形式保留 reactor 摘要:
[INFO] libraryd-parent .................................... SUCCESS [ 0.480 s]
[INFO] libraryd-domain .................................... SUCCESS [ 2.165 s]
[INFO] libraryd-persistence ............................... SUCCESS [ 0.506 s]
[INFO] libraryd-web ....................................... SUCCESS [ 1.074 s]
[INFO] libraryd-app ....................................... SUCCESS [ 0.687 s]
[INFO] libraryd-integration-tests ......................... SUCCESS [ 0.677 s]
[INFO] BUILD SUCCESS
本机输出。可复现性验证必须带 clean;不带 clean 时 Maven 可能复用上一次的 target/classes,构建快,但比较的已经不是同一组输入。
libraryd-app 后续会用 Shade 打可执行 jar。Shade 插件也有自己的条目顺序和时间戳处理,第 16 章接入时要重新跑一次本节的双构建比较,不能假设普通 jar 可复现,fat jar 就自动可复现。
常见错误与边界
把构建时间写进 outputTimestamp。 例如每次 CI 都传 date -u +%Y-%m-%dT%H:%M:%SZ,等于把「构建时刻」变成输入,两次构建永远不同。正确来源是 commit 时间或一个固定的发布基准时间。
只固定 jar 插件,不固定 resources 和 compiler。 resources 处理过滤时可能写入时间,compiler 的 release 和参数会影响字节码。可复现性检查要看最终产物,不要只看某一个插件的配置。
忽略 SNAPSHOT 依赖。 libraryd 自身是 1.0.0-SNAPSHOT,开发期没问题;如果依赖了外部的 SNAPSHOT,同一个版本号在仓库里可能被覆盖,今天和明天解析到不同字节。发布构建应只使用 release 版本,或者把 SNAPSHOT 的校验和写进锁文件。
把「可复现」理解成「跨平台完全一致」。 不同 JDK 厂商、不同操作系统、不同 CPU 架构可能产出不同字节。可复现构建的目标是「相同工具链、相同输入,得到相同输出」;跨工具链的差异要靠容器化构建环境消除,第 16 章会用固定基础镜像。
忘记清理本地仓库缓存。 Maven 本地仓库里的 _remote.repositories 和校验和会影响解析。CI 用干净缓存跑一次,才能发现「本地碰巧能过」的问题。
小结
- 生命周期插件版本也是构建输入,父 POM 的
pluginManagement要把 clean、resources、compiler、surefire、jar、install、deploy 全部写死。 project.build.outputTimestamp固定 jar 条目时间;值应取 commit 时间,不能取构建时刻。- 可复现性的判据是两次
clean package的产物 SHA-256 相同,不是「构建成功」。 - 本机结果:有
outputTimestamp时两个 jar 的哈希完全一致;删掉属性后 domain jar 的哈希变成另一个值。 - 编码、
release、依赖版本同样是输入;只锁插件或只锁时间戳都不够。 - 普通 jar 可复现不代表 fat jar 可复现,Shade 接入后要重新验证。
构建能复算之后,接下来把镜头转向代码本身:先让编译器把所有警告说出来,再把它们变成不能忽略的门禁。
阅读导航:上一节:1.2 依赖治理:BOM、作用域与版本冲突 · 下一节:2.1 编译告警:把 -Xlint 变成门禁 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。