《Java 编程入门》17.3 版本、发布与可复现构建

把 library 的构建从「我这儿能打包」推进到「谁都能复现」:用 outputTimestamp 固定 jar 条目时间,用固定依赖、BOM 和 enforcer 禁止动态版本,再用 sha256、dependency:tree 与离线构建验证发布产物。

本节目标:让同一份源码、同一套工具链构建出字节完全相同的 jar,并能用校验和、依赖清单和离线构建证明这一点;同时把版本号、SNAPSHOT 和发布流程讲清楚。
适用版本:JDK 25 LTS(Temurin 25.0.5)、Maven 3.9.12。

为什么同一个项目要构建两次

先做一个实验。把 library 连续构建两次,每次都算一次 SHA-256:

mvn -q clean package -DskipTests
shasum -a 256 target/library.jar
mvn -q clean package -DskipTests
shasum -a 256 target/library.jar

如果 pom.xml 里没有特殊配置,本机两次输出的哈希并不相同:

889abbf2307bbd12752ab2b020792ec21082cceb6774f46f98016060ee166251
0b122be1732a1f9d829b355434fa0ac671553777cb71d9333c57172a8f64ca6c

同一个项目、同一台机器、同一份源码,只是中间隔了几秒。原因不是代码变了,而是 jar 条目记录了构建时刻。用 unzip -l 看条目时间:

     0  10-27-2026 16:29   META-INF/
   123  10-27-2026 16:29   META-INF/MANIFEST.MF
     0  10-27-2026 16:29   com/

这带来三个实际问题:

  • 无法用哈希证明产物没被篡改。 官方发布一个哈希,用户重新构建后对不上,无法判断是构建差异还是恶意修改。
  • 缓存和增量发布失效。 内容没变但哈希变了,制品仓库、CDN、部署系统都认为这是新版本。
  • 审计困难。 需要证明「线上这个 jar 对应哪个提交」时,哈希对不上就只能靠日志。

可复现构建(reproducible build)要解决的就是这件事:给定相同的源码、依赖和工具链,产出的字节应当一致。

版本号与发布单元

在谈可复现之前,先确认「发布的是什么版本」。library 的坐标是:

com.plume:library:0.1.0

Maven 的版本号没有强制语义,但社区通常按「主版本.次版本.修订号」递增:0.1.0 是卷一的第一个可运行版本,接口变化升次版本,修 bug 升修订号。真正影响构建行为的是 SNAPSHOT 后缀。

0.1.0-SNAPSHOT 表示「还在开发中的 0.1.0」。Maven 对它的处理和正式版不同:本地安装时,~/.m2 里的文件名仍带 SNAPSHOT;从远程仓库解析时,会检查更新,可能拿到同名的不同字节。正式版 0.1.0 一旦发布,就不应该再改变内容。

library 当前是正式版号 0.1.0,因为卷一的教学版本要能稳定复现。开发过程中可以临时改成 0.1.0-SNAPSHOT,但打发布标签前必须去掉后缀。

安装到本地仓库:

mvn -q install -DskipTests
ls ~/.m2/repository/com/plume/library/0.1.0/

本机输出:

_remote.repositories
library-0.1.0.jar
library-0.1.0.pom

注意文件名是 library-0.1.0.jar,即使 pom.xml 里用 <finalName>library</finalName> 把 target 下的产物命名为 library.jar。finalName 只影响构建目录,不影响安装坐标。

发布流程里有两个容易混淆的动作:

动作命令结果
安装到本地仓库mvn install只影响本机 ~/.m2
部署到远程仓库mvn deploy需要 distributionManagement,供团队或公众使用

library 没有配置 distributionManagement,因为它是应用而不是库。卷二的 libraryd 会打成容器镜像,发布单元从 jar 变成镜像,但版本号和可复现的要求不变。

可复现构建的四个变量

同一个项目构建出不同字节,通常来自四类变量:

变量典型来源控制方式
时间jar 条目时间、META-INF 里的构建时间project.build.outputTimestamp
工具链JDK 版本、Maven 版本、插件版本Toolchains、插件版本写死
依赖动态版本、SNAPSHOT、仓库里被覆盖的包精确版本、BOM、依赖清单
路径与顺序源码绝对路径、文件系统遍历顺序固定工作目录、稳定排序

四类里,时间和依赖最容易失控;工具链和路径问题更隐蔽,但一旦出现就很难排查。

先说结论:可复现是「相同输入」下的可复现,不是跨 JDK 版本的字节一致。 JDK 21 和 JDK 25 编译出的 class 文件版本不同,清单里的 Build-Jdk-Spec 也不同,哈希必然不一样。可复现的目标是:同一个提交、同一套工具链、同一组依赖,任何人构建都得到同一个哈希。

用 outputTimestamp 固定时间

Maven 提供了 project.build.outputTimestamp 属性。设置一个固定的 ISO 时间后,maven-jar-plugin 会把 jar 内所有条目的时间统一写成这个值。library 的配置是:

<properties>
  <maven.compiler.release>21</maven.compiler.release>
  <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  <project.build.outputTimestamp>2026-10-15T00:00:00Z</project.build.outputTimestamp>
</properties>

重新构建两次:

mvn -q clean package -DskipTests
shasum -a 256 target/library.jar
mvn -q clean package -DskipTests
shasum -a 256 target/library.jar

本机输出:

ddd34267c3b09c6e2a0961050f73b88797725d2ac2252ee769a68e2dc2dc9c73  target/library.jar
ddd34267c3b09c6e2a0961050f73b88797725d2ac2252ee769a68e2dc2dc9c73  target/library.jar

两次完全一致(哈希随源码变化,这是本节项目快照的实测值)。再看条目时间:

unzip -l target/library.jar | head -6
Archive:  target/library.jar
  Length      Date    Time    Name
---------  ---------- -----   ----
        0  10-15-2026 00:00   META-INF/
      123  10-15-2026 00:00   META-INF/MANIFEST.MF
        0  10-15-2026 00:00   com/

所有条目都固定到 2026-10-15 00:00(UTC)。这个时间不必等于发布日期,它只是一个常量;关键是每次构建都用同一个值。

不要用 ${maven.build.timestamp} 作为 outputTimestamp。那个属性在每次构建时取当前时间,等于把时间变量又放了回去。也不要在 MANIFEST.MF 里加自定义的构建时间字段,除非你明确接受哈希随构建变化。

固定依赖与禁止动态版本

时间固定后,下一个变量是依赖。Maven 允许在 <version> 里写版本范围,例如 [5.11,6)。这种写法在解析时会选一个满足条件的版本,今天可能是 5.11.4,明天仓库里出现 6.0.0-RC3 就可能变成 RC 版。构建的可复现性从源头被破坏。

用一个实验项目 enforcer-demo 声明动态版本:

<dependency>
  <groupId>org.junit.jupiter</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>[5.11,6)</version>
  <scope>test</scope>
</dependency>

再加 maven-enforcer-plugin 的 banDynamicVersions 规则:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <version>3.5.0</version>
  <executions>
    <execution>
      <id>enforce-versions</id>
      <goals><goal>enforce</goal></goals>
      <configuration>
        <rules>
          <banDynamicVersions/>
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

执行 mvn validate,本机输出:

[ERROR] Rule 0: org.apache.maven.enforcer.rules.dependency.BanDynamicVersions failed with message:
[ERROR] Found 1 dependency with dynamic versions.
[ERROR] Dependency org.junit.jupiter:junit-jupiter:jar:6.0.0-RC3 (test) is referenced with a banned dynamic version [5.11,6)

这个输出同时说明两件事:规则生效了;版本范围在当前仓库里真的解析到了 6.0.0-RC3。如果这是生产项目,一次普通构建就可能悄悄换掉测试框架。

library 的做法是版本只写一处:junit-bom 固定 5.11.4,junit-jupiter 不写版本。BOM 的内容是确定的,不会随时间漂移。

除了 enforcer,还可以把解析结果落盘,作为发布附件:

mvn -q dependency:tree -DoutputFile=dependencies.txt -DappendOutput=false

本机生成的 dependencies.txt:

com.plume:library:jar:0.1.0
\- org.junit.jupiter:junit-jupiter:jar:5.11.4:test
   +- org.junit.jupiter:junit-jupiter-api:jar:5.11.4:test
   |  +- org.opentest4j:opentest4j:jar:1.3.0:test
   |  +- org.junit.platform:junit-platform-commons:jar:1.11.4:test
   |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
   +- org.junit.jupiter:junit-jupiter-params:jar:5.11.4:test
   \- org.junit.jupiter:junit-jupiter-engine:jar:5.11.4:test
      \- org.junit.platform:junit-platform-engine:jar:1.11.4:test

它和 jar 一起归档,审计时能直接看到「这个版本用了哪些依赖、各自什么版本」,不需要重新跑构建。

在 library 里落地

把上面的配置合起来,library 的可复现构建只需要三块:

  1. 固定时间和编码:outputTimestamp、sourceEncoding。
  2. 固定编译目标:maven.compiler.release=21,避免开发机 JDK 差异改变 class 版本。
  3. 固定依赖:BOM 管 JUnit,插件版本写死,不使用版本范围。

完整的验证命令:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
export PATH="$JAVA_HOME/bin:$PATH"
mvn -B -ntp clean package -DskipTests
shasum -a 256 target/library.jar

本机输出(节选):

[INFO] BUILD SUCCESS
...
ddd34267c3b09c6e2a0961050f73b88797725d2ac2252ee769a68e2dc2dc9c73  target/library.jar

在另一台机器上,只要 JDK、Maven、插件和依赖版本相同,工作目录结构一致,就应当得到同一个哈希。注意这里的前提是「相同工具链」:换 JDK 25.0.4 为 25.0.5,或者换 maven-jar-plugin 3.4.2 为 3.5.0,都可能改变字节。所以发布记录里要写清楚工具链,而不只是源码提交号。

如果 CI 需要离线或干净环境验证,可以先把依赖拉到本地仓库,再离线构建:

mvn -B dependency:go-offline
mvn -B -o clean package -DskipTests

-o 是离线模式。本机在依赖齐全时执行成功;如果本地仓库缺插件或依赖,会直接失败,而不是悄悄联网换版本。这个特性适合在发布流水线里做一次「干净构建」校验。

发布检查清单

发布前按顺序过一遍:

  1. 版本号去掉 SNAPSHOT。 0.1.0-SNAPSHOT 不能作为正式发布号。
  2. 工作区干净。 git status 没有未提交的源码改动;构建产物不参与提交。
  3. 工具链固定。 java -version、mvn -version 与发布记录一致。
  4. 依赖无动态版本。 banDynamicVersions 通过,dependencies.txt 已归档。
  5. 构建两次哈希一致。 至少在同一条流水线上连续构建两次并比较。
  6. 产物可运行。 java -jar target/library.jar 能启动,jar --describe-module 能读出模块信息。
  7. 记录哈希和提交号。 发布说明里同时写 sha256、git commit、JDK 和 Maven 版本。

这份清单不保证「绝对安全」,但它把「我觉得没问题」变成了可复核的记录。发布出问题时,能快速判断是源码变了、依赖变了,还是构建环境变了。

常见错误与边界

「用了 outputTimestamp 就一定可复现。」 时间只是四个变量之一。依赖版本漂移、插件版本不同、JDK 不同,仍然会让哈希变化。

「版本范围更方便,反正 Maven 会选最新。」 上面的 enforcer 输出已经说明,范围可能解析到 RC 版。构建工具不该在无人值守时替你做版本升级决定。

「SNAPSHOT 也能发布。」 技术上可以部署,但它表示「内容可能变」。把 SNAPSHOT 当正式版发,等于放弃「同一个版本号对应同一份字节」的约定。

「换台机器哈希不同,说明构建不可复现。」 先确认工具链和依赖是否一致。跨 JDK 版本、跨操作系统(文件路径、换行符)本来就不保证字节一致。可复现的范围要写清楚,不能无限扩大。

小结

  • 不固定时间时,同一份源码连续构建两次,jar 的 SHA-256 不同。
  • project.build.outputTimestamp 把 jar 条目时间固定为常量,是消除时间变量的关键。
  • 可复现的前提是相同工具链和相同依赖;跨 JDK 版本不保证字节一致。
  • 版本范围会漂移,maven-enforcer-plugin 的 banDynamicVersions 能在构建早期拦住它。
  • dependency:tree -DoutputFile 把解析结果落盘,和产物一起归档。
  • library 用 outputTimestamp、maven.compiler.release=21、JUnit BOM 和固定插件版本,两次构建得到同一个哈希。
  • 发布记录要同时写哈希、提交号、JDK 和 Maven 版本,缺一项都无法复核。

到这里,构建、依赖和发布三条线都齐了。下一章把它们合起来,写出 library 的完整版本:先定领域模型和借阅规则,再接上内存仓储、CLI 和标准库 HTTP 服务。

阅读导航:上一节:17.2 可执行 jar 与模块化 jar · 下一节:18.1 需求与领域模型:借阅、归还、查询 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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