统一 API 响应格式:让所有接口长成一个样子
后端到前端,绝大多数团队最后靠的是一纸 接口返回 JSON 长什么样 的约定。可约定挂在嘴上没有用,得让它变成代码里跑不掉的规则。这篇文章讲的是:怎么定义一套统一的响应格式,怎么让全项目 想不一致都难 ,以及几种常见的姿势和它们各自的坑。

一、为什么要统一
先感受一下不统一的后果。有的接口返回:
{ "code": 0, "data": [...], "message": "ok" }有的返回:
{ "status": 200, "result": [...], "msg": "成功" }还有的干脆出错了就 500 抛一个 HTML 页面或者一把报错堆栈。
于是前端需要为每一个接口写一套兼容逻辑:这个用 code 、那个用 status 、报错要去看 msg 还是 message 还是干脆 解析异常 。等到页面一多,bug 全出在 解析字段名对不上 上 - 这跟业务逻辑一点关系都没有,纯粹是格式不统一的债。
统一响应格式的价值,不是 好看 ,而是消除这块低级的认知负担 :让前端拿到任何接口,都有着相同的 解剖结构 ,处理成功、失败、分页的逻辑可以抽象成公共代码,一次写好处处复用。
二、先定一套 JSON 结构
统一不是说 字段名全一样 ,而是 语义稳定、字段最少、约定清楚 。最常见的做法是四个核心字段:
{
"code": 200, // 业务/HTTP 状态码
"message": "", // 给人看的提示信息,成功可为空
"success": true, // 是否成功,前端最常用的布尔开关
"data": {}, // 业务数据;列表时可能是数组,也可能带 total
}这么设计的理由很朴实:
success:前端第一眼判断成败,直接if (!res.success) return showError(...)。code+message:给失败留出足够的描述空间,前端能展示 具体哪错了 。data:真正的业务载荷,成功时才关心。
列表接口额外带一个 total (有的团队放在 data 里),用于分页:
{
"code": 200,
"message": "",
"success": true,
"rows": [],
"total": 0
}三、初级做法:每个接口手写,行不行
最省事、最少引入抽象的做法是:在每个 Controller 里手动拼一个这样的 JSON:
async list() {
const res = await this.service.user.list(this.ctx.query);
this.ctx.body = {
code: 200,
message: '',
rows: res.rows,
success: true,
total: res.total,
};
}它完全可行,而且很多小团队就是这么干的。 它的缺点也很明显:
- 重复 :每个接口都要写那几行
code/message/success,手累且容易漏字段。 - 不一致 :有人返回
success: true,有人忘了写total,有人把message写成msg,没有强制约束,约定形同虚设。 - 失败路径难收口 :成功还好,一旦出错,很多人直接
ctx.body = { code: 500 },格式和成功的对不齐。
所以 手写 适合作过渡,长期要往前再走一步 - 把成功和失败两个动作收敛成公共方法 。
四、进阶做法一:把 成功/失败 收敛成两个公共方法
思路是:在框架的 context (每次请求一个 ctx)上扩展两个方法,全项目统一调用。
// app/extend/context.js - 挂到 ctx 上的两个快捷方法
module.exports = {
// 抛出 业务异常 ,交给全局异常中间件统一转成 JSON
returnError(msg = '失败', code = 400) {
const err = new Error(msg);
err.status = code;
err.isBiz = true; // 标记:是业务异常,而不是系统崩溃
throw err; // 关键:抛出去,让异常中间件接
},
// 返回成功
returnSucc(data, message = '') {
// 这里取消 ctx.body 不能直接 throw,得真的赋值给 ctx.body 才会响应
// 细节看下面 为什么 returnSucc 不能 throw
},
};实际的框架(比如 Egg.js)会有对应机制,但核心思想一致: 成功走赋值,失败走抛异常 。
前面那篇文章里提到 returnSucc 其实会抛异常,这里我展开为什么会有这种让人困惑的写法。
五、为什么 失败走异常 是更稳的一条腿
如果你把失败也做成 returnError ,内部是 throw 出来的,那么它就必须有一个「全局异常中间件」在收尾:
// 全局异常中间件(简化的伪代码)
module.exports = () => async (ctx, next) => {
try {
await next(); // 让 Controller 跑完
} catch (err) {
if (err.isBizError) {
// 业务异常:用 err.message/status 拼统一 JSON
ctx.status = 200; // 业务上仍 200,码在 body 里
ctx.body = { code: err.status, message: err.message, success: false, data: '' };
} else {
// 系统异常:这里是安全边界,别把内部堆栈怼给前端
ctx.logger.error(err); // 记录完整堆栈
ctx.status = 500;
ctx.body = { code: 500, message: '服务器开小差了', success: false, data: '' };
}
}
};这样做的两个好处戏剧性地放大:
- 失败格式保证统一 - 因为只有这一个地方负责拼错误 JSON,前端 100% 能信任结构。
- 成功路径保持干净 - Controller 里的成功分支就是
ctx.body = {...success:true},一旦异常,自动滑入 catch,你不用在每个接口里反复if/else。
这是一条 架构上的取舍 :追求 所有接口永远一致 的人会选它;追求 直观、理解成本低 的人会怀疑 为什么成功赋值、失败抛异常,搞得不对称 。其实不对称恰恰是它最聪明的点 - 因为 失败 是不可预期的、到处都可能发生,把它统一收割,才最省事。
六、一个数字化前端的完整正确姿势
前端拿到统一格式后,通常会在请求层统一拦截,写一次就好:
// 封装在公共 http 请求里
http.interceptors(response => {
const { code, success, message } = response.data;
if (code === 401) { redirectToLogin(); return Promise.reject(...); }
if (!success) { showError(message); return Promise.reject(...); }
return response.data.data; // 直接吐业务数据,业务层不用再剥壳
});于是业务侧拿到的永远是 剥离了壳 的 data,既成功又清爽。这正是统一格式带来的最深收益: 把 包壳 / 拆壳 收敛成一次,而不是每个页面自己剥。
七、三套常见方案的取舍表
| 方案 | 说明 | 适用 | 坑 |
|---|---|---|---|
| 手写每个接口的 JSON | 最朴素 | 极短、一次性接口 | 易漏字段、格式漂移 |
| context 两个方法(成功赋值+失败抛异常) | 配全局异常中间件 | 中大型、强调一致 | 需写异常中间件并打 logging,否失败回本就崩 |
| 自定义异常类 + 中间件兜底 | 业务异常有类型、可携带更多字段(httpCode) | 复杂异常分级 | 需要区分 业务异常 与 系统异常 ,别把详细堆栈泄露 |
选型建议 :如果是新起一个中型项目,直接上 两个方法 + 全局异常中间件 ,一次到位;如果不想要太多的抽象,也至少要保证 失败路径有一个统一出口 ,否则报错 JSON 就是前端的噩梦。
八、静态如何保证 真统一
统一的格式若不落地成约束,人总会写出例外。三个常用的 把关人 :
- 约定文档 :
api/协议.md一篇,写明成功/失败/列表/401 各长什么样。 - 规范示例 :让别人 clone 一个示例 controller,照着抄。
- 接口返回类型(TS) :前端把响应定义一个泛型
ApiResult<T>,逼着所有接口 status 顺序。
// 前端类型,保证每个接口解析结构一致
interface ApiResult<T> {
code: number;
message: string;
success: boolean;
data: T;
}
interface PagedResult<T> { rows: T[]; total: number; }九、小结
统一响应格式这件事,说得大点,是在办 前端和后端之间的语言是一个 的事。它不复杂,但完整解读任何一个项目的地基都不可以松动:
- 定一个字段最少的 JSON 结构(code/message/success/data);
- 让 成功 成为一个赋值、让 失败 走一个异常中间件收敛;
- 前端在请求层统一剥壳,业务层不关心壳;
- 用类型、文档、跨前示例把约定固定下来。
下一篇文章,我会单独重点讲 全局异常处理 这一截 - 尤其是 生产环境怎么藏起详细报错、怎么在日志里留下完整的堆栈 ,这是安全里一个绝对不能被忽略的点。






