统一 API 响应格式:让所有接口长成一个样子

2026-09-18 102 浏览 0 评论 0 字

后端到前端,绝大多数团队最后靠的是一纸 接口返回 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,
  };
}

它完全可行,而且很多小团队就是这么干的。 它的缺点也很明显:

  1. 重复 :每个接口都要写那几行 code/message/success ,手累且容易漏字段。
  2. 不一致 :有人返回 success: true ,有人忘了写 total ,有人把 message 写成 msg ,没有强制约束,约定形同虚设。
  3. 失败路径难收口 :成功还好,一旦出错,很多人直接 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: '' };
    }
  }
};

这样做的两个好处戏剧性地放大:

  1. 失败格式保证统一 - 因为只有这一个地方负责拼错误 JSON,前端 100% 能信任结构。
  2. 成功路径保持干净 - 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 就是前端的噩梦。

八、静态如何保证 真统一

统一的格式若不落地成约束,人总会写出例外。三个常用的 把关人 :

  1. 约定文档 : api/协议.md 一篇,写明成功/失败/列表/401 各长什么样。
  2. 规范示例 :让别人 clone 一个示例 controller,照着抄。
  3. 接口返回类型(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);
  • 让 成功 成为一个赋值、让 失败 走一个异常中间件收敛;
  • 前端在请求层统一剥壳,业务层不关心壳;
  • 用类型、文档、跨前示例把约定固定下来。

下一篇文章,我会单独重点讲 全局异常处理 这一截 - 尤其是 生产环境怎么藏起详细报错、怎么在日志里留下完整的堆栈 ,这是安全里一个绝对不能被忽略的点。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论