REST 资源、状态与错误契约:接口怎样表达稳定业务语义
PUT /orders/o-1 表示客户端要替换一个已知订单资源的可写表示。请求体说明目标值,If-Match 指定这次修改依据的版本;服务端检查权限、输入和当前版本后,才决定是否执行。路径、方法、头部和响应共同组成接口契约。
一个资源怎样映射到 HTTP
资源、表示与操作
资源是能被标识和操作的对象,例如订单、订单集合、上传会话或导出任务。表示是某次请求中传输的内容:同一个订单可以有 JSON 明细、列表摘要和 PDF 单据。数据库表、Java 实体与外部资源无需一一对应。
PUT https://api.example.com/orders/o-1
├─ https 使用 TLS 保护的 HTTP 通信
├─ api.example.com:443 目标主机与默认端口
├─ /orders/o-1 资源标识
├─ PUT 替换可写表示
├─ Content-Type 发送的表示格式
├─ Accept 可以接收的响应格式
├─ Authorization 调用身份与凭据
├─ If-Match: "v7" 执行修改所需的当前版本
└─ 请求体 契约允许客户端设置的字段路径适合放资源身份,query 适合过滤、排序和分页,头部承载协议元数据及认证信息,请求体承载输入表示。密码、访问令牌和个人数据不应进入 URL:浏览器历史、代理和访问日志可能保留它。对象标识即使难以猜测,服务端仍须检查当前身份能否操作该对象。
REST 还包含客户端与服务端分离、无状态请求、缓存、统一接口和分层等约束。无状态指服务端理解请求时无需依赖客户端会话的隐含上下文,不禁止保存订单等业务数据。统一接口中的超媒体约束强调通过表示中的链接发现后续操作;只提供 JSON 和名词路径的接口通常更准确地称为资源导向 HTTP API。REST 原始论述给出了这些约束的联系。
集合和长操作也可以成为资源。POST /orders 创建订单后,用 201 Created 和 Location 返回新资源地址;导出尚未完成时,可用 202 Accepted 返回任务地址,让客户端查询状态、取得结果或提交取消请求。202 的含义止于接受处理,完成与否由任务资源继续表达。
取消订单这类业务动作有多种设计:创建 /orders/o-1/cancellations 子资源,或在明确的操作端点提交取消命令。选择取决于取消是否有独立身份、原因、审批与后续查询。不要为了让路径只含名词,把支付、发货等有前置条件的动作伪装成任意字段修改。
方法、幂等与缓存各自约束什么
| 方法 | 常见用途 | 重复调用时应理解的语义 |
|---|---|---|
| GET | 读取表示 | 安全且幂等;不应因访问链接扣款或删除数据 |
| HEAD | 读取与 GET 对应的响应头 | 不返回消息内容,适合检查元数据 |
| POST | 创建资源或提交业务处理 | 通用语义不保证幂等;需要业务操作键时另行约定 |
| PUT | 在已知 URI 创建或替换表示 | 相同请求的预期服务端效果幂等;响应码可以不同 |
| PATCH | 按补丁文档修改 | 是否幂等由补丁操作决定,例如“增加一项”可能重复执行 |
| DELETE | 移除 URI 与当前功能的关联 | 幂等;第二次可能返回 404,物理删除和保留策略另行定义 |
| OPTIONS | 查询通信选项 | 也用于浏览器 CORS 预检,不能代替业务授权 |
“安全”约束客户端请求的业务效果,服务端仍可记录访问日志。幂等也不要求每次响应完全相同:第一次删除成功,第二次资源已不存在,资源最终状态仍一致。方法定义和状态含义见 HTTP Semantics。
缓存还取决于响应状态、Cache-Control、验证器及缓存类型。no-cache 允许保存但使用前需要验证;no-store 要求不存储。协商内容的响应需用适当的 Vary 区分,例如不同 Accept-Language 的表示。共享缓存对带认证请求的处理有额外条件,个人化响应应明确给出私有或禁止缓存策略。HTTP Caching详细规定了存储和重用条件。
PUT 与两类常用 PATCH
PUT 提交目标资源可写表示的完整替换值。服务端生成的 ID、审计字段与只读状态可以不在写入模型里,但缺字段的处理必须明确,不能让同一个 PUT 有时覆盖、有时只改非空值。
PATCH 必须约定补丁媒体类型。JSON Merge Patch用对象表达变更:成员缺失表示保留,成员值为 null 表示移除,数组通常整体替换。若业务需要把 JSON null 本身作为数据保存,应采用能区分删除和赋值的格式。JSON Patch使用 add、remove、replace、test 等操作及 JSON Pointer 路径,适合精确变更,但数组下标在并发修改下很脆弱。
补丁应用需要整体成功或不应用变更,不能返回失败却留下前半个补丁;PATCH 方法规范还定义了 Accept-Patch 和使用条件请求的方式。无论哪种格式,允许写入的字段都应显式列出,客户端不能通过补丁修改 tenantId、价格结算结果或管理员权限。
条件请求怎样避免覆盖和重复处理
ETag、条件读和条件写
ETag 是某一表示的验证器,通常由服务端生成。强 ETag 可用于需要强比较的 If-Match;带 W/ 的弱 ETag 表达语义等价,不能直接用于这种强比较。不要由客户端把数据库更新时间随意拼成 ETag:时间精度、同一时间内多次修改和不同表示形式都可能让验证器失效。
读取返回 ETag: "v7" 后,客户端有两种不同用途:
已有缓存,需要检查变化
GET + If-None-Match: "v7"
├─ 表示未变 → 304,无消息内容,使用已保存的表示
└─ 表示已变 → 200,新内容与新 ETag
编辑基于 v7,需要防止覆盖他人修改
PUT + If-Match: "v7"
├─ 当前满足条件 → 执行修改
└─ 当前已是 v8 → 412,不执行这次更新如果接口要求条件更新却未收到条件头,可以返回 428 Precondition Required;这个状态和响应禁止缓存的要求来自 RFC 6585。If-Match: * 只要求存在当前表示,不能发现客户端读取之后的版本变化。
条件判断必须与写入处于同一个并发控制过程。在关系数据库里,可将预期版本放进更新条件:
UPDATE orders
SET note = :note, version = version + 1
WHERE tenant_id = :tenant_id
AND id = :id
AND version = :expected_version;这里是带命名参数的 SQL 结构示意,不是可直接粘贴到数据库终端的命令。参数来自已认证身份和已验证输入,受影响行数为零后,还需按接口策略区分资源不存在、无权限和版本冲突。先 SELECT 对比版本、再无条件 UPDATE,会在两条语句之间重新打开覆盖窗口。多行约束和事务写入见任务幂等与重试。
幂等键解决另一类重复
ETag 约束“基于哪个版本修改”,业务幂等键识别“是不是同一次操作”。提交付款后连接断开时,重试应携带原操作键;换一个键会被识别为新付款。
幂等记录通常以“租户、操作类型、业务键”为唯一范围,保存请求摘要、处理中或完成状态和可重放结果。同键不同参数应拒绝。同键同参数仍在处理时,应返回约定的处理中结果或允许查询,不能再执行一次。键的保存期要覆盖自动重试、人工补跑及离线重发窗口。
数据库更新、幂等记录和结果若能落在同一事务中,重试判断较直接;调用外部支付系统则需要对方提供幂等键或结果查询。单纯在 Redis 设置一个短期锁,无法回答上一次付款是否已经完成。具体事务与不确定结果处理见从写入到一致状态。
批量操作和异步结果
批量接口需说明是整批原子,还是允许每项独立成功。后一种情况下,每项应带稳定的输入标识及对应结果;客户端只重试明确失败项,并继续使用原业务键。只返回一个 success: false 会丢失已成功项目的信息。
异步任务至少区分排队、执行、成功、失败和取消结果。响应中应给出可查询地址,查询要重新检查权限。超时是客户端停止等待的事实,任务可能仍在运行;取消请求也要等任务确认停止后,才能对外宣布取消完成。这些运行状态与 HTTP 请求自身的成功或失败分别记录。
用真实请求观察更新、冲突与恢复
启动一个可控服务
下载 HTTP 契约实验工程,在 Linux amd64 上解压并进入 contract-http。需要 Docker Engine、Bash;手动请求另需 curl 和 jq。宿主用户必须能执行 Docker,构建使用该用户的 UID/GID,Maven 缓存写在解压目录内。容器编译 Java 17 源码,使用 Maven 3.9.12;两个 Maven 镜像实际分别带 Temurin 17.0.18 和 25.0.2。
unzip contract-http-lab.zip
cd contract-http
bash run-docker.sh正常结果是 13 个测试通过,其中 REST 有 4 个测试方法,另外覆盖 Schema、生成客户端、数据转换与分页游标。第一次运行需要下载依赖。出现依赖解析失败时先处理仓库连接,出现 /work/.m2 无法写入时检查解压目录权限;这些都发生在 HTTP 实验之前。
测试中的服务绑定随机端口,完成后关闭。要手动发请求,在当前目录启动固定端口实例:
mkdir -p .m2
docker run --rm --name contract-http-manual --user "$(id -u):$(id -g)" --entrypoint mvn \
--env MAVEN_CONFIG=/tmp/m2 --memory 2g -p 127.0.0.1:18230:18230 \
-v "$PWD:/work" -w /work maven:3.9.12-eclipse-temurin-17 \
-B -ntp -Duser.home=/tmp -Dmaven.repo.local=/work/.m2 \
-Dexec.mainClass=example.contract.OrdersServer exec:java看到 HTTP_PORT=18230 后,在另一个终端操作。服务约五分钟后自动退出,也可执行 docker stop contract-http-manual。宿主只发布回环端口;容器里的 0.0.0.0 用于接收 Docker 转发。它使用 JDK HTTP Server 和内存订单,没有认证、TLS、持久存储或集群协调,只能用于本机实验。
读取并更新当前表示
BASE_URL=http://127.0.0.1:18230
curl -q --noproxy '*' --fail-with-body -i "$BASE_URL/orders/o-1"刚启动时,响应状态为 200,ETag 为 "v1",JSON 含 id: o-1、note: first 和 version: 1。头部字段名大小写和 JSON 成员顺序不影响语义。
-q 位于首选项,跳过用户 curl 配置;--noproxy '*' 排除代理;--fail-with-body 让预期成功的 HTTP 错误返回非零退出码。选项职责见 curl 手册。这里的直连只针对本机实验,生产环境仍要遵循网络访问策略。
先观察缓存验证:
curl -q --noproxy '*' --fail-with-body -i \
-H 'If-None-Match: "v1"' "$BASE_URL/orders/o-1"结果应为 304 且没有消息内容。curl 没有替你保存上一份 JSON;浏览器或 SDK 若要使用条件缓存,需自己关联已经保存的表示。随后修改备注:
curl -q --noproxy '*' --fail-with-body -i -X PUT \
-H 'Content-Type: application/json' -H 'If-Match: "v1"' \
--data '{"note":"second"}' "$BASE_URL/orders/o-1"结果为 204。服务把只含 note 的输入转换成包含 ID 和版本的读取表示,因此此 PUT 响应不发送 ETag;客户端再 GET 获得当前表示及 "v2"。这是 HTTP 对转换后 PUT 表示的验证器限制,不应为了少一次读取随意返回一个与提交内容不对应的验证器。
实验通过同一个 Java 同步临界区完成检查和修改。真实数据库实现需采用前面的条件 UPDATE 或适当锁,不能把这个进程内锁复制成多实例并发方案。
拒绝过期版本,再依据当前内容恢复
保持旧 ETag 再提交另一份备注。此处预期 HTTP 错误,故不使用 --fail-with-body;仍检查 curl 的传输退出结果及精确 HTTP 状态:
status=$(curl -q --noproxy '*' -sS -o conflict.json -w '%{http_code}' \
-X PUT -H 'Content-Type: application/json' -H 'If-Match: "v1"' \
--data '{"note":"lost"}' "$BASE_URL/orders/o-1") || exit 1
test "$status" = 412 || exit 1
jq -e '.type == "urn:example:problem:version-conflict"' conflict.json || exit 1
curl -q --noproxy '*' --fail-with-body -i "$BASE_URL/orders/o-1"订单备注仍应为 second,版本仍为 2。恢复时先读取当前内容,决定保留、合并还是放弃本地修改,再携带 "v2" 提交。例如双方确认后的合并结果为 merged:
curl -q --noproxy '*' --fail-with-body -i -X PUT \
-H 'Content-Type: application/json' -H 'If-Match: "v2"' \
--data '{"note":"merged"}' "$BASE_URL/orders/o-1"
curl -q --noproxy '*' --fail-with-body -sS "$BASE_URL/orders/o-1"最后一次读取应得到 merged 和版本 3。只把旧请求的 ETag 改为最新值后自动重发,会跳过合并决策,把并发保护变成形式检查。
工程中的 verify-http.sh 自动执行同一条读取、条件读、更新、冲突、恢复链路。它要求刚启动的服务;手动实验已经改变状态后,应先重启再运行。当前示例接受单个 ETag 或 *,未实现验证器列表的完整语法;接入正式框架时应使用其协议解析能力。
错误响应怎样指导下一次操作
状态码和 Problem Details
HTTP 状态码表达通用结果,问题类型表达具体失败类别。application/problem+json 的标准字段包括 type、title、status、detail 和 instance,定义见 Problem Details。其中 type 是问题类别的主要标识,detail 是本次发生的可读说明,客户端不能靠匹配中文文案决定是否重试。
{
"type": "urn:example:problem:version-conflict",
"title": "version-conflict",
"status": 412,
"detail": "Read the current order before editing",
"instance": "/orders/o-1"
}生产接口可采用可查阅的 HTTPS 问题类型地址,并用独立 occurrence ID 关联某次失败。响应体的 status 应与实际 HTTP 状态一致;错误详情可包含授权后的字段路径和修复提示,不包含 SQL、堆栈、令牌或其他用户数据。
| 响应 | 调用方下一步 | 服务端排查位置 |
|---|---|---|
| 400 | 修复请求语法或格式 | JSON 解析、参数转换,确认失败前未写业务数据 |
| 401 / 403 | 按认证挑战获取有效身份,或停止越权操作 | 401 对应认证挑战;检查主体、租户和对象授权 |
| 404 / 405 | 检查目标资源或方法;405 查看 Allow | 路由匹配、对象可见策略、方法映射 |
| 406 / 415 | 分别调整可接受响应格式、发送格式 | Accept 与 Content-Type,不要混查 |
| 409 / 412 / 428 | 分别处理业务状态冲突、版本变化、缺失前置条件 | 业务状态机、条件更新影响行数、条件头 |
| 422 | 修改语法正确但不满足字段或业务约束的输入 | 字段路径、约束和合法值,不原样重试 |
| 429 / 503 | 结合 Retry-After、总期限和幂等性延后尝试 | 限流/过载/依赖状态及请求是否已经产生副作用 |
| 500 / 502 / 504 | 先分类结果是否未知,再决定查询或重试 | 服务日志、网关和下游耗时、业务操作键 |
429 与 Retry-After 的定义同见 RFC 6585。超时和连接中断时,客户端甚至可能没有收到任何 HTTP 状态;这时应保存原操作身份并查询结果。对于只读请求可做有上限的重试,对于具有副作用的操作则先确认幂等协议,避免网关、SDK 和业务层同时放大尝试次数。
框架响应与浏览器行为
Spring MVC 中可在 @RestControllerAdvice 里集中映射异常和 ProblemDetail。字段绑定、校验失败、业务冲突及未知异常应分别处理;响应写出后才捕获的异常可能已无法改成另一份 JSON。过滤器、拦截器与响应体处理的顺序见从请求到响应,不要在 postHandle 中假定响应仍可修改。
浏览器 Network 面板可以对照请求 URL、方法、Request Headers、Payload、响应状态和 Response Headers。跨域时,先分清失败的是 OPTIONS 预检还是实际请求;CORS 决定浏览器脚本能否读取响应,不决定服务器是否允许当前用户访问订单。curl 请求成功、浏览器报错时,先比较 Origin、认证方式及凭据策略,再排查代理和网关。
接口版本演进还会影响状态码、字段默认值、分页排序与 SDK 行为。将资源语义描述成 OpenAPI 后,可以生成客户端和比较结构变化;实际返回是否满足消费者要求,需要继续执行真实请求,见OpenAPI 与 JSON Schema及版本、SDK 与契约测试。
权威资料与规范地址
REST 约束与 HTTP 语义
- REST 原始约束:https://ics.uci.edu/~fielding/pubs/dissertation/rest_arch_style.htm
- HTTP 方法、条件请求和状态:https://www.rfc-editor.org/rfc/rfc9110.html
- HTTP 缓存:https://www.rfc-editor.org/rfc/rfc9111.html
局部更新方法与补丁格式
- PATCH 方法:https://www.rfc-editor.org/rfc/rfc5789.html
- JSON Merge Patch:https://www.rfc-editor.org/rfc/rfc7396.html
- JSON Patch:https://www.rfc-editor.org/rfc/rfc6902.html
错误响应与请求调试
- 428、429 等附加状态:https://www.rfc-editor.org/rfc/rfc6585.html
- Problem Details:https://www.rfc-editor.org/rfc/rfc9457.html
- curl 选项:https://curl.se/docs/manpage.html
