全局异常处理:业务异常与系统异常如何一招收割

2026-09-20 90 浏览 0 评论 0 字

你负责的后端,最怕的是什么?不是某个接口写错,而是 某个接口崩了,前端拿到一把堆栈,用户看到一屏看不懂的英文。这篇聊的是全局异常处理 - 怎么把项目里所有会抛出的异常,分门别类地截住、转成用户看得懂的统一错误响应,并且安全边界守着别把内部细节给前端。重点是 分清楚两种异常 生产环境怎么藏细节 日志怎么留全 。

一、先分清楚:你的异常其实有两种

写多之后你会发现,大多数后端异常能粗暴分成两类,它们的 处理态度 完全相反:

类型是什么例子该不该给用户看到
业务异常意料之中的、可以预见的 不符合条件参数缺失、记录不存在、没有权限、余额不足应该!要给出用户能看懂的中文提示
系统异常意料之外的运行错误、bug数据库连接断了、空指针、类型错误、第三方超时不应该给明文堆栈,但要在日志里留全

它们的分界线,就是 是否预先可预期 。 业务异常是 合理的原因失败 ,系统异常是 我们代码/依赖出问题了 。能不能把这两个分开,决定了一个错误处理机制的 下限 。

二、两种异常要怎么表达

比较标准的做法是:给业务异常做一个专门的类,带上 给用户看的信息 / 状态码 / 期望的 HTTP 状态 ,系统异常则统统是普通 Error。

// 一个简单的业务异常类
class BizError extends Error {
  constructor(message = '操作失败', code = 400, httpCode = 200) {
    super(message);
    this.name = 'BizError';
    this.code = code;       // 业务码(放 body 里的)
    this.httpCode = httpCode; // 期望的 HTTP 状态,默认 200(码都在 body)
  }
}

关键在设计上让 业务异常 好认 - 有没有 user-friendly 的中文 message、携带的 code 。而系统异常不自定义,就是原生 Error (或框架抛的)。

请求进来,业务里只要 throw new CustomError('该记录不存在') 就够了,不用再关心响应长啥样。

三、全局异常中间件:唯一的 出口

不管业务还是系统异常,最终都要在同一个地方被截住,统一转成统一 JSON。这个 出口 就是全局异常中间件。它的职责是:

  1. 包住整条链路;
  2. 抓到异常就判断类型;
  3. 业务异常 → 给统一错误 JSON,带上用户提示;
  4. 系统异常 → 记日志(完整堆栈),给前端一个不暴露内部的 通用错误 响应。

伪代码:

module.exports = () => async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err instanceof BusinessError) {
      //  -  业务异常  - 
      ctx.status = err.httpCode || 200;
      ctx.body = {
        code: err.code || 400,
        message: err.message,   // 这是想给用户看的
        success: false,
        data: '',
      };
      return;
    }

    //  -  系统异常  - 
    ctx.logger.error('[uncaught]', err);   // 完整堆栈进日志
    if (ctx.app.config.env === 'prod') {
      // 生产:藏细节,只给笼统提示
      ctx.status = 500;
      ctx.body = { code: 500, message: '服务器开小差了,请稍后再试', success: false, data: '' };
    } else {
      // 开发:反而给一点线索(但不建议,仍应看日志)
      ctx.status = 500;
      ctx.body = { code: 500, message: err.message || '服务器错误', success: false, data: '' };
    }
  }
};

这段代码里藏着两个安全要点,值得深刻读:

  1. instanceof BusinessError 判断 :这是把业务异常和系统异常分开的唯一依据,所以前面的异常类是不是 能被认出来 就无比重要 - 别用普通的 new Error 到处 throw。
  2. env === 'prod' 时隐藏内部错误信息 :这是安全底线。生产和开发返回给前端的信息完全不一样。真正给内部开发者看的是日志里的完整堆栈,而不是面向用户的 HTTP 响应。

四、生产环境为什么要 藏着掖着

很多人不理解 为什么不直接把错误堆栈给前端 ?

  • 堆栈里会泄露 内部路径、数据库库名、代码结构、依赖版本 ,全是攻击者爱收集的素材。
  • 用户根本看不懂一堆英文堆栈,给他看只会徒增客服量、竭尽体验。
  • 内部排查根本不靠 让前端打印堆栈 ,靠的是 服务端日志 - 所以该做的是写日志,不是把堆栈推给前端。

一句话: 面向用户的响应要 友好且少泄露 ,面向开发者的详细 Stack 请写进日志。 这两个出口一旦对错(把堆栈甩给前端、日志却几乎不落),就是安全事故的雏形。

五、中间件顺序:要包在最外层

全局异常中间件的原理是 包住后续」,所以它在中间件链里的位置很重要 - 尽量靠前注册,让它把后面的链路都包进去 。

想象一下错误发生在鉴权中间件里(token 过期),如果异常中间件挂得比它晚,异常就只被 下一层 的 try/catch 处理,而没进到装出统一 JSON 的那层。

所以,一个典型的中间件顺序:

请求进来
   └─ 全局异常处理 (第 1 个/最外层,负责包住一切)
        └─ 鉴权中间件 (token 校验)
             └─ 日志/其他
                └─ Controller/Service/Model

顺序错,统一格式就 漏 了:某些中间件自己手写 ctx.status=401; ctx.body=... ,格式跟全局不统一。要想 真统一 ,就得让异常中间件当 outer/inner,把一层层全包住。

六、日志:异常处理的另一半

异常处理不只是 响应给前端的那部分 ,还欠 写日志的这一半 。系统异常至少记:

  • 完整 err.stack (必须);
  • 出事的接口路径、方法、query/body(脱敏后);
  • 当前操作人(若有);
  • 发生时间。

在日志框架里,通常 logger.error(context, err) 一次搞定。注意 别把敏感字段(密码、token)打全 ,可以先脱敏。

七、 吞异常 是最需要警惕的反面模式

新手最常见的坑不是 没处理 ,而是 过度吞异常 :

try {
  await insertUser(data);
} catch (e) {
  // 什么都没做 😱
}

这种 抓了又不处理,也不日志 的模式,会把所有隐藏在深处的系统问题悄悄杀于无形 - 上下两行代码看着没问题,可数据没写进去、用户没注册上,你却浑然不知。 异常可以 不宕机 ,但不能 不留痕 。 就算你决定 这里失败了也不崩 ,也要至少留下一行日志,让问题可被找到。

八、常见问题:业务异常怎么区分参数校验失败与权限不足

常常会遇到 要同时支持参数错误、无权限、记录不存在 的场景。用一个码就能区分:

throw new BusinessError('缺少必要参数', 400);      // 参数
throw new BusinessError('无访问权限', 403);        // 权限
throw new BusinessError('记录不存在', 404);        // 不存在

code 语义约定下来,前端就能针对不同码做不同事(401 就跳登录、404 就给提示……)。这比 全部给个 400 要友好得多。

九、检查清单

全局异常处理不复杂,但对齐这几点基本安全落地:

  • [ ] 做了一个能被 instanceof 认出的业务异常类;
  • [ ] 全局异常中间件是 最外层 ,包住所有链路;
  • [ ] 业务异常 → 转统一错误 JSON,带为用户准备的提示;
  • [ ] 系统异常 → 完整堆栈进日志;生产响应不泄露内部细节;
  • [ ] 任何 try/catch 都不允许 空吞 - 至少留一行日志;
  • [ ] 403/404/400 等业务码与前端约定好,前端按码差异化处理。

到这里,你把 统一响应格式 和 全局异常 两手都握住了。下一篇我们离开异常,去看数据库的迁移 - 改结构这件让很多后端 心跳加速 的事,怎么通过迁移做得安全、可回滚、不心虚。


发布评论

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

评论列表 0

暂无评论