本节目标:让同一份源码、同一套工具链构建出字节完全相同的 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 的可复现构建只需要三块:
- 固定时间和编码:
outputTimestamp、sourceEncoding。 - 固定编译目标:
maven.compiler.release=21,避免开发机 JDK 差异改变 class 版本。 - 固定依赖: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 是离线模式。本机在依赖齐全时执行成功;如果本地仓库缺插件或依赖,会直接失败,而不是悄悄联网换版本。这个特性适合在发布流水线里做一次「干净构建」校验。
发布检查清单
发布前按顺序过一遍:
- 版本号去掉 SNAPSHOT。
0.1.0-SNAPSHOT不能作为正式发布号。 - 工作区干净。
git status没有未提交的源码改动;构建产物不参与提交。 - 工具链固定。
java -version、mvn -version与发布记录一致。 - 依赖无动态版本。
banDynamicVersions通过,dependencies.txt已归档。 - 构建两次哈希一致。 至少在同一条流水线上连续构建两次并比较。
- 产物可运行。
java -jar target/library.jar能启动,jar --describe-module能读出模块信息。 - 记录哈希和提交号。 发布说明里同时写
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 需求与领域模型:借阅、归还、查询 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。