2.2 洋葱模型:穿透与执行序


2.2 洋葱模型:穿透与执行序

别相信任何一张把洋葱模型画成"单向管道"的示意图——包括你记忆里那张。这一节不背定义,直接做实验:写一段会打印日志的中间件栈,让请求真实地穿过去,把执行序从运行结果里钉出来。之后你再回头看各种图,都能一眼判断对错。

实验一:把执行序打印出来

注册四层中间件,每层在 next() 前后各打印一行,用序号标记位置:

const 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, next) => { console.log('3-进'); await next(); console.log('3-出'); }); app.use(async (ctx) => { console.log('内核:写响应'); ctx.body = 'done'; }); app.listen(3000);

请求一次根路径(curl 访问本地服务),控制台输出固定为:

1-进 2-进 3-进 内核:写响应 3-出 2-出 1-出

这就是洋葱模型的全部事实:进层按注册顺序(1、2、3),出层按注册逆序(3、2、1)。把它想象成调用栈更准确——await next() 就是一次"调用",内层执行完,控制权必然回到调用点之后。所谓"洋葱",只是把调用栈画成同心圆后的样子。

图 2-1:洋葱剖面与进出轨迹对照

图 2-1:洋葱剖面与进出轨迹对照

实验二:next() 的返回值是什么

next() 返回一个 Promise,它"兑现"的时刻是所有内层中间件执行完毕。这个细节决定了一种高级写法:不 await 而是"挂起再回来"。

const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // 并行型中间件:不等内层完成就开始自己的耗时任务,最后再等内层收尾 app.use(async (ctx, next) => { const inner = next(); // 注意:没有 await,先拿到 Promise const cached = await fetchFromCache(); // 与内层并行执行 await inner; // 此刻才等洋葱内层走完 ctx.set('X-Cache', cached ? 'hit' : 'miss'); });

这种"先放行、最后收口"的写法在需要并行的场景(如提前预热缓存)有价值,但收益有限、风险不小:出层逻辑的执行时机会变得难以推理,异常也更容易漏接。我的建议是默认不写,除非压测证明这里就是瓶颈。

实验三:忘掉 await 会怎样

把层 2 的 await next() 改成 next(),再跑一次:

1-进 2-进 3-进 内核:写响应 1-出 ← 2-出 去哪了?

3-出2-出 的日志消失了——准确地说,它们没有消失,而是脱离了请求生命周期next() 返回的 Promise 没人等待,内核拿到的是一个未完成的链,响应可能在内核阶段提前发送(因为 2 的出层逻辑被剥离出链了),也可能在发送之后才执行(表现为日志晚到、报错 UnhandledPromiseRejection)。两种形态都对应生产事故:前者响应里缺了出层阶段补的字段,后者告警群里全是 unhandled rejection。

反过来,如果某层压根不调用 next,请求在这一层截停,内层不执行。有意截停是设计(鉴权失败直接 401),无意截停是事故(某分支 return 漏了 next)。区分二者的办法只有一个:每写一层,明确回答"这层截停的条件是什么、放行的条件是什么"。

响应在哪一刻定型

Koa 的响应不是某个中间件"发送"的,而是整条链走完后由内核统一发送。所有层都给 ctx.body 赋值的权利,后写的覆盖先写的;出层阶段(逆序)执行的赋值拥有最高优先级——这就是为什么"给响应统一加头"的中间件要放在最外层:它的出层阶段最后执行,能盖到所有内层写过的东西。

// 统一响应头层:放最外层,出层阶段最后执行 app.use(async (ctx, next) => { await next(); ctx.set('X-Request-Id', ctx.state.reqId); // 内层无论写了什么,这里都能补上 ctx.set('X-Powered-Time', String(Date.now() - ctx.state.start)); });

由此也能解释一个经典面试题:"多个中间件都设置了 ctx.status,最终以哪个为准?"答案:以最后执行的那次赋值为准——进层阶段是外层先写内层后写,出层阶段是内层先写外层后写,按时间线找最后一次即可。

💡 关键直觉:把每一层想成"两次执行机会"——进层一次(请求半程),出层一次(响应半程)。日志、耗时、响应头这类"结果统计"天然属于出层;鉴权、限流、解析这类"准入检查"天然属于进层。安排层序时先问自己:这层的逻辑工作在半程。

本节要点回顾

  • 执行序铁律:进层按注册序、出层按注册逆序,等价于 async 函数的调用栈行为;
  • next 返回 Promise:不 await 会让内层脱离生命周期,表现为响应异常或 unhandled rejection;
  • 截停是合法手法:健康检查、静态资源、鉴权失败都靠不调用 next 停住请求;
  • 响应由内核统一发送:出层阶段的赋值拥有最终解释权,统一加头的层应放最外;
  • 两层半程模型:准入检查属进层、结果统计属出层,层序设计从这个二分开始。

执行序钉死了,但层与层之间靠什么传递数据、请求和响应对象到底怎么被统一封装——下一节解剖 Context,那颗在所有层之间穿行的公共通道。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U