在很多语言里,修改对象看起来是一件很自然的事:从数据库加载对象,调用几个 setter,再执行一次 save。如果对象下面还有子对象,也一起修改。至于框架怎样发现这些变化,通常交给 ORM 的快照比较、代理对象或者 identity map 处理。
当我们把同一套模型实现到 Rust 时,这种“自然”突然消失了。
Rust 不允许我们轻易地把共享可变状态藏在对象图后面。所有权和借用规则迫使我们正面回答一些以前可以暂时回避的问题:
- 待提交的修改究竟由谁拥有?
- 父对象和多个子对象同时变化时,修改边界在哪里?
- 通过某个子对象引用发生的修改,保存父对象时怎样发现?
- 乐观锁需要的原始版本应该保存在哪里?
- 校验失败或数据库写入失败以后,内存中的修改还算不算待提交?
最开始,我们觉得这是 Rust 给一个普通 API 增加了额外难度。后来才发现,真正的问题不是 Rust 太严格,而是其他语言让一个没有被完整定义的设计也能运行。
最后,我们设计了一个由整个对象图共享的 mutation ledger(修改账本)。它最初只是为了解决 Rust 中修改对象很麻烦的问题,最终却成为 TeaQL 七种 runtime 共同采用的修改模型。
一个 setter 背后隐藏了什么
假设我们从数据库加载了一个订单和两条订单明细。业务代码完成了下面几件事:
- 修改订单的收货地址;
- 连续两次修改第一条明细的数量;
- 删除第二条明细;
- 保存整个订单。
保存操作不能只看某个对象当前在内存中的样子。它还需要知道:
- 订单和两条明细属于同一个修改单元;
- 数量最终只需要提交最后一个值,而不是执行两次数据库更新;
- 消失的明细是明确的删除,而不是因为调用方没有加载它;
- 每个已有对象都必须使用加载时的版本执行乐观锁检查;
- checker 和自动 fix 必须在访问数据库之前运行;
- 保存失败时,不能把内存状态错误地标记成“已经提交”。
传统 ORM 可以通过快照比较推断出其中一部分,也可以在 setter 中加入拦截逻辑。但如果这些规则没有被明确写出来,不同语言的 runtime 很容易得到不同的语义。
Rust 提前暴露了这个问题。我们不能依赖大量隐形别名,等到保存时再猜测对象图中究竟发生过什么。修改状态必须有一个明确的所有者。
一个 Root,一本待提交账本
我们的做法是:一个工作中的对象图里,所有可修改实体都持有或引用同一个 EntityRoot。
这里的 root 是技术意义上的修改状态根节点,不是 DDD 中的 aggregate root,也不是租户或者权限上下文。
它最小的语言无关模型如下:
EntityKey = (entityType, entityId)
EntityRoot
changes: Map<EntityKey, Map<FieldName, NewValue>>
originalVersions: Map<EntityKey, Version>
newKeys: Set<EntityKey>
deletedKeys: Set<EntityKey>
生成出来的字段更新方法会把最终值写入这个 root。例如下面是概念上的 Rust 代码:
line.update_quantity(2);
line.update_quantity(3);
账本中最终只有一项 quantity = 3。同一字段在一次保存之前被修改多次时,我们采用 last-write-wins,因为账本描述的是最终准备提交的状态,而不是完整的操作历史。
当父对象和子对象作为一个对象图被加载或组装时,它们共享同一个 root。因此,通过子对象发生的修改也会进入同一本账本。之后即使从父对象调用保存,普通保存路径也能消费整个对象图的修改。
这对 Rust 实现非常关键。我们不再要求每个实体偷偷观察其他实体,而是让修改状态成为一个生命周期明确的共享资源。
为什么只记录新值,不复制旧值
审计通常需要同时显示字段的旧值和新值。一个很直接的方案,是把两者都写入修改账本。但这样会复制实体状态,并产生新的同步问题。
从数据库加载的实体本身已经保留原始字段状态。EntityRoot 只需要记录最终新值,以及 hydration 时取得的原始版本。真正生成审计记录时,可以把实体原始状态和账本中的新值组合起来。
因此,root 是“待提交意图”的记录,而不是对象图的第二份完整副本。
这也意味着它不是一个永久历史系统。如果应用已经丢弃加载时的原始实体状态,就不能只依靠 root 重建全部旧值。这是有意为之:轻量级的内存修改账本不应该假装自己具备持久化历史和重放能力。
Checker 和 Fix 也必须写入同一本账本
在 TeaQL 中,数据进入 provider 之前会经过两类处理:
- checker 负责拒绝不合法的业务状态,并在数据库报错之前给出面向业务的错误;
- fix 根据上下文自动补充创建时间、更新时间、root 引用等信息。
这两类处理不能绕开 mutation ledger。
如果 fix 自动补充了更新时间,这个变化也必须像业务代码主动修改字段一样写入账本。这样,最终数据库命令、审计信息和问题诊断看到的就是同一份修改意图。
整个顺序可以简化为:
业务代码修改对象
|
v
共享 mutation ledger
|
v
checker 与 context-driven fix
|
v
审计保存与 provider 命令
|
+--> 成功:接受数据库返回的权威版本,清理已提交项
|
`--> 失败:保留待提交状态
数据库约束仍然是最后一道安全边界,但它不应该是应用第一次发现必填业务字段缺失的地方。
失败之后的状态比成功路径更难设计
很多 dirty tracking 方案主要讨论怎样发现变化,却很少说明失败以后内存状态代表什么。
我们的规则是:
- checker 拒绝修改时,provider 调用次数必须是零,账本继续保留;
- provider 写入失败时,不能清理账本,也不能假装版本已经递增;
- 保存成功以后,清理已经提交的修改,并接受数据库返回的 ID 和版本等权威值。
这样一来,失败行为和重试边界是可理解的。它也为未来的调试留下了很有价值的结构化信息:
- 业务代码原本想修改什么;
- checker 或 fix 又补充了什么;
- 实际向数据库提交了什么命令;
- 最终提交成功还是失败。
它不是 Event Sourcing
“ledger”这个名字很容易让人想到 event sourcing,但两者解决的问题不同。
Event sourcing 通常把有顺序的领域事件持久化,并将其作为系统事实来源。这里的 mutation ledger 只是暂存的待提交状态。同一个字段的多次赋值可以折叠成一个最终值,保存成功后也会被清理。
系统当然可以根据这本账本生成持久化审计信息,但持久化和重放并不是账本自身的能力。
它也不同于普通 ORM 的快照式 dirty checking。快照比较问的是“对象现在有什么不同”,而账本记录的是“这个对象图明确准备修改什么”。在删除语义、乐观锁、上下文 fix、失败处理和跨实体保存这些场景下,两者的差别会变得很重要。
Rust 是一次强制的架构评审
实现跨语言框架时,我们逐渐发现一个规律:某个功能在一种语言里很容易隐藏,并不代表它已经被设计清楚。
垃圾回收、引用语义、动态代理和拦截器,可以让 API 显得非常方便,同时把所有权、生命周期和副作用留在框架内部。Rust 移除了很多这样的逃生通道。这当然会带来摩擦,但这些摩擦本身就是架构信息。
它会告诉我们:这里存在一个没有命名的所有者,那里依赖一个隐含的生命周期,另一个地方则存在边界不清的副作用。
这一次,Rust 迫使我们识别出真正的修改单元。它不是单个实体,也不是碰巧调用了 save 的那个对象,而是附着在共享对象图上的整组待提交变化。
这个边界明确之后,设计反而更容易解释、测试和移植。Rust runtime 能够工作只是第一项收益;更大的收益是,其他 runtime 也获得了一份更清晰的契约。
从 Rust 的一个问题,变成七种 runtime 的共同设计
TeaQL 目前已经在 Rust、Java、Python、Go、.NET、Swift 和 TypeScript 中采用同一套 mutation-ledger 语义。各语言的 API 仍然保持自己的命名和使用风格,但可观察行为保持一致:
- 一个对象图共享一个 root;
- 字段保存最终修改值;
- 加载时版本用于乐观锁;
- 新建和删除具有明确分类;
- checker 在 provider 之前运行;
- 保存成功与失败具有明确的状态转换。
我们也在 TeaQL conformance 中保留了跨语言的可执行验证,而不是把七份相似的文档当作实现已经对齐的证据。
这个设计最初来自 TeaQL Rust。随后,Java、Python、Go、.NET、Swift 和 TypeScript 六种实现参考并采用了相同的模型。
最初我们只是想解决“Rust 中修改对象很麻烦”这个问题。最后却发现,Rust 并不是单纯让我们付出了更多工作,而是让原本隐藏的契约变得可见。只有当契约可见之后,七种语言才真正有机会保持一致。
英文原文及完整 runtime 链接:
评论区
写评论这文章看起来每个字都懂,可是组合起来,每一句话,每一个段落,整篇文章看得人是晕头转向的,对象不变性特性本来可以好好的利用的,可偏偏被急功近利的搞成这副样子。。。
借用是不是让 用 rust 写程序的人很头疼?说说看