中间件执行顺序

要点

  • 中间件按注册顺序执行,先 app.use() 的先接触请求
  • 洋葱模型把每个中间件切成前后两半,await next() 是分界点
  • CORS 必须在鉴权之前,否则 OPTIONS 预检请求会被 401 拦截
  • 日志放在最外层,确保被拦截的请求也有记录
  • 请求 ID 紧跟日志,后续中间件和路由可以读取
  • 顺序错误是中间件调试里最常见的问题,往往要抓包才能看清

1. 洋葱模型回顾

上一节已经出现过洋葱模型。这里把重点放在「执行顺序」本身。

多个中间件注册后,请求穿过它们的顺序是:

请求 → A 前段 → B 前段 → C 前段 → 路由处理 → C 后段 → B 后段 → A 后段 → 响应

await next() 之前的代码叫「前段」,之后的代码叫「后段」。前段按注册顺序执行,后段按注册的相反顺序执行。

// src/index.ts
import { Hono } from 'hono'
 
const app = new Hono()
 
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')
})
 
export default app

输出:

// output.txt
A - 前
B - 前
C - 前
handler
C - 后
B - 后
A - 后

这个执行模型决定了中间件能做两类事:

  1. 前段做的事:在请求到达路由之前检查、修改或拦截。鉴权、参数校验、请求 ID 注入都在这里
  2. 后段做的事:在路由返回后处理响应。修改响应头、记录耗时、写入日志都在这里

2. 推荐的全局中间件顺序

一个经过验证的推荐顺序:

app.use('*', logger())
app.use('*', requestId())
app.use('*', cors())
app.use('*', secureHeaders())
app.use('*', timing())
// 后续才是业务中间件(鉴权、限流等)

每个位置的理由:

2.1 logger 在最外层

app.use('*', logger())  // 1
app.use('*', cors())    // 2

如果 CORS 中间件拦截了 OPTIONS 预检请求并返回,logger 仍然能记录这次请求。把 logger 放后面,被拦截的请求就不会出现在日志里,排查问题时缺少线索。

2.2 requestId 紧随 logger

app.use('*', logger())      // 1
app.use('*', requestId())   // 2

请求 ID 是后续中间件和路由追踪用的。越早生成,能被越多模块引用。logger 放在它前面,是为了让日志也能带上 requestId(如果 logger 支持从 context 读取的话)。

2.3 CORS 在鉴权之前

app.use('*', cors())        // 3
app.use('/api/*', auth)     // 4

浏览器跨域请求会先发 OPTIONS 预检请求,预检请求不带 Authorization 头。如果 auth 在 CORS 之前,预检请求会被 401,浏览器拿不到 CORS 响应头,真正的请求不会发出。

这个顺序错误调试起来非常隐蔽,因为 Postman 或 curl 不发预检请求,问题只在浏览器环境出现。

2.4 secureHeaders 放在 CORS 之后

app.use('*', cors())            // 3
app.use('*', secureHeaders())   // 4

secureHeaders 会设置 CSP、X-Frame-Options 等响应头。放在 CORS 之后,能保证这些安全头在所有响应上生效(包括 CORS 预检的响应)。

2.5 timing 包住整个链路

app.use('*', secureHeaders())   // 4
app.use('*', timing())          // 5
// 业务中间件 ...

timing 统计的是从注册点到路由返回、再到 timing 后段的耗时。放得越靠外,统计到的耗时越完整。如果放在鉴权之后,被鉴权拦截的请求不会被 timing 统计到。

3. 顺序错误的常见案例

3.1 鉴权在 CORS 之前

// ❌ 错误顺序
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')
  if (!token) return c.json({ error: 'Unauthorized' }, 401)
  await next()
})
app.use('*', cors())

浏览器跨域请求流程:

  1. 浏览器发 OPTIONS 预检(不带 Authorization)
  2. 鉴权中间件返回 401
  3. 浏览器拿不到 Access-Control-Allow-Origin
  4. 浏览器拒绝发送真正的请求
  5. 前端报 CORS 错误

正确顺序:

// ✅ 正确顺序
app.use('*', cors())
app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')
  if (!token) return c.json({ error: 'Unauthorized' }, 401)
  await next()
})

OPTIONS 请求由 CORS 中间件直接响应,不会到达鉴权中间件。

3.2 日志在 CORS 之后

// ❌ 日志位置靠后
app.use('*', cors())
app.use('*', logger())

如果 CORS 中间件处理 OPTIONS 后直接返回,logger 的后段仍然会执行(因为 OPTIONS 是 CORS 中间件的响应阶段)。但如果 CORS 的实现在前段就 return 了,logger 的后段不会被执行。

把 logger 放在最外层,可以确保所有请求(无论被谁拦截)的前段都被记录。

3.3 secureHeaders 在路由之后

// ❌ 安全头位置靠后
app.use('/api/*', auth)
app.get('/api/users', (c) => c.json([]))
app.use('*', secureHeaders())  // 永远不会执行

app.use() 注册的中间件只对注册之后的路由生效。如果 secureHeaders() 写在路由声明之后,它不会影响之前声明的路由。

正确做法:所有 app.use() 放在路由声明之前。

4. 中间件提前返回

中间件可以不调用 await next(),直接返回响应。此时后续中间件和路由处理函数都不会执行:

app.use('*', async (c, next) => {
  console.log('A - 前')
  // 不调用 next(),直接返回
  return c.json({ error: 'blocked' }, 403)
  // 以下代码不会执行
  console.log('A - 后')
})
 
app.use('*', async (c, next) => {
  console.log('B - 前')
  await next()
  console.log('B - 后')
})
 
app.get('/', (c) => {
  console.log('handler')
  return c.text('ok')
})

输出:

// output.txt
A - 前

B 的前段、handler、B 的后段、A 的后段都不会执行。

这种机制用于鉴权失败、限流触发等场景。需要注意:提前返回的中间件之后的所有中间件都不会运行,包括 logger 的后段、timing 的后段等。如果 logger 在这个中间件之前注册,logger 的前段已经执行过了,日志仍然会被记录。

5. 前缀中间件和内联中间件的顺序组合

全局中间件、前缀中间件、内联中间件同时存在时,执行顺序是:

请求
→ 全局中间件(按注册顺序)
  → 前缀中间件(按注册顺序)
    → 内联中间件(按书写顺序)
      → 路由处理函数
    ← 内联中间件(逆序)
  ← 前缀中间件(逆序)
← 全局中间件(逆序)
响应
app.use('*', async (c, next) => {
  console.log('G1')
  await next()
  console.log('G1 - end')
})
 
app.use('*', async (c, next) => {
  console.log('G2')
  await next()
  console.log('G2 - end')
})
 
app.use('/api/*', async (c, next) => {
  console.log('P1')
  await next()
  console.log('P1 - end')
})
 
const inline1 = async (c, next) => {
  console.log('I1')
  await next()
  console.log('I1 - end')
}
 
const inline2 = async (c, next) => {
  console.log('I2')
  await next()
  console.log('I2 - end')
}
 
app.get('/api/data', inline1, inline2, (c) => {
  console.log('handler')
  return c.text('ok')
})

访问 /api/data 的输出:

// output.txt
G1
G2
P1
I1
I2
handler
I2 - end
I1 - end
P1 - end
G2 - end
G1 - end

每一层按注册或书写顺序进入,按相反顺序退出。

6. 子应用挂载时的顺序

主应用和子应用各有自己的中间件栈。请求穿过它们的顺序是:

请求
→ 主应用全局中间件
  → 主应用前缀中间件
    → 子应用全局中间件
      → 子应用前缀中间件
        → 子应用路由
      ← 子应用前缀中间件
    ← 子应用全局中间件
  ← 主应用前缀中间件
← 主应用全局中间件
响应
// src/apps/api.ts
import { Hono } from 'hono'
 
const api = new Hono()
 
api.use('*', async (c, next) => {
  console.log('sub-global')
  await next()
  console.log('sub-global - end')
})
 
api.get('/users', (c) => {
  console.log('sub-handler')
  return c.json([])
})
 
export default api
// src/index.ts
import { Hono } from 'hono'
import api from './apps/api'
 
const app = new Hono()
 
app.use('*', async (c, next) => {
  console.log('main-global')
  await next()
  console.log('main-global - end')
})
 
app.route('/api', api)
 
export default app

访问 /api/users 的输出:

// output.txt
main-global
sub-global
sub-handler
sub-global - end
main-global - end

主应用的中间件先于子应用执行。子应用内部的中间件只在子路由的范围内生效。

7. 排查顺序问题的方法

中间件顺序问题通常不会报类型错误,也不会在启动时暴露。排查方式:

7.1 添加临时日志

在每个中间件的前段和后段加 console.log,看实际执行顺序是否符合预期。

7.2 抓包看响应头

浏览器 DevTools 的 Network 面板可以看到响应头。CORS 头缺失、安全头缺失、Server-Timing 缺失等问题,都可以通过响应头判断。

7.3 检查被拦截的请求

如果一个 POST 请求在浏览器里报 CORS 错误,但 Postman 里正常,大概率是鉴权中间件在 CORS 之前拦截了 OPTIONS 请求。

7.4 单元测试执行顺序

用测试验证中间件的执行顺序,避免回归:

it('中间件按注册顺序执行', async () => {
  const app = new Hono()
  const order: string[] = []
 
  app.use('*', async (c, next) => {
    order.push('A-before')
    await next()
    order.push('A-after')
  })
 
  app.use('*', async (c, next) => {
    order.push('B-before')
    await next()
    order.push('B-after')
  })
 
  app.get('/', (c) => c.text('ok'))
 
  await app.request('/')
 
  expect(order).toEqual([
    'A-before',
    'B-before',
    'B-after',
    'A-after',
  ])
})

总结

中间件执行顺序是 Hono 中间件体系里最容易出错、也最容易排查的部分。

这一节涉及到的几个关键判断:

  1. 洋葱模型await next() 把中间件切成前后两半,前段按注册顺序执行,后段按相反顺序执行
  2. 推荐顺序logger → requestId → cors → secureHeaders → timing,业务中间件放在这些公共能力之后
  3. CORS 在鉴权之前:避免 OPTIONS 预检被拦截导致跨域失败
  4. 日志在最外层:确保被拦截的请求也有记录
  5. 提前返回的影响:中间件不调用 next() 时,后续所有中间件和路由都不会执行
  6. 排查方法:临时日志、抓包看响应头、单元测试执行顺序

顺序问题不会在编译期暴露,只会在运行时以「跨域失败」「日志缺失」「响应头缺失」等形式出现。理解洋葱模型的执行规律,是预防这类问题的基础。

下一篇看自定义中间件开发——从内联函数到 createMiddleware(),怎样写出类型安全、可复用的中间件。