从模型到 HTTP 接口:资源、命令、条件更新与受理
订单接口按 REST 的「标准做法」设计:
GET /orders/{id}读取,PUT /orders/{id}更新。客服在后台改了收货地址,几分钟后用户在 App 里加了一条备注。最后订单上的地址又变回了旧的,两次请求都返回了 200。
领域模型里,订单的变化通过表达意图的方法发生:changeAddress、cancel、pay,每个方法守着自己的规则,并发由聚合的版本号控制。把它暴露成 HTTP 接口时,最容易出现的偏差是把聚合当成一份可以整体读写的文档:客户端读出来、改几个字段、整体写回去。于是规则被绕过,并发修改互相覆盖,重试产生重复,耗时的操作让连接一直挂着。
本文在 Spring MVC 7.0.9 上用一个订单接口,把这几种偏差和对应的 HTTP 契约逐个跑一遍。实验见文末。
一、整体替换会丢掉别人的修改
两个客户端都读到 {"address":"北京","note":"","status":"NEW"};客服 PUT 改地址为上海,用户随后基于旧数据 PUT 加备注:
最终 {"address":"北京","note":"放门口","status":"NEW"},客服的修改丢了,两次都是 200PUT 的语义是「用请求体替换整个资源」。用户的 App 拿着几分钟前读到的旧数据(地址还是北京),只想加一条备注,却把整个订单写了回去,客服刚改的地址被覆盖了。服务端无从知道这个请求是基于哪个版本做出的。
这和数据库里的丢失更新是同一个问题,解法也一样:带上版本号,版本不符就拒绝。HTTP 里这个机制叫条件请求:
两个客户端都带 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 缺字段等于清空
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 只是把「改哪些字段」说清楚了,它并没有回答「这次修改的业务意图是什么」。
三、改状态用命令,不用字段
已发货的订单,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 不是幂等的
响应丢失后客户端重发同一个 POST:不带幂等键新增 2 个订单;带同一个 Idempotency-Key:第一次 /orders/o-3,重发 /orders/o-3,新增 1 个PUT 和 DELETE 按定义是幂等的,重发不会多出东西;POST 不是。网络超时之后,客户端不知道服务端到底有没有处理,重发就可能创建第二个订单。
幂等键是客户端为「这一次业务操作」生成的唯一值,放在请求头 Idempotency-Key 里。服务端第一次处理时把键和结果一起保存,重发时直接返回保存的结果。要点有三个:
- 键和结果要在同一个事务里保存,否则「处理了但键没存上」又会重复。
- 重发的请求体要与第一次一致,不一致应该报错,而不是返回旧结果。
- 键要有过期时间,过期之后的重发按新请求处理。
另一种做法是让客户端生成资源 id,用 PUT /orders/{客户端生成的 id} 创建,天然幂等。超时与重试的整体设计,见超时预算与重试。
五、耗时操作:202 和状态资源
生成报表: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 规则不允许。
配套实验
- codesphere-labs/ddd/model-to-http-api:Spring MVC 7.0.9、JDK 25.0.4.1,MockMvc:整体替换的丢失更新、If-Match 与 412/428、PUT 缺字段与 PATCH、状态字段与命令端点、幂等键、202 受理(验证记录)
参考资料