中间件洋葱模型
要点
- 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 响应。生产环境建议总是注册自定义的错误处理中间件,避免把堆栈信息暴露给客户端。
延伸阅读
- 04-Context 上下文对象原理 —
c.set()/c.get()在中间件中的实际用法 - Hono 官方文档 - Middleware — 内置中间件列表和自定义中间件写法
- Hono 源码 - compose.ts — 洋葱模型的实现,不到 100 行
总结
洋葱模型是 Hono 中间件的核心机制。每个中间件被 await next() 切成两半:前半段在请求进入时执行,后半段在响应返回时执行。多个中间件按注册顺序从外向内嵌套,请求从外向内穿透,响应从内向外返回。
这种模型的好处是,每个中间件都能在请求和响应两个阶段做事,而不像 Express 那样需要借助事件监听来处理响应阶段的逻辑。日志、计时、鉴权、CORS、错误处理——这些常见需求在洋葱模型下都能用统一的模式解决。