返回首页

Koa2 洋葱模型:为什么中间件能"先进后出"

同样是中间件,Koa 的 async/await + 洋葱模型和 Express 有本质区别。这篇讲清洋葱模型怎么运转,以及核心 compose 的实现。

19 约 4 分钟 · 4552 字 后端

Koa 是 Express 原班人马打造的"下一代"框架。它把框架本身砍到极小(连路由、body 解析都不内置),核心只留下一样东西:基于 async/await 的**洋葱模型(Onion Model)**中间件。

如果说 Express 的中间件是"流水线",那 Koa 的中间件就是"洋葱" —— 这篇讲清这两者的本质区别。

先看洋葱模型长什么样

直接上代码,感受执行顺序:

import Koa from 'koa'
const app = new Koa()

app.use(async (ctx, next) => {
  console.log('1 进入第一层')
  await next()
  console.log('6 离开第一层')
})

app.use(async (ctx, next) => {
  console.log('2 进入第二层')
  await next()
  console.log('5 离开第二层')
})

app.use(async (ctx, next) => {
  console.log('3 进入第三层')
  ctx.body = 'hello'
  console.log('4 处理完成')
})

app.listen(3000)

一个请求进来,输出是:

1 进入第一层
2 进入第二层
3 进入第三层
4 处理完成
5 离开第二层
6 离开第一层

看出来了吗?执行路径像一根针穿过洋葱再穿回来:await next() 之前的代码按注册顺序正序执行(进入),await next() 之后的代码按逆序执行(离开)。这就是"先进后出"。

        ┌───────────────────────────┐
        │   1 ────────────────► 6    │  第一层
        │   ┌───────────────────┐    │
        │   │  2 ──────────► 5   │    │  第二层
        │   │   ┌───────────┐   │    │
   ───► │   │   │  3 ──► 4   │   │    │ ───►  最内层
        │   │   └───────────┘   │    │
        │   └───────────────────┘    │
        └───────────────────────────┘

和 Express 到底差在哪

Express 的 next() 是个普通函数调用,调完就调完了,它不"等待"下游执行结束。所以 Express 里你很难在"所有后续中间件都跑完之后"再做点什么。

Koa 的 next() 返回一个 Promise,配合 await,当前中间件会暂停await next() 这一行,等里面所有中间件(以及它们的 await next())全部执行完,再回来执行后半段。

这个差异带来一个杀手级能力:优雅地包裹整个请求。比如统计响应耗时,在 Express 里要 hack res 的事件,在 Koa 里天然就能写:

// 响应耗时统计中间件,放在最外层
app.use(async (ctx, next) => {
  const start = Date.now()
  await next() // 等内层全部处理完
  const ms = Date.now() - start
  ctx.set('X-Response-Time', `${ms}ms`)
  console.log(`${ctx.method} ${ctx.url} - ${ms}ms`)
})

错误处理也一样优雅,一个 try/catch 兜住所有下游:

app.use(async (ctx, next) => {
  try {
    await next()
  } catch (err) {
    ctx.status = err.status || 500
    ctx.body = { message: err.message }
    // Koa 里 async 抛错能被外层 catch 到,这是 Express 4 做不到的
  }
})

注意最后这点:Koa 因为全程基于 Promise,异步错误能自然地被外层 try/catch 捕获,不像 Express 4 那样需要手动 next(err)。这是 async/await 架构带来的红利。

ctx:把 req 和 res 合二为一

Express 给你 reqres 两个对象,Koa 把它们封装进一个 ctx(Context) 对象,并挂上一堆便捷属性:

app.use(async (ctx) => {
  // 请求相关
  ctx.method      // GET
  ctx.url         // /users?id=1
  ctx.query       // { id: '1' }
  ctx.headers     // 请求头

  // 响应相关:赋值即响应,不用 res.send()
  ctx.status = 200
  ctx.body = { ok: true } // 自动序列化成 JSON 并设好 Content-Type

  // ctx.request / ctx.response 是 Koa 封装的对象
  // ctx.req / ctx.res 是 Node 原生对象,需要时可下钻
})

最舒服的是 ctx.body —— 你只管赋值,Koa 自动帮你判断是字符串、Buffer、Stream 还是对象,设好对应的 Content-TypeContent-Length

洋葱模型的核心:compose

洋葱模型怎么实现的?答案就是 Koa 依赖的 koa-compose。它把中间件数组"组合"成一个函数,核心逻辑非常精炼:

function compose(middleware) {
  return function (ctx, next) {
    let index = -1
    function dispatch(i) {
      // 防止一个中间件里多次调用 next()
      if (i <= index) {
        return Promise.reject(new Error('next() called multiple times'))
      }
      index = i
      let fn = middleware[i]
      if (i === middleware.length) fn = next
      if (!fn) return Promise.resolve()
      try {
        // 关键:把 dispatch(i+1) 作为 next 传给当前中间件
        // 当前中间件 await 的就是"下一个中间件的完整 Promise"
        return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }
    return dispatch(0)
  }
}

精髓在这一行:fn(ctx, dispatch.bind(null, i + 1))

每个中间件拿到的 next,其实就是"调用下一个中间件的 dispatch"。当你 await next() 时,等的是下一个中间件返回的 Promise,而那个 Promise 又包着它自己的 await next()…… 一层层 Promise 嵌套,自然形成了"进去再出来"的洋葱结构。

一个常见错误:忘了 await

新手最容易犯的错:写了 next() 但忘了 await

// ❌ 错误:没 await,洋葱"回来"的时机就乱了
app.use(async (ctx, next) => {
  const start = Date.now()
  next() // 忘了 await!
  // 下游还没跑完,这里的耗时统计就是错的
  ctx.set('X-Response-Time', `${Date.now() - start}ms`)
})

少了 await,当前中间件不会等下游执行完,后半段会"提前"运行,耗时统计、错误捕获全部失效。Koa 中间件里调 next() 几乎总要配 await

小结

  • Koa 的中间件是洋葱模型:await next() 前正序"进入",之后逆序"离开",先进后出
  • 和 Express 的本质区别:Koa 的 next() 返回 Promise,能 await 等下游全部跑完,从而优雅地包裹整个请求(耗时统计、统一错误处理)
  • async 错误能被外层 try/catch 自然捕获,不像 Express 4 要手动 next(err)
  • ctx 把 req/res 合一,ctx.body 赋值即响应
  • 洋葱靠 compose 实现:把 dispatch(i+1)next 传下去,Promise 层层嵌套
  • 中间件里调 next() 务必 await,否则洋葱"回来"的时机会错乱

Koa 用更少的代码、更现代的 async/await,把中间件这件事做得比 Express 更优雅。理解了洋葱模型,你再看 Nest、Egg 这些基于 Koa 思想的框架会更通透。