< 返回版块

Philip Z 发表于 2026-08-21 18:10

Tags:teaql,aicoding

在很多语言里,修改对象看起来是一件很自然的事:从数据库加载对象,调用几个 setter,再执行一次 save。如果对象下面还有子对象,也一起修改。至于框架怎样发现这些变化,通常交给 ORM 的快照比较、代理对象或者 identity map 处理。

当我们把同一套模型实现到 Rust 时,这种“自然”突然消失了。

Rust 不允许我们轻易地把共享可变状态藏在对象图后面。所有权和借用规则迫使我们正面回答一些以前可以暂时回避的问题:

  • 待提交的修改究竟由谁拥有?
  • 父对象和多个子对象同时变化时,修改边界在哪里?
  • 通过某个子对象引用发生的修改,保存父对象时怎样发现?
  • 乐观锁需要的原始版本应该保存在哪里?
  • 校验失败或数据库写入失败以后,内存中的修改还算不算待提交?

最开始,我们觉得这是 Rust 给一个普通 API 增加了额外难度。后来才发现,真正的问题不是 Rust 太严格,而是其他语言让一个没有被完整定义的设计也能运行。

最后,我们设计了一个由整个对象图共享的 mutation ledger(修改账本)。它最初只是为了解决 Rust 中修改对象很麻烦的问题,最终却成为 TeaQL 七种 runtime 共同采用的修改模型。

一个 setter 背后隐藏了什么

假设我们从数据库加载了一个订单和两条订单明细。业务代码完成了下面几件事:

  1. 修改订单的收货地址;
  2. 连续两次修改第一条明细的数量;
  3. 删除第二条明细;
  4. 保存整个订单。

保存操作不能只看某个对象当前在内存中的样子。它还需要知道:

  • 订单和两条明细属于同一个修改单元;
  • 数量最终只需要提交最后一个值,而不是执行两次数据库更新;
  • 消失的明细是明确的删除,而不是因为调用方没有加载它;
  • 每个已有对象都必须使用加载时的版本执行乐观锁检查;
  • 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 Made Object Mutation Hard—So We Designed It Properly

评论区

写评论
xbee 2026-08-21 20:16

这文章看起来每个字都懂,可是组合起来,每一句话,每一个段落,整篇文章看得人是晕头转向的,对象不变性特性本来可以好好的利用的,可偏偏被急功近利的搞成这副样子。。。

作者 Philip Z 2026-08-21 18:12

借用是不是让 用 rust 写程序的人很头疼?说说看

1 共 2 条评论, 1 页