洋葱芯的最后一站留给异常,因为它决定了这个框架"摔跤时疼不疼"。Koa 的 async 语义让错误有了机制性的出口:任何一层抛出的异常都会沿 Promise 链冒泡,一直冒到最外层。本节把这条路径走完整——异常怎么冒、在哪接、接住之后回什么、漏网之后谁兜底——最后给出一套可以直接抄进项目的错误层写法。
在内核中间件里抛个错,观察它去哪:
app.use(async (ctx, next) => { try { await next(); // 内层的一切异常都会在这一行抛出 } catch (err) { console.log('外层接住:', err.message); ctx.status = 500; ctx.body = { error: 'internal error' }; } }); app.use(async (ctx) => { throw new Error('boom'); // 模拟业务层炸了 });
请求之后控制台打印"外层接住:boom",客户端收到 500 加 JSON。这个过程没有经过任何特殊 API——async 函数 throw 出的 rejection 沿调用链上溯,await next() 那一行就是链条上的接线柱。理解这一点后,两件事变得显然:其一,兜底层必须放在最外层,放在内层接不到外面的错;其二,每层之间的 await 不能断,断了链,rejection 就在断口处变成 unhandled rejection,兜底层毫无察觉。
⚠️ 常见坑:在中间件里用
next().catch(() => {})或吞掉异常的空 catch"修好"报错。异常被吞后,请求可能挂着直到超时,监控系统一片安静,问题比报错难查得多。错误的正确归宿是被转换(转成规范响应),不是被消灭。
统一捕获的前提是把错误分成两类,它们的待遇应当完全不同。
业务异常是预期内的失败:参数不合法、余额不足、资源不存在、权限不够。它们有明确的语义、稳定的结构,应该有自己的类型,兜底层据此转成对应的 HTTP 状态码。
意外异常是预期外的失败:空指针、数据库连接断开、第三方接口超时后返回了脏数据。它们不该直接暴露给客户端(会泄漏内部细节),统一转成 500 加模糊提示,同时带请求 ID 记录完整堆栈供排查。
给业务异常立一个基类,是让"抛错"变得有纪律的最小投资:
// errors.js:业务异常基类与常用子类 class HttpError extends Error { constructor(status, code, message) { super(message); this.status = status; // 对应的 HTTP 状态码 this.code = code; // 业务错误码,供前端判断 this.expose = true; // 允许把 message 返回给客户端 } } class BadRequest extends HttpError { constructor(message) { super(400, 'BAD_REQUEST', message); } } class NotFound extends HttpError { constructor(message = '资源不存在') { super(404, 'NOT_FOUND', message); } } class Forbidden extends HttpError { constructor(message = '无权访问') { super(403, 'FORBIDDEN', message); } } module.exports = { HttpError, BadRequest, NotFound, Forbidden };
业务代码从此可以"抛得理直气壮":
router.get('/items/:id', async (ctx) => { const item = await repo.find(ctx.params.id); if (!item) throw new NotFound(`条目 ${ctx.params.id} 不存在`); if (!ctx.state.user.canRead(item)) throw new Forbidden(); ctx.body = item; });
把分类逻辑放进最外层,就是这个样子——建议通读一遍再抄,每一行都有讲究:
const { HttpError } = require('./errors'); app.use(async (ctx, next) => { try { await next(); } catch (err) { // 一类:业务异常——按语义回状态码与错误码 if (err instanceof HttpError) { ctx.status = err.status; ctx.body = { code: err.code, message: err.message }; return; } // 二类:意外异常——模糊响应,完整记录 ctx.status = 500; ctx.body = { code: 'INTERNAL', message: '服务开小差了,请稍后重试', requestId: ctx.state.reqId, // 前端报障时报这个号,后端按号查日志 }; ctx.app.emit('error', err, ctx); // 转交实例级监听做记录与告警 } });
三个值得注意的细节。第一,意外异常的响应体不带堆栈、不带 SQL、不带文件信息——错误详情只进日志不进响应,这是安全底线。第二,ctx.app.emit('error') 把意外异常转给 app.on('error') 监听器,那里通常接日志系统与告警(第 6.2 节展开)。第三,兜底层自己千万别再抛错——catch 块里再炸,异常就真的无路可走了,所以这段代码要写得保守,不带任何可能失败的花活。
能漏进 app.on('error') 的异常有两种来源:兜底层显式转交的意外异常,以及响应已发送后才发生的异常(此时无法再改响应,Koa 只能走事件通道)。它的职责只有记录与告警,不负责响应:
app.on('error', (err, ctx) => { logger.error({ type: 'unhandled', message: err.message, stack: err.stack, url: ctx && ctx.url, requestId: ctx && ctx.state.reqId, }); // 接告警:错误率突增时通知值班,第 8 章监控一节细化 });
三道防线至此闭合:业务代码抛类型化异常 → 兜底层转换响应 → app.on('error') 记录落网之鱼。任何一层都不越位。
拿这套标准去审任何 Koa 项目,九成能挑出问题。清单如下:
throw new Error('xxx') 用字符串区分;💡 关键直觉:错误处理的目标不是"不报错",而是"每个错误都有确定的归宿与确定的表现形态"。用户看到体面的提示,值班看到结构化的日志,前端拿到可判断的错误码——三方各取所需,才算处理完了。
洋葱芯解剖完毕。下一章进入卷层工坊——内核给了队列,真正的工程能力在于你怎么造层、怎么叠层,那才是 Koa 项目的日常。