REST API设计的那些"反直觉"时刻

做后端两年,写了几十个接口,回头看发现自己踩过的坑比写过的接口还多。有些东西书上写得清清楚楚,但真到自己动手写的时候才发现——书上说的"最佳实践"在真实场景下经常是反直觉的。

今天聊聊我在实际项目里学到的几个教训。

1. 少返回一点字段,比多返回一点更安全

刚开始写接口的时候,我习惯把数据库查出来的对象直接序列化返回:

# 开始是这样写的
@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = db.query(User).filter(User.id == user_id).first()
    return user  # ORM对象直接返回,含password_hash

看起来简单粗暴,直到我发现接口把 password_hash 也返回出去了。虽然密码是哈希过的,但这仍然是个严重的安全隐患。

后来改成用 Pydantic 做序列化控制:

from pydantic import BaseModel

class UserResponse(BaseModel):
    id: int
    username: str
    email: str
    created_at: str
    # password_hash 不在这里,就不会泄露

    class Config:
        from_attributes = True

@app.get("/users/{user_id}")
def get_user(user_id: int):
    user = db.query(User).filter(User.id == user_id).first()
    return UserResponse.model_validate(user)

教训就是:接口的返回字段永远比你能想到的多。少即是安全。

默认原则应该是最小化暴露——需要什么字段就返回什么字段,多的一个都不要。看到ORM对象直接扔给前端就跑的代码,现在我会直接打回去。

2. HTTP状态码不是你想用就能用的

刚学REST的时候觉得:200成功、400参数错、401没权限、404不存在、500服务器崩——这多清晰啊。

现实是:前端同学会告诉你,"你返回401的时候浏览器自动弹登录框了,我拦不住";"你返回404的时候我分不清是路由不存在还是数据不存在"。

后来学乖了,团队约定统一返回200,用业务code区分状态:

{
  "code": 0,
  "message": "ok",
  "data": { ... }
}

错误时:

{
  "code": 10001,
  "message": "未登录或Token已过期",
  "data": null
}

HTTP状态码更多用来表示"请求到没到服务器?到了那就200",真正的业务状态交给业务code。这个取舍没有对错,但要在团队里对齐——最烦的就是同一个项目里A接口用HTTP码区分、B接口用业务code区分,调用方要写两套判断逻辑。

3. 分页不是 "limit + offset" 就完了

大二的时候我这么写分页:

SELECT * FROM articles ORDER BY created_at DESC LIMIT 20 OFFSET 40;

直到表里有几十万条数据,发现OFFSET越大越慢——因为数据库需要扫过前面所有的行再丢掉。offset=100000的时候你等于让数据库读10万行然后扔掉。

在销售ERP项目里,我改成了游标分页:

def list_orders(cursor: str = None, limit: int = 20):
    query = "SELECT * FROM orders"
    params = []
    if cursor:
        created_at, order_id = cursor.split("_")
        query += " WHERE (created_at, id) < (%s, %s)"
        params = [created_at, order_id]
    query += " ORDER BY created_at DESC, id DESC LIMIT %s"
    params.append(limit)
    return db.execute(query, params)

不过游标分页没法跳页,产品说"我要直接跳到第10页"。最后折中方案:列表页用游标分页(无限滚动),管理后台才用传统分页(数据量可控)。

分页方案没有银弹,看场景选。

4. 幂等性不是你加个"防重"就完事的

支付相关接口,我的第一反应是加个唯一请求ID拦截:

@router.post("/orders/{order_id}/pay")
def pay_order(order_id: int, request_id: str):
    if redis.exists(f"req:{request_id}"):
        return {"code": 0, "message": "请勿重复请求"}
    redis.set(f"req:{request_id}", "1", ex=300)
    # ... 执行支付逻辑

看起来完美。然后老板灵魂拷问来了:"如果支付逻辑执行到一半redis挂了怎么办?如果支付成功了但redis写失败了怎么办?"

这就是幂等性的核心——不是"拦截重复请求",而是"重复执行结果一样"。

真正的做法是在数据库层面利用唯一约束:

CREATE TABLE payment_records (
    id BIGINT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    request_id VARCHAR(64) NOT NULL UNIQUE,  -- 唯一约束保证幂等
    amount DECIMAL(10,2),
    status VARCHAR(20),
    created_at TIMESTAMP
);

重复请求会因为UNIQUE约束插入失败,业务代码捕获这个异常后查询已有记录返回"已处理"——这才是真正的幂等,不依赖任何外部缓存。

5. 接口文档写不写,团队效率差一倍

很长一段时间我觉得写接口文档浪费时间——"代码即文档"嘛,Swagger自动生成不就行了?

结果联调的时候微信群炸了:这个字段是必传还是可选?这个枚举值到底有哪些?错误码1003到底是什么意思?

后来养成了习惯:每个接口除了Swagger注解,还额外写一段简短说明,把业务含义边界条件讲清楚。Swagger只能告诉你字段类型,不能告诉你"当订单状态为已取消时这个接口返回什么"、"用户没有权限时返回的是空列表还是报错"。

@router.get("/orders", summary="查询订单列表")
def list_orders(status: str = None):
    """
    按状态筛选当前用户的订单列表。

    边界情况:
    - 未登录:返回 code=10001
    - status=已取消:仅返回90天内已取消订单
    - 无数据:返回空列表,不报错
    """
    ...

这几行注释值千金——特别是三个月后你自己回来改这段代码的时候。

总结

这些坑本质上都指向一件事:工程化和理论是两回事。 书上讲REST要语义化用HTTP状态码,书上讲分页用OFFSET就行,书上讲"代码自解释不用写注释"——都没错,但工程里要考虑的东西远不止这些:安全性、性能、团队的真实协作成本、异常情况下的兜底方案。

两年之后回头看这些代码可能会觉得幼稚,但写下来、记住教训,比不了了之强。

posted @ 2026-07-03 16:32  C(5,3)  阅读(2)  评论(0)    收藏  举报