教程
⚙️

后端开发

Node.js、Python、Go 等服务端开发与 API 设计。

API 统一响应与错误处理:让错误也能"被读懂"

从"为什么要有统一响应"讲起,设计一致的成功/错误结构、业务错误码、分页响应与校验失败提示,让前后端联调不再靠猜。

· 更新于 2026-07-20 阅读量 --

你有没有遇到过这种情况:调一个接口,成功时返回 {data: {...}},失败时又变成 {error: "xxx"},另一个接口失败时却是 {code: 500, message: "..."}——每个接口长得都不一样,前端同学每接一个接口都要重新看文档、写不同的解析分支,联调全靠”试”。

专业的 API 有一个隐形门槛:无论成功还是失败,返回结构都要一致、可预测。这一篇我们设计一套”统一响应 + 错误处理”规范,让你写的接口从”能跑”跨入”好用”。

为什么要统一响应

三个理由足够说服你:

  • 前端好写:一套解析逻辑通吃所有接口,不用每个接口 special case。
  • 错误好定位:统一的错误码和消息格式,监控、日志、前端提示都能标准化。
  • 演进好扩展:以后要加 request_idtrace_id 做链路追踪,套一层对象就能加,不用改每个接口。

核心思想:所有响应都包一层信封(envelope),里面放 codemessagedata 等固定字段。

成功响应:用 code 表达业务状态

常见两种流派,先说结论——推荐用 HTTP 状态码做”大分类”,用业务 code 做”细分”。最干净的结构:

{
  "code": 0,
  "message": "success",
  "data": { "id": 123, "name": "小明" }
}

约定:code0 表示业务成功(或干脆用 HTTP 200 表示成功,body 里不写 code);非 0 表示各类业务异常。但更 REST 派的做法是:成功就老老实实返回 2xx + data,错误才返回 4xx/5xx + error 结构。两种都行,关键是全站统一。

我们采用”HTTP 状态码为主、body 给细节”的规范:

// 成功:200 OK
{
  "data": { "id": 123, "name": "小明" },
  "request_id": "a1b2c3"
}

// 列表:200 OK
{
  "data": [ { "id": 1 }, { "id": 2 } ],
  "page": 1,
  "size": 20,
  "total": 135,
  "request_id": "a1b2c3"
}

request_id 是每次请求生成的唯一 ID,出问题时前端把它报给后端,后端拿去日志里一搜就定位到这次请求的全链路——这是生产环境排障的利器。

错误响应:结构固定、信息分层

错误时,body 返回统一结构,配合合适的 HTTP 状态码(别忘了上一篇讲的 4xx/5xx):

// 400 Bad Request
{
  "error": {
    "code": "INVALID_PARAM",
    "message": "name 不能为空",
    "details": { "field": "name", "rule": "required" }
  },
  "request_id": "x9y8z7"
}

字段含义:

  • error.code机器可读的错误码(字符串或数字),前端可据此做分支(如 TOKEN_EXPIRED 触发重新登录)。比”看 message 字符串”可靠。
  • error.message人类可读的中文/英文提示,直接展示给用户或写进日志。
  • error.details可选的结构化细节,如具体哪个字段错了、违反了什么规则,方便前端高亮输入框。
  • request_id:排障用。

业务错误码怎么设计

如果只用 HTTP 状态码,信息量不够(比如”参数错”可能是 name 错、也可能是 age 错)。所以引入业务错误码分层:

  • 按模块前缀USER_xxxORDER_xxxAUTH_xxx,一眼看出是哪个域。
  • 按性质分类INVALID_PARAM(参数错)、NOT_FOUND(不存在)、FORBIDDEN(无权限)、CONFLICT(冲突)、RATE_LIMITED(限流)。
  • 可枚举:把错误码集中定义成常量/枚举,别在代码里散落字符串,避免拼写不一致。

示例枚举:

AUTH_TOKEN_EXPIRED   // token 过期
AUTH_TOKEN_INVALID   // token 非法
USER_NOT_FOUND       // 用户不存在
USER_NAME_DUPLICATE  // 用户名重复
ORDER_STATUS_INVALID // 订单状态不合法

前端拿到 AUTH_TOKEN_EXPIRED 就知道该跳登录页;拿到 USER_NAME_DUPLICATE 就知道该提示”用户名已被占用”。错误码是前后端之间的”精确语言”。

参数校验失败:批量返回

前端表单提交,可能同时多个字段填错。别只返回一个错就打发,最好一次性把所有错误都返回,提升体验:

// 422 Unprocessable Entity
{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "有 2 个字段校验未通过",
    "details": [
      { "field": "name", "message": "不能为空" },
      { "field": "age", "message": "必须是不小于 0 的整数" }
    ]
  }
}

后端用校验框架(如 Go 的 validator、Java 的 hibernate-validator)能自动收集所有错误,不用手写一堆 if

分页响应:列表的统一形状

前面提过,列表别裸返回数组。统一成:

{
  "data": [ ... ],
  "page": 1,
  "size": 20,
  "total": 135,
  "total_pages": 7
}

total_pages 让前端直接算页码。若数据量大,还可返回 has_more 布尔配合”无限滚动”场景。关键是:所有列表接口长一个样,前端封装一个 fetchList 函数就能复用。

常见新手坑

  • 成功失败结构不一致:成功 data、失败 error,但字段名乱跳,前端被迫写兼容。统一成”成功 data / 失败 error”的固定形状。
  • 错误信息暴露内部细节:把 SQLException: table user not exist 直接返给前端,既难看又泄密。对外给友好提示,内部细节写日志。
  • 用 message 做分支判断:前端靠 if (msg.includes("过期")) 判断,一旦文案改了就崩。用 code 做机器判断,message 只给人看。
  • 忘了 request_id:出生产问题没法定位是哪次请求,排查靠猜。每次请求生成一个,随响应返回、随日志记录。
  • 500 时返回空 body:前端只看到”失败”却不知为何。500 也要返回统一 error 结构(哪怕 message 是”服务异常,请稍后重试”)。
  • 校验只做前端:前端 required 挡不住 curl,后端必须再校验一遍,永远假设输入不可信。

实战:一个统一的响应封装函数

以 Go 伪代码示意”成功/失败”怎么收口:

// 成功
func ok(w http.ResponseWriter, data any) {
    writeJSON(w, 200, map[string]any{
        "data":       data,
        "request_id": getRequestID(w),
    })
}

// 失败
func fail(w http.ResponseWriter, status int, code, msg string, details any) {
    writeJSON(w, status, map[string]any{
        "error": map[string]any{
            "code":    code,
            "message": msg,
            "details": details,
        },
        "request_id": getRequestID(w),
    })
}

// 使用
func createUser(w http.ResponseWriter, r *http.Request) {
    var u User
    if err := decode(r, &u); err != nil {
        fail(w, 400, "INVALID_PARAM", "参数解析失败", nil)
        return
    }
    if u.Name == "" {
        fail(w, 422, "VALIDATION_FAILED", "name 不能为空",
            []any{map[string]string{"field": "name", "message": "不能为空"}})
        return
    }
    ok(w, u)
}

所有接口都走 ok/fail,返回形状天然统一。这就是”收口”的价值——规范不是写在文档里,而是写进代码骨架里,谁都绕不开。

新手怎么把响应规范落地

统一响应不是”知道就行”,而是要写进项目的基础设施。建议:每个新项目第一件事,就是把”成功/失败响应函数""错误码枚举""参数校验中间件”搭好,再开始写业务接口。这样后面几十个接口自动遵守规范,而不是靠每个人自觉。

另一个心法:错误信息分两层——给用户的和给开发的。返回给前端的 message 要友好、不泄密;真正的堆栈、SQL、内部状态写进服务端日志(带上 request_id)。这两层分开,既安全又好用。

还有,把”错误码文档”当成 API 文档的一部分维护。前端联调最怕”这个 code 是什么意思”。一份清晰的 错误码 → 含义 → 前端该怎么做 的对照表,能省下无数微信扯皮。规范的价值,往往在团队协作放大后才显现。

小测验:看看你掌握了没

  • 问题一:为什么错误要用 code 而不是 message 做前端分支判断?答案:message 是给人看的、易变;code 是机器可读的、稳定,文案改了分支也不崩。
  • 问题二:request_id 有什么用?答案:唯一标识一次请求,前端报障时提供它,后端据此在日志里定位全链路。
  • 问题三:参数校验为什么前后端都要做?答案:前端校验提升体验,但拦不住直接打接口的攻击者,后端必须再校验,永远假设输入不可信。

这一篇你该记住的

  • 所有响应包一层”信封”,成功 data / 失败 error,结构全站一致。
  • 用 HTTP 状态码做大分类(2xx/4xx/5xx),用业务 code 做细分。
  • 错误结构固定:code(机器判断)、message(给人看)、details(字段级)、request_id(排障)。
  • 业务错误码按模块+性质枚举,集中定义,别散落字符串。
  • 校验失败可批量返回字段级错误;分页响应统一 data/page/size/total
  • 对外给友好提示,内部细节写日志;错误信息分两层。

下一篇我们讲 API 文档:用 OpenAPI/Swagger 让接口”自己会说话”,前端、测试、调用方都能自助对接。