Scala 35:sbt、模块与依赖
订单程序可以运行以后,构建还需要证明什么
订单总额计算只有几行代码,把它和命令行入口放在一起很方便。但当另一个程序也要复用计算规则时,两个问题随之出现:核心规则能否独立编译,使用者拿到发布的 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 | |
organization、version 和 scalaVersion 是设置值。加载构建时,sbt 根据设置定义计算它们;compile、test、update 则代表可以调用的任务。runMain 还需要命令行输入,是输入任务。它们虽然都能出现在 build.sbt 中,求值时机和使用方式并不相同。sbt 构建定义
ThisBuild 为构建内项目提供共享设置的位置,子项目可以覆盖相应值。它没有把所有子项目变成同一个编译单元。core 与 cli 仍有各自的源码目录、编译产物和配置范围,后面的测试就是分别针对这些范围执行的。
:= 也不能简单理解为“立刻执行右侧代码并赋给变量”。键的种类决定右侧描述的是设置还是任务。例如同样写在 settings 中的 Test / test 定义,会在调用测试任务时执行;加载 build.sbt 本身不会自动跑订单断言。
本例明确把测试任务接到一个带断言的测试入口:
1 | |
Test 指配置范围;runMain 需要主类名,toTask 把给定输入转成可依赖的任务;value 将这项依赖接入 test。这里确实执行 CoreCheck,不是没有安装测试框架、没有找到测试用例,却把一个空 test 结果当成业务验证。
构建 DSL 中的 value 表达设置或任务之间的依赖,不能据代码块从上到下的外形推断所有任务严格串行。只有依赖关系约束的先后才有依据;互不依赖的任务可以被并行调度。同一次任务图求值中,共同依赖也不需要因调用方数量而重复执行。sbt 任务图
排查“配置已经写了但没有生效”时,应先写全键的范围。core 的 Test / test 与 cli 的 Compile / compile 指向不同工作;把配置写在错误项目或错误范围,语法合法也不代表影响了目标任务。构建日志中的项目名和配置名因此是定位信息,不只是装饰。
拆模块的理由来自可见性要求
纯核心只保留金额与数量:
1 | |
cli 调用 total 并将结果变成字符串。核心不需要知道标准输出、命令行主类或展示格式;它的调用者可以改变,计算规则仍能被单独测试。这个方向给出一个具体的模块约束:cli 可以引用 core,core 不应依赖 cli。
1 | |
dependsOn 建立项目类路径依赖,cli 编译时可以取得 core 的生产类。工程中的箭头方向可以写成:
1 | |
这两条路径承担不同检查。第一条验证同一构建内的项目依赖;第二条验证打包、坐标与仓库解析。若只有第一条成功,漏打进 jar 的类、错误的发布名称或缺失的依赖元数据仍可能没有暴露。
反向依赖实验把一份失败源码临时加入 core 的生产目录,让它引用 scalalab35.cli.Main。core/compile 非零退出,诊断指出 cli 不是可见成员。失败发生在真正的 core 类路径上,因此证明该方向没有被构建配置开放;它不是仅凭目录名字推断的架构规则。
若以后纯规则开始需要一个“打印函数”,可以由调用者传入抽象或把输出留在外层。直接为 core 增加对 cli 的依赖会改变已经验证的边界,甚至形成循环。这时应先重新界定需要传递的数据,而不是先为编译错误补上一条依赖。
aggregate 组织任务,不提供业务类路径
根项目的定义很短:
1 | |
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 | |
这里包含组织、模块名和业务版本。双百分号使模块名按 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 | |
两条路径对同一模块提出不同版本要求。为了让这个分歧成为可观察失败,本例为自建组织启用严格冲突检查:
1 | |
运行 conflict/update 非零退出。诊断包含 order-core_3:0.1.1 和 0.1.0 wanted,说明解析图中的版本要求没有全部满足。sbt 依赖冲突管理
限定组织是实验条件的一部分。全局 strict 还可能检查工具链的传递依赖;本环境中,Scala 文档工具的 Jackson、jsoup 依赖也出现版本淘汰。若目标是验证订单库冲突,必须看清失败来自哪个模块,不能看到任意非零退出就算反例成功。
覆盖选择写成:
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:高阶类型与组合定律。

