《Java 编程实战》1.3 可复现构建:插件锁定与可重复打包

让同一 commit 在任何机器上产出相同字节:锁定 Maven 生命周期插件版本,设置 project.build.outputTimestamp,连续两次 clean package 比较 jar 的 SHA-256,并说明 SNAPSHOT、时区与本地缓存会怎样破坏可复现性。

本节目标:让 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 变成门禁 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发