未付款订单为什么会带着物流单号

考虑一份用字符串表示状态的订单:

1
2
case class Order(status: String, receipt: Option[String],
tracking: Option[String])

这个模型可以表示已经付款但没有收据、尚未付款却带着物流单号,以及状态拼错成 "paidd" 的记录。构造器只检查三个参数各自的类型,没有检查它们之间的关系。增加三个条件判断可以拦住一部分输入,但下一处直接调用构造器的代码仍可能重新制造矛盾。

问题不只是字符串容易拼错。三个字段是彼此独立的选择:状态可以取很多字符串,两个可选字段又各有存在与不存在两种形状,组合空间远大于业务允许的状态空间。修复的方向是先列出有效形状,再决定每种形状必须携带哪些信息。本文使用草稿、已付款、已发货三个阶段;取消、退款和部分发货暂不属于这份模型,遇到相关命令时明确拒绝。

实验固定 Scala 3.3.7、标准库 2.13.16、JDK 21 和 Scala CLI 1.9.1。这里讨论的是有限分支建模与状态转换;订单存在于数据库、消息是否重复送达、两个线程能否同时发货,仍需要模型外的持久化和并发协议。

和类型决定分支,积类型决定分支内的数据

1
2
3
4
enum State:
case Draft
case Paid(receipt: String)
case Shipped(tracking: String)

一个 State 值必须选择其中一个分支。它可以是 Draft,也可以是携带收据的 Paid,或者携带物流单号的 Shipped。这种“若干可能之一”的结构称为和类型。名字中的“和”指可能值集合的组合,不是对数字求和。

Paid 的收据是这个分支的数据。如果把付款记录扩成 Paid(receipt: String, cents: Long),构造一个付款值就必须同时给出收据和金额,这种“所有字段同时具备”的结构称为积类型。通常用 case class 表达积,用 enum 或封闭继承层次表达和,再把两者嵌套起来得到代数数据类型,也就是 ADT。

这两个方向可以用有限集合手算。假设某个测试世界只有两种币种和三种配送方式,把它们放进同一个 case class,有六种字段组合;若定义两个分支,一个只保存币种,一个只保存配送方式,则有五种可能。真实的 String 和 Long 不适合按这个小例子直接数尽,但“相乘还是相加”仍能帮助检查模型是否容许了不必要的组合。

ADT 描述数据形状,没有要求每个字段都是不可变对象。case class 的参数默认成为 val,只能保证该字段绑定不能重新赋值。如果字段是可变集合,其内部内容仍能变化。订单状态中的收据采用 String,避免了这份最小实验中的嵌套修改问题;换成外部可变对象后需要重新分析,而不能仅凭 enum 断言线程安全。

enum 简化封闭层次,但不会验证每个字符串

类似的模型也能写成 sealed trait 加 case object、case class。sealed 限制直接子类型的定义位置,让编译器有条件列举已知分支;enum 把同一组分支直接组织在一处,并为不同种类的分支生成相应实现。两种写法的取舍应围绕模型组织和接口需要,不能从语法长短推导运行速度。

Scala 3 的带参数 enum 分支与不带参数分支不是一回事。前者能够产生携带不同参数的多个值;后者常用来表示不需要附加数据的固定情况。因而业务状态的“分支数量有限”,不等于状态值的数量有限:Paid("r1") 与 Paid("r2") 属于同一分支,却是两个不同值。

编译器会拒绝 State.Paid(42),因为 Int 不能充当 String。它却会接受 State.Paid(""),因为空字符串仍是 String。这恰好划出了本章模型的保证范围:付款分支必须带有字符串收据,但字符串是否非空,尚未进入类型定义。增加一个带校验的 Receipt 类型可以收紧边界;在引入这种类型之前,可以先让唯一的业务转换函数负责验证输入。

直接公开所有构造器,意味着调用方仍能绕过转换函数构造状态。是否允许这样做取决于接口职责:用于解析历史数据的模型常需要表达异常记录,已验证领域对象则可能需要隐藏构造入口。这里保留公开分支,目的是把“类型能阻止的错误”和“转换规则阻止的错误”同时暴露出来。

命令是请求,状态是已经成立的事实

命令不能直接复用状态。例如 Pay("r1") 表示请求付款,Paid("r1") 表示转换成功之后的结果。如果把请求直接当作结果,就跳过了判断当前状态能否接受该请求的步骤。

1
2
3
4
5
6
7
8
enum Command:
case Pay(receipt: String)
case Ship(tracking: String)
case Cancel

enum Error:
case InvalidField(name: String)
case IllegalTransition(state: State, command: Command)

错误也是和类型。字段格式错误携带字段名,非法转换携带发生冲突的状态与命令,调用方可以分别记录、展示或统计。用一个任意字符串替代这些分支虽然方便,却丢失了编译器能检查的分类边界。这里错误不包含订单隐私字段;真实系统还要决定哪些信息适合进入日志。

对于非空的收据和物流单号,转换表如下。表中的拒绝是函数正常返回的业务结果,不代表程序崩溃。

当前状态 Pay Ship Cancel
Draft Paid 拒绝 拒绝
Paid 拒绝 Shipped 拒绝
Shipped 拒绝 拒绝 拒绝

为什么表里仍然出现 Cancel?它使“已知请求但当前规则不支持”的情况能被准确表达。若业务决定支持取消,需要同时增加状态、转换规则和验收样例。仅仅往 enum 里加一个名字,不会自动实现这个功能。

把转换表写成总函数

1
2
3
4
5
6
7
8
9
10
11
12
def step(state: State, command: Command): Either[Error, State] =
(state, command) match
case (State.Draft, Command.Pay(r)) if r.nonEmpty =>
Right(State.Paid(r))
case (State.Paid(_), Command.Ship(t)) if t.nonEmpty =>
Right(State.Shipped(t))
case (_, Command.Pay(r)) if r.isEmpty =>
Left(Error.InvalidField("receipt"))
case (_, Command.Ship(t)) if t.isEmpty =>
Left(Error.InvalidField("tracking"))
case _ =>
Left(Error.IllegalTransition(state, command))

输入是一对状态与命令,这对数据是积;匹配再分别检查其中两个和类型的分支。前两条处理合法转换,中间两条处理空字段,最后一条承接其余组合。函数的正常返回总有一个解释:成功得到新状态,失败得到具体错误。本文未把 null 当作受支持的输入,外部 Java 或反序列化边界应先完成清理,不能拿这里的覆盖声明证明任意 JVM 引用都安全。

分支顺序也是规则的一部分。假设已发货订单收到空收据付款命令,本实现返回字段错误,而不是非法转换。先检查状态再检查字段的实现可以选择相反优先级,两者都需要在接口契约中说明。业务错误排序不是 match 帮忙决定的,它由书写顺序决定。

最后的通配分支让所有未列出的组合都返回拒绝,有助于避免 MatchError,但也有代价:未来增加新状态后,编译器可能无法提醒这里缺少新业务处理,因为通配分支已经覆盖它。对安全性要求较高的转换表,应另有矩阵测试或刻意展开各状态,而不能把“没有穷尽警告”当成新功能已经实现。

正常链路和非法转换分别验收

独立程序在 examples/scala-lab/snippets/07/Chapter07.scala,入口是 scalaexamples.Chapter07。正常链路从草稿开始付款,再用成功的付款结果发货:

1
2
3
val paid = step(State.Draft, Command.Pay("r1"))
val shipped = paid.flatMap(step(_, Command.Ship("t1")))
assert(shipped == Right(State.Shipped("t1")))

第二步的输入来自第一步成功结果,因此不是两个独立校验。如果第一步失败,第二步不应把原草稿转换成已发货。Either 的组合细节将在后文展开;此处只把它作为显式传递成功或失败的容器。

程序另外验证草稿直接发货得到 IllegalTransition,空收据得到 InvalidField,并枚举三个代表状态与三个命令形成九个组合,其中只有两条合法边。这个计数只覆盖非空代表值,没有穷举所有字符串,也不证明幂等性和并发安全。测试成立的条件必须和它的输入范围一起保留。

编译反例位于 negative/07/wrong-payload/,把整数传给付款分支。验收同时要求编译非零退出,以及诊断出现实际类型与所需 String,防止把环境下载失败误认为模型拒绝了非法值。公开构造器接受空字符串的反例则在正常程序中用断言保存,它展示的是模型尚未提供的业务保证,不应被写成编译失败。

扩展模型时先检查保证在哪一层

增加退款功能之前,应先问退款是一个新状态,还是付款事实之后新增的一份记录。若一个订单允许部分退款,单个 Refunded 标记可能无法表达剩余金额;若允许分包发货,单个物流单号也可能不足。ADT 能让当前选择清楚,但错误的领域分解依然会得到能编译的代码。

可以沿三层检查一个变化。第一层是数据形状:某状态必须有哪些字段,是否还有无意义的可选组合。第二层是转换规则:从哪个状态接受什么命令,失败保留哪些信息。第三层是外部协调:数据库更新是否带版本条件,重试是否沿用幂等键,消息重复是否产生第二次业务副作用。这三层各有证据,不能互相替代。

同样,序列化协议不能直接依赖 enum 的声明顺序当作永久业务编号。源码中的分支排列可以重构,外部存储的历史值却必须保持可解释。稳定的业务代码与版本化解析应显式设计;本例没有数据库格式,因此不对任何自动编码的兼容性作保证。

重复请求不等于重复状态

同一笔付款被网络重试两次,是这个模型有意保留的一处边界。第二次 Pay 到达时状态已经是 Paid,当前规则统一返回非法转换。它没有比较收据是否相同,也没有区分“重复确认同一事实”和“用新收据再次付款”。若调用方需要幂等成功,可以在 Paid 分支中比较请求标识,只有与已保存记录一致时返回原状态;不同标识仍然拒绝。这个变化应有两条不同断言,不能只把所有重复付款都改成成功。

把幂等性直接写成“状态已经付款就返回成功”会丢掉冲突信息。比如同一订单先收到收据甲,再收到收据乙,静默接受后者会让调用方误以为乙也关联成功。用 ADT 保存收据可以提供比较所需的数据,但比较规则是否正确仍由业务协议决定。类型只提供可表达的材料,不替程序选择冲突策略。

另一处容易混淆的是旧状态与新状态的关系。step 返回新值,不修改传入的旧值,所以调用方可以保留旧状态用于审计或比较。不过,返回值并没有自动写回数据库。只有持久化成功之后,外界才能把这个转换当作已经提交的事实。如果读取和写入之间存在竞争,应让存储更新检查预期版本,并在失败时重新判断命令。函数式转换让重算容易解释,却不会消除读取过期状态的问题。

手算与修改练习

手算:从 Draft 连续执行 Pay(“r1”)、Pay(“r2”)、Ship(“t1”),采用“遇到第一个 Left 就停止”的组合规则,最终会是什么?答案是第二次付款返回 IllegalTransition,第三个命令不执行,成功前缀只到 Paid(“r1”)。若一个处理器在失败后继续处理后续命令,它采用的是另一种批处理策略,必须显式定义保留状态的方法。

修改练习:增加 Cancelled 状态,让 Draft 接受 Cancel,但 Paid 和 Shipped 仍拒绝。新增断言应覆盖草稿取消成功、取消后付款失败以及矩阵合法边数量由二变三。答案的关键修改是增加无参数状态分支和 (State.Draft, Command.Cancel) 匹配;其余组合继续进入 IllegalTransition。若还希望防止直接构造无效收据,单改转换表不够,还需要受控的收据类型或构造边界。

实验记录与依据

实验状态与逐条命令见同名素材目录中的 RUN.md。本章只声明日志中实际验证的转换、构造与诊断,不用这份小状态机推导生产订单完整性。

前置阅读:泛型、边界与型变。

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