4. 设计统一响应格式与错误码封装

设计统一响应格式与错误码封装 学习卡片

为什么需要统一响应格式

在 Express 项目中,如果每个控制器都手写 res.json({...}),会出现三个问题:字段名不统一(有的用 msg,有的用 message)、HTTP 状态码与业务状态码混用、错误处理散落在各处。本节的目标是建立一套可复用的响应契约:所有接口都返回相同的顶层结构,业务错误通过错误码枚举集中管理。

先约定响应体结构。成功与失败共用同一外壳,用 code 区分业务结果,data 承载成功数据,message 承载人类可读信息:

// 成功
{ "code": 0, "message": "ok", "data": { ... } }
// 失败
{ "code": 1001, "message": "参数校验失败", "data": null }
约定:code === 0 表示业务成功;非 0 为业务错误码。HTTP 状态码仍按语义返回(如 400、404、500),业务错误码用于前端做精确分支判断,两者职责分离。

定义业务错误码枚举

错误码集中在一个模块中定义,避免魔法数字散落。每个错误码绑定默认 HTTP 状态码与默认提示语,便于统一抛出:

// errors.js
const ErrorCode = {
  SUCCESS: 0,
  PARAM_INVALID: 1001,      // 参数校验失败
  UNAUTHORIZED: 1002,       // 未登录或凭证失效
  FORBIDDEN: 1003,          // 无权限
  RESOURCE_NOT_FOUND: 1004, // 资源不存在
  CONFLICT: 1005,           // 资源冲突
  INTERNAL_ERROR: 5000,     // 服务器内部错误
};

// 每个错误码对应的 HTTP 状态码与默认消息
const ErrorMeta = {
  [ErrorCode.PARAM_INVALID]:      { http: 400, message: '参数校验失败' },
  [ErrorCode.UNAUTHORIZED]:       { http: 401, message: '未登录或凭证失效' },
  [ErrorCode.FORBIDDEN]:          { http: 403, message: '无权限访问' },
  [ErrorCode.RESOURCE_NOT_FOUND]: { http: 404, message: '资源不存在' },
  [ErrorCode.CONFLICT]:           { http: 409, message: '资源冲突' },
  [ErrorCode.INTERNAL_ERROR]:     { http: 500, message: '服务器内部错误' },
};

module.exports = { ErrorCode, ErrorMeta };

封装响应工具函数

提供 success 与 fail 两个函数,控制器只调用它们,不再直接拼装 JSON。同时定义一个 BizError 类,让业务代码可以用 throw 表达失败,由统一错误中间件兜底:

// response.js
const { ErrorCode, ErrorMeta } = require('./errors');

function success(res, data = null, message = 'ok') {
  return res.status(200).json({ code: ErrorCode.SUCCESS, message, data });
}

function fail(res, code = ErrorCode.INTERNAL_ERROR, message) {
  const meta = ErrorMeta[code] || ErrorMeta[ErrorCode.INTERNAL_ERROR];
  return res.status(meta.http).json({
    code,
    message: message || meta.message,
    data: null,
  });
}

// 业务异常:控制器中 throw new BizError(ErrorCode.PARAM_INVALID, '手机号格式错误')
class BizError extends Error {
  constructor(code, message) {
    super(message);
    this.code = code;
  }
}

module.exports = { success, fail, BizError };

在控制器中使用

控制器只关心业务逻辑,成功调用 success,失败直接 throw new BizError,不再出现 res.status(...).json(...) 的重复拼装:

// userController.js
const { success, BizError } = require('./response');
const { ErrorCode } = require('./errors');

const users = [{ id: 1, name: 'Alice' }];

exports.getUser = (req, res) => {
  const id = Number(req.params.id);
  if (!Number.isInteger(id) || id <= 0) {
    throw new BizError(ErrorCode.PARAM_INVALID, 'id 必须为正整数');
  }
  const user = users.find((u) => u.id === id);
  if (!user) {
    throw new BizError(ErrorCode.RESOURCE_NOT_FOUND, `用户 ${id} 不存在`);
  }
  return success(res, user);
};

统一错误处理中间件

Express 的错误中间件必须放在所有路由之后,且参数为四个。它负责把 BizError 与未知异常都转成统一响应:

// app.js(片段)
const express = require('express');
const { fail, BizError } = require('./response');
const { ErrorCode } = require('./errors');
const userController = require('./userController');

const app = express();
app.use(express.json());

app.get('/users/:id', userController.getUser);

// 404 兜底
app.use((req, res) => fail(res, ErrorCode.RESOURCE_NOT_FOUND, `路径 ${req.path} 不存在`));

// 统一错误处理(四参数)
app.use((err, req, res, next) => {
  if (err instanceof BizError) {
    return fail(res, err.code, err.message);
  }
  console.error(err);
  return fail(res, ErrorCode.INTERNAL_ERROR);
});

app.listen(3000);
错误中间件必须注册在路由之后,否则不会捕获前面路由抛出的异常。Express 5 会自动捕获 async 函数中 reject 的 Promise;Express 4 中 async 控制器需手动 try/catch 或使用包装函数,否则异常不会进入错误中间件。

响应流转关系

下图展示一次请求从进入控制器到最终响应的两条路径:成功路径直接经 success 返回;失败路径通过 throw BizError 冒泡到统一错误中间件,再由 fail 输出。两条路径最终都落到同一响应结构上。

HTTP 请求 GET /users/:id 控制器 校验参数 查询业务数据 判断成功 / 失败 success(res, data) code = 0 throw BizError code / message 错误中间件 fail(res, code) 统一响应结构 { code, message, data } HTTP 状态码按语义 成功 失败 ErrorCode 枚举 1001 参数校验失败 1004 资源不存在 5000 内部错误

验证方式

启动服务后,用以下请求验证两条路径是否都返回统一结构:

# 成功:GET /users/1
curl -i http://localhost:3000/users/1
# 预期 HTTP 200,body: {"code":0,"message":"ok","data":{"id":1,"name":"Alice"}}

# 参数错误:GET /users/abc
curl -i http://localhost:3000/users/abc
# 预期 HTTP 400,body: {"code":1001,"message":"id 必须为正整数","data":null}

# 资源不存在:GET /users/99
curl -i http://localhost:3000/users/99
# 预期 HTTP 404,body: {"code":1004,"message":"用户 99 不存在","data":null}

# 路径不存在:GET /nope
curl -i http://localhost:3000/nope
# 预期 HTTP 404,body: {"code":1004,"message":"路径 /nope 不存在","data":null}
判断标准:所有响应体的顶层键必须恰好是 code、message、data 三个;成功时 code 恒为 0;失败时 data 恒为 null;HTTP 状态码与 ErrorMeta 中定义的映射一致。