5月27日 18:30

Koa 错误处理怎么写?从洋葱模型到完整方案

Koa 的错误处理和其他框架有什么不同?

Koa 的错误处理设计跟 Express 有本质区别。Express 用中间件参数签名来区分普通中间件和错误处理中间件——四个参数 (err, req, res, next) 才是错误处理中间件。Koa 走了另一条路:它借助 async/await,让 try-catch 自然地包裹整个下游中间件链,配合洋葱模型实现错误冒泡。这意味着你只需要在洋葱模型的最外层放一个 try-catch,就能捕获所有内层抛出的错误。

理解这一点,是写好 Koa 错误处理的前提。

Koa 错误传播的原理是什么?

Koa 的洋葱模型中,每个中间件都有机会在 await next() 之后执行逻辑。如果某个内层中间件抛出错误,这个错误会沿着调用栈向上冒泡,直到被某一层的 try-catch 捕获,或者到达框架顶层。

关键细节:Koa 框架顶层有兜底逻辑。如果一个错误始终没被任何中间件捕获,Koa 会尝试返回 500,并触发 app.on('error') 事件。但如果响应头已经发送(ctx.headerSent 为 true),Koa 无法再修改状态码和响应体,只能把错误抛给 Node.js 的 unhandledRejection。这是实际开发中容易踩的坑——在流式响应场景中尤其要注意。

如何用 ctx.throw 抛出标准 HTTP 错误?

ctx.throw 是 Koa 提供的快捷方法,用于抛出带 HTTP 状态码的错误:

javascript
app.use(async (ctx) => { if (!ctx.query.token) { ctx.throw(401, 'Token is required'); } ctx.body = 'Success'; });

ctx.throw 的第一个参数是状态码,第二个参数是错误消息。它内部会创建一个 HttpError 对象并抛出,这个对象携带 statusmessage 等属性,方便外层中间件统一处理。

需要注意的是,ctx.throw 只支持 HTTP 标准状态码对应的错误。如果你需要携带自定义的业务错误码(比如 INVALID_PARAM),应该用自定义错误类代替 ctx.throw

怎么写错误处理中间件?

错误处理中间件必须放在所有业务中间件之前,也就是洋葱模型的最外层。只有这样,内层所有中间件的错误才能被捕获:

javascript
async function errorHandler(ctx, next) { try { await next(); } catch (err) { ctx.status = err.status || 500; if (ctx.app.env === 'development') { ctx.body = { error: err.message, stack: err.stack, code: err.code }; } else { ctx.body = { error: 'Internal Server Error', code: 'INTERNAL_ERROR' }; } ctx.app.emit('error', err, ctx); } } app.use(errorHandler);

这段代码做了三件事:设置状态码、构建响应体、触发错误事件。开发环境返回堆栈信息方便调试,生产环境隐藏细节防止信息泄露。ctx.app.emit('error', err, ctx) 把错误转发给全局监听器,用于日志记录和监控上报。

常见误区:有人把错误处理中间件放在路由中间件之后,这样它就无法捕获路由中抛出的错误——因为洋葱模型中,后注册的中间件在内层,内层的 try-catch 捕获不到外层已经抛出的错误。

如何设计自定义错误类?

ctx.throw 只能抛出 HTTP 标准错误,实际项目中往往需要更丰富的错误信息。自定义错误类可以携带业务错误码、错误详情等字段:

javascript
class AppError extends Error { constructor(status, message, code) { super(message); this.status = status; this.code = code; this.name = 'AppError'; } } class NotFoundError extends AppError { constructor(message = 'Resource not found') { super(404, message, 'NOT_FOUND'); this.name = 'NotFoundError'; } } class ValidationError extends AppError { constructor(message = 'Validation failed') { super(400, message, 'VALIDATION_ERROR'); this.name = 'ValidationError'; } } class AuthError extends AppError { constructor(message = 'Authentication required') { super(401, message, 'AUTH_ERROR'); this.name = 'AuthError'; } }

使用时直接抛出,错误处理中间件会自动识别 statuscode

javascript
app.use(async (ctx) => { const user = await findUser(ctx.params.id); if (!user) { throw new NotFoundError('User not found'); } if (!user.isActive) { throw new AuthError('User account is deactivated'); } ctx.body = user; });

设计自定义错误类时,建议让所有业务错误继承同一个基类 AppError,这样错误处理中间件可以通过 instanceof 判断错误类型,做差异化处理。

全局错误事件怎么用?

app.on('error') 是 Koa 的全局错误事件监听器。所有未被中间件完全处理的错误,以及中间件中手动 ctx.app.emit('error', err, ctx) 触发的错误,都会到达这里:

javascript
app.on('error', (err, ctx) => { console.error(`[${new Date().toISOString()}] ${ctx.method} ${ctx.url}`); console.error(`Status: ${err.status || 500}, Code: ${err.code || 'UNKNOWN'}`); console.error(`Message: ${err.message}`); // 上报监控系统 monitoringService.report(err, ctx); // 严重错误发送告警 if (err.status >= 500) { alertService.send(err, ctx); } });

全局错误事件的职责是日志记录、监控上报、告警通知。不要在这里修改 ctx 的响应——因为到了这一步,响应可能已经发出去了。响应格式化是错误处理中间件的事,全局监听只管记录。

还有一个容易忽略的点:如果错误处理中间件捕获了错误并正常响应了客户端,但没有调用 ctx.app.emit('error'),这个错误就不会到达全局监听器。这意味着你需要做一个选择——哪些错误需要全局记录。通常建议:所有 500 及以上的错误都应该 emit 到全局,4xx 的客户端错误可以视情况决定。

404 怎么处理?

Koa 不会自动返回 404。如果一个请求没有匹配到任何路由,也没有任何中间件设置响应体,Koa 默认返回 404 状态码和 Not Found 纯文本。但在实际项目中,你通常需要返回统一格式的 JSON 响应:

javascript
// 放在所有路由之后 app.use(async (ctx) => { ctx.status = 404; ctx.body = { error: 'Not Found', code: 'NOT_FOUND', path: ctx.url }; });

这个中间件的原理是:如果前面的路由中间件已经处理了请求(设置了 ctx.body),Koa 不会再执行后续中间件。只有请求穿透了所有路由,才会落到这个兜底中间件。

更优雅的做法是判断 ctx.status === 404 && !ctx.body,避免覆盖其他中间件故意设置的 404 响应。

异步错误在 Koa 中怎么处理?

Koa 基于 async/await,能自动捕获 async 函数中抛出的同步错误。但有些场景需要额外注意:

javascript
// 直接 await — 错误会正常冒泡 app.use(async (ctx) => { const data = await fetchData(); ctx.body = data; }); // 未 await 的 Promise — 错误不会被捕获 app.use(async (ctx) => { fetchData().then(data => { // 危险!如果 fetchData reject,错误不会冒泡 ctx.body = data; }); });

第二条规则:永远不要在 Koa 中间件里写 .then() 而不 await。未 await 的 Promise 如果 reject,错误会被吞掉,不会冒泡到错误处理中间件,也不会触发全局错误事件。这是 Node.js 中 unhandledRejection 的常见来源。

对于第三方回调风格的异步操作,用 Promise 包装后再 await:

javascript
const { promisify } = require('util'); const readFile = promisify(fs.readFile); app.use(async (ctx) => { try { const content = await readFile(ctx.query.path, 'utf8'); ctx.body = content; } catch (err) { if (err.code === 'ENOENT') { throw new NotFoundError('File not found'); } throw err; } });

数据库和第三方服务的错误怎么统一处理?

数据库驱动抛出的错误通常有特定的错误码,需要转换成 HTTP 友好的格式。在错误处理中间件中针对不同错误类型做转换:

javascript
app.use(async (ctx, next) => { try { await next(); } catch (err) { // PostgreSQL 唯一约束冲突 if (err.code === '23505') { ctx.throw(409, 'Resource already exists'); } // PostgreSQL 外键约束冲突 if (err.code === '23503') { ctx.throw(400, 'Invalid reference'); } // MongoDB 重复键 if (err.code === 11000) { ctx.throw(409, 'Duplicate key error'); } // JWT 过期 if (err.name === 'TokenExpiredError') { ctx.throw(401, 'Token expired'); } // 请求超时 if (err.code === 'ECONNABORTED' || err.code === 'ETIMEDOUT') { ctx.throw(504, 'Request timeout'); } throw err; } });

这种做法把底层错误码翻译成 HTTP 语义,对客户端更友好。但要注意,这些转换逻辑不应该无限膨胀——如果某个数据库的错误码特别多,应该封装成专门的错误转换函数。

一个完整的错误处理方案长什么样?

把上面的各个部分组合起来,得到一个可用的完整方案:

javascript
const Koa = require('koa'); const app = new Koa(); // 自定义错误类 class AppError extends Error { constructor(status, message, code) { super(message); this.status = status; this.code = code; this.name = 'AppError'; } } class NotFoundError extends AppError { constructor(message = 'Resource not found') { super(404, message, 'NOT_FOUND'); } } class ValidationError extends AppError { constructor(message = 'Validation failed') { super(400, message, 'VALIDATION_ERROR'); } } // 错误处理中间件 — 放在最前面 app.use(async (ctx, next) => { try { await next(); // 兜底 404 if (ctx.status === 404 && !ctx.body) { ctx.body = { error: 'Not Found', code: 'NOT_FOUND', path: ctx.url }; } } catch (err) { ctx.status = err.status || 500; const response = { error: err.message, code: err.code || 'INTERNAL_ERROR', timestamp: new Date().toISOString() }; if (app.env === 'development') { response.stack = err.stack; } ctx.body = response; ctx.app.emit('error', err, ctx); } }); // 全局错误事件 app.on('error', (err, ctx) => { console.error(`[${new Date().toISOString()}] ${ctx.method} ${ctx.url} - ${err.status || 500}`); if (err.status >= 500) { monitoringService.report(err, ctx); } }); // 业务路由 app.use(async (ctx) => { if (ctx.path === '/users/:id') { const user = await findUser(ctx.params.id); if (!user) throw new NotFoundError('User not found'); ctx.body = user; } ctx.body = { message: 'OK' }; }); app.listen(3000);

这套方案覆盖了自定义错误类、错误处理中间件、全局事件监听、404 兜底、开发/生产环境差异化响应。把它作为项目模板,根据实际需求增减即可。

写 Koa 错误处理,核心就是三件事:把错误处理中间件放在最前面,用自定义错误类统一错误格式,在全局事件中做好日志和监控。搞清洋葱模型中错误的传播方向,其他问题都好解决。

标签:Koa