全局中间件

要点

  • 全局中间件通过 app.use('*', ...) 注册,所有请求都会经过
  • 日志、安全头、CORS、请求 ID 这类横切关注点通常放在全局
  • 全局中间件的注册顺序就是执行顺序,先注册的先接触请求、最后接触响应
  • 全局中间件里尽量避免做重计算,否则会拖慢每一条路由
  • 有些中间件看似适合全局,实际按路径挂载更合适,例如鉴权和限流
  • 全局中间件与 app.route() 组合使用时,子应用会继承父应用的全局栈

1. 注册方式

全局中间件的标志性写法是路径参数为 '*'

// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
 
const app = new Hono()
 
// 三条全局中间件
app.use('*', logger())
app.use('*', cors())
app.use('*', secureHeaders())
 
app.get('/', (c) => c.text('Hello'))
app.get('/api/users', (c) => c.json([{ id: 1, name: 'Alice' }]))
 
export default app

'*' 匹配任意路径。无论请求的是 //api/users 还是 /docs/intro,都会依次走过 loggercorssecureHeaders

如果换成具名路径或前缀,例如 app.use('/api/*', cors()),那就不是全局中间件,而是路径级中间件,下一节会展开。

2. 适合放在全局的中间件类型

经验上,适合全局挂载的中间件具备两个特征:

  1. 几乎每条路由都需要它
  2. 放在单个路由里会带来重复代码

常见的候选:

  • logger():请求日志。运维排障时希望所有请求都有记录,包括被 404 或鉴权拦截的
  • secureHeaders():安全响应头。浏览器访问任何页面都需要 CSP、X-Frame-Options 等防护
  • cors():跨域配置。前后端分离项目里,几乎所有 API 都要响应预检请求
  • requestId():请求 ID。分布式追踪的入口,每条请求都应该有一个可追溯的 ID
  • timing():Server-Timing 头。浏览器性能面板会展示后端各阶段耗时

这几类中间件的共同点是「不做业务判断,只补充公共能力」。它们不会根据路径或用户角色改变行为,挂在全局是自然的选择。

3. 注册顺序与执行顺序

全局中间件按注册顺序执行。app.use('*', A)app.use('*', B) 之前注册,A 就先于 B 接触请求。

结合洋葱模型,实际执行顺序是:

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

一个典型的推荐顺序:

app.use('*', logger())         // 1. 日志最先,确保被拦截的请求也有记录
app.use('*', requestId())      // 2. 请求 ID 紧随其后,后续中间件可以读取
app.use('*', cors())           // 3. CORS 在鉴权之前,避免 OPTIONS 预检被拦截
app.use('*', secureHeaders())  // 4. 安全头与业务无关,放在这里即可
app.use('*', timing())         // 5. timing 包住整个链路,统计的耗时更完整

顺序带来的差异在 CORS + 鉴权的组合里最明显。如果鉴权中间件在 CORS 之前,浏览器的 OPTIONS 预检请求不带 Authorization 头,会被直接 401。浏览器拿不到 CORS 头,跨域就失败了。这种问题调试起来往往要抓包才能看清,预防的方式就是把 CORS 放在鉴权之前。

4. 全局中间件的性能考量

全局中间件的代码会在每一条请求上运行。一个写得不够克制的实现,容易把整站拖慢。

两个常见陷阱:

4.1 在中间件里做同步 IO

// ❌ 每次请求都同步读文件
app.use('*', async (c, next) => {
  const config = fs.readFileSync('/etc/app/config.json', 'utf-8')
  c.set('config', JSON.parse(config))
  await next()
})

readFileSync 会阻塞 Node.js 的事件循环。改成在启动时加载一次,通过闭包引用:

// ✅ 启动时加载一次
const config = JSON.parse(fs.readFileSync('/etc/app/config.json', 'utf-8'))
 
app.use('*', async (c, next) => {
  c.set('config', config)
  await next()
})

4.2 在中间件里重复分配大对象

// ❌ 每次请求都构造一份新的元数据
app.use('*', async (c, next) => {
  const metadata = {
    routes: Object.keys(app.routes),
    middlewares: [...],
    // ...
  }
  c.set('metadata', metadata)
  await next()
})

如果元数据在运行时不会变化,把它提到模块顶层,中间件里只做引用传递。

全局中间件的开销是乘法关系:单次耗时 × 请求量。单次多花 1ms,QPS 1000 的服务每秒就多消耗 1 秒的 CPU 时间。

5. 全局中间件与子应用

Hono 的 app.route() 可以把一个子应用挂载到主应用上。子应用内部的全局中间件只对子应用内的路由生效,主应用的全局中间件则对所有路由生效:

// src/apps/admin.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
 
const admin = new Hono()
 
// 子应用的全局中间件,只作用于 /admin/*
admin.use('*', logger())
 
admin.get('/dashboard', (c) => c.text('Admin Dashboard'))
 
export default admin
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import admin from './apps/admin'
 
const app = new Hono()
 
// 主应用的全局中间件,作用于所有路由
app.use('*', cors())
 
app.route('/admin', admin)
 
app.get('/', (c) => c.text('Home'))
 
export default app

访问 /admin/dashboard 时,请求的处理链路是:

  1. 主应用的 cors() 前段
  2. 子应用的 logger() 前段
  3. /admin/dashboard 路由处理
  4. 子应用的 logger() 后段
  5. 主应用的 cors() 后段

这种分层挂载的机制,让「公共能力全局化、业务特性局部化」成为可能。主应用负责跨域、日志、安全头等横切关注点,子应用各自决定是否需要额外的鉴权、限流等中间件。

6. 不适合全局挂载的中间件

并非所有中间件都适合挂在全局。判断标准是「是否每条路由都需要它」。

几类通常按路径挂载的中间件:

  • 鉴权中间件:公开路由(首页、健康检查、登录)不需要鉴权,全局挂载会让这些路由也得带上 token,或者需要在中间件里写排除列表
  • 限流中间件:不同路由的限流策略往往不同,读接口和写接口的阈值差别可以到 10 倍以上
  • 请求体解析中间件:GET 请求没有 body,解析是浪费;文件上传路由需要的解析方式与 JSON 接口不同
  • 角色校验中间件/admin/*/user/* 的权限模型不一样,放在一起会让中间件内部出现分支

这些中间件更适合在子应用或具体路由上挂载,避免全局中间件里出现过多的路径判断。

7. 一个全局中间件的完整示例

把这一节的要点组合起来,一个典型的全局中间件栈如下:

// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
import { timing } from 'hono/timing'
import { requestId } from 'hono/request-id'
 
const app = new Hono()
 
// 全局中间件栈:按顺序注册
app.use('*', logger())
app.use('*', requestId())
app.use('*', cors({
  origin: ['https://example.com', 'https://app.example.com'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization', 'X-Request-Id'],
  exposeHeaders: ['X-Request-Id'],
  maxAge: 86400,
}))
app.use('*', secureHeaders())
app.use('*', timing())
 
// 公开路由
app.get('/', (c) => c.text('Hello'))
app.get('/health', (c) => c.json({ status: 'ok' }))
 
// API 路由(后续会加鉴权中间件)
app.get('/api/users', (c) => c.json([]))
 
export default app

这段代码里有几个值得注意的细节:

  1. logger() 放在最前,保证所有请求(包括被 CORS 或鉴权拦截的)都有日志
  2. requestId() 紧随其后,后续中间件和路由可以通过 c.get('requestId') 读取
  3. cors() 配置了 exposeHeaders: ['X-Request-Id'],让前端 JS 也能读到这个响应头
  4. timing() 放在最后注册(也就是最靠近路由),统计的耗时覆盖整个中间件栈

总结

全局中间件是 Hono 处理横切关注点的主要载体。通过 app.use('*', ...) 注册后,所有请求都会依次穿过这些中间件。

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

  1. 适合全局挂载的中间件:logger、secureHeaders、cors、requestId、timing 这类与路径无关、每条路由都需要的公共能力
  2. 注册顺序决定执行顺序:一般按 logger → requestId → cors → secureHeaders → timing 排列,CORS 必须在鉴权之前
  3. 性能是乘法关系:全局中间件的单次耗时会被请求量放大,避免在内部做同步 IO 或重复分配
  4. 与子应用组合:主应用的全局中间件作用于所有路由,子应用的全局中间件只作用于自己的路由
  5. 不适合全局的中间件:鉴权、限流、角色校验、请求体解析通常按路径或路由挂载,避免中间件内部出现分支

下一篇看路由级中间件——当中间件只需要作用于 /api/* 或某一条具体路由时,该怎么组织。