Koa 洋葱模型的执行机制是怎样的?有哪些实际应用场景
Koa 洋葱模型到底是什么
先看一个现象:在 Koa 里写三个中间件,控制台打印的顺序是 1-前置 → 2-前置 → 3-核心处理 → 2-后置 → 1-后置。请求像穿透洋葱一样从外层进到最里层,再从里层一层层返回——这就是"洋葱模型"的名字由来。
这个机制不是 Koa 凭空发明的,它借鉴了 koa-compose 的函数组合思想,核心就一句话:每个中间件拿到 next 函数,调用它就进入下一层,await 它返回后就执行后置逻辑。
执行流程拆解
用一个最小可运行的例子说明:
javascriptconst Koa = require('koa'); const app = new Koa(); app.use(async (ctx, next) => { console.log('1-前置'); await next(); console.log('1-后置'); }); app.use(async (ctx, next) => { console.log('2-前置'); await next(); console.log('2-后置'); }); app.use(async (ctx) => { console.log('3-核心处理'); ctx.body = 'Hello Koa'; }); app.listen(3000);
请求进来后,执行路径是这样的:
- 进入第一个中间件,执行
console.log('1-前置') - 遇到
await next(),暂停当前中间件,进入第二个中间件 - 执行
console.log('2-前置'),再遇到await next(),进入第三个中间件 - 第三个中间件没有调用 next,设置 ctx.body 后返回
- 回到第二个中间件,执行
await next()之后的console.log('2-后置') - 回到第一个中间件,执行
console.log('1-后置')
关键点在于 await next() 这一行。它不是简单的函数调用,而是一个 Promise——下一个中间件(以及它后续的所有中间件)全部执行完毕后,这个 Promise 才 resolve。所以 await 之后的代码天然就在所有下游中间件之后执行。
compose 函数怎么实现的
洋葱模型的本质是 koa-compose,核心代码不到 30 行:
javascriptfunction compose(middleware) { return function (context, next) { let index = -1; function dispatch(i) { if (i <= index) return Promise.reject(new Error('next() called multiple times')); index = i; let fn = i === middleware.length ? next : middleware[i]; if (!fn) return Promise.resolve(); try { return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); } catch (err) { return Promise.reject(err); } } return dispatch(0); }; }
dispatch(i) 取出第 i 个中间件,把 dispatch(i+1) 作为 next 传进去。每个中间件内部 await next() 就是 await dispatch(i+1),递归调用下一层。当 i 等于 middleware.length 时,fn 为 next(外层传入的,通常为 undefined),递归终止。
有一个容易忽略的细节:index 变量用来检测 next() 是否被调用了多次。同一个中间件里调用两次 next() 会抛错,因为第二次调用时 i <= index 成立。这是有意为之——多次调用 next() 会导致下游中间件重复执行,产生不可预期的行为。
和 Express 中间件有什么区别
Express 的中间件是线性的:调用 next() 之后,控制权交给下一个中间件,不会再回来。Koa 的洋葱模型让控制权"去了又回",这是最根本的区别。
javascript// Express 风格 app.use((req, res, next) => { console.log('前置'); next(); // 交出控制权,不再回来 console.log('这行也会执行,但响应可能已经发出'); }); // Koa 风格 app.use(async (ctx, next) => { console.log('前置'); await next(); // 等下游全部完成,控制权回来 console.log('后置,此时可以修改响应'); });
这意味着在 Koa 里,后置逻辑可以可靠地操作响应——比如统一格式化返回值、记录响应日志、计算耗时。Express 里 next() 后面的代码虽然也能执行,但响应可能已经被下游发出了,再改就晚了。
另一个区别是错误处理。Express 需要在中间件链末尾放一个四个参数的错误处理中间件 (err, req, res, next) => {}。Koa 只需要在最外层 try-catch:
javascriptapp.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.status || 500; ctx.body = { error: err.message }; } });
因为洋葱模型保证了外层中间件的后置逻辑一定会执行,所以 try-catch 能捕获到任何内层抛出的异常。
实际项目中怎么用洋葱模型
请求耗时统计
javascriptapp.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; ctx.set('X-Response-Time', `${ms}ms`); });
前置逻辑记录开始时间,后置逻辑计算差值并写入响应头。这是洋葱模型最直观的用法——前置做初始化,后置做收尾。
统一错误处理
javascriptapp.use(async (ctx, next) => { try { await next(); } catch (err) { ctx.status = err.statusCode || 500; ctx.body = { code: ctx.status, message: err.message }; // 生产环境不暴露堆栈 if (process.env.NODE_ENV !== 'production') { ctx.body.stack = err.stack; } } });
放在最外层,任何内层抛出的异常都会被捕获。不需要在每个路由里单独 try-catch。
认证与权限控制
javascriptapp.use(async (ctx, next) => { const token = ctx.headers.authorization; if (!token) { ctx.throw(401, '未登录'); } try { ctx.state.user = jwt.verify(token.replace('Bearer ', ''), SECRET); } catch { ctx.throw(401, 'token 无效'); } await next(); });
如果认证失败,直接抛错不调用 next(),下游中间件不会执行。这是洋葱模型的另一个特性:中间件可以选择"截断"请求,不往下传。
响应格式统一
javascriptapp.use(async (ctx, next) => { await next(); if (ctx.body && !ctx.body.code) { ctx.body = { code: 0, data: ctx.body, message: 'success' }; } });
后置逻辑里检查 ctx.body,如果路由返回的是裸数据,就包装成统一格式。业务代码不需要关心响应结构。
使用洋葱模型容易踩的坑
忘记 await next()
javascriptapp.use(async (ctx, next) => { console.log('前置'); next(); // 忘记 await console.log('后置'); // 会立即执行,不等下游完成 });
next() 返回 Promise,不加 await 后置逻辑会立即执行,洋葱模型失效。更严重的是,如果下游中间件是异步操作(查数据库、调接口),后置逻辑执行时响应可能还没准备好。
中间件顺序搞反
洋葱模型里,先注册的中间件包裹在后注册的外面。所以日志和错误处理要放最前面,路由放最后面。顺序写反了,错误处理就捕获不到路由层的异常。
在后置逻辑里修改请求
有些开发者习惯在后置逻辑里继续操作 ctx.request,但此时请求已经处理完了,修改请求对象没有意义。后置逻辑应该只操作 ctx.response 或 ctx.body。
洋葱模型适用于哪些场景
不是所有场景都需要洋葱模型。如果你的应用只有简单的请求-响应,Express 的线性中间件更直观。洋葱模型的优势在于需要在请求前后都执行逻辑的场景:日志、计时、错误兜底、认证拦截、响应包装。中间件越多、前后置逻辑越复杂,洋葱模型的价值越大。
理解洋葱模型的关键不是记住执行顺序,而是理解 await next() 是一个分界线——之前的代码在请求进入时执行,之后的代码在响应返回时执行。把握住这一点,写中间件就不会出错。