函数式编程33:状态不变量与对象封装
订单支付后不能取消,是状态转换规则;金额不能为负,是构造约束;同一请求重复提交不能再执行一次,是命令处理规则。把三种规则都放进一个名为 validate 的方法,会让调用者难以判断它究竟检查了什么。函数式领域模型需要分别描述合法数据、允许的转换和重复命令的处理方式。
本章使用只有草稿、已支付、已取消三个状态的订单。它不连接支付平台,也没有数据库事务。这个范围足以比较两种实现:对象在方法内部替换自身持有的状态,纯函数接收旧状态并返回结果。比较的对象是业务可观察行为,不是代码中是否出现 class。
构造约束和转换约束
金额使用非负的 long 分值。构造器拒绝负数、空订单标识和空状态,并复制请求回执映射。这里的回执记录请求键以及对应动作,而不是只有一个“见过的键”集合。同一个键先请求支付、随后请求取消,需要被识别为冲突,不能当作正常重试。
1 | |
复制映射解决的是别名问题。调用者传入 HashMap 后继续增加键,已经构造的 Order 不受影响。映射元素是 String 和枚举,示例没有嵌套可变元素,因此浅复制足够覆盖当前模型。若元素变为带可变列表的 Receipt 类,Map.copyOf 不会自动复制列表,必须重新决定快照边界。
构造器没有证明历史的真实性。调用者仍能直接构造一个 PAID 订单,或者构造带回执的 DRAFT 订单。本章把这种构造视为受信任的快照装载,不声称类型排除了所有非法历史。若系统要求所有订单只能从草稿开始演化,就要限制构造入口,并在持久化恢复时验证版本和历史一致性。仅仅把数据写成 record 不会得到这些保证。
业务规则规定:只有 DRAFT 可以接受新命令;PAY 产生 PAID;CANCEL 产生 CANCELLED;终态拒绝新键请求。三个状态与两个动作形成六种组合。这是一个很小的有限空间,可以直接枚举,不必为了测试框架而引入随机性。
返回值携带新状态与拒绝原因
结果分为 Changed 和 Refused。前者包含新订单以及 replay 标记,后者包含原订单和原因。这样调用方不会把“拒绝后的缺席”误解为“订单已经删除”。
1 | |
检查顺序也是契约的一部分。代码先查回执,再检查终态。因此一个已经支付的订单仍能接受原支付命令的重放,返回同一个业务状态;它不能接受另一个键的取消命令。若交换两个分支,合法重试会被 terminal 拒绝。这个变化不会导致编译错误,却会改变客户端在网络超时后的恢复方式。
局部 HashMap 只在 step 内部存在,返回前再次复制进 Order。函数不修改传入的 old,也不把临时可变容器泄漏出去。调用者能观察到的只是输入与输出之间的关系。函数内部是否使用赋值,不足以判断它是否破坏了纯核心边界。
Changed 的名字也要按约定理解:replay 为 true 时并未产生新的业务变化。生产接口可以另设 Replayed 分支,让穷尽匹配强制区分首次成功与重试成功。本章保留一个布尔标志,是因为只有这两种成功观察,没有独立的通知、计费或审计动作。新增这些动作时,必须先定义重放是否再生成动作。
与对象方法做独立对照
可变版本 MutableOrder 持有 current,apply 成功后替换 current。它的拒绝分支不做赋值,重试分支返回当前快照。函数版本则将保存新快照的责任交给调用方。两者都可以封装相同的规则;区别在于状态所有权和更新发生的位置。
实验没有让对象版本直接调用 step。如果这样做,两个结果相同只能说明一次函数调用得到了相同返回值,无法帮助发现两个实现之间的差异。当前对象版本独立写出回执、终态和更新分支,再用相同输入比较结果。独立实现仍可能共享同一个理解错误,因此测试还单独写出了负金额、冲突键和终态拒绝等期望。
枚举六种状态动作组合时,每次创建一个空回执快照,并给对象版本一个相同初值。断言比较 Result 的结构相等,同时检查函数版本的旧快照仍保持原状态和空回执。对于成功结果,再重放同一命令,期望得到 replay=true。对于拒绝结果,检查对象版本 current 仍等于输入。
实验还从含 K/PAY 回执的 PAID 快照分别提交 other/CANCEL 和 K/CANCEL,独立期望为 terminal 与 key-conflict。两次都比较 MutableOrder 返回的完整 Refused 和 current 的完整 Order,包括标识、金额、状态与非空回执;纯函数也与相同期望比较。假如对象版本在拒绝后清空回执,即使拒绝原因正确,完整状态断言仍会失败。空回执枚举本身无法暴露清空操作,非空样例保护的是这一额外边界。
把转换规则和语言保证分开
Java record 提供受限制的数据聚合形式和组件访问器,并不替领域定义允许的状态迁移。语言规范描述的是类结构与构造机制;支付后不得取消则来自本实验的业务政策。JLS 21 record 类
模型对照还需要区分保存当前状态与保存历史。MutableOrder 替换 current 后,旧 Order 仍能被其他引用持有,因为快照本身没有改变;这说明对象持有状态与数据不可变可以同时存在。纯函数接口把选择哪个快照继续计算的责任交给调用方,调用方传错旧快照仍会产生合法但过时的决策。
若要支持撤销,不能简单把 phase 从 PAID 改回 DRAFT 并删除回执。支付可能已经对应外部资金动作,撤销应有独立命令与补偿规则。当前模型没有这些效果,所以终态拒绝是有意的范围限制。扩展状态机时需要从业务可逆性出发,而不是从 enum 能增加多少常量出发。补偿成功也不意味着原动作未发生,审计记录应保留两次转换各自的业务含义。
幂等性由哪一层保证
这里的重放保证以“调用者传入包含上次回执的新状态”为前提。若两次调用都拿到同一个旧 DRAFT,两次都会计算出成功结果。这符合纯函数的定义,也说明纯函数本身不协调并发写入。数据库里两个事务读取同一版本时,需要乐观锁、条件更新或具有相应隔离性的事务决定谁能提交。
命令键的作用范围也是当前订单。两个不同订单可以使用同一个键,不会发生模型内冲突。若 API 将请求键定义为租户级或账户级唯一,就必须把相应身份加入查找范围。把 String 改名为 IdempotencyKey 不会自动扩大存储约束。
本实验保存的是“键对应动作”,足以区分 PAY 与 CANCEL;完整支付接口还需要把金额、币种、付款方等业务载荷纳入指纹。否则相同键、相同动作但不同金额可能被误判为重试。不能用当前两个枚举动作的测试结果证明完整支付协议安全。
回执保存多久同样是业务决策。删除回执能降低空间占用,但旧请求在删除后再次到达时,系统如何响应必须明确。当前终态仍会拒绝新键,所以这个简化模型不会再次支付;对于允许追加操作的账户模型,删除回执可能直接影响重复扣款风险。实验没有模拟回执过期,不把内存 Map 当成持久幂等服务。
不变量的归属
可以把当前约束分成三个观察层次。单个快照保证字段满足构造条件;一次转换保证草稿才能迁移、拒绝不改变状态;一段调用历史保证同一命令重试不增加新的业务变化。测试应当针对各自的输入空间,不能只写构造器测试就宣称状态机已经安全。
有限枚举覆盖了当前状态和动作的笛卡尔积,但没有覆盖所有回执映射。回执的关键等价类是键缺席、键存在且动作一致、键存在但动作冲突。实验另外测试这些分支。将来给命令增加货币或版本字段,需要扩充等价类,不能继续用“六种组合全部通过”描述新模型的完整覆盖。
错误字符串便于这个小实验观察。实际代码若需要根据拒绝原因执行不同恢复操作,sealed 的拒绝原因类型比任意字符串更稳妥。比如 Terminal、KeyConflict、StaleVersion 分支可以让调用方在编译期看到新增情况。但错误类型再精细,也不能代替对“旧版本成功计算却提交失败”的运行时处理。
同样,record 的结构相等适合当前无外部身份的快照比较。领域对象的相等规则可能只比较订单标识,不能直接拿这种相等规则验证所有字段不变。测试必须声明它比较的是完整快照还是领域身份,否则金额被改掉而 equals 仍返回 true,会产生虚假的回归信心。
与旧文的边界
旧文函数式领域建模讨论过租借领域中对象封装与显式快照的对照,并指出外部一致性不由纯函数自动保证。本章沿用这个区分,把焦点收窄到命令重放、键冲突和六种转换的独立对照,没有改写旧文,也没有把对象模型描述为天然不安全。
纯核心与外部端口处理的是计算与文件、时钟、报价的边界。本章处理的是边界内部哪些状态更新合法。两者可以组合:纯核心产出新状态和待执行动作,应用层负责提交;提交失败时不能把纯计算成功直接报告为订单已经持久化。
运行与练习
完整Java 实验源码和本次运行证据分别提供实现与环境、命令、源码散列、退出码和断言输出。仓库根目录执行:
1 | |
本次输出包含 combinations=6、replay、key-conflict、terminal-refusal、alias-snapshot,以及 nonempty-terminal-state-unchanged、nonempty-conflict-state-unchanged 两条非空回执状态断言。它们不代表数据库并发场景已经运行,证据只覆盖这个进程内模型。
手算题:从空回执 DRAFT 出发,依次执行 K/PAY、K/PAY、K/CANCEL、L/CANCEL,写出每一步的 Result、phase 和回执。第二次是重放成功,第三次是键冲突,第四次是终态拒绝;只有第一次新增回执。再说明把回执检查移到终态检查后,哪一步的行为改变。
修改题:增加 REFUND 动作与 REFUNDED 状态,规定只能从 PAID 退款,并且支付命令的旧重放在退款后仍返回什么。先把这条语义写成独立期望,再扩展两种实现和有限枚举。不要直接复制当前分支后只检查程序能编译;新增状态会改变旧重试的含义,必须把预期写清楚。

