中间件洋葱模型

要点

  • Hono 采用洋葱模型组织中间件,与 Express 的线性模型有本质区别
  • await next() 是洋葱模型的核心分界点,它将每个中间件分成「进入」和「返回」两个阶段
  • 中间件的注册顺序决定执行顺序,先注册的先执行
  • 全局中间件和路由级中间件按注册顺序依次嵌套
  • 多个中间件可以组合成独立的处理单元
  • 错误通过 throw 传播,会跳过后续中间件,但外层的「返回」阶段仍然执行

1. 什么是洋葱模型

Express 的中间件是线性的。请求从第一个中间件流到最后一个,每个中间件调用 next() 把控制权交给下一个,响应则通过 res.send() 直接发回客户端,没有「回头」的过程。

Express 线性模型:

请求 → mw1 → mw2 → mw3 → handler → 响应直接返回客户端

Hono 用的是洋葱模型。每个中间件像一层洋葱皮,把内层包裹起来。请求从外向内穿透每一层,到达最内层的路由处理函数后,再从内向外逐层返回。

Hono 洋葱模型:

           ┌──────────────────────────────────┐
           │          Middleware A            │
           │   ┌──────────────────────────┐   │
           │   │      Middleware B        │   │
           │   │   ┌──────────────────┐   │   │
           │   │   │   Route Handler  │   │   │
           │   │   └──────────────────┘   │   │
           │   └──────────────────────────┘   │
           └──────────────────────────────────┘

请求方向:从外向内 →
响应方向:← 从内向外

两者的关键区别:Express 的中间件只能影响请求阶段,响应由 handler 直接发出;Hono 的中间件通过 await next() 把自己分成两半——前半段处理请求,后半段处理响应。这意味着你可以在中间件里做「请求进来时记个日志,响应出去时再补个状态码」这类事情。

2. 执行流程可视化

假设注册了三个中间件 A、B、C 和一个路由处理函数:

app.use('*', async (c, next) => {
  console.log('A:进入')
  await next()
  console.log('A:返回')
})
 
app.use('*', async (c, next) => {
  console.log('B:进入')
  await next()
  console.log('B:返回')
})
 
app.use('*', async (c, next) => {
  console.log('C:进入')
  await next()
  console.log('C:返回')
})
 
app.get('/', (c) => {
  console.log('Handler 执行')
  return c.text('OK')
})

请求到达时,执行顺序如下:

时间线 ──────────────────────────────────────────────→

  ┌─ A 进入
  │  ┌─ B 进入
  │  │  ┌─ C 进入
  │  │  │  ┌─ Handler 执行
  │  │  │  │  返回 c.text('OK')
  │  │  │  └─ C 返回
  │  │  └─ B 返回
  │  └─ A 返回
  └─ 响应发送给客户端

控制台输出:
  A:进入 → B:进入 → C:进入 → Handler 执行 → C:返回 → B:返回 → A:返回

这就像一个函数调用栈:A 调用 B,B 调用 C,C 调用 Handler。Handler 执行完毕后,控制权沿着调用链原路返回。await next() 就是「调用下一层」的那个动作。

3. await next() 的语义

await next() 是每个中间件里的分界线。理解它只需要记住一件事:

  • await next() 之前的代码:请求从外向内穿过时执行,此时还没有最终响应
  • await next() 之后的代码:内层全部处理完毕、响应已经生成后执行
app.use('*', async (c, next) => {
  // ─── 进入阶段(请求方向) ───
  // 在这里读取请求信息、做鉴权、修改请求头等
 
  await next()  // ← 分界线:把控制权交给内层
 
  // ─── 返回阶段(响应方向) ───
  // 在这里读取响应、添加响应头、计算耗时等
})

一个常见的疑问:await next() 之后还能修改响应吗?能,但有限制。你可以在返回阶段追加响应头、修改 c.res 引用的 Response 对象,但如果内层已经通过 c.json() 等方法返回了一个完整的 Response,你不能「替换」它的内容,只能在它的基础上做调整。

另一个注意点:如果你不调用 await next(),请求就停在当前层,内层中间件和路由处理函数都不会执行。这正是鉴权中间件拦截未授权请求的原理——直接返回 401,不调用 next()

app.use('/admin/*', async (c, next) => {
  const token = c.req.header('Authorization')
 
  if (!token) {
    // 不调用 next(),请求到此为止
    return c.json({ error: 'Unauthorized' }, 401)
  }
 
  // 验证通过,继续向内层传递
  await next()
})

4. 实际代码示例

日志中间件

一个典型的日志中间件,在「进入」阶段记录请求信息,在「返回」阶段记录响应状态:

app.use('*', async (c, next) => {
  const start = Date.now()
  const { method, path } = c.req
 
  await next()
 
  const ms = Date.now() - start
  const status = c.res.status
 
  console.log(`${method} ${path} ${status} - ${ms}ms`)
})

输出示例:

GET /api/users 200 - 12ms
POST /api/users 201 - 45ms
GET /api/users/999 404 - 3ms

这个中间件能同时拿到请求信息和响应状态码,正是因为洋葱模型让它在两个阶段都有机会工作。如果用 Express 的线性模型,你需要额外监听 res.on('finish') 事件才能达到同样效果。

计时中间件

更精细的版本,通过响应头把耗时暴露给客户端:

app.use('*', async (c, next) => {
  const start = performance.now()
 
  await next()
 
  const duration = (performance.now() - start).toFixed(2)
  c.header('X-Response-Time', `${duration}ms`)
})

performance.now()Date.now() 精度更高,适合测量接口耗时。在「返回」阶段通过 c.header() 把耗时写进响应头,浏览器开发者工具的 Network 面板可以直接看到。

鉴权中间件

结合上一篇讲到的 c.set() / c.get(),展示中间件如何向路由传递数据:

// 鉴权中间件
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
 
  if (!token) {
    return c.json({ error: 'Missing token' }, 401)
  }
 
  try {
    const user = await verifyToken(token)
    c.set('user', user) // 把解析出的用户存进 Context
    await next()
  } catch {
    return c.json({ error: 'Invalid token' }, 401)
  }
})
 
// 路由直接读取
app.get('/api/profile', (c) => {
  const user = c.get('user')
  return c.json(user)
})

5. 中间件执行顺序

中间件的执行顺序取决于两个因素:注册顺序和路径匹配范围。

全局中间件 vs 路由级中间件

全局中间件(app.use('*') 或不指定路径)对所有请求生效。路由级中间件(app.use('/api/*'))只对匹配路径的请求生效。两者的嵌套关系由注册顺序决定:

// 1. 全局日志(最外层)
app.use('*', async (c, next) => {
  console.log('[日志] 请求进入')
  await next()
  console.log('[日志] 响应返回')
})
 
// 2. API 鉴权(中间层)
app.use('/api/*', async (c, next) => {
  console.log('[鉴权] 校验中...')
  await next()
  console.log('[鉴权] 校验通过')
})
 
// 3. 路由处理(最内层)
app.get('/api/users', (c) => {
  console.log('[处理] 执行业务逻辑')
  return c.json({ users: [] })
})

请求 GET /api/users 时的执行顺序:

[日志] 请求进入       ← 全局中间件,进入阶段
  [鉴权] 校验中...    ← 路由级中间件,进入阶段
    [处理] 执行业务逻辑 ← 路由处理函数
  [鉴权] 校验通过     ← 路由级中间件,返回阶段
[日志] 响应返回       ← 全局中间件,返回阶段

先注册的中间件在外层,后注册的在内层。 请求先经过外层中间件的「进入」阶段,到达路由处理函数后,再按相反顺序经过各层中间件的「返回」阶段。

如果全局日志中间件注册在鉴权中间件之后,执行顺序就会反过来——鉴权在外层,日志在内层。所以注册顺序不是随意的,它直接决定了谁先看到请求、谁最后看到响应。

同类中间件的顺序

同一层级的多个中间件按注册顺序依次嵌套:

app.use('*', loggerMiddleware)   // 第 1 层
app.use('*', timingMiddleware)   // 第 2 层
app.use('*', corsMiddleware)     // 第 3 层

请求依次穿过 logger → timing → cors,返回时依次经过 cors → timing → logger。计时中间件记录的耗时会包含 CORS 处理的时间,日志中间件记录的时间会包含计时和 CORS 的时间——因为它们在更外层。

6. 中间件的组合模式

提取为独立函数

当多个路由需要相同的中间件组合时,可以提取成独立函数:

const apiMiddleware = async (c: Context, next: Next) => {
  // 组合多个中间件的逻辑
  await loggerMiddleware(c, async () => {
    await authMiddleware(c, async () => {
      await rateLimitMiddleware(c, async () => {
        await next()
      })
    })
  })
}
 
app.use('/api/*', apiMiddleware)

这种嵌套写法在中间件数量少时还能接受,多了就会变成回调地狱。

使用 compose 工具函数

Hono 内部用 compose 函数把中间件数组串联成洋葱模型。你也可以用它来组合中间件:

import { compose } from 'hono/compose'
 
const apiMiddleware = compose([
  loggerMiddleware,
  authMiddleware,
  rateLimitMiddleware,
])
 
app.use('/api/*', apiMiddleware)

这样三个中间件会按数组顺序依次嵌套,等价于手动嵌套的写法,但代码是平的。

子应用级别的中间件

app.route() 可以给子应用挂载独立的中间件栈:

const api = new Hono()
 
// api 子应用的中间件,只对 /api/* 生效
api.use('*', async (c, next) => {
  console.log('[API] 进入')
  await next()
})
 
api.get('/users', (c) => c.json({ users: [] }))
api.get('/posts', (c) => c.json({ posts: [] }))
 
// 挂载到主应用
app.route('/api', api)

子应用的中间件栈和主应用的中间件栈是独立的。一个请求 GET /api/users 会先经过主应用的全局中间件,再进入子应用的中间件栈。这种隔离让你可以为不同模块配置不同的中间件组合,互不干扰。

7. 错误在洋葱模型中的传播

当某个中间件或路由处理函数抛出异常时,错误会沿着调用链向外传播,跳过所有尚未执行的中间件和路由处理函数:

// 外层:日志
app.use('*', async (c, next) => {
  console.log('1. 进入')
  await next()
  console.log('5. 返回')  // ← 仍然执行
})
 
// 内层:抛出错误
app.get('/error', (c) => {
  console.log('3. 即将抛出')
  throw new Error('Something went wrong')
  // 从这里开始,内层全部跳过
})

执行流程:

1. 进入(外层中间件)
  3. 即将抛出(路由处理函数)
  → throw Error ← 错误向外传播
5. 返回(外层中间件的返回阶段仍然执行)

注意:外层中间件的「返回」阶段仍然执行了。await next() 会捕获内层抛出的错误并重新抛出,所以如果你需要在「返回」阶段做一些事情(比如清理资源),可以用 try/finally 包裹:

app.use('*', async (c, next) => {
  try {
    await next()
  } finally {
    // 无论是否出错,这里都会执行
    console.log('清理资源')
  }
})

错误处理中间件

Hono 提供了 app.onError() 来统一捕获错误:

app.onError((err, c) => {
  console.error(err.message)
  return c.json({ error: 'Internal Server Error' }, 500)
})

app.onError() 相当于洋葱模型最外层的 catch 块。错误从内层抛出后,最终被它接住,返回一个结构化的错误响应。

结合完整的错误处理流程:

// 1. 错误处理(最外层 catch)
app.onError((err, c) => {
  return c.json({ error: err.message }, 500)
})
 
// 2. 日志中间件
app.use('*', async (c, next) => {
  await next()
  // 即使内层出错,这里仍然执行
  // 此时 c.res.status 已经被 onError 设置为 500
})
 
// 3. 可能出错的路由
app.get('/error', (c) => {
  throw new Error('boom')
})

传播路径:

日志中间件(进入)
  → 路由处理函数 throw Error
    → onError 捕获,设置 500 响应
  ← 日志中间件(返回阶段,c.res.status === 500)
← 500 响应发送给客户端

如果没有注册 app.onError(),Hono 会使用默认的错误处理,返回一个通用的 500 响应。生产环境建议总是注册自定义的错误处理中间件,避免把堆栈信息暴露给客户端。

延伸阅读

总结

洋葱模型是 Hono 中间件的核心机制。每个中间件被 await next() 切成两半:前半段在请求进入时执行,后半段在响应返回时执行。多个中间件按注册顺序从外向内嵌套,请求从外向内穿透,响应从内向外返回。

这种模型的好处是,每个中间件都能在请求和响应两个阶段做事,而不像 Express 那样需要借助事件监听来处理响应阶段的逻辑。日志、计时、鉴权、CORS、错误处理——这些常见需求在洋葱模型下都能用统一的模式解决。