「微内核」这个词在框架宣传里被用滥了,但在 Koa 身上它经得起源码级检验:整个 Application 没有多少行代码,却决定了一个实例从创建到监听、从接收请求到进程退出的完整行为。本章从洋葱芯开始解剖,第一刀就切在这里——new Koa() 到底创建了什么。
Application 实例身上只有三组核心状态。其一是中间件队列:每次调用 app.use(fn) 都是把函数追加进一个数组,仅此而已——没有任何排序、去重或依赖分析,顺序完全等于你书写的顺序。其二是环境配置:app.env(默认取自环境变量 NODE_ENV)、app.proxy(是否信任反向代理头)、app.keys(给 Cookie 签名用的密钥组)。其三是事件能力:Application 继承自 Node.js 的 EventEmitter,app.on('error', ...) 之所以存在,根源就在这里。
const Koa = require('koa'); const app = new Koa(); console.log(typeof app.use); // function:注册中间件 console.log(typeof app.listen); // function:启动原生 server console.log(typeof app.on); // function:继承自 EventEmitter console.log(app.env); // 默认等于 process.env.NODE_ENV 或 'development' console.log(app.middleware.length); // 0:队列此刻还是空的
把这三组状态记牢,后面很多"玄学问题"就有了机械答案。比如"为什么中间件顺序不对"——因为队列从不排序,你 use 的顺序就是执行顺序;比如"为什么生产环境拿到的都是内网地址"——因为你没设 app.proxy = true,Koa 默认不信 X-Forwarded-For 这类代理头。
一个 Koa 实例的一生可以拆成四段。创建段:new Koa() 初始化队列与配置,此时什么都没发生。装配段:连续调用 app.use 把中间件压入队列,这一段通常在进程启动时一次性完成。监听段:调用 app.listen(3000),内核用当前 http 模块创建原生 server 并开始监听——app.listen 本质上就是 http.createServer(app.callback()).listen(...) 的语法糖。接收段:每个请求抵达时,回调函数创建一个新的 Context,把中间件队列组合成一条 Promise 链执行,结束后按 Context 里的响应状态发送 HTTP 回包。
// app.listen 的等价展开:看清"糖"下面是什么 const http = require('http'); const server = http.createServer(app.callback()); server.listen(3000); // 这也意味着你可以把 Koa 挂到已有的 server 上,比如与 WebSocket 共享端口
理解这个展开有两重价值。调试时,你能明白为什么 app.on('error') 之外还会有 server 层的 error 事件,两者监听的对象不同。架构上,它解释了 Koa 为什么能被嵌进各种宿主环境——第 9 章讲 Serverless 适配时,核心手法就是把 app.callback() 返回的函数直接交给云函数运行时,跳过 listen 这一步。
⚠️ 常见坑:在测试或 Serverless 场景里调用了
app.listen,导致端口冲突或进程挂不住。需要"拿到处理函数但不启动服务"时,用app.callback();需要"模拟请求"时,用 supertest 直接吃这个 callback(第 8.3 节有完整示例)。
app.use(fn) 的函数签名必须遵守两条纪律,违反任何一条都会在运行期出问题。第一条:fn 必须是 async 函数(或返回 Promise 的函数)。 内核会把整条队列组合成一个 Promise 链,普通同步函数返回值不会被等待,层序立刻错乱。第二条:除非你明确知道自己在做什么,否则必须 await next()。 不调用 next,请求就在这一层停住,后面的层全部不执行——响应要么由这一层给出,要么以 404 收场。
// 纪律一的反例:同步函数打断了 Promise 链 app.use((ctx, next) => { // 没有 async console.log('sync layer'); next(); // 返回值没有被 await,出层时机失控 }); // 纪律二的正例:明确地"穿过"或"截停" app.use(async (ctx, next) => { if (ctx.path === '/health') { // 健康检查不需要进内层 ctx.body = 'ok'; // 截停:直接在出层发送 return; } await next(); // 其余请求继续向内 });
截停本身是合法且常用的手法——静态资源层、健康检查层都靠它省掉内层开销。危险的是"无意截停":某层在某些分支上忘了 await next,请求随机性地 404,这类 bug 在代码审查里极难肉眼发现,第 3.1 节会给出用测试钉死它的方法。
请求处理过程中未被捕获的异常,内核会调用 app.emit('error', err, ctx)——这就是实例级兜底的入口。它的定位是最后一道栅栏:业务异常应该被中间件层的 try/catch 处理掉(2.4 节的主题),能漏到这里的,要么是兜底层自身出了 bug,要么是响应已经发送后发生的异常(此时无法再改响应,只能记录)。
app.on('error', (err, ctx) => { // 能走到这里的异常都越过了所有中间件,只适合记录与告警 console.error('未捕获异常', err.message, ctx && ctx.url); });
三道防线的关系现在可以画清楚了:中间件内层 try/catch 管"预期内的业务异常",最外层兜底中间件管"把意外异常转成 500 响应",app.on('error') 管"越过一切防线后的落网之鱼"。层层递减,各司其职。
进程收到退出信号时,正在处理的请求怎么办?Koa 本身不提供优雅退出,需要你在进程层自己编排:先让 server 停止接新请求,再等在途请求处理完,最后释放数据库连接等资源。
const server = app.listen(3000); function shutdown(signal) { console.log(`收到 ${signal},开始优雅退出`); server.close(() => { console.log('在途请求已全部处理完毕'); // 在这里释放连接池、刷新日志缓冲等 process.exit(0); }); // 兜底:限时后强制退出,防止某个请求挂住导致进程无法退出 setTimeout(() => process.exit(1), 10000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));
这段代码在容器化部署里是刚需:没有它,每次发版都会掐断在途请求。第 6.3 节会把进程稳定性展开成完整的清单,这里先立住"生命周期不止到 listen 为止"的观念。
app.callback() 的返回函数起原生 server,这使 Koa 可嵌入各类宿主;app.on('error') 收网,职责不可混用;实例的骨架看清了,下一节进入全章核心:这条中间件队列到底按什么顺序执行,next() 前后到底发生了什么——洋葱模型的执行序,值得用一整节的实验来钉死。