上一章留下了一个很关键的判断:从进程内调用换成 HTTP 调用,并不只是把函数名改成 URL。网络会把一次原本很确定的调用,变成一个带有歧义的事件。我们先从一笔订单说起:订单服务向支付服务提交了扣款请求,支付服务已经完成入账,但响应在返回途中丢了。订单服务只看见超时,却无法从“没收到响应”推断支付失败,因为还有另一种可能:支付成功了,只是成功回执没回来。
如果接口只写了请求字段和响应字段,没有写清资源身份、重复请求和并发修改的语义,那么调用方只能猜。猜错一次,可能多扣一笔款;猜错另一次,可能把已经支付的订单标成失败。

所以这一章不从“URL 应该用复数名词”开始背规则。我们从故障往回推:为了让客户端在超时后还能做出正确动作,API 必须给业务对象稳定的身份,给每种方法明确的语义,并把冲突、失败和下一步行动写进契约。
产品需求里常出现这样的句子。它把一整条业务链压缩成三个连续动作,听起来像是照顺序调用三个函数就能完成:
用户提交订单,系统锁定库存并完成支付。
如果我们按代码执行顺序设计接口,很容易直接把三个动词搬进 URL。于是,一开始的接口通常会长成这样:
POST /createOrder
POST /lockStock
POST /chargeMoney它们能工作,却把最重要的信息藏起来了:执行之后,系统里究竟留下了什么?订单创建后有订单号、有状态,还会被查询、取消和审计;库存锁定后有预留编号、有数量、有释放状态,甚至可能先于订单失败而独立进入补偿流程;支付后也有支付编号、有扣款结果,还可能随后退款。这些都不是一闪而过的函数调用,而是有身份、有状态、有生命周期的业务对象。换个角度看,订单、预留和支付都需要在调用结束后继续存在,把它们写成资源,接口会变成:
POST /orders
POST /reservations
POST /payments
GET /orders/ord-0001
DELETE /reservations/res-0001这里的变化不只是命名更整齐。一旦库存预留成为 /reservations/res-0001,我们就能回答“它是否已经释放”;一旦支付成为 /payments/pay-0001,我们也能回答“这次重试是在创建新支付,还是在重放原结果”。资源身份把调用结束后的状态保留下来,让故障恢复不再依赖猜测。
识别资源时,不要先盯着数据库表或控制器名称。我们可以围绕对象在调用结束后的处境,连续问四个问题。
回答“是”的次数越多,它越值得拥有独立资源。订单项则是另一种情况:在这套教学项目里,一条订单项随订单一起创建,金额和数量也由订单整体校验,外部调用方通常不会脱离订单去修改它。因此,订单项放在订单聚合里很自然,同时可以通过 /orders/{orderId}/items 读取。
支付和库存预留不适合硬塞进订单内部。它们由不同服务维护,有自己的编号和状态;订单取消时,系统还要分别释放预留、退款。如果把四者做成一个巨大订单对象,每次修改其中一个状态都要重写整份对象,服务之间会共享一个越来越难守住的强一致边界。

资源边界没有一条放之四海而皆准的公式。这里把订单项留在订单边界内,是因为它们共同创建、共同校验;把支付和库存预留拆开,是因为它们有独立身份和失败处理。换一个业务,如果订单项可以单独退货、转赠或履约,它就可能需要更独立的资源模型。
把所有数据都嵌进订单,看似少发了请求,代价是订单表述越来越大,并发修改也容易互相覆盖;把每个字段都拆成子资源,看似边界清晰,代价是客户端为了展示一个订单要连续请求许多端点。判断粒度时,不妨看真实的写入事务和故障恢复方式,而不是追求越大或越小越好。
总是一起校验、一起提交的数据,可以先放在一个聚合里;拥有独立失败、独立重试或独立权限的数据,应该认真考虑拆出资源。这仍然是在交换复杂度:聚合大一些,网络交互少,但并发边界更粗;资源细一些,服务自治更容易,但调用链和一致性处理更多。
资源边界确定以后,路径就有了明确的对象。我们先看一个很常见、也很容易不断膨胀的接口:
GET /getUserOrders?userId=u-1001这个 URL 把“获取”写进了路径,又把“用户的订单”拆成一个动词和一个参数。它在只有一种查询时似乎没问题,可需求一增加,团队往往就会继续添加动作端点:
/getPendingUserOrders
/getUserOrdersByDate
/getRecentPaidUserOrders每出现一种查询视图,就发明一个新动作名,客户端必须记住这些名字,网关也要维护越来越多的路由。换成资源思维,我们先承认“用户 u-1001 的订单集合”本身就是可以被标识的资源:
GET /users/u-1001/orders有了稳定的集合身份,状态、时间范围、排序和分页就只是观察同一个集合时附加的条件。它们不需要分别变成新的动作路径,而可以统一写在查询参数里:
GET /users/u-1001/orders?status=COMPLETED&limit=20这样,路径回答“你访问的是谁”,查询参数回答“你想看它的哪一部分、按什么顺序看”。需求再增加一种筛选方式时,通常只需扩展集合查询,不必再发明一个近义动作端点。

/users/u-1001/orders 强调订单属于哪个用户,适合用户中心页面,也便于在父资源上下文里做权限判断。后台运营人员却可能需要跨用户查所有待处理订单,此时顶级集合更合适:
GET /orders?status=PROCESSING&limit=50这两个端点可以提供同一批订单资源的不同集合视图,并不要求复制两套订单。真正要保持一致的是:同一个订单 ord-0001 的规范身份不要随入口变化,返回表述也应给出稳定的 self 链接:
{
"id": "ord-0001",
"status": "COMPLETED",
"links": {
"self": "/orders/ord-0001",
"items": "/orders/ord-0001/items",
"collection": "/orders",
"cancel": "/orders/ord-0001"
}
}客户端不必根据当前页面猜下一条 URI,它可以沿着响应里的关系继续操作。这样即使集合入口不同,单个订单的身份和后续动作仍然是一致的。
/orders/ord-0001/items 很容易理解,因为订单项只在这张订单的上下文中有意义。但如果沿着每一层关联继续嵌套,路径会反过来泄露客户端不需要知道的组织结构:
/users/u-1001/orders/ord-0001/items/item-1/product/keyboard-001如果商品 keyboard-001 有全局身份,直接使用 /products/keyboard-001 更稳定。父子路径适合表达所有权、局部编号或生命周期依赖;普通关联更适合用链接或标识符表达,否则上层结构一调整,所有深层路径都会跟着变化。
“支付订单”会留下可查询、可退款的支付记录,因此可以变成创建支付资源。这个业务名词比 /payOrder 更能说明操作结束后留下了什么:
POST /payments“预留库存”也不是瞬间完成后就消失的动作,后面还可能释放或对账。沿着同样的思路,可以创建库存预留资源:
POST /reservations“生成订单导出”耗时更长,客户端还要查询它的处理进度。既然任务本身有状态,就可以把它建模成导出资源:
POST /order-exports不过,不要为了表面纯粹把所有动词都扭成别扭名词。如果操作只是订单的一个简单状态变更,PATCH /orders/ord-0001 携带 {"status":"CANCELLED"} 就很清楚;如果取消会形成独立记录、需要审批并允许查询进度,那么 /orders/ord-0001/cancellations 才更像一个真正的资源集合。模型应反映业务里真实存在的对象,不是把语法规则演成文字游戏。
客户端看到方法,不只是在猜服务端会执行哪个控制器。代理、缓存、SDK 和重试器也会根据方法语义,决定能不能预取、缓存或自动重试。所以“GET 用来查、POST 用来建”只是入门记忆,我们还得看安全性和幂等性这两个属性。
安全指客户端请求的语义是只读的,它没有要求服务器改变业务状态。记录访问日志、统计流量这类附带行为不改变 GET 的安全语义;但用 GET 扣库存、发短信或取消订单,就破坏了调用方和中间组件共同依赖的约定。
幂等指发送多次相同请求,服务端预期产生的业务效果与发送一次相同。它不要求每次响应状态码和响应体完全一样,也不表示并发请求天然没有竞争;它回答的只是重放同一意图后,业务最终会不会多做一次。

安全方法最怕“名字像查询,行为却在写入”。下面这个接口看起来只是一个普通链接,实际却很危险:
GET /orders/ord-0001/cancel浏览器预取、搜索爬虫或监控探测都可能发出 GET。只要访问这个地址就取消订单,系统等于把一个有破坏性的动作伪装成读取,任何一次“帮你提前加载”都有可能误伤业务。正确的 GET 应该返回订单的当前表述:
GET /orders/ord-0001它可以被重复读取,也可以配合缓存验证减少传输。至于取消订单,应使用明确会改变状态的方法,让调用方知道这不是一次无害读取。
向集合 POST /orders 时,客户端把创建意图交给订单集合处理,通常由服务端分配订单号。连续发送两次相同 POST,默认可能创建两张订单,所以 POST 既不安全,也不天然幂等。这不代表 POST 无法安全重试,只是“去重”必须由应用契约额外提供,后面会用幂等键解决。
PUT 的重点是目标 URI 已经明确,请求内容表示要放到该位置的新状态。比如用户通知偏好由用户 ID 确定身份,客户端就可以提交一份完整表述:
PUT /notification-preferences/u-1001
Content-Type: application/json
{
"email": true,
"sms": false
}相同请求执行一次或多次,最终偏好都是同一个状态,因此它应当是幂等的。不要把 PUT 实现成“请求中没出现的字段保持原值”,否则客户端无法判断自己提交的是完整替换还是局部修改,方法语义也会随具体字段悄悄改变。
PATCH 表示对现有资源应用一组修改,不天然保证幂等。判断它能否重放,要看补丁到底是在“设成某个值”,还是在“基于当前值再做一次动作”。下面的补丁把状态设为固定值,重复应用后结果通常相同:
PATCH /orders/ord-0001
Content-Type: application/json
{
"status": "CANCELLED"
}对比之下,“在现值上继续修改”就不是同一回事。下面的补丁如果被定义成“数量加一”,每次重放都会继续增加:
{
"incrementQuantityBy": 1
}因此,看到 PATCH 不能直接得出“可以重试”的结论。还要检查补丁格式、服务端语义以及是否带有版本前置条件,否则一次网络重试就可能把同一变化应用两遍。
第一次删除库存预留,服务端可能返回 200 并给出 RELEASED 状态;第二次删除同一预留,可以继续返回已有结果,也可以返回资源已不存在。两次响应不必完全一样,只要库存不会被释放两次,最终业务效果就是幂等的。
“方法是幂等的”不等于“任何失败都可以无脑重试”。请求可能携带过期版本,也可能被服务端错误实现,连续重试还会放大流量。重试策略仍要有超时、次数上限、退避和明确的可恢复错误范围。
回到开头那笔支付。教学项目故意让支付服务在入账后延迟第一次回执,订单服务等待 180 毫秒没有收到结果,于是再次提交支付请求。这里不是为了展示一个偶发错误,而是要看清“同一业务意图经过两次网络尝试”时,接口能否守住只扣一次款的边界。最终链路得到的是:
HTTP 201
orderId: ord-0002
status: COMPLETED
paymentAttempts: 2
secondAttemptReplayedOriginalPayment: true发生了两次网络尝试,但只留下一个支付资源。关键不在于服务端“猜出”两次请求相同,而在于订单服务为同一个支付意图重复使用同一个键,让两次传输能够被认作同一次业务操作:
await requestJson('/payments', {
method: 'POST',
headers: {
'Idempotency-Key': `payment:${order.id}`
},
body: {
orderId: order.id,
amount: order.amount,
currency: order.currency
}
});
一个可用的幂等处理流程不能只在结果表里查一次键。它还要覆盖并发到达、请求未完成和键被误用等状态,至少包含这些判断:
客户端为一次业务意图生成键。网络重试继续使用原键,用户主动创建另一笔订单则使用新键。
服务端第一次看到键时,记录请求摘要与处理状态,再开始创建资源,避免两个并发请求同时穿过检查。
相同键、相同请求再次到达时,如果首次请求已完成,就返回先前保存的结果,不再执行扣款或扣库存。
相同键对应的请求仍在处理时,返回明确的处理中状态,并告诉客户端何时再查或再试。
教学项目为了把重点放在“同一键返回同一资源”上,只保存了幂等键到订单编号的映射,没有计算请求摘要。因此,上面关于“相同键但内容不同就拒绝”的检查是生产实现应补上的契约,不能把当前这份简化代码直接当成完整的幂等组件。
在教学项目里,订单服务收到重复键时会先找到原订单。如果原订单还处于 PROCESSING,它返回 409 REQUEST_IN_PROGRESS,并附带 Retry-After: 1;如果原订单已经完成,它就返回原来的订单表述,同时带上:
Idempotency-Replayed: true第一次创建可以返回 201,重放返回 200。状态码不相同并不破坏幂等性,因为系统里仍然只有一张订单,第二次请求也没有再次扣库存或发起一笔新的支付。
键应和调用者身份、目标操作一起构成去重作用域。不同商户偶然使用同一个字符串,不应该互相命中;创建订单和创建支付使用同一个字符串,也不应该共享记录。否则,原本用来识别一次业务意图的键,反而会把互不相关的请求错误合并。
服务端还要约定幂等记录保存多久。如果客户端在去重窗口结束后再次发送旧键,服务端是否当作新请求,必须事先写清楚。只保存成功结果也不够,因为某些失败已经产生部分业务效果,某些请求仍在执行,二者都需要有可判断的状态。这就是为什么幂等不能只做成“查一下缓存,没有就执行”两行代码:检查与占位之间如果不是一个不可分割的过程,两个并发请求仍可能同时穿过检查,最终制造重复操作。
状态码不是给日志涂颜色。它先告诉通用 HTTP 组件结果属于成功、客户端问题还是服务端问题,响应头和响应体再补足业务细节;三者合起来,才真正回答了客户端接下来应该读取、修正还是重试。
先看同步创建已经完成的情况。教学项目创建第一张订单后,会把资源位置、当前版本和关键关联结果一起返回:
HTTP 201
Location: /api/orders/ord-0001
ETag: "v1"
status: COMPLETED
reservationId: res-0001
paymentId: pay-0001201 Created 表示请求已经形成新资源,Location 给出新资源的位置,客户端不需要猜订单号,也不需要把请求里的集合 URI 当成单体 URI。响应体返回服务器接受后的订单表述,让客户端立即看到服务器生成的标识、默认币种和关联资源;ETag 则标记这份表述的版本,后续读取缓存和并发修改都会用到。
导出大量订单可能需要几十秒。如果 HTTP 请求一直挂着,客户端不知道服务器是否还在工作,中间代理也可能先超时。这时,“导出过程”本身也需要身份和状态,教学项目把它建模成异步操作资源,创建动作仍然只占用一次短连接:
POST /order-exports请求被接受后,服务端先返回操作资源,而不是假装导出已经完成。响应可以写成这样:
HTTP/1.1 202 Accepted
Location: /api/operations/op-0001
Retry-After: 1
Content-Type: application/json
{
"id": "op-0001",
"type": "ORDER_EXPORT",
"status": "RUNNING",
"result": null,
"links": {
"self": "/operations/op-0001"
}
}202 只表示请求已被接受,不表示导出已经成功。客户端应沿 Location 查询操作资源,看到 SUCCEEDED 后再读取下载地址;如果服务端只返回一个模糊的“任务已提交”字符串,客户端既无法查询,也无法区分排队、执行、失败和完成。
设想客户端创建订单失败,它真正关心的是“改请求、重新登录、稍后重试,还是停止操作”。400 适合请求在 HTTP 或基本输入层面就无法理解,例如缺少必需的幂等键;401 表示缺少有效认证凭据,客户端通常要重新获取凭据;403 表示身份已确认但无权执行操作,重复登录通常不会改变结果。
资源相关的失败也要区分。404 表示目标资源不可用,在涉及资源归属隐私时,系统也可能用它避免泄露资源是否存在;405 表示资源存在,但当前方法不受支持,响应还应告诉客户端允许哪些方法;409 则表示请求与资源当前状态冲突,比如库存不足、订单不可取消,或者同一幂等键仍在处理。
前置条件和内容校验对应另一组恢复动作。412 表示请求携带的条件不成立,常见原因是 If-Match 中的版本已经过期;422 表示内容能被解析,但业务校验无法通过,比如数量不是正整数,或收单方拒绝支付;428 表示服务端要求条件请求,而客户端没有提供 If-Match。
429 表示调用频率超过限制,客户端应结合重试提示退避。502、503 和 504 都属于服务侧故障,但含义仍然不同:上游返回无效结果、服务暂时不可用、等待上游超时,对应的排查方向和重试策略都不一样。
不要把所有业务失败都塞进 200 OK,再让客户端解析 success: false。代理和监控只看见“成功”,SDK 也无法使用通用错误处理。也不要把所有失败都变成 500,那会让客户端对永久性的参数错误持续重试。
订单客服和用户可能同时操作同一张订单。客服先读到版本 v1,准备取消;可就在他提交之前,订单已经被另一个流程更新到 v2。如果客服仍然直接提交 PATCH,服务端可能用旧视图覆盖新状态,而 ETag 和条件请求就是把“我修改的是自己刚才看到的版本”写进协议。

GET /api/orders/ord-0001这次 GET 不只返回订单内容,还要把当前表述的验证器放进响应头。响应带回:
HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/jsonETag 是表述验证器,不要求客户端理解 v1 的内部编码。客户端应把它当作不透明值原样保存,下一次读取或修改时再通过条件请求交还给服务端。
PATCH /api/orders/ord-0001
If-Match: "v1"
Content-Type: application/json
{
"status": "CANCELLED"
}服务端先比较 If-Match 与当前版本,再决定是否执行修改。如果当前仍是 v1,修改成功,订单版本递增,响应返回新的 ETag:
HTTP/1.1 200 OK
ETag: "v2"如果资源已经更新,旧条件不成立,服务端返回 412 Precondition Failed,而不是悄悄覆盖;如果客户端完全没带 If-Match,教学项目返回 428 Precondition Required。这两个状态给出的修复动作不同:前者要重新读取并处理冲突,后者要补上版本条件。
条件请求也能用在读取上,避免重复传输没有变化的表述。客户端已有 "v2" 时,可以这样检查订单是否变化:
GET /api/orders/ord-0001
If-None-Match: "v2"没有变化时,服务端返回 304 Not Modified,不再传输完整响应体。这样看,If-Match 解决并发写入的前置条件,If-None-Match 解决重复读取时的缓存验证;两者用途不同,却都依赖稳定、正确更新的 ETag。
只有状态码还不够。同一个 409 可能表示库存不足,也可能表示幂等请求仍在处理;客户端需要稳定的机器可读信息来选择处理分支,界面也需要面向人的解释来说明发生了什么。为了让不同服务的失败保持同一种结构,订单、库存和支付服务共用一套 HTTP 错误层,用 application/problem+json 返回问题详情:
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://demo.local/problems/payment-declined",
"title": "支付被拒绝",
"status": 422,
"detail": "演示收单方拒绝了这笔支付。",
"instance": "/orders",
"code": "PAYMENT_DECLINED",
"traceId": "trace-7f33c9"
}当前教学网关会重新序列化上游 JSON,却没有透传上游的 Content-Type,所以从网关访问时会变回普通的 application/json。这是一处应该由契约测试抓住的漂移:对外契约如果声明问题详情媒体类型,网关也必须保留它。下面对字段的设计仍按正确的问题详情契约来理解。
这些字段各有职责。type 标识稳定的问题类型,不要把每次错误随机生成的地址放在这里;title 是该类问题的简短名称,同一错误类型下通常保持一致;status 复制 HTTP 状态码,方便错误对象脱离原始响应后仍能被理解,但它必须与真正的响应状态一致。
detail 解释这一次具体发生了什么,适合展示给人看,不适合让程序用字符串匹配分支;instance 标识本次问题发生在哪个请求或资源上下文中;code 是本项目扩展的稳定业务错误码,客户端可以据此决定展示库存提示还是支付提示。最后,traceId 把用户看到的失败与服务端整条调用链连接起来,下一章讲可观测性时还会再次用到。
同一种问题结构还允许加入业务需要的扩展字段。参数错误就可以增加字段级信息,让客户端一次发现所有需要修改的输入:
{
"type": "https://demo.local/problems/validation-error",
"title": "参数校验失败",
"status": 422,
"detail": "请修正以下字段后重新提交。",
"code": "VALIDATION_ERROR",
"traceId": "trace-7f33c9",
"errors": [
{ "field": "productId", "rule": "string" },
{ "field": "quantity",
字段级错误可以帮助客户端一次修正多处输入,但数据库语句、堆栈、内网地址和密钥不能进入响应。问题详情是 API 对调用方解释接口失败的格式,不是把服务端调试日志原样搬到公网;需要排查内部实现时,应通过 traceId 回到受控日志中定位。
订单列表看起来没有副作用,却有一个很容易被忽略的分布式问题:客户端看第二页时,第一页之后可能已经插入了新订单。只要集合在两次请求之间变化,“第二页”代表的位置就可能跟着移动。最直观的分页方式是记录页码和偏移量,请求通常会写成这样:
GET /orders?page=2&pageSize=20第一页顶部插入一条记录后,原来排在第 20 位的订单可能被挤到第二页,再次出现;删除记录则可能让后面的订单前移,导致客户端漏读。也就是说,偏移量记录的是一个会随集合变化的位置,并没有记住客户端上次究竟读到了哪条订单。
游标换了一个问题:不再问“跳过多少条”,而是问“从上次读到的位置之后继续”。教学项目的集合端点支持 cursor、limit 和 status:
GET /api/orders?status=COMPLETED&cursor=0&limit=20服务端除了返回当前页,还要告诉客户端下一次从哪里继续。响应结构是:
{
"items": [
{
"id": "ord-0001",
"status": "COMPLETED"
}
],
"page": {
"limit": 20,
"nextCursor": null,
"total": 1
},
"links": {
"self": "/orders?status=COMPLETED&cursor=0&limit=20"
}
}这个教学实现为了便于阅读,用数字位置充当游标。生产系统更常把排序字段和唯一标识编码成不透明令牌,例如把“创建时间与订单号”一起放进游标;客户端只负责把 nextCursor 原样带回,不应解析或修改它。这样服务端以后调整游标内部结构时,也不会迫使客户端跟着改解析代码。
如果只按 createdAt 排序,而同一毫秒创建了多张订单,游标就无法判断从哪一张之后继续。为每条记录建立确定位置,需要再增加一个唯一字段作为次级排序:
createdAt DESC, id DESC游标也要同时记录这两个值,这样新订单插入时,已经翻过的边界仍然可判断。游标分页的代价是不能随意跳到第 37 页;对于滚动列表、事件流和大订单集合,这通常是可以接受的交换,因为稳定地继续读取比随机跳页更重要。
查询参数不是把数据库查询语言直接暴露给客户端,而是服务端选择并承诺支持的一组集合视图。例如可以约定:
GET /orders?status=COMPLETED&sort=-createdAt&limit=20服务端需要明确哪些状态可过滤、哪些字段可排序、默认顺序是什么、多个条件如何组合,以及 limit 的上限。教学项目把 limit 限制在 1 到 50 之间,避免单次请求拉取无边界的数据;如果客户端提交不支持的排序字段,也应该明确拒绝,而不是悄悄忽略。total 也不是免费信息,大数据集上的精确计数可能比读取当前页更贵;如果产品只需要“还有下一页吗”,返回 nextCursor 就够了,把计数做成默认必返字段只会让一个本来轻量的列表请求承担额外成本。
路径、过滤、排序和分页共同决定集合的含义。服务端如果悄悄改变默认排序,旧游标可能失效;如果增加新的筛选规则,也要定义游标能否跨条件复用。最稳妥的做法是把查询条件纳入游标校验,并把游标当作短期、不透明的服务端契约。
当接口只有几条时,开发者可能觉得“大家口头说一下就行”。可服务一多,最先漂移的通常就是这些口头约定:有人以为幂等键可选,有人以为 quantity 可以传小数,还有人只实现了 200,却忘了并发冲突。等到联调才发现分歧,修改的就不再只是一份文档。机器可读契约要从具体操作开始,而不是只写一段原则说明,教学项目先在 OpenAPI 文件里声明创建订单的请求:
/api/orders:
post:
summary: 创建订单
parameters:
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrder'创建操作会产生不可随意重复的业务效果,因此幂等要求也要进入契约。这里把幂等键定义成必填请求头:
IdempotencyKey:
name: Idempotency-Key
in: header
required: true
schema:
type: string
minLength: 1只声明端点还不够,服务端和客户端也要对内容边界达成一致。请求体因此同时限制了字段和取值:
CreateOrder:
type: object
additionalProperties: false
required: [productId, quantity, amount]
properties:
productId: { type: string }
quantity: { type: integer, minimum: 1 }
amount: { type: number, exclusiveMinimum:
这份文件不是接口完成后补的一张表,而应进入设计评审、契约测试和客户端生成流程,并持续与服务行为对照。如果实现返回 412,规范却没声明;或者规范承诺 Location,实现却忘了发送,契约就已经漂移,调用方拿到的“说明书”和真正的接口也就成了两套东西。
additionalProperties: false 表示服务端拒绝未声明字段。它能尽早发现客户端把 quantity 拼成 quanity,但也意味着以后增加请求字段时要认真考虑旧客户端行为;minimum: 1 也不只是数据类型校验,它直接写明了订单数量不能是零或负数。状态枚举写成 PROCESSING、COMPLETED、REJECTED、CANCELLED 后,生成的客户端会把它们当成有限集合;服务端未来增加 PARTIALLY_REFUNDED 时,某些旧客户端可能无法反序列化。因此,“只增加一个枚举值”看似只是扩展响应,实际上也可能破坏真实客户端。
API 演进最昂贵的地方不是部署新版服务,而是你无法同时升级所有调用方。移动端可能几个月不更新,合作方可能按季度发布,内部批处理任务甚至无人维护。所以版本策略的第一问不是“版本号放路径还是请求头”,而是“这次变化能不能兼容旧客户端”。
响应增加可选字段,旧客户端若会忽略未知字段,一般可以继续工作;新增资源或新增可选查询参数,通常也不会改变旧请求的语义。改善错误 detail 的文字也可以兼容,但稳定的 code 和 type 不应随意更名,因为程序依赖的是机器可读标识,不是展示文案。扩大字段长度、放宽校验看似安全,也要检查下游数据库和生成代码能否接受;所谓向后兼容,不能只看服务端愿不愿意接收,还要看旧客户端会如何解析、新数据又会怎样流入它依赖的其他系统。
删除字段、重命名字段、改变字段类型会直接破坏解析。把金额从“元”改成“分”更隐蔽:它仍然是数字,旧客户端可能正常解析,却在业务上把金额放大或缩小一百倍,这种“格式没坏、含义变了”的变化尤其危险。把原来可选的请求字段改成必填,会让旧客户端立刻收到校验失败;改变默认排序,会破坏依赖旧顺序的分页逻辑。收紧权限、改变状态流转、给枚举增加旧客户端不认识的值,也都可能形成实际不兼容,不能因为 JSON 结构还能解析就把它们当成安全变化。
如果语义变化确实无法兼容,就不要让旧客户端在同一路径上误解新数据。此时可以发布新的主版本,例如:
/api/v1/orders
/api/v2/orders路径版本直观,但会让两套路由、文档、监控和修复并存;通过媒体类型或请求头协商版本,可以保持资源路径稳定,却增加调试和缓存配置的复杂度。没有免费的选择,团队应选一种方式并长期保持一致,让客户端不必在每个 API 上重新猜版本规则。
旧版也不能在某天毫无预告地消失。应该先宣布弃用时间,再给出停止服务时间和迁移说明;在过渡期里监控仍在调用旧版的客户端,并让新旧版本通过同一组业务语义测试。版本号只隔离了不兼容变化,它不会替你解决数据迁移、双写、回滚和跨版本一致性。
现在给出一个需求:用户提交订单,订单服务预留库存并扣款;客户端超时后会自动重试;客服可以取消已完成订单。先不要马上画控制器,我们按下面的顺序检查设计,看看前面的原则怎样落到同一条业务链路上:
先识别会留下状态的对象。订单、库存预留和支付都有独立身份与恢复动作,订单项先留在订单聚合内。
再确定集合、单体和子资源。用 /orders 创建和查询集合,用 /orders/{orderId} 读取单体,用 /orders/{orderId}/items 表达从属项。
为方法写出失败后的重试含义。GET 只读;创建订单的 POST 必须带幂等键;取消订单的 PATCH 必须带 If-Match。
为响应写下一步动作。创建成功返回 201、Location 和 ETag;请求仍在执行时返回可识别状态;版本过期返回 412;业务拒绝返回统一问题详情。
这一章把 REST 从命名习惯拉回到了业务契约。资源让订单、预留和支付拥有可追踪的身份;HTTP 方法让中间组件理解读取、替换和删除的语义;幂等键与 ETag 分别处理重复提交和并发覆盖;状态码、问题详情和 OpenAPI 则把失败与演进变成客户端可以依赖的约定。
但有了这些契约,还会继续遇到更具体的设计题:批量操作怎样返回部分结果,异步任务怎样推进状态,资源关系怎样展开而不制造请求风暴。下一章会从这些反复出现的难题出发,把它们整理成可以复用、也能看清代价的 API 模式。
相同键却携带不同订单内容时,拒绝请求。否则一个键会同时代表两种业务意图,去重记录本身就失去了含义。
最后把路径、参数、请求体、响应头、状态码和 Schema 固化进 OpenAPI,并用契约测试检查实现没有漂移。