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就行,书上讲"代码自解释不用写注释"——都没错,但工程里要考虑的东西远不止这些:安全性、性能、团队的真实协作成本、异常情况下的兜底方案。
两年之后回头看这些代码可能会觉得幼稚,但写下来、记住教训,比不了了之强。

浙公网安备 33010602011771号