本章是全书的起点,而这一节是起点的起点——不搞清楚洋葱模型从哪来,后面每一层都会像背口诀。我们从一个深夜的故障复盘会说起,沿着 Connect、Express、generator、async 这条线走到 Koa 2 定型,最后回答一个问题:这段历史对今天写 Koa 的你有什么用。
某天凌晨两点,一个用原生 http 模块搭的老服务出了事故:下单接口偶发超时,日志里堆着三层嵌套回调里的异常栈,值班的工程师翻了半小时才定位到——某个回调里的错误被吞掉了,数据库连接没释放。复盘会上有人提议上框架,有人反对:引入框架不是多一层抽象、多一摞依赖吗?
这个反对声恰恰是理解 Koa 的入口。框架的价值不在"帮你写代码",而在"约束错误的流动方式"。原生 http 模块里,每个请求处理函数各自为政:有人忘了 return、有人漏了 try/catch、有人在回调里 throw 了一个没人接的异常。没有统一的控制流,就没有统一的错误边界。Node.js 早期生态给出的第一个答案,叫 Connect。
Connect 在 2010 年前后提出了一组极简的约定:每个中间件是一个函数,收到 (req, res, next) 三个参数,处理完调用 next() 把控制权交给下一层。这是 Node.js 世界"中间件"概念的起点——今天所有 Node 框架的中间件说法,源头都在这里。
Express 在 Connect 的约定上补齐了路由、视图、配置等开箱能力,迅速成为事实标准。但 Express 的中间件是单向的:请求进来,逐层流过,响应在某一层被写死,控制权一去不回。这带来两个后遗症。其一,资源没有对称的释放点——你想"进入时拿连接、离开时还回去",只能靠监听 res 的 finish 事件补一枪,代码四处开花。其二,错误传播靠约定不靠机制——回调里的异常不会自动汇到一处,中间件里 throw 的错误只有同步抛出才能被 Express 的错误中间件接住,一旦包在异步回调里就石沉大海。
看一段典型的 Express 时代代码:
// 回调风格:错误要手动传,漏传就丢 app.get('/user/:id', function (req, res) { db.query('SELECT * FROM user WHERE id = ?', [req.params.id], function (err, rows) { if (err) return res.status(500).send('db error'); // 错误被就地消化,上层无感知 fs.readFile('/etc/config.json', function (err2, text) { if (err2) return res.status(500).send('config error'); res.json({ user: rows[0], config: JSON.parse(text) }); }); }); });
嵌套不深的时候这段代码能跑,问题是它不可组合:每多一步异步就多一层缩进,每个回调都要重复一遍错误兜底,而且"500 加一句字符串"这种处理方式让监控系统拿不到结构化信息。深夜复盘的那位工程师面对的,正是这坨东西日积月累的形态。
2013 年 Koa 发布时,JavaScript 还没有 async/await——那是个连 Promise 都刚进标准的年代。Koa 团队押注的是 ES6 的 generator:用 yield 把异步流程"暂停再继续",配合同期出现的 co 库做驱动,第一次让 Node.js 代码里出现了"同步的写法、异步的实质"。
// Koa 1.x 的写法:function 后面带星号,异步靠 yield app.use(function *(next) { const start = Date.now(); yield next; // 进入下层,等它走完再回来 const ms = Date.now() - start; console.log(`${this.method} ${this.url} - ${ms}ms`); // 出层后还能拿到控制权 });
注意 yield next 之后的那个 console.log——请求穿过内层之后,控制权回到了这一层,层号、耗时都能拿到。这就是洋葱模型第一次在 Node.js 里成型:控制流是双向的,进层和出层对称。Express 做不到的事,generator 语义顺便做到了。
但过渡带并不舒服。generator 函数要加星号、this 的指向要靠 co 绑定、类型系统帮不上忙,yield 能接的东西有各种隐式规则,新手十个有九个在这里栽过。Koa 团队从第一天就明确:generator 只是等 async/await 成熟的临时方案。
2017 年,Node.js 8 把 async/await 带进长期支持线,Koa 2 同期发布正式版,中间件签名从 generator 换成 async 箭头函数:
// Koa 2.x:同样的洋葱,正常的函数写法 app.use(async (ctx, next) => { const start = Date.now(); await next(); const ms = Date.now() - start; console.log(`${ctx.method} ${ctx.url} - ${ms}ms`); });
与 generator 版逐行对照,你会发现结构完全同构:yield 换成 await,this 换成 ctx。变的只是语言层面的载体,洋葱模型的骨架纹丝未动。这个稳定性正是 Koa 设计能力的证明——语义载体迭代了,控制流模型一个字没改。此后的故事顺理成章:async/await 全面普及,Koa 把洋葱模型带进海量生产系统,Egg 等企业级框架直接以它为内核。近年发布的 Koa 3 主要收紧了对旧 Node 版本的支持并做了内部清理,2.x 至今仍是生产环境的主力版本。

历史不是怀旧素材,它解释了三件你写代码时天天遇到的事。第一,为什么 Koa 中间件必须是 async 函数——因为整个洋葱模型建立在"可等待"的控制流上,一旦某一层忘了 await,出层逻辑就永远不执行,第 3 章会专门拆这个坑。第二,为什么错误能自动冒泡——async 函数的 rejection 沿调用链向上传播,这正是 Koa 2 能把 try/catch 写在最外层一网打尽的语言基础,第 2.4 与第 6 章会展开。第三,为什么 Koa 内核敢做这么小——路由、模板这些能力在 Express 里是内置的,在 Koa 里全部外置成中间件,因为历史已经证明:控制流模型比功能清单更值得内置。
关键直觉:洋葱模型不是 Express 的改良版,它是把"对称"引入异步控制流的第一个主流实现——进层做什么、出层做什么,写在一个函数里。
(req, res, next) 的约定是整个 Node.js 中间件生态的源头;下一节我们把 Koa 和 Express 放上同一张评审桌,从内核、生态、错误模型三个维度做一次正面比较——那也是技术选型会上最常吵起来的三个点。