RESTful API 落地的三个核心:资源建模、语义约束与工程化

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 按资源拆文件,结构自然就出来了:

python 复制代码
# 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")
python 复制代码
# 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 去重,重复请求直接返回首次的处理结果:

python 复制代码
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"做锚点,索引直接定位:

python 复制代码
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"。

相关推荐
不一样的少年_1 小时前
原来 AI Agent 的核心循环这么简单:手搓一个 Agent Loop
前端·后端·agent
Conan在掘金1 小时前
鸿蒙报错速查:struct 里嵌 class 声明就炸,Unexpected token 编译报错,根因 + 真解法
后端
Conan在掘金1 小时前
鸿蒙报错速查:arkts-no-destruct-decls 禁用解构声明,const { x, y } = obj 就炸,根因 + 真解法
后端
她说彩礼65万2 小时前
ASP.NET Core 环境配置
后端·asp.net
SimonKing2 小时前
OpenCode 桌面版这 10 天偷偷迭代了 5 个版本,你还在用旧版吗?
java·后端·程序员
爱勇宝2 小时前
你以为自己性格不好,其实只是被环境反复训练
前端·后端·程序员
网易云信2 小时前
企业级 IM,不是功能更多,而是场景更对
人工智能·后端
swipe2 小时前
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
前端·后端·全栈
AmazingEgg2 小时前
Eggblog博客部署文档
后端