REST 与 API 设计

本页目录

API契约定义的不只是路径和JSON字段,还包括一次操作何时生效、失败后能否重试、并发修改如何处理,以及客户端怎样判断下一步。REST提供架构约束;HTTP提供方法与状态等语义;OpenAPI等工具描述接口。三者不能互相替代。

本篇用订单资源贯穿创建、更新、异步处理和分页,优先回答调用方在真实失败与并发下该怎么做。

问题重点机制
请求超时,订单到底创建了吗操作身份、幂等键与结果查询
两个人同时修改订单强ETag、If-Match与原子比较更新
翻页时出现重复或遗漏稳定排序、复合游标与快照边界
202之后何时算成功可查询的操作资源与终态
增加一个字段为何使旧客户端失败消费者兼容假设与契约检查
REST、GraphQL、gRPC怎么选交互、演进及工具要求

REST 约束不是 URL 命名评分表

REST包含客户端/服务端分离、无状态交互、可缓存、统一接口、分层,以及可选的按需代码。统一接口还包括资源标识、通过表示操作资源、自描述消息和超媒体驱动应用状态,不只是把URL中的动词删除。

无状态指请求不能依赖服务端隐含保存的交互会话上下文才能被理解;它不禁止数据库、业务资源、缓存和授权资料。也不要求使用JWT:认证凭据格式与是否满足该交互约束是不同问题。

资源可以是订单、支付尝试或导出任务,不一定一对一映射数据库表。某个资源的JSON是表示之一,不是资源本身。仅提供JSON CRUD的HTTP API可以很实用,但不能据此断言已满足REST全部约束。Fielding:REST架构风格

用完整业务动作确定资源边界

操作示例接口契约要点
创建订单POST /orders返回201与新资源Location,或明确异步接受
读取订单GET /orders/42返回表示、ETag及缓存策略
替换可写表示PUT /orders/42定义服务端生成字段和遗漏字段如何处理
部分修改PATCH /orders/42明确patch媒体类型与原子性
发起退款POST /orders/42/refunds建模退款尝试、金额和状态,而非GET副作用
查询导出任务GET /operations/export-7返回运行状态、终态结果或失败信息

路径使用名词有助一致性,但POST本身并不只代表创建,动作型接口也不天然错误。真正危险的是用GET触发删除或扣款,因为缓存、预取和重试会依赖其安全语义。

请求契约还需明确身份与租户作用域、字段缺失和null差异、金额单位、时间格式及边界值。比如amount=100究竟是100元还是100分,比路径是否带一个动词更影响正确性。

方法幂等、业务幂等与可重试性

安全方法的预期语义是读取,不要求完全没有日志等附带效果。幂等方法的多次相同请求具有与一次相同的预期效果,不要求每次响应状态、时间戳或审计记录相同。DELETE首次204、再次404并不自动违背幂等。

GET、HEAD、PUT、DELETE等具有相应幂等语义;POST没有通用幂等保证,但具体操作可以通过契约实现幂等。PATCH整体不保证幂等,取决于操作内容。网络失败后能否重试,还要考虑请求体是否可重放、执行状态与操作前提,而非只检查方法名。RFC 9110:方法性质

幂等键要覆盖提交与崩溃之间的缝隙

幂等键记录的是业务操作身份,不能只做一个内存 set 收到操作键 K 限定调用方和操作范围 原子占用 / 执行 绑定输入摘要与业务记录 完成并保存结果 重复 K 返回相同操作结果 同键不同输入:冲突;同键正在执行:按契约等待或返回状态 进程崩溃后仍能根据持久状态确认已做什么 跨数据库与外部支付等副作用,需要额外事务协议、下游幂等或可恢复工作流。

假设客户端提交订单后超时,再带同一个K重试。服务端应在约定的调用方/租户及操作范围内识别K,绑定规范化请求摘要,重复相同输入返回既有操作结果;同键不同金额应拒绝,不能静默复用上一笔订单。

先创建订单、再写幂等记录,如果中间崩溃,就会重复创建。只先写一条“已处理”,再创建订单,则可能永远吞掉未执行操作。需要把去重占用、业务变更与完成记录纳入合适的事务或可恢复状态机,并处理并发同键请求。

如果还要调用外部支付系统,本地数据库事务不会自动覆盖外部副作用。需要下游提供可重用操作键、结果查询,或使用有持久状态的工作流解决不确定性。记录过期之后重复K怎么处理也属于契约,不能声称一个有限TTL缓存提供永久exactly-once。

并发更新:ETag 不是装饰性版本号

If-Match 必须参与原子更新,才能防止丢失更新 客户端 A 读到 v7 双方开始编辑同一资源 客户端 B 也读到 v7 双方开始编辑同一资源 A:If-Match v7 → 更新成功 存储条件命中,版本成为 v8 B:If-Match v7 → 412 旧版本不再命中,不覆盖 A 不能先在应用读版本,再无条件写入;版本比较和更新要形成同一个并发控制动作。

客户端A和B都读取版本v7。A携带If-Match: "v7"更新成功后,B仍携带v7就应得到412,并重新读取或让用户处理冲突。If-Match使用强比较,不能用弱ETag冒充写入并发控制。

校验必须参与原子写入。下列内存SQLite例子用一个条件UPDATE表达比较与更新;演示两个客户端持有同一个旧版本,不依赖真实网络或服务。

import sqlite3

db = sqlite3.connect(':memory:')
db.execute('CREATE TABLE orders(id INTEGER PRIMARY KEY, note TEXT, version INTEGER NOT NULL)')
db.execute('INSERT INTO orders VALUES (42, ?, 7)', ('initial',))

def update(expected, note):
    changed = db.execute(
        'UPDATE orders SET note=?, version=version+1 WHERE id=42 AND version=?',
        (note, expected)).rowcount
    db.commit()
    return 204 if changed == 1 else 412

assert update(7, 'from A') == 204
assert update(7, 'from B') == 412
assert db.execute('SELECT note, version FROM orders WHERE id=42').fetchone() == ('from A', 8)
print('旧版本更新被拒绝,A 的修改保留')

真实接口还需区分资源不存在、调用方无权限和前置条件失败;例子省略这些层,只验证丢失更新的核心机制。若响应表示经过协商或转换,应确保ETag所指版本与受保护的更新对象有清楚关系。RFC 9110:If-Match

PATCH 的媒体类型决定修改方式

JSON Merge Patch把对象成员合并,null用于移除成员,数组通常整体替换;因此它不适合不加约定地表达“把成员值设置成JSON null”。JSON Patch则使用op/path/value等操作,向数组末尾add同一值两次会追加两次,并非所有数组操作都不幂等。RFC 7396、RFC 6902

PATCH要求按规范原子应用整组变化,不能失败到一半就留下部分补丁。对于基于旧表示构造的补丁,配合If-Match能避免把数组位置或旧值假设应用到已变化资源。RFC 5789

两个客户端都拿着v7,谁会覆盖谁?

先提交A再提交B,B的旧条件无法通过,服务器保留A;交换提交顺序则对称。B重新读取后可基于新版本提交,交互不替用户自动“解决冲突”,因为如何合并是业务判断。

正在呈现知识画面
两个客户端都拿着v7,谁会覆盖谁?

旧版本提交得到412,不会覆盖已提交的新内容。重新读取获得新版本后再提交,是一次明确的新决定。

错误和异步状态应能驱动程序

application/problem+json用type表达问题类别,title提供简短名称,detail描述本次情况,instance标识本次问题实例;扩展成员由API定义。客户端应按稳定类别与字段决策,不解析可能变化或本地化的detail文本。

{
  "type": "https://api.example.com/problems/order-state-conflict",
  "title": "Order state conflict",
  "status": 409,
  "detail": "A shipped order cannot be cancelled.",
  "instance": "/problem-instances/req-123",
  "order_id": "42",
  "current_state": "shipped"
}

这里HTTP状态也应为409;body中的status是说明信息,不能替代实际HTTP状态。order_id等是本API扩展,不是Problem Details强制字段。不要把内部堆栈或数据库语句当detail返回。RFC 9457

202只代表接受请求,不承诺处理成功。导出接口可返回操作资源链接,客户端轮询得到pending/running/succeeded/failed,并在成功后取结果。契约需要说明建议轮询节奏、取消是否尽力而为、任务和结果保存多久,以及重复发起如何识别。

401通常伴随认证挑战,403表达拒绝履行,不必然证明已识别用户;404也可能用于隐藏资源是否存在。409用于业务状态冲突、412用于条件失败,不能把所有冲突一律压成500。

游标分页:稳定排序不等于固定快照

以created_at DESC, id DESC为排序,id打破同一时间戳下的平局。第一页最后一项为(100,8),下一页应取键严格小于(100,8)的记录;仅使用created_at游标会遗漏同时间戳的其他记录。

rows = [(101, 10), (100, 9), (100, 8), (100, 7), (99, 6)]
first = sorted(rows, reverse=True)[:3]
cursor = first[-1]
assert cursor == (100, 8)
# 第一页之后插入一个更靠前的条目。
now = sorted(rows + [(102, 11)], reverse=True)
offset_page = now[3:6]
keyset_page = [r for r in now if r < cursor][:3]
assert (100, 8) in offset_page  # offset 漂移,重复第一页最后一项
assert keyset_page == [(100, 7), (99, 6)]
assert [r for r in now if r[0] < cursor[0]] == [(99, 6)]  # 错误单字段游标漏掉(100,7)
print('offset:', offset_page, '复合游标:', keyset_page)

数据库查询需配合相应索引;cursor可以编码排序键、过滤条件版本及必要的快照标识,但base64不是加密或防篡改。每一页仍需检查租户和权限,不能因为cursor来自服务端就绕过授权。

Keyset减少插入靠前记录引起的漂移,不自动提供一致快照:记录被删除、排序字段被修改仍可能改变结果。如果要求导出某个固定时刻的全集,需要快照或其他明确的一致性方案。跳页需求也应据场景设计,不能把cursor绝对定义为“永远不能实现跳转”。

超媒体与 OpenAPI:发现下一步和描述固定契约

响应可以提供next链接或当前允许的操作及其媒体类型语义。隐藏取消链接有助客户端呈现,但服务端执行时仍需检查订单状态与权限,因为读取后状态可能改变。随便加一个_links对象,也不意味着客户端已经可以在没有约定的情况下理解任意动作。

OpenAPI描述HTTP接口,不限于严格REST。下面固定使用3.1.1语法,给出可独立阅读的最小只读接口;它没有宣称这是最新版本,也未覆盖整篇订单契约。

openapi: 3.1.1
info:
  title: Order Example
  version: 1.0.0
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Order found
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [id, state]
                properties:
                  id:
                    type: string
                  state:
                    type: string
        '404':
          description: Order not found or not visible

生成客户端、交互文档和结构校验能减少手工偏差,但不会验证扣款是否恰好一次,也不会从字段类型推出跨字段业务约束。契约测试应覆盖状态变化、旧版本客户端和典型错误,而不是只检查成功JSON能否解析。OpenAPI 3.1.1

风格选择:比较具体交互,不给固定性能排名

方案核心交互需要承担的设计工作
资源型HTTP API按资源和通用方法交换表示粒度、条件请求、缓存及演进
GraphQL按schema选择字段并执行查询/变更查询成本、字段授权、N+1和部分错误
gRPC按服务方法与消息契约调用deadline、状态、流式背压与协议接入

REST不限定JSON;GraphQL语言规范不限定HTTP,也不能直接定义成“只能单POST因此无法缓存”。HTTP部署可支持GET查询和持久查询,从而利用适当缓存,仍需处理用户相关结果隔离。GraphQL over HTTP说明

gRPC常用Protobuf与HTTP/2,支持不同流式调用形式,但并非每个接口都自动低延迟。浏览器通常通过gRPC-Web等适配方案接入,代理不一定必须是Envoy,也可由支持该协议的服务端直接处理;流式能力需按所选实现核实。gRPC概念、gRPC-Web项目

兼容性:增加字段也需要消费方契约

在约定忽略未知字段的客户端上,新增可选响应字段通常兼容;严格反序列化、封闭schema、枚举穷举或签名输入规则却可能因此失败。新增必填请求字段、改变默认值、收紧校验、改变排序或分页语义,均可能是实际破坏性变化。

版本可以放在路径或协商媒体类型等位置,重要的是路由、缓存键和生成客户端达成一致。仅把v1改成v2不能替代迁移说明、旧版本观测和明确支持期。

Deprecation表达弃用状态,Sunset表达预期停止服务时间,二者不同。现行Deprecation字段使用Structured Field Date格式,例如@<unix秒数>;Sunset使用HTTP日期,不能复制相同字符串给两者。RFC 9745、RFC 8594

继续查阅