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 输出。两条路径最终都落到同一响应结构上。
验证方式
启动服务后,用以下请求验证两条路径是否都返回统一结构:
# 成功: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 中定义的映射一致。