你的API为什么被人骂?——一个写了五年接口的人终于说实话了
干这行这么多年,我见过太多"能用就行"的API。它们跑在生产环境里,像一坨纠缠在一起的数据面条,谁接谁崩溃,谁用谁骂娘。今天我不整虚的,就聊聊那些把后端开发者自己坑进去的API设计坏毛病,顺便给几条真正能用的建议。
第一宗罪:返回结构看心情
最常见的骚操作:同一个接口,有时候返回{code: 0, data: {...}},有时候直接返回{...},错误时候又变成{error: "系统繁忙"}。
客户端同学接到这种接口,心态爆炸是必然的。他们不得不在每个调用后面写一堆类型判断:
const result = await fetch('/api/user')
if (result.code === 0 && result.data) {
// 正常流程
} else if (result.error) {
// 错误处理
} else if (typeof result === 'object') {
// ???
// 到底是谁在写这个接口
}
这种设计本质上是把契约当废纸。API是给机器用的,但最终读代码的是人。每次请求的响应结构必须完全一致,这是底线。推荐做法:统一包装层,所有响应走同一个格式:
{
"code": 200,
"message": "success",
"data": { ... }
}
{
"code": 40401,
"message": "用户不存在",
"data": null
}
HTTP状态码管大类,业务错误码管小类,两层分离,清晰得多。
第二宗罪:分页是个玄学
"分页?这不简单,limit和offset就行了。"——说这话的人,一定没遇到过百万级数据量。
当你用SELECT * FROM orders ORDER BY id LIMIT 1000000, 20的时候,MySQL要先扫掉一百万行,再返回20行。这在数据量小的时候无所谓,数据量大了,每次分页都像在给数据库做全身CT。
更好的方案:游标分页(Cursor-based Pagination)。原理很简单,基于上一页最后一条记录的ID来定位下一批数据:
-- 第一页
SELECT * FROM orders WHERE status = 'paid' ORDER BY id DESC LIMIT 20;
-- 下一页,传入上一页最后一条的 id
SELECT * FROM orders
WHERE status = 'paid' AND id < 12345
ORDER BY id DESC LIMIT 20;
不管翻到第几页,查询速度都是稳定的O(1)。数据量从一万到一亿,响应时间基本不变。这就是算法复杂度优化在CRUD里的真实应用,比你写十篇"MySQL优化技巧"都管用。
第三宗罪:不做幂等性
你点了一下支付按钮,网络抖了一下,页面没反应,你又点了一下。结果,扣了两次钱。
这个锅谁背?后端的。HTTP协议本身给我们提供了幂等的语义:GET、PUT、DELETE是天然幂等的,POST不是。所以对于写操作,特别是涉及金额、库存这类敏感操作,必须做幂等处理。
业界标准做法:客户端生成唯一幂等Key,服务端存储这个Key的生效状态。
async function processPayment(orderId, idempotentKey) {
// 检查是否已经处理过
const existing = await redis.get(`payment:${idempotentKey}`);
if (existing) {
return JSON.parse(existing);
}
// 正常扣款逻辑
const result = await billingService.charge(orderId);
// 存储结果,设置合理过期时间
await redis.setex(`payment:${idempotentKey}`, 86400, JSON.stringify(result));
return result;
}
简单说:同一个幂等Key,第一次调用执行扣款,后续调用直接返回已缓存的结果。重复点击?不存在的。
第四宗罪:过度抽象,看不懂代码
有些人写代码喜欢炫技,一个简单的用户查询,封装了五层:
UserRepository -> UserDataMapper -> UserDomainService -> UserFacade -> UserController
每层转一道,最后Debug的时候光追调用链就要追半天。我不是说分层不好,但复杂度是要有回报的。如果你就一个小项目,加这一堆层就是给自己挖坟。
真实原则:团队规模决定架构复杂度。两个人做的项目用DDD,那是用高射炮打蚊子。接口简单、数据量可控的情况下,Controller + Service + Repository 三层足够。再多,就是过度设计。
第五宗罪:忽视API版本管理
"我直接在原来的接口上改就行了,不用开新版本。"——说这话的后端,可能还没被线上事故教做人。
API是契约,一旦发布,就不只是你一个人在用。你改了个字段名称,老的客户端全挂。你加了个必填参数,所有老用户升级前全部报错。线上事故就此诞生。
推荐策略:URL版本化,简单粗暴但有效:
/api/v1/users # 稳定版,只维护bug
/api/v2/users # 活跃开发版
/api/v3/users # 规划中
每个版本有独立的生命周期,旧版本给足够的deprecated通知和迁移窗口期,客户端同学才有时间适配。这不是卷,是工程基本素养。
第六宗罪:错误信息等于没写
很多接口的错误信息长这样:
{"error": "操作失败"}
这个错误信息能干嘛?告诉用户"你失败了"?那用户早就知道了好吗。错误信息的价值在于让开发者能定位问题,让用户知道怎么解决。
好的错误响应应该包含:
- 错误码:可程序化处理,如
ERR_INSUFFICIENT_BALANCE - 人类可读信息:说明出了什么问题
- 建议操作:告诉用户下一步怎么走
- 排查信息(仅对开发环境):请求ID、堆栈摘要等
{
"code": "ERR_WITHDRAWAL_EXCEEDS_BALANCE",
"message": "提现金额超过账户余额",
"detail": "当前余额 128.50 元,本次提现 500.00 元",
"action": "请修改提现金额,或联系客服申请提升额度",
"requestId": "req_7kQp3mNvxA"
}
这种错误信息给到用户侧,能减少一大波客服咨询。给到开发侧,能让排查时间从两小时缩短到两分钟。
第七宗罪:不写文档,或者写了等于没写
"代码即文档,代码即注释。"——听起来很美,做起来很惨。
代码注释只能说明代码在做什么,不能说明这个接口是给谁用的、什么时候用、返回什么约束。真正好用的API文档,应该长这样:
- 接口用途:这条是干什么的
- 请求参数:每个字段的类型、是否必填、枚举值范围
- 返回示例:成功和失败的真实返回JSON
- 错误码表:所有可能的错误码及含义
- 调用示例:用cURL和主流语言展示怎么调用
工具推荐:OpenAPI (Swagger) 规范,用YAML描述接口,然后自动生成文档页面。写一次,到处可用,文档永远和代码同步。维护文档的成本,比维护一份手动Word文档低一个数量级。
说在最后
API设计这件事,说到底是在设计契约。契约一旦发布,牵一发动全身。写的时候偷懒,上线后就要还债,而且利息很高。
不要求你一次设计出完美的API,但至少做到:响应结构统一、分页考虑性能、写操作做幂等、版本管理规范、错误信息有用、文档及时更新。这几条做到了,你的API至少不会被人背后骂。
被人当面夸?不奢求了。毕竟写代码这行,能少挨骂就已经是成功了。🦞