Review 接口代码时,最常见的不是 bug,而是各种"伪 RESTful":URI 里塞动词、不管什么错都返回 200、POST 接口被前端超时重试刷出一堆重复订单。这些问题单独看都是小事,凑在一起就是线上事故。
RESTful 本身不难,难的是落地时不走样。这篇不讲 Fielding 的论文,只抠三个真正影响线上质量的点:资源建模、语义约束、工程化。示例代码用 FastAPI,思路对 Flask、Django 同样适用。
一、资源建模:URI 里只允许出现名词
RESTful 的核心一句话:URI 描述资源"是什么",HTTP 方法描述"做什么"。URI 里出现动词,基本就走偏了。
拿电商接口举例,对比一下两种写法:
| 意图 | RPC 风格(反例) | RESTful 风格 |
|---|---|---|
| 商品列表 | POST /api/getGoodsList |
GET /api/v1/goods |
| 商品详情 | GET /api/getGoodsById?id=42 |
GET /api/v1/goods/42 |
| 创建订单 | POST /api/createOrder |
POST /api/v1/orders |
| 查店铺的商品 | GET /api/getGoodsByShop?shopId=7 |
GET /api/v1/shops/7/goods |
几个落地时容易被忽略的点:
1. 资源用复数。 /goods 表示集合,/goods/42 是集合里的一个成员。单复数混用(列表叫 /goods、详情又叫 /order)是接口文档里最廉价的混乱来源。
2. 嵌套最多两层。 /shops/7/goods 表达归属关系很自然,但 /shops/7/goods/42/skus/15/stocks 这种四层嵌套,可读性和路由维护成本都失控了。超过两层就拆开,用查询参数表达过滤:GET /api/v1/stocks?sku_id=15。
3. 状态变更不是动词问题,是状态字段问题。 "取消订单"推荐两种写法:简单场景直接 PATCH /api/v1/orders/123,body 传 {"status": "CANCELLED"};动作复杂(比如要触发退款审批)时用动作子资源 POST /api/v1/orders/123/cancellation。两种都不算犯规,怕的是一个系统里两种混着用还没规律。
代码层面,FastAPI 用 APIRouter 按资源拆文件,结构自然就出来了:
# app/main.py
from fastapi import FastAPI
from app.routers import goods, orders
app = FastAPI(title="shop-api")
app.include_router(goods.router, prefix="/api/v1")
app.include_router(orders.router, prefix="/api/v1")
# app/routers/goods.py
from fastapi import APIRouter
router = APIRouter(tags=["goods"])
@router.get("/goods") # GET /api/v1/goods
def list_goods():
...
@router.get("/goods/{goods_id}") # GET /api/v1/goods/42
def get_goods(goods_id: int):
...
@router.get("/shops/{shop_id}/goods") # 一层嵌套:店铺下的商品
def list_shop_goods(shop_id: int):
...
资源拆清楚了,URI 设计就完成了一大半,剩下的是方法语义。
二、语义约束:方法用对、状态码用全、幂等做到位
方法与状态码
五个方法的边界很清晰:GET 只读、POST 创建、PUT 全量更新、PATCH 局部更新、DELETE 删除。重灾区有两个:GET 请求带 body 做复杂查询(部分网关和 CDN 会直接丢 body),以及所有操作都用 POST 包打天下——后者等于主动放弃 HTTP 层的语义,缓存、幂等、监控全得自己造轮子。
状态码同理。别所有响应都 200 再在 body 里塞 code,客户端按 HTTP 状态码分支远比解析自定义枚举可靠:
201创建成功(顺手在响应里带上新资源的 URI)400参数错误、401未认证、403已认证但无权限——这三个别混404资源不存在、409状态冲突(比如重复提交)429触发限流、500服务端兜底
幂等性:电商接口的命门
GET、PUT、DELETE 天然幂等,POST 不是。下单、支付这类 POST 接口,一旦前端超时重试、用户手抖连点、网关自动重发,没有幂等保护就是重复扣库存、重复扣款。
通用做法:客户端生成 Idempotency-Key 请求头,服务端按 key 去重,重复请求直接返回首次的处理结果:
import hashlib
import time
from fastapi import FastAPI, Header, Response
from pydantic import BaseModel
app = FastAPI()
# 演示用内存存储;生产环境换 Redis:SET key value NX EX 86400
_idem_store: dict[str, dict] = {
}
class CreateOrderReq(BaseModel):
user_id: int
sku_id: int
quantity: int
@app.post("/api/v1/orders", status_code=201)
def create_order(
req: CreateOrderReq,
response: Response,
idempotency_key: str = Header(...), # 对应请求头 Idempotency-Key
):
# 同一个 key 重复到达:返回首次结果,不再执行业务逻辑
if idempotency_key in _idem_store:
response.status_code = 200 # 明确告诉客户端这是重放
return _idem_store[idempotency_key]
# ---- 真实业务:校验库存 -> 扣减 -> 落库 ----
order = {
"order_id": hashlib.md5(
f"{req.user_id}-{time.time_ns()}".encode()
).hexdigest()[:16],
"user_id": req.user_id,
"sku_id": req.sku_id,
"quantity": req.quantity,
"status": "CREATED",
}
_idem_store[idempotency_key] = order
return order
两个细节:key 的粒度建议绑定到"用户 + 业务动作",避免不同用户的 key 碰撞串单;缓存有效期覆盖客户端最大重试窗口即可,一般 24 小时足够。
三、工程化:版本、分页、错误格式
这一层跟理论关系不大,纯粹是踩坑踩出来的共识。
版本:URI 版本优先。 /api/v1 直白、好调试、网关路由规则好写。Header 版本(Accept: application/vnd.shop.v2+json)理论上优雅,但联调和抓包排查时极其折磨。中小团队直接 URI 版本,别过度设计。
分页:数据量小用 offset,量大或实时流用 cursor。 offset 分页(?page=10&size=20)实现简单,但深翻页时数据库要扫描并丢弃前 N 行,而且翻页过程中有新数据写入会导致重复或漏数据。cursor 分页用"上一页最后一条的 ID"做锚点,索引直接定位:
import base64
import json
from fastapi import FastAPI, Query, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
app = FastAPI()
# ---------- 统一错误格式(RFC 7807 风格) ----------
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=400,
media_type="application/problem+json",
content={
"type": "about:blank",
"title": "Invalid parameters",
"status": 400,
"detail": exc.errors()[0]["msg"],
"instance": request.url.path,
},
)
# ---------- cursor 分页 ----------
_ORDERS = [{
"id": i, "status": "CREATED"} for i in range(1, 101)] # 演示数据
def fake_query_orders(last_id: int, limit: int) -> list[dict]:
# 等价 SQL:SELECT * FROM orders WHERE id > :last_id ORDER BY id LIMIT :limit
return [o for o in _ORDERS if o["id"] > last_id][:limit]
def encode_cursor(last_id: int) -> str:
return base64.urlsafe_b64encode(json.dumps({
"last_id": last_id}).encode()).decode()
def decode_cursor(cursor: str) -> int:
return json.loads(base64.urlsafe_b64decode(cursor.encode()))["last_id"]
@app.get("/api/v1/orders")
def list_orders(cursor: str | None = None, limit: int = Query(20, le=100)):
last_id = decode_cursor(cursor) if cursor else 0
rows = fake_query_orders(last_id=last_id, limit=limit + 1) # 多查一条判断是否还有下一页
has_more = len(rows) > limit
rows = rows[:limit]
return {
"data": rows,
"next_cursor": encode_cursor(rows[-1]["id"]) if has_more and rows else None,
}
cursor 用 base64 包一层不是为了安全,是为了让客户端把它当不透明字符串、别自己拼——服务端后续想换实现(比如改成"时间戳 + ID"复合游标)时不用动客户端。
错误格式:全系统统一,带上 request_id。 上面代码里用的是 RFC 7807 的 application/problem+json;团队自定义 {code, message, request_id} 也行,关键是所有接口一个格式,前端能写统一拦截器。request_id 接上链路追踪,线上排查时能少加很多班。
写在最后
规范的意义不在于符合理论,在于降低协作和排障成本:URI 只放名词、方法语义用对、幂等和分页按场景选方案、错误格式全系统统一。这几点做不到位,文档写得再漂亮也是"伪 RESTful"。