订单表单同时缺少商品编号、又把数量写成 bad 时,可以一次返回两个字段问题。数量尚未解析为整数时,却不能继续声称它“不大于零”,更不能拿一个默认数量检查库存。错误越多不代表校验越完整,只有具备前置数据的检查才有资格产生对应的错误。

本章使用 Scala 3.3.7 与 Cats 2.12.0,将字段内部的依赖检查和字段之间的独立检查分开。Either 表达先取得值再继续的过程,ValidatedNel 累积相互独立的错误,andThen 在完整订单形成后接上库存规则。实验不访问数据库,库存固定为三件,用它验证组合语义,不模拟真实库存交易。

系列导读与能力自测

先确定错误要表达什么

1
2
3
case class FieldError(field: String, code: String)
case class Order(sku: String, quantity: Int)
type Errors = NonEmptyList[FieldError]

FieldError 的 field 指向输入字段,code 表示稳定的机器原因。实验使用 sku、quantity 两个字段,以及 missing、malformed、non-positive、stock 四种代码。界面可以在更外层将代码转换为本地化文本,测试则直接比较结构,不依赖一句容易变化的提示语。

Errors 是非空错误列表。失败时必须至少解释一条原因;成功时则直接保存 Order,不需要制造空错误列表。两份非空列表连接后仍然非空,因此可以用 Semigroup 合并,不需要一个非空的“零条错误”单位元。这与第十五篇区分 Semigroup 和 Monoid 的理由一致。

本章没有保存原始输入内容,避免将所有用户数据自动复制到错误对象。行号、字段路径、关联标识可以按接口需求增加,但应独立决定是否允许日志记录。错误可序列化不代表所有错误信息都适合暴露给调用方,数据模型仍需明确公开边界。

Order 只是校验成功时构造的不可变记录,它的公开构造器本身不禁止空商品编号或非正数量。因此这里的保证是“通过 independent 返回的成功结果满足指定字段检查”,不是“任何 Order 值都必然合法”。若其他代码可直接构造 Order,库存函数的调用方必须遵守前置约定,或者进一步将构造权限收窄。

字段内部先解析再比较

数量检查包含两个有依赖的阶段:先将字符串转换为整数,再判断正数范围。第二阶段需要第一阶段产生的 Int,不可能在解析失败时正确执行。

1
2
3
4
5
6
def quantity(raw: String): Either[Errors,Int] =
raw.toIntOption
.toRight(NonEmptyList.one(FieldError("quantity","malformed")))
.flatMap(n =>
Either.cond(n > 0, n,
NonEmptyList.one(FieldError("quantity","non-positive"))))

bad 无法解析,因此得到 malformed;零可以解析,但不满足大于零,因此得到 non-positive。两者的失败位置不同。实验专门断言 bad 只产生格式错误,不附带非正数错误;这条负向约束能阻止未来重构时用零填补解析失败,再误报额外范围错误。

toIntOption 将解析结果的缺席转换成指定错误后,Either.flatMap 才决定是否运行范围判断。转换成 Either 不是为了增加语法层次,而是恢复错误原因并保留后续所需的类型信息。范围检查拿到的是 Int,而不是仍需解释的原始文本。

商品编号在当前实验中只检查非空字符串。它不检查空白、长度、字符集或真实商品是否存在;传入的字符串也约定非 null。这个范围刻意保持狭窄,便于单独观察组合行为。生产入口若需要支持 Java 可空字段,应先把 null 与缺席协议归一化,再选择相应错误代码,不能宣称当前函数覆盖了所有输入污染。

同样,数量的格式政策由 toIntOption 及本章样例限定,不表示所有业务都应该接受完全相同的文本格式。若协议要求拒绝前导符号或限定小数位,应该增加明确解析规则,并让新测试锁定它们。组合库只能组合校验结果,不能代替协议定义。

跨字段组合保留两份错误

商品编号和数量来自两个独立输入。即使商品编号为空,数量仍可单独判断能否解析,因此不需要通过商品编号的成功值才能调用数量检查。

1
2
def independent(s: String, q: String): ValidatedNel[FieldError,Order] =
(sku(s).toValidated, quantity(q).toValidated).mapN(Order.apply)

两个 Either 转成 Validated 后保持各自已经得到的成功值或错误列表。mapN 在两边成功时调用 Order.apply;只有一边失败时保留那一边;两边失败时连接两份错误。这里的错误合并依赖 NonEmptyList 的实例,而不是依赖 Order 的构造器。

ValidatedNel 是 Validated 的错误侧固定为 NonEmptyList 后的别名,成功侧仍由参数决定。它不是包含成功和失败两套字段的大对象,也不是允许“部分成功的订单”继续流动的类型。存在任一 Invalid 时,最终没有可供后续规则使用的完整 Order。

本章对 independent(“”,“bad”) 的预期是按顺序保存 sku/missing 与 quantity/malformed。实验比较完整 NonEmptyList,而不是只比较错误条数。否则错误重复两次、字段名称写错或错误顺序反转,都可能在“长度为二”的弱断言下通过。

这种组合并没有创造额外线程。两个同步校验先取得结果,再由 mapN 组合;名称中的独立只表示数据没有前后依赖。若某个校验包含网络调用,是否并发执行取决于额外效果类型和执行策略,不在当前实验的验证范围内。Cats Validated 文档说明了累积组合和依赖组合的差别;本章运行结果另以实际依赖版本保存。

与短路路径做同输入对照

为了确认差别来自组合方式,实验对相同的空商品编号与 bad 数量写了一条 Either 路径。它把数量检查放在商品编号成功后的 flatMap 回调里,并在回调入口增加计数。

1
2
3
4
5
var calls = 0
val short = sku("").flatMap(s => {
calls += 1
quantity("bad").map(q => Order(s,q))
})

断言同时要求结果只有 sku/missing,且 calls 等于零。这证明当前表达式没有执行后面的回调。只断言第一个错误是不够的,因为另一种写法可能先严格计算两份结果,再选择第一份失败;最终错误一样,调用次数却不同。

累积路径从一开始就单独调用两项校验,因此得到两条原因。Either 这个类型名不代表全局求值策略:本例将数量检查放进 flatMap 回调,Left 分支不调用它。若提前把 quantity(“bad”) 保存为严格 val,数量检查已经执行,后面的 flatMap 无法撤回。

这种差别对纯解析只影响无用计算量,对带副作用的校验却可能改变外部行为。如果数量检查会写日志或调用服务,读者需要决定这些行为是否也必须跳过。本章使用计数器作为测试观察,不将其放入实际校验函数,避免把核心规则变成依赖全局状态的逻辑。

短路没有天然优于累积,累积也不是完整性的同义词。前置身份不成立时继续访问受限资源,可能不符合业务要求;表单里互不依赖的字段格式错误,一次给全则能减少交互。选择依据是依赖与授权规则,而不是哪个 API 写起来更短。

形成完整值后再检查库存

库存规则接收 Order 而不是两段原始字符串,表明它需要已经形成的字段值。本章固定可用数量为三,只检查请求数量是否超过三。

1
2
3
4
5
def stock(o: Order): ValidatedNel[FieldError,Order] =
Validated.condNel(o.quantity <= 3, o,
FieldError("quantity","stock"))

val result = independent("sku1","2").andThen(stock)

andThen 在成功分支把 Order 交给 stock;前面已经 Invalid 时不调用 stock,保留原来的错误。实验对空商品编号与格式错误的组合增加调用计数,断言库存回调仍为零,且累积的两条字段错误完整保留。

合法的 sku1 与数量二得到成功 Order;数量四通过格式与正数检查后,在库存阶段得到 stock 错误;数量零在字段阶段失败,不需要再进入库存阶段。这三条路径把字段错误、成功以及业务拒绝区分开来,避免只测试“所有失败都能返回 Invalid”。

固定三件库存不是数据库快照,也没有扣减、并发冲突、幂等或事务保护。即使一次读取表明库存足够,后续写入也可能因并发失败;那些问题需要另外的效果和事务设计。本章的 stock 名称只是一个依赖校验例子,不是可直接上线的库存服务。

若需要检查商品是否存在,规则通常还依赖已经解析出的商品标识,并可能执行查询。可以先取得商品,再继续其他依赖检查;不能为了累积错误而在无效标识上发出查询,随后把查询失败与字段错误混为一组。能否安全执行与能否合并错误是两项独立判断。

为什么不能把累积组合直接换成依赖组合

实验额外构造两个已经失败的 Validated 值,左边错误是 a/bad,右边错误是 b/bad。对它们使用 mapN,加法回调没有成功值可接收,但组合仍得到两条错误。再使用左边的 andThen 包住右边的 map,结果只保留第一条错误。

这不是依赖库偶然选择了不同名称。依赖回调的参数是成功值 A,而左边失败时根本没有 A。没有这个值就无法一般性地调用回调,更无法知道回调将产生哪一份错误。两项独立的现成结果却都能被检查,所以允许累积。

因此,若同时规定 ap 累积两边错误,又规定 flatMap 在失败时跳过回调,那么由 flatMap 派生的 ap 就不会与原来的累积 ap 一致。Cats 文档通过一致性定律解释为什么不把这两种行为包装成同一个兼容的 Monad 实例。本章只用“两条与一条”的具体反例观察区别,不宣称完成了抽象体系的形式证明。

andThen 仍然是有用的顺序方法。它让一组独立校验之后接上依赖校验,只是不承诺把当前累积实例改造成具有同一 ap 语义的 Monad。看到方法能接收返回 Validated 的函数,不应据此推断所有平铺组合操作都遵循相同定律。

toEither 与 toValidated 也只是转换已有表示,不会重跑此前跳过的检查。一个只保存第一条错误的 Either 转成 Validated 后,仍只有那一条;一份含两条错误的 Validated 转成 Either 后,Left 的载荷仍可以是两条错误。类型转换不会恢复从未计算或已经丢弃的信息。

错误顺序是可观察约定

实验把两个输入的元组顺序反过来,先数量后商品编号,再使用 tupled。结果错误列表也反过来。这说明当前顺序来自组合参数和非空列表连接的约定,不是按照字段名称自动排序,更不是按照某种线程完成时间排序。

稳定顺序方便界面定位、快照比较与日志差异审查。若产品希望按表单布局显示,可以在组合时使用布局顺序,或在展示层按显式规则排序;不能假设收集库知道界面的优先级。测试中若不关心顺序,也应该明确说明,而不是无意中将结果转成集合。

重复错误同样有语义。两个规则返回相同代码,可能是重复执行,也可能分别针对不同路径。简单去重会掩盖前一种缺陷,也可能误删后一种有效信息。若要去重,应先定义错误身份由字段路径、代码还是规则标识组成,再讨论相应合并操作是否仍满足所需规律。

当前 FieldError 只有一个短字段名,适用于这个两字段示例。嵌套表单、数组项或批量文件通常需要更精确路径,例如第几行的哪个字段,否则两条相同错误无法定位。扩大错误结构并不改变累积和依赖的基本区别,但会影响相等、序列化和展示排序的测试。

本章没有做排序或去重优化,也没有以性能为理由替换错误容器。NonEmptyList 的选择首先表达失败至少有一条原因;如果规模扩大,需要实际评估连接与遍历成本,再考虑其他有相同语义保证的结构。不能从两个错误的实验推断百万条错误的运行表现。

按依赖层组织更复杂的表单

可把一个表单理解为若干检查层,而不是一条全部短路的链,也不是一个所有检查同时运行的平面。第一层从原始文本取得类型化值,第二层比较这些值之间的关系,后面的层才可能依赖外部事实。每层中真正独立的检查可以累积,跨层则需要成功值。

例如起止日期分别解析时可以同时返回两个格式问题;只有两边都变成日期后,才检查结束是否早于开始。若解析失败时用今天补值,后面的范围错误就建立在虚构输入上。即使界面显示了更多红字,这些红字也不对应用户实际提交的数据。

又如单个数量字段要同时满足正数和不超过协议上限。两条范围规则都依赖同一个已解析整数,但在获得整数之后,它们之间未必有数据依赖,可以在该层独立检查。是否同时报告两条,仍要考虑规则是否互斥以及提示是否有用,不能机械地将每个布尔条件都转换成一条错误。

跨字段规则如果需要多个成功字段,就应接收这些字段组成的中间类型。这个类型不一定是最终业务对象,可以是仅说明“解析已经完成”的记录。明确中间阶段有助于避免在过早位置构造一个名字看似完全有效的 Order,然后又不断发现它缺少必要条件。

本章为了篇幅和实验聚焦,复用 Order 作为字段成功后的中间结果,并明确其不代表已通过库存检查。实际命名可以区分 ParsedOrder、ValidatedOrder 或 AcceptedOrder,但名称不能替代构造权限和调用边界。要让阶段保证可信,还需要限制其他代码绕过校验直接构造的方式。

错误展示与程序缺陷不走同一条路径

Validated 保存的是显式产生的错误值,不会自动捕获任意异常。若回调内部写错索引或主动抛出异常,当前组合并不保证将其转为 FieldError。把程序缺陷统一包装成“输入不合法”,会让调用方错误地反复修改输入,也让维护者失去故障信号。

如果某个底层解析 API 使用特定异常报告已知的格式失败,可以在很窄的边界进行翻译,像第十三篇处理数字解析那样。不要在整个 independent 外层捕获所有异常后返回一条泛化字段错误,因为那会将错误实例、空引用或库使用错误一并掩盖。

本章使用 toIntOption 避免手动处理常见数字格式异常,但并没有围绕整个输入流程安装异常捕获器。测试里所有预期输入错误都由明确数据分支构造;没有运行服务不可用或数据库超时场景,因此不对这些情况的恢复作保证。

对用户可见错误还应区分可修改输入与暂时无法完成的操作。库存不足可以按接口约定归入业务拒绝,远端不可达通常不能说成数量字段格式错误。即使最终 API 统一返回某个响应对象,内部错误种类也应保留,便于上层选择展示、重试或告警,而不是依赖解析文案猜测。

实验覆盖的具体场景

完整 Main.scala固定 Cats 核心库版本并导入实际 mapN、tupled、toValidated 与 andThen 语法。运行 node examples/functional-programming/run.mjs 18;本次 result.json包含环境、命令、源码哈希、退出码及断言输出,不依赖手写示意结果。

断言覆盖 Either 回调未运行、两字段错误按顺序累积、无效结果跳过库存、格式失败不追加范围失败、合法订单成功、库存拒绝、零数量拒绝、反转参数改变错误顺序,以及两份失败分别在独立和依赖组合下保留两条与一条。每个场景都检查实际值或调用计数,没有用“没有抛异常”代替结果正确。

这些断言没有覆盖真实商品查询、并发扣减、空引用输入、国际化文案或任意错误规模。它们也没有证明所有 Semigroup 实例都合法。更换错误容器、修改字段规则或改变组合顺序后,应补对应反例并重新运行,而不是沿用旧结果文件当作新代码的证据。

已有 Scala 错误短路与错误累积讨论字段内部依赖和独立校验的区分。本章在这个边界上增加实际 Cats 依赖、错误顺序检查和 ap/依赖组合的行为对照。旧文内容保留,新实验的版本、命令和结果独立记录。

从测试反推接口保证

测试中最重要的不是出现十行 PASS,而是每一行保护的契约不同。完整错误列表保护字段身份与顺序,计数为零保护依赖步骤未运行,合法 Order 的值相等保护成功载荷未被改写。若将它们简化成一次总的 isInvalid 检查,就会失去区分这些故障的能力。

维护时可先改变一项业务规则,再观察究竟哪些断言需要更新。例如允许零数量是一项协议变化,应更新范围检查及相关测试,而不应顺手改掉错误累积顺序。一次只改变一个约定,能让新证据说明具体变化,不至于让所有失败都被笼统解释成版本升级。

手算与修改练习

手算任务:为 independent("","0").andThen(stock) 写出每一阶段的类型、错误列表与库存回调是否执行。随后将数量改成四,商品编号改成有效字符串,指出错误首次出现的位置。最后解释为什么把第一个结果转成 Either 不会自动删除其中某一条字段错误。

修改任务:增加可独立解析的收货地区字段,并让库存规则只接收所有字段成功后的记录。要求同时覆盖三个字段失败、只有地区失败、字段全部成功但库存失败。错误顺序必须写入断言,不能只检查长度。新增规则不要读取全局可变配置;将允许地区集合显式传入,便于测试输入决定结果。

第二个修改可增加同一数量字段的协议上限检查。先取得 Int,再在该层组合正数与上限两项规则,思考哪些输入会触发哪些错误。这个练习不要求把所有失败都累积到同一层,而是要求每条错误能指向实际执行过、且前置数据充分的判断。练习代码尚未包含在本章已运行证据中。