订单程序可以运行以后,构建还需要证明什么

订单总额计算只有几行代码,把它和命令行入口放在一起很方便。但当另一个程序也要复用计算规则时,两个问题随之出现:核心规则能否独立编译,使用者拿到发布的 jar 后能否得到相同行为?源码放进同一个仓库,只能说明文件接近,不能回答这两个问题。

本章把纯计算放在 core,把输出入口放在 cli,再增加一个只读取发布坐标的 consumer。模块边界由实际编译成功和失败来观察;发布边界由另一条依赖路径来验证。测试采用简单断言,业务规则仍是两项订单合计五百分,避免构建问题被复杂业务掩盖。

实验固定 sbt 1.11.7、JDK 21.0.11-amzn,主版本 Scala 3.3.7,并以 Scala 2.13.16 做交叉构建。实验状态为 LAB_VERIFIED,最终运行编号为 20261002-ch35-r3。完整源码位于 examples/scala-lab/modules/35,命令、退出码和原始日志见配套 RUN.md。

构建定义同时描述配置值与任务依赖

下面是工程的版本配置:

1
2
3
4
ThisBuild / organization := "lab.scala35"
ThisBuild / version := "0.1.0"
ThisBuild / scalaVersion := "3.3.7"
ThisBuild / crossScalaVersions := Seq("2.13.16", "3.3.7")

organization、version 和 scalaVersion 是设置值。加载构建时,sbt 根据设置定义计算它们;compile、test、update 则代表可以调用的任务。runMain 还需要命令行输入,是输入任务。它们虽然都能出现在 build.sbt 中,求值时机和使用方式并不相同。sbt 构建定义

ThisBuild 为构建内项目提供共享设置的位置,子项目可以覆盖相应值。它没有把所有子项目变成同一个编译单元。core 与 cli 仍有各自的源码目录、编译产物和配置范围,后面的测试就是分别针对这些范围执行的。

:= 也不能简单理解为“立刻执行右侧代码并赋给变量”。键的种类决定右侧描述的是设置还是任务。例如同样写在 settings 中的 Test / test 定义,会在调用测试任务时执行;加载 build.sbt 本身不会自动跑订单断言。

本例明确把测试任务接到一个带断言的测试入口:

1
2
Test / test :=
(Test / runMain).toTask(" scalalab35.core.CoreCheck").value

Test 指配置范围;runMain 需要主类名,toTask 把给定输入转成可依赖的任务;value 将这项依赖接入 test。这里确实执行 CoreCheck,不是没有安装测试框架、没有找到测试用例,却把一个空 test 结果当成业务验证。

构建 DSL 中的 value 表达设置或任务之间的依赖,不能据代码块从上到下的外形推断所有任务严格串行。只有依赖关系约束的先后才有依据;互不依赖的任务可以被并行调度。同一次任务图求值中,共同依赖也不需要因调用方数量而重复执行。sbt 任务图

排查“配置已经写了但没有生效”时,应先写全键的范围。core 的 Test / test 与 cli 的 Compile / compile 指向不同工作;把配置写在错误项目或错误范围,语法合法也不代表影响了目标任务。构建日志中的项目名和配置名因此是定位信息,不只是装饰。

拆模块的理由来自可见性要求

纯核心只保留金额与数量:

1
2
3
4
5
6
7
package scalalab35.core

final case class Line(cents: Long, quantity: Int)
object Order {
def total(lines: List[Line]): Long =
lines.foldLeft(0L)((sum, line) => sum + line.cents * line.quantity)
}

cli 调用 total 并将结果变成字符串。核心不需要知道标准输出、命令行主类或展示格式;它的调用者可以改变,计算规则仍能被单独测试。这个方向给出一个具体的模块约束:cli 可以引用 core,core 不应依赖 cli。

1
2
3
4
5
6
7
8
9
10
11
lazy val core = (project in file("core")).settings(
name := "order-core",
Test / test := (Test / runMain)
.toTask(" scalalab35.core.CoreCheck").value
)
lazy val cli = (project in file("cli")).dependsOn(core).settings(
name := "order-cli",
publish / skip := true,
Test / test := (Test / runMain)
.toTask(" scalalab35.cli.CliCheck").value
)

dependsOn 建立项目类路径依赖,cli 编译时可以取得 core 的生产类。工程中的箭头方向可以写成:

1
2
cli ──编译与运行依赖──> core
consumer ──发布坐标──> 本地仓库中的 order-core jar

这两条路径承担不同检查。第一条验证同一构建内的项目依赖;第二条验证打包、坐标与仓库解析。若只有第一条成功,漏打进 jar 的类、错误的发布名称或缺失的依赖元数据仍可能没有暴露。

反向依赖实验把一份失败源码临时加入 core 的生产目录,让它引用 scalalab35.cli.Main。core/compile 非零退出,诊断指出 cli 不是可见成员。失败发生在真正的 core 类路径上,因此证明该方向没有被构建配置开放;它不是仅凭目录名字推断的架构规则。

若以后纯规则开始需要一个“打印函数”,可以由调用者传入抽象或把输出留在外层。直接为 core 增加对 cli 的依赖会改变已经验证的边界,甚至形成循环。这时应先重新界定需要传递的数据,而不是先为编译错误补上一条依赖。

aggregate 组织任务,不提供业务类路径

根项目的定义很短:

1
2
3
4
5
6
lazy val root = (project in file("."))
.aggregate(core, cli)
.settings(
crossScalaVersions := Nil,
publish / skip := true
)

aggregate 使根项目的相应任务可以扩展到被聚合项目,方便一次调用管理多个子项目。它与 dependsOn 的用途不同:根项目能组织 core 的编译,不意味着根项目源码就能引用 Order。sbt 多项目构建

实验另定义 aggregateOnly,它只 aggregate(core),没有 dependsOn(core)。随后在自己的生产源码中引用 Order.total,编译失败并指出 core 不在可见包成员中。这个对照排除了“只要被聚合就自动取得其类”的理解。

聚合也不能用来暗示业务初始化顺序。假设以后增加生成数据和读取数据的两个任务,若读取必须等生成完成,就应表达实际任务依赖;把两者列在 aggregate 参数的先后位置不能代替依赖关系。

因此,修改构建图时可以分别检查两个问题:需要一起调用哪些任务,需要给哪个编译器提供哪些类。前者可能用聚合,后者需要项目或库依赖。将这两个问题分开,增加批量构建入口时就不必同时扩大模块可见性。

编译可见与运行可用要分别检查

类路径错误并不都发生在同一个阶段。源码引用不到类型时,问题已经出现在编译输入中;成功生成类文件以后,运行入口还必须获得所需类。打包又是另一个动作:把当前项目的生产产物放进 jar,并不意味着把所有依赖自动合成一个可独立分发的应用包。

本例没有制作包含全部依赖的整合包。consumer 能运行,是因为 sbt 根据依赖声明建立运行类路径,加载本地仓库里的核心 jar 及 Scala 运行库。若只复制 consumer 自己的 jar 到另一目录,再假定系统会自行找到其依赖,就改变了实验条件,原来的成功结果不能保证那条命令仍然成立。

这一差异也影响失败排查。出现“找不到符号”时,应先检查目标配置的编译类路径;出现运行时缺类时,则要检查启动命令提供的运行类路径和实际部署产物。若错误来自依赖元数据缺项,仅仅在开发环境临时补一个文件路径,可能让当前命令通过,却没有修复消费者下次解析时仍会遇到的问题。

生产模块的名称还应与业务角色区别开。core 是构建内项目标识,order-core 是发布名称,scalalab35.core 是源码包名;这三个名称在实验中有关联,但没有要求必须相同。阅读诊断时要先确认它指的是项目任务、仓库坐标还是源码包,避免用改包名去修复坐标问题。

Test 的代码不会默认成为下游测试公共库

core 的测试文件包含 CoreTestFixture 和 CoreCheck。夹具给出两项订单,测试断言总额五百、空列表零;cli 的测试则只检查自己的 render 结果。两份测试分别验证各自公开行为,不靠共享测试对象才能运行。

为了检查配置边界,实验向 cli 的 Test 目录注入 TestLeak,让它访问 core 的 CoreTestFixture。尽管 cli 已 dependsOn(core),编译仍失败。默认项目依赖没有把 core 的测试产物作为下游测试的可见接口。

这有实际用途。测试源码可能依赖替身、临时资源或测试专用库;若这些内容成为生产或下游接口,修改夹具就可能影响并不相关的模块。需要共享时,应明确选择测试配置映射或独立测试支持模块,并重新验证它的发布与可见范围。本例没有增加这种映射。

发布产物又提供一次独立核查。实际打开两个版本的 order-core 二进制 jar,都能找到生产 Order.class,找不到 CoreTestFixture 和 CoreCheck。这同时回答“下游是否看得见”和“发布包是否混入测试类”,两个问题不能只用其中一个结果代替。

测试本身仍有覆盖边界。两组断言验证固定订单和空输入,没有覆盖负数量、金额溢出或输入解析。它们适合本章检查构建路径是否执行了正确代码,不能据此宣称订单系统已经具备完整业务验收。

百分号选择发布坐标,交叉构建验证各份产物

consumer 没有 dependsOn(core),只声明:

1
2
libraryDependencies +=
"lab.scala35" %% "order-core" % "0.1.0"

这里包含组织、模块名和业务版本。双百分号使模块名按 Scala 二进制版本补后缀;在本例两个构建中,实际解析到 order-core_2.13 和 order-core_3。后缀中的 3 不等于精确补丁 3.3.7,业务版本 0.1.0 也不等于 Scala 版本。sbt 交叉构建

单百分号用于按给定名称声明坐标,不自动补这个 Scala 后缀。如果把本例改成单百分号,却仍请求没有后缀的 order-core,就改变了仓库查询目标。只有仓库里确实存在该目标才能解析成功,不能把百分号数量当作无关紧要的格式差异。

交叉版本列表只是声明要尝试的版本,并不会自动改写不兼容源码。实验 core 和 cli 使用两个版本都能编译的语法;执行 +core/test 和 +cli/test 后,日志中两版本各出现一次核心断言、一次 CLI 断言,合计四次真实测试运行。

随后执行 +core/publishLocal,将两份产物发布到本轮专属本地仓库。consumer 分别使用两个 Scala 版本编译和运行,均得到五百。这样才能把“源码兼容两个编译器”和“两个坐标都存在且可消费”联系起来,而不是仅凭发布目录里出现两个文件名判断成功。

这项实验不是跨版本互操作测试。Scala 2.13.16 的 consumer 读取对应的 _2.13 产物,Scala 3.3.7 的 consumer 读取 _3 产物;没有让 Scala 2 编译器去读取 Scala 3 的 TASTy。两种验证的依赖方向不同,不能用交叉构建结果推导任意双向兼容。

增量编译观察的是修改后的依赖影响

首次构建之后,再运行 cli/compile,日志没有出现新的源码编译。下一步只把核心求和表达式从“累加值加当前金额”改成“当前金额加累加值”,然后运行 cli/test。两种表达式在本实验 Long 运算中结果相同,方法签名没有变化。

修改后的日志显示 core 有一个 Scala 源文件重新编译,随后 CLI 断言仍得到五百,没有显示 CLI 源码重新编译。这说明这次实现变更没有要求重编译已编译的调用方,同时测试运行确实使用了更新后的构建产物。

增量编译并不等于只看修改时间,也不等于每次修改都重编译全部模块。sbt 的编译任务连接到增量编译与分析数据;冻结源码中可以看到 compileIncremental 和编译分析文件的配置。sbt 1.11.7 编译默认设置

本例只证明一个无公共签名变化的修改。改变公开返回类型、删除方法、修改 inline 定义或宏可能影响其他源码,不能从这次日志推出“下游永远不重编译”。判断增量行为时,应把改动形态、受影响的源码、实际重新编译记录和业务回归结果放在一起。

日志中的耗时也不是可靠的性能结论。它混合了 JVM 状态、文件缓存和依赖缓存,实验没有控制基准条件。可以据日志判断有没有发生编译,却不能据一次两秒运行宣传模块拆分加速了多少倍。

解析成功、冲突消失与兼容性是三个问题

同一业务库可能经不同路径进入应用。本例先发布 order-core 0.1.0,再发布 API 相同的 0.1.1;一个只携带依赖元数据的 bridge 依赖 0.1.1,而 conflict 项目直接请求 core 0.1.0,又请求这个 bridge。

1
2
conflict ──直接请求──> order-core 0.1.0
└──> order-bridge 0.1.0 ──> order-core 0.1.1

两条路径对同一模块提出不同版本要求。为了让这个分歧成为可观察失败,本例为自建组织启用严格冲突检查:

1
2
conflictManager :=
ConflictManager.strict.withOrganization("lab.scala35")

运行 conflict/update 非零退出。诊断包含 order-core_3:0.1.1 和 0.1.0 wanted,说明解析图中的版本要求没有全部满足。sbt 依赖冲突管理

限定组织是实验条件的一部分。全局 strict 还可能检查工具链的传递依赖;本环境中,Scala 文档工具的 Jackson、jsoup 依赖也出现版本淘汰。若目标是验证订单库冲突,必须看清失败来自哪个模块,不能看到任意非零退出就算反例成功。

覆盖选择写成:

1
2
conflict / dependencyOverrides +=
"lab.scala35" %% "order-core" % "0.1.1"

再次 update 成功,解析报告给出 order-core_3:0.1.1。这个变化证明构建接受了统一版本选择。它不会检查老版本调用方是否依赖某个已经删除的方法,更不会自动判断金额规则有没有变化。

在真实升级中,override 应有兼容性依据和回归验证。若新库删了旧调用方需要的方法,版本选择统一后仍可能在链接或运行时失败;若保留签名但改变计算规则,类型检查也未必发现。此处两个自建版本故意保持 API 和业务行为相同,以隔离解析机制。

本地发布与新目录重建分别排除什么

publishLocal 在本实验中只写入运行专属的 ivy-local 目录。runner 为每次运行创建新的工作区,复制构建文件与源码,把 sbt 的本地仓库指向该工作区;没有执行公共仓库发布,也没有复用个人日常仓库中同名的订单库。

这能排除一种常见干扰:工程依赖写错,但机器里刚好残留一个以前发布的同名 jar,导致开发机继续成功。使用独立本地仓库后,本轮 consumer 必须消费本轮发布的坐标。workspace.json 记录目录,published-artifacts.json 记录实际 jar 的哈希,结果可以对应到具体产物。

实验最后再创建一份新源码目录,不复制已有 target,运行 core/test、cli/test 和 consumer。三者重新编译或执行并通过,证明生产与测试源码没有依赖第一份工作区中遗留的编译产物。

这里复用了 sbt、编译器与依赖下载缓存,也复用了本轮已建立的本地发布仓库。它不是空缓存重建、断网重建或全新操作系统验证。若要检验供应链可获得性,还要另行验证仓库访问、下载校验和完整依赖清单;不能给“干净”一词附加实验没有执行的条件。

稳定重建需要同时固定输入与明确缓存边界。版本号帮助选择工具,哈希帮助识别具体文件,独立目录帮助排除残留产物;三者提供的证据不同。只记录一条 compile 成功日志,无法解释换机器后失败究竟来自源码、工具还是仓库。

反例必须因目标规则失败

反向依赖、测试夹具泄漏、聚合类路径和严格版本冲突都以非零退出结束,但退出码本身不能辨别原因。下载超时、仓库不可访问、构建文件语法错误也可能产生相同退出码。若只断言“编译失败了”,测试很容易在尚未进入目标阶段时就误判通过。

runner 为每个场景保存命令、工作目录、退出码和完整输出,再要求输出包含对应诊断。反向依赖必须出现不可见的 cli,测试范围反例必须出现 CoreTestFixture,聚合反例必须指出核心包不可见;冲突反例必须同时包含被请求的两个版本。只有退出码和这些诊断共同成立,才计入本章失败验收。

失败源码放在单独目录,运行时临时复制到所属模块的对应配置下,完成后移除。这样能准确控制哪个模块正在犯哪一种错误;若把全部失败源码常驻正常目录,一次编译可能先停在另一个错误上,后面的案例就没有得到验证。隔离反例既方便重跑,也使失败原因更容易对应到构建设置。

正常结果也有类似要求。交叉测试日志需要分别出现两次核心测试和两次 CLI 测试,发布消费需要出现两次消费者断言。仅看到进程返回成功,不能排除选错任务、漏掉某个版本或测试任务没有实际执行。运行编号把这组命令与产物绑定在一起,避免正文引用的输出来自不同实验条件。

这些检查没有代替源码审阅。比如测试入口若只是打印“通过”却不做断言,日志模式照样可能匹配。本例因此同时保留断言源码与日志,发布 jar 另查实际类条目;每一种观察只支持它直接覆盖的事实,组合以后才形成较完整的证据。

从本例迁移到自己的工程

拆模块前可以先写出一条希望被编译器拒绝的依赖,例如“核心不能引用网络适配器”。若所有包本来就互相自由引用,单纯按目录名字拆分只会把循环显露出来。先识别输入输出和依赖方向,再让构建类路径反映这些约束,反例才有明确含义。

验收发布库时应保留一个没有源码项目依赖的消费者。它能暴露打包与坐标问题;消费者所用 Scala 版本、仓库与入口也必须明确。否则测试可能仍然绕过 jar 直接读取工作区产物。

遇到的问题 本例采用的检查 不能据此推出的结论
多个模块要一起构建 aggregate 任务组织 自动拥有彼此类路径
下游需要生产 API dependsOn 和反向引用失败 测试夹具自动共享
支持两个 Scala 版本 两版本测试、发布与消费 任意跨版本产物互读
两条路径请求不同版本 strict 诊断、明确 override 统一版本必然兼容
工作区重建是否可靠 新源码目录与真实发布消费 已证明空缓存或离线构建

练习与答案

手算题:core 的业务版本为 0.1.0,交叉版本为 2.13.16 和 3.3.7。consumer 使用双百分号请求 order-core。应检查哪两个完整模块名?若根项目 aggregate(core) 却没有 dependsOn(core),根源码能否直接引用 Order?若 consumer 依赖 core 0.1.0,bridge 依赖 core 0.1.1,严格冲突检查关注的是哪一部分不同?

答案:模块名分别为 order-core_2.13 和 order-core_3,业务版本仍为 0.1.0。聚合不会提供 Order 的类路径,所以不能据聚合关系引用它。冲突发生在同一个带后缀的模块拥有两个业务版本要求,不能把 0.1.0 与 Scala 编译器版本混在一起比较。

修改练习:给 core 增加 discount(total: Long, cents: Long): Long,要求优惠额不能为负,结果不能小于零;让 cli 调用它。在两个 Scala 版本分别测试,再发布一个新业务版本并修改 consumer 的依赖。保留 consumer 不使用 dependsOn(core) 的条件。

一种答案是把结果定义为 Either[String, Long]:负优惠返回 Left,合法优惠返回 Right(math.max(0L, total - cents)),并明确本练习总额限定非负。测试至少覆盖零优惠、普通优惠和超过总额的优惠。只有 core 测试通过还不够,cli 的呈现行为和发布消费者也要执行。

若 consumer 编译时仍找不到新方法,应先查看它实际解析的完整坐标和版本,再检查该 jar 是否包含更新后的 API;不要立即增加源码 dependsOn 来“修好”消费者,那会绕开练习要验证的发布路径。修改练习是待读者实施的任务,上述新方法没有混入本章已运行结果。

资料与实验入口

完整实验入口为 examples/scala-lab/modules/35/run.py,构建定义、错误源码和测试入口均在同目录。原始日志位于其中 evidence/20261002-ch35-r3,四个预期失败都保存诊断,成功与失败条件由脚本逐项验证;配套 RUN.md 给出重跑方法。

官方构建与任务语义见 Build definition 和 Task graph;依赖组织见 Multi-project builds,发布后缀见 Cross-building,冲突设置见 Library Management。手册为 1.x 文档,行为以本章固定的 1.11.7 实测为准;编译接线可对照固定提交的 Defaults.scala。

前置内容可参阅 Scala 20:高阶类型与组合定律。

顺序导航:系列入口:00 · 上一篇:34 · 下一篇:36。