Skip to content

从模型到 HTTP 接口:资源、命令、条件更新与受理 ​

订单接口按 REST 的「标准做法」设计:GET /orders/{id} 读取,PUT /orders/{id} 更新。客服在后台改了收货地址,几分钟后用户在 App 里加了一条备注。最后订单上的地址又变回了旧的,两次请求都返回了 200。

领域模型里,订单的变化通过表达意图的方法发生:changeAddress、cancel、pay,每个方法守着自己的规则,并发由聚合的版本号控制。把它暴露成 HTTP 接口时,最容易出现的偏差是把聚合当成一份可以整体读写的文档:客户端读出来、改几个字段、整体写回去。于是规则被绕过,并发修改互相覆盖,重试产生重复,耗时的操作让连接一直挂着。

本文在 Spring MVC 7.0.9 上用一个订单接口,把这几种偏差和对应的 HTTP 契约逐个跑一遍。实验见文末。

把聚合当文档按模型设计契约并发修改PUT 整体替换:两次 200,先到的修改丢失If-Match:后到的 412;缺少时 428只改一个字段PUT 只带 address:备注被清空PATCH:没出现的字段不动改状态PUT status=CANCELLED:已发货也取消POST …/cancellation:409 已发货不能取消响应丢失后重发重发 POST:新增 2 个订单同一个 Idempotency-Key:新增 1 个耗时操作连接一直等结果202 + Location 指向状态资源ETag 是聚合版本号在 HTTP 上的表达;命令端点对应聚合上的方法;系统决定的字段(状态、版本)在请求体里只读错误码表达业务含义:412 版本冲突,428 缺少前置条件,409 规则不允许
图 1 · 实测(Spring MVC 7.0.9):把订单当成一份可以整体写回的文档时,过期的写入覆盖了别人的修改,缺字段被当成清空,状态字段绕过了取消规则,重发的 POST 多建了一个订单。对应的契约是 If-Match 条件更新、PATCH、命令端点、幂等键和 202 受理,它们分别把聚合的版本号、修改意图和规则带到了接口上。

一、整体替换会丢掉别人的修改 ​

text
两个客户端都读到 {"address":"北京","note":"","status":"NEW"};客服 PUT 改地址为上海,用户随后基于旧数据 PUT 加备注:
最终 {"address":"北京","note":"放门口","status":"NEW"},客服的修改丢了,两次都是 200

PUT 的语义是「用请求体替换整个资源」。用户的 App 拿着几分钟前读到的旧数据(地址还是北京),只想加一条备注,却把整个订单写了回去,客服刚改的地址被覆盖了。服务端无从知道这个请求是基于哪个版本做出的。

这和数据库里的丢失更新是同一个问题,解法也一样:带上版本号,版本不符就拒绝。HTTP 里这个机制叫条件请求:

text
两个客户端都带 If-Match: "1":第一个 200,第二个 412(版本已变为 2),最终 {"address":"上海","note":"","status":"NEW"}
不带 If-Match 的 PUT:428(缺少 If-Match)

读取时响应带 ETag(这里直接用聚合的版本号),修改时客户端在 If-Match 里带回它读到的那个值。服务端版本已经变了,就返回 412 Precondition Failed,客户端重新读取、合并后再提交。不带 If-Match 的修改直接返回 428 Precondition Required,强制客户端参与并发控制,而不是默认「最后写入者获胜」。

ETag 和聚合版本号是同一个东西在两个层次的表达:版本号让数据库拒绝过期的写入,ETag 让 HTTP 客户端知道自己的写入是基于哪个版本。服务端在检查 If-Match 之后,仍然要用 UPDATE … WHERE version = ? 把检查和写入放在一起,否则两次检查之间还有窗口。版本号在聚合上的实现,见持久化与重建。ETag 在缓存上的另一种用法(If-None-Match 与 304),见页面已发布,用户为什么还在用旧版本。

二、PUT 缺字段等于清空 ​

text
PUT 只带 address:{"address":"上海","note":null,"status":"NEW"},note 被清空:PUT 的语义是整体替换
PATCH 只带 address:{"address":"深圳","note":"放门口","status":"NEW"}

客户端只想改地址,请求里只带了 address,结果备注被清空了。对 PUT 来说这是正确的行为:没有出现的字段就是「替换成空」。客户端的作者往往以为 PUT 是「更新这几个字段」,这个误解在旧版本客户端不认识新字段时尤其危险:新加的字段每次都被旧客户端清空。

部分修改用 PATCH。application/merge-patch+json(RFC 7396)的规则是:出现的字段替换,null 表示删除,没出现的不动。但 PATCH 只是把「改哪些字段」说清楚了,它并没有回答「这次修改的业务意图是什么」。

三、改状态用命令,不用字段 ​

text
已发货的订单,PUT 把 status 写成 CANCELLED:{"address":"北京","note":null,"status":"CANCELLED"},没有经过「已发货不能取消」
改用 POST /orders/o-d/cancellation:409(已发货不能取消)

把 status 作为一个可写字段,等于允许客户端把订单设置成任何状态,状态机的转换规则被整个绕过。即使在 PUT 的处理里加上校验,也要从「旧状态 → 新状态」反推客户端想做什么,再调用对应的领域方法,这段反推逻辑本身就容易出错。

更直接的做法是把领域里的动作暴露成命令端点:POST /orders/{id}/cancellation、POST /orders/{id}/payment。每个端点对应聚合上的一个方法,规则不满足时返回 409 Conflict 和业务语言的原因。这时资源上的 status 只读,客户端想改状态只能通过命令。

这不违背 REST。命令本身也可以是资源:「一次取消」可以有自己的 id,可以查询、可以审计。它让接口说出了业务的意图,而不是只说「把这个字段改成这个值」。命令对象只包含客户端有权决定的字段,这在请求体不是领域对象里讨论过。状态转换本身的建模,见状态机与工作流。

两类修改可以并存:地址、备注这类没有规则、只是数据的字段,用 PATCH 加 If-Match;有规则、有意图的状态变化,用命令端点。

四、重试:POST 不是幂等的 ​

text
响应丢失后客户端重发同一个 POST:不带幂等键新增 2 个订单;带同一个 Idempotency-Key:第一次 /orders/o-3,重发 /orders/o-3,新增 1 个

PUT 和 DELETE 按定义是幂等的,重发不会多出东西;POST 不是。网络超时之后,客户端不知道服务端到底有没有处理,重发就可能创建第二个订单。

幂等键是客户端为「这一次业务操作」生成的唯一值,放在请求头 Idempotency-Key 里。服务端第一次处理时把键和结果一起保存,重发时直接返回保存的结果。要点有三个:

  • 键和结果要在同一个事务里保存,否则「处理了但键没存上」又会重复。
  • 重发的请求体要与第一次一致,不一致应该报错,而不是返回旧结果。
  • 键要有过期时间,过期之后的重发按新请求处理。

另一种做法是让客户端生成资源 id,用 PUT /orders/{客户端生成的 id} 创建,天然幂等。超时与重试的整体设计,见超时预算与重试。

五、耗时操作:202 和状态资源 ​

text
生成报表:202,Location /reports/j-1,状态资源 {"status":"RUNNING"}

生成报表要几分钟,不能让 HTTP 连接一直等着。返回 202 Accepted,表示「请求已受理,还没有完成」,Location 指向一个状态资源,客户端轮询它,或者在完成时收到通知。202 只承诺受理,不承诺成功;状态资源要能表达完成、失败和失败原因。受理之后怎样保证最终有结果,见接口返回了 202,这件事算办完了吗。

六、从模型到接口的对照 ​

模型里的东西接口里的表达
聚合资源,GET 返回它的表示和 ETag
聚合的版本号ETag / If-Match,不符返回 412,缺少返回 428
没有规则的数据修改PATCH(merge-patch)+ If-Match
有规则的业务动作命令端点 POST /资源/{id}/动作,规则不满足返回 409
创建POST + 幂等键,或客户端生成 id 的 PUT
耗时操作202 + 状态资源
只由系统决定的字段(状态、租户、版本)只读,不出现在请求体里

七、测试要证明什么 ​

  • 并发修改:两个客户端基于同一 ETag 修改,后到的得到 412;不带 If-Match 得到 428。
  • 字段语义:PUT 缺字段的结果、PATCH 的 null 与缺失,按约定断言。
  • 规则:通过任何接口都无法让已发货的订单变成已取消。
  • 重试:同一个幂等键重发,只创建一次;请求体不同时报错。
  • 受理:202 之后,状态资源最终变成完成或失败。

八、检查清单 ​

  • 读取返回 ETag,修改要求 If-Match;服务端用条件更新把检查和写入放在一起。
  • PUT 只用于真正的整体替换;部分修改用 PATCH。
  • 状态和其他由规则决定的字段只读;状态变化通过命令端点。
  • POST 创建支持幂等键,或改用客户端生成 id 的 PUT。
  • 耗时操作返回 202 和状态资源。
  • 错误码表达业务含义:412 版本冲突、428 缺少前置条件、409 规则不允许。

配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权