数量超限、程序空指针、用户主动取消,都可能让订单处理没有返回成功值。把它们统一记成“调用失败”,上层就很难决定该提示用户修正数量、报警修复程序,还是安静结束已经取消的工作。ZIO 把预期错误放在类型参数里,同时保留能够描述缺陷和中断的终止原因。

ZIO[R, E, A] 描述一项计算:执行需要环境 R,业务失败值属于 E,成功值属于 A。这个签名让一部分依赖和失败能够进入类型检查,但没有承诺“除了 E 什么也不会发生”。缺陷、运行时中断以及进程终止仍然需要单独理解。

本篇冻结 Scala 3.3.7、ZIO 2.1.16、JDK 21。独立实验目录为 examples/scala-lab/electives/E03,成功记录是 evidence/20261002-b/error-channels.log。实验只验证本机 fiber 的生命周期,不比较不同效果库的整体优劣。

环境参数表达显式依赖

订单数量校验依赖一个上限。把上限直接写成全局变量,调用方无法从方法签名看出依赖;把它放进 Config 环境后,校验描述可以先构造,再由调用端提供具体配置。

1
2
3
4
5
6
7
final case class Config(limit: Int)

def validate(n: Int): ZIO[Config, String, Int] =
ZIO.serviceWithZIO[Config] { config =>
if n <= config.limit then ZIO.succeed(n)
else ZIO.fail("quantity")
}

Config 不是异常通道。缺少环境是一项构造与组合问题,数量超限才是此例明确建模的业务失败。调用端通过 ZLayer.succeed(Config(3)) 提供环境,得到不再要求调用方提供该配置的效果。

1
validate(2).provide(ZLayer.succeed(Config(3)))

这段代码返回 2。换成 validate(4),效果在错误通道中产生字符串 quantity。本实验故意只定义上限规则,未把所有订单约束隐含进去;例如负数是否合法,需要另一个明确规则,不能因为方法名叫 validate 就假设它已完成所有输入验证。

环境类型还说明依赖注入与服务生命周期是两件事。不可变配置可以通过 succeed 直接提供;数据库连接池还需要获得和释放。把池装入环境只能让方法拿到它,池何时关闭仍需要受管理作用域。提供一个已经关闭的对象,不会因为环境类型匹配就恢复有效性。

同样,测试环境与生产环境共享接口,不表示二者性能和失败行为相同。一个永远立即返回的内存仓库,可以证明调用路径和业务规则,却不能证明数据库事务、连接耗尽或网络取消正常工作。环境替换扩大了可测试范围,也要求明确替身省略了哪些行为。

三种终止原因分别注入

为了把错误分类放在可观察层面,实验分别构造 typed failure、defect 和 interruption。前两者使用 exit 保存终止结果,第三者通过 Fiber.interrupt 获取被中断任务的结果。

1
2
val typed = validate(4).provide(ZLayer.succeed(Config(3))).exit
val defect = ZIO.die(new IllegalStateException("bug")).exit

ZIO.fail 在这里产生 String 类型的预期错误;ZIO.die 则明确把异常作为缺陷。两种程序都没有正常产生业务结果,但 Cause 的结构不同。实验分别通过 failureOption 和 defects 检查它们,避免仅比较日志中的英文单词。

预期错误适合被调用方按业务规则处理。例如数量超限可以返回字段错误;库存不足可以选择等待或拒绝。缺陷通常表示程序内部不变量已经破坏,不能随意转成“用户输入不合法”。否则监控会把程序 bug 当作正常业务拒绝,真实故障率被掩盖。

不过,分类最终仍取决于程序如何建模。网络异常可能被显式捕获为可重试错误,也可能因错误封装而变成 defect。ZIO 无法从 Throwable 的类名推断团队的业务意图。需要在第三方接口边界明确哪些异常进入 E,哪些异常保持缺陷,并保存足够的诊断上下文。

UIO[A] 的错误类型为 Nothing,表达没有预期失败值,并不意味着这项任务永不终止失败。代码仍可以抛出缺陷,也可以被中断。把“没有 typed error”解释成“不会出错”,会使日志、资源释放和进程退出策略缺失。

[PATTERN] 类型化错误描述可预期的失败协议;实际终止原因还应覆盖缺陷和中断。两者在接口层与运维层分别保留。

catchAll 能看见什么

catchAll 工作在 E 通道上。对于 ZIO[Any, String, Int] 一类效果,它能处理字符串失败;一个内部 defect 不会因为错误处理器接受字符串,就自动转成字符串进入该处理器。需要查看完整终止原因时,应选择暴露 Cause 的接口。

这个边界能防止恢复策略过宽。若把所有 Throwable 都吞成默认值,损坏数据、业务拒绝和主动取消会共享一条“成功返回零”的路径。订单金额被无错误记录地改成零,比保留一次明确失败更难排查。恢复值必须来自业务允许的替代行为,不能只为了让链条继续运行。

中断尤其不能被当作普通错误重试。当上游已经不需要结果时,捕获中断后无限重试会让任务脱离调用方生命周期。观察中断的原因、记录日志与撤销中断状态是不同操作;某个处理器能够读到 Cause,并不证明它可以随意让已中断任务继续正常运行。

本实验让三种失败保留在各自的 Exit 中进行断言。这种结构既能检查效果库边界,也能核对监控标签:typed error、defect、interrupt 应进入不同的计数维度。

若服务把这些分类传给 HTTP 层,还需要另做协议映射。数量错误可能对应客户端错误,内部缺陷可能对应服务端错误,取消则可能已经没有可写响应的连接。ZIO 的内部原因图并不是对外响应格式,异常细节也不应原样暴露给不可信调用方。

中断与 finalizer 的先后关系

取消实验使用 Promise 确认任务已经启动,再调用 interrupt。任务本体在 ZIO.never 上等待,释放器通过 ensuring 把关闭标记设置为 true。

1
2
3
4
5
6
7
8
9
10
11
12
13
for
ready <- Promise.make[Nothing, Unit]
closed <- Ref.make(false)
fiber <- (ready.succeed(()) *> ZIO.never)
.ensuring(closed.set(true)).fork
_ <- ready.await
interrupted <- fiber.interrupt
released <- closed.get
_ <- ZIO.succeed {
assert(interrupted.causeOption.exists(_.isInterrupted))
assert(released)
}
yield ()

ready 排除了“还未执行任务就被中断”的另一种路径。interrupt 返回之后才读取 closed,所以检查的是清理完成后可见的状态。只在外部等待任意十毫秒再看标记,会把调度延迟引入测试,结果可能偶然通过或偶然失败。

Ref 提供效果中的状态更新,Promise 提供一次性的完成协调。两者职责不同:计数、布尔状态适合放在 Ref;等待某个阶段完成适合用 Promise。轮询 Ref 也能实现等待,但会增加调度和退出逻辑,没有必要用轮询替代这里的一次性通知。

此例释放的是逻辑标记。若换成文件或线程池,需要让 finalizer 调用实际关闭 API,并在关闭成功后设置观测标记。若关闭操作自身阻塞,终止过程也可能等待;“具有 finalizer”不能推导出取消延迟总是有界。

当资源的获得与释放成对出现时,可以采用受作用域管理的资源接口。单独的 ensuring 更适合展示一段效果退出时执行的动作,不能自动补上资源获得失败时的处理。若获得动作在 finalizer 注册之前已经泄漏句柄,末尾再加 ensuring 也来不及修复。

与现有异步接口对接

实际应用往往已经使用 Future、Java 客户端或回调接口。把它们桥接到 ZIO,首先要检查任务何时提交,其次检查取消是否能够传到底层操作。一个只提供完成回调、没有取消方法的客户端,不会因外层有 fiber 就自动停止网络请求。

阻塞调用也需要放在正确的执行路径。把同步数据库读取伪装成普通纯函数,会让运行时错误地把它当作短暂计算。即使把阻塞操作隔离出来,也仍需检查驱动对中断、超时和连接关闭的定义。线程被中断与服务端事务回滚不存在普遍等价关系。

对于外部写入,最可靠的测试通常包含服务端可查询的操作标识。取消后查询操作状态,才能区分未开始、处理中、已提交和结果未知。仅观察本地 fiber 的 interrupt,最多说明本地计算终止原因,不能证明外部系统没有执行。

这也是不同效果库比较时应固定业务场景的原因。本篇的样例只使用配置依赖、业务失败和 fiber 清理;它不足以给 Cats Effect、ZIO 或 Future 排出全局优劣。采用哪一种抽象,还取决于已有接口、团队约定、资源管理方式及测试成本。

错误分类影响恢复策略

将所有失败都转换为一个通用错误码,确实可以简化 HTTP 响应格式,却会丢掉恢复所需的信息。数量不合法属于输入问题,重复相同请求不会自动成功;程序缺陷需要定位代码;任务中断则可能是调用方主动结束等待。若它们进入同一个无限重试分支,输入错误会浪费资源,程序缺陷会被重复触发,取消请求也可能失去预期效果。

错误类型可以进一步建模成代数数据类型,区分缺少配置、库存不足和临时不可用。这样恢复函数可以只重试某些分支,并让遗漏的分支暴露在模式匹配检查中。不过,静态分类仍依赖边界翻译是否准确:数据库超时可能发生在提交之前,也可能发生在提交之后。把它命名为可重试错误,并不会改变实际提交状态。

环境参数也会影响可复现性。本例显式提供数量上限,因此测试无需读取进程环境或全局单例。实际配置若会热更新,应明确一次效果执行使用启动时的快照,还是每次操作重新读取。相同输入在两次执行中产生不同结果,可能来自依赖变化,而不是效果类型失去确定性。

审查这类程序时,可以沿三条路径逐项核对:输入错误在哪里进入 E 通道,意外异常在哪里成为 defect,中断请求在哪里到达任务。最后再检查各条退出路径如何触发清理。这样的顺序能把类型声明、执行行为和资源释放对应起来,避免只看一个宽泛返回类型就断言程序已经安全。

实测结果与练习

程序退出码为 0,输出如下,所有字段均有对应断言。

1
success=2;typed=quantity;defect=bug;interrupt=true;finalizer=true
注入路径 检查方式 实测结果
数量 2、上限 3 成功值 2
数量 4、上限 3 failureOption quantity
die(IllegalStateException) defects 包含 bug
对等待任务 interrupt isInterrupted true
等待任务的 ensuring Ref 标记 true

手算题:把数量错误改成 ZIO.die,方法仍声明错误类型 String,调用方的 catchAll 是否一定处理它?不会。返回类型允许预期错误,不会强迫所有缺陷改走预期错误通道。

修改练习:为订单校验增加“数量必须为正”的错误码,把错误类型从 String 改为 enum。分别输入零、超限和合法数量,检查错误分支和成功结果;再保留一个独立 defect 注入,确认业务恢复没有吞掉它。扩展错误模型时,不要顺手删掉中断断言。

可迁移规则 对应设计检查
环境展示依赖 配置与有生命周期资源是否区分
E 展示预期失败 defect 是否仍可诊断
Cause 展示终止原因 中断是否被错误重试
释放需要完成证据 finalizer 之后读取关闭状态

完整运行方式见实验说明。

参考资料

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