前面两节造好了层,这一节回答装配台上最后的问题:这些层谁先谁后。层序不是代码风格问题——同样的层换个位置,行为直接改变,性能也随之浮动。本节用几组可复现的实验把"顺序改变行为"钉死,再给出一张装配排序表和性能开销的估算方法。
把 bodyparser 挪到路由之后装配,然后向接口 POST 一段 JSON:
// 错误装配:解析层在路由层之后 app.use(router.routes()); app.use(bodyParser()); // 太晚了 router.post('/echo', async (ctx) => { ctx.body = { got: ctx.request.body }; // 输出 { got: undefined } });
结果稳定复现:ctx.request.body 恒为 undefined。原因不需要玄学解释——请求按注册序进层,到达路由层时解析层还没执行过,body 自然还没被解析。修复只需把解析层挪到路由前面。这类错序的特征很典型:不报错,只是静默地拿不到数据,因此排障时第一反应该是检查装配顺序,而不是怀疑解析库。
第 2.4 节讲过兜底层必须最外。反着装配试试:
// 错误装配:兜底层在业务层内侧 app.use(bodyParser()); app.use(errorHandler()); // 应该放在 bodyParser 外面 app.use(businessRouter()); // 当业务层抛出异常,异常向外冒泡,先经过 errorHandler——没问题; // 但 bodyParser 自身抛出的 413 大包异常发生在 errorHandler 之前(更内侧), // 冒泡时已经没有兜底层可经过,只能落到 app.on('error'),客户端拿到裸 500。
规则由此清晰:你的层想接住谁,就必须装配在谁的外面。兜底层要接住解析层、路由层、业务层的一切异常,所以它是装配序列的第一个;压缩层要压住所有人的最终输出,所以它也在外侧;安全头层要最后说话,所以它贴着最外。外侧到内侧的装配序,就是"关心的范围从全局收窄到局部"的序。
静态资源层(koa-static)命中即截停,这一特性叠加层序就是性能手段。同一个请求 GET /static/logo.png:
同理,健康检查层、限流层这类"越早拦截越省钱"的层都应该尽量靠外。反过来,必须等数据准备好的层必须靠内——鉴权层依赖 body 里的凭证,就得排在解析层之后。层序设计的核心矛盾就这两句话的拉扯:拦截型要靠外省开销,依赖型要靠内拿数据。
把本章出现过的层按"该在的位置"排一遍,这张表可以直接当 code review 的对照清单:
| 装配位 | 层 | 理由 |
|---|---|---|
| 最外 | 错误兜底 | 接住所有内侧层的异常 |
| 次外 | 请求 ID | 最早生成,之后所有层与日志都要用 |
| 外侧 | 安全头 helmet | 最后说话,防止被覆盖 |
| 外侧 | 压缩 compress | 压最终响应 |
| 中外 | 静态资源 serve | 命中即截停,省内侧开销 |
| 中段 | 限流 rateLimit | 尽早拦截,但要在静态层之后(静态不占配额) |
| 中段 | 请求体解析 bodyParser | 依赖它的层都在其内 |
| 中内 | 鉴权 auth | 依赖 body 或头,产出 ctx.state.user |
| 内侧 | 业务路由 router | 洋葱的内核 |
有一处细节值得点破:请求 ID 层的位置几乎决定全册日志的可用性——它必须早于任何可能抛异常的层,否则异常发生时 ctx.state.reqId 还不存在,日志里查无此请求。
层序错了是行为问题,层数多了是性能问题。每个中间件给每请求增加的开销由两部分构成:固定开销(一次函数调用、一个 Promise 的创建与恢复,纳秒到微秒量级)与逻辑开销(该层自己的 IO 或计算,微秒到毫秒不等)。前者决定了"几十层洋葱"在现代硬件上也就增加零点几毫秒——层数本身几乎从来不是瓶颈,层里的逻辑才是:
// 瓶颈层长这样:在进层阶段做同步阻塞或每请求重活 app.use(async (ctx, next) => { const rule = JSON.parse(fs.readFileSync('./rules.json', 'utf8')); // 每请求同步读盘 const heavy = expensiveTransform(ctx.headers); // 每请求重计算 await next(); });
优化手法与之对应:读配置改成启动时加载加监听变更;重计算改成预计算加缓存;同步调用(fs 的 Sync 系列、复杂正则、大 JSON 序列化)逐个清掉——同步代码在 Node.js 里阻塞的是整个事件循环,不是当前请求,这是第 8.1 节性能剖析的重点对象。层内只留"必须每请求做的事",其他都往工厂层(启动时)或缓存里搬。
⚠️ 常见坑:为了"优化"把多个层的逻辑合并成一个大层。合并省下的微秒级调用开销,换来的是不可截停、不可测试、不可复用的巨型函数——这笔账怎么算都是亏的。层要小而专,开销问题用剖析工具找到真凶再动手。
工坊三课到此齐备:会写层、会选层、会排层。洋葱装配完毕,下一章跟着请求正式进层——路由怎么匹配、RESTful 怎么落、参数怎么验,主战场见。