Review 接口代码时,最常见的不是 bug,而是各种"伪 RESTful":URI 里塞动词、不管什么错都返回 200、POST 接口被前端超时重试刷出一堆重复订单。这些问题单独看都是小事,凑在一起就是线上事故。
RESTful 本身不难,难的是落地时不走样。这篇不讲 Fielding 的论文,只抠三个真正影响线上质量的点: 资源建模、语义约束、工程化。示例代码用 FastAPI,思路对 Flask、Django 同样适用。
RESTful 的核心一句话:URI 描述资源"是什么",HTTP 方法描述"做什么"。URI 里出现动词,基本就走偏了。
拿电商接口举例,对比一下两种写法:
|
意图 |
RPC 风格(反例) |
RESTful 风格 |
|---|---|---|
|
商品列表 |
|
|
|
商品详情 |
|
|
|
创建订单 |
|
|
|
查店铺的商品 |
|
|
几个落地时容易被忽略的点:
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.pyfrom 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.pyfrom fastapi import APIRouter
router = APIRouter(tags=["goods"])
@router.get("/goods") # GET /api/v1/goodsdef list_goods():
...
@router.get("/goods/{goods_id}") # GET /api/v1/goods/42def 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"。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70050389/viewspace-3176797/ ,如需转载,请注明出处,否则将追究法律责任。