路由级中间件

要点

  • 全局中间件(app.use('*', mw))对所有请求生效,路由级中间件只对匹配的路径生效
  • Hono 支持在单条路由上直接内联中间件:app.get('/path', mw1, mw2, handler)
  • 路由组级中间件通过子 Hono 实例的 subApp.use('*', mw) 实现,作用范围跟着 app.route() 的挂载点走
  • 执行顺序是:全局中间件 → 路由组中间件 → 路由级中间件 → handler,返回时反向穿过
  • 常见路由级中间件模式包括:认证、限流、请求体大小限制、CORS 差异化配置、分级日志
  • 中间件通过 c.set() 向 handler 传递数据(用户信息、请求 ID),通过 c.get() 读取

1. 全局中间件与路由级中间件

03.07 讲过洋葱模型的运行机制。这一篇聚焦中间件的作用范围——哪些请求会经过这个中间件。

全局中间件用 app.use('*') 注册,对所有请求生效。典型用途是日志、CORS、错误处理这类无论什么路径都需要执行的逻辑:

// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
 
const app = new Hono()
 
app.use('*', logger())
app.use('*', cors())
 
app.get('/health', (c) => c.text('ok'))
app.get('/users', (c) => c.json({ users: [] }))
 
export default app

路由级中间件只在请求路径匹配时才执行。Hono 提供了两种粒度:

  1. 路径匹配式app.use('/admin/*', mw) —— 对匹配路径的所有 HTTP 方法生效
  2. 路由内联式app.get('/admin/users', mw1, mw2, handler) —— 只对这一条路由的指定方法生效

区别在于方法筛选范围。路径匹配式对 GET/POST/PUT/DELETE 都生效,路由内联式只覆盖注册时指定的方法:

// src/index.ts
 
// /admin/ 下的所有请求都要经过 adminAuth
app.use('/admin/*', adminAuth)
 
// 只有 GET /login 受 rateLimit 控制,POST /login 不受影响
app.get('/login', rateLimit({ max: 5 }), loginHandler)
app.post('/login', loginHandler)

如果写成 app.use('/login', rateLimit()),GET 和 POST 都会被限流。需要按方法区分时,路由内联式更精确。

2. 单路由中间件

路由内联中间件直接写在路由注册方法里,和处理函数并列:

// src/index.ts
import { Hono } from 'hono'
import { authMiddleware } from '@/middleware/auth'
import { validateBody } from '@/middleware/validate'
import { createUserSchema } from '@/schemas/user'
 
const app = new Hono()
 
// 单个中间件
app.get('/profile', authMiddleware, (c) => {
  const user = c.get('user')
  return c.json(user)
})
 
// 多个中间件——按书写顺序从外向内嵌套
app.post(
  '/users',
  authMiddleware,
  validateBody(createUserSchema),
  async (c) => {
    const data = c.get('validatedBody')
    return c.json({ message: 'created' }, 201)
  }
)
 
export default app

多个中间件在一条路由上出现时,按书写顺序嵌套。/users POST 请求进入时依次穿过 authMiddlewarevalidateBody → handler,返回时反向穿过。这和 03.07 的洋葱模型是同一套机制,只是中间件从 app.use() 独立注册变成了写在路由注册方法内部。

拆分到独立文件

中间件逻辑较复杂时,提到独立文件保持路由注册简洁:

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
 
export const authMiddleware = createMiddleware(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) {
    return c.json({ error: 'Missing token' }, 401)
  }
  const user = await verifyToken(token)
  c.set('user', user)
  await next()
})
// src/middleware/validate.ts
import type { z } from 'zod'
import { createMiddleware } from 'hono/factory'
 
export function validateBody<T extends z.ZodType>(schema: T) {
  return createMiddleware(async (c, next) => {
    try {
      const body = await c.req.json()
      c.set('validatedBody', schema.parse(body))
      await next()
    } catch {
      return c.json({ error: 'Validation failed' }, 400)
    }
  })
}

createMiddleware 来自 hono/factory,提供类型推导,让 c.set() / c.get() 的键值对在编译期可检查。

3. 路由组中间件

单路由中间件适合「只有这一条路由需要」的场景。一组路由共享相同中间件时,逐条注册重复且容易遗漏。

路由组中间件通过子 Hono 实例实现,04.08 的分层架构已经用过这种模式:

// src/routes/admin/index.ts
import { Hono } from 'hono'
 
const admin = new Hono()
 
admin.use('*', authMiddleware)       // 只对 admin 实例下的路由生效
admin.use('*', adminRoleMiddleware)
 
admin.get('/users', (c) => {
  return c.json({ users: [], operator: c.get('user') })
})
 
admin.delete('/users/:id', (c) => c.json({ message: 'deleted' }))
 
export default admin
// src/index.ts
app.use('*', logger())     // 全局:所有请求
app.route('/admin', admin) // admin 实例的中间件:只影响 /admin/*

请求 GET /admin/users 的中间件链:logger → authMiddleware → adminRoleMiddleware → handler,返回时反向穿过。admin.use('*', ...) 只对 /admin/* 生效。GET /health 不经过 authMiddleware

4. 执行顺序

路由级中间件的执行顺序遵循一条规则:从外向内进入,从内向外返回

「外」和「内」取决于中间件注册的位置:

层级注册位置示例
最外层主 app 全局中间件app.use('*', logger())
中间层子实例组中间件subApp.use('*', auth)
最内层路由内联中间件app.get('/path', mw, handler)
// src/index.ts
const app = new Hono()
 
app.use('*', async (c, next) => {       // 第 1 层
  console.log('1. 全局进入')
  await next()
  console.log('6. 全局返回')
})
 
const api = new Hono()
 
api.use('*', async (c, next) => {       // 第 2 层
  console.log('2. 组进入')
  await next()
  console.log('5. 组返回')
})
 
api.get('/data', async (c, next) => {   // 第 3 层
  console.log('3. 路由中间件进入')
  await next()
  console.log('4. 路由中间件返回')
}, (c) => {                              // handler
  console.log('→ handler')
  return c.json({ ok: true })
})
 
app.route('/api', api)
 
// GET /api/data 输出:1 → 2 → 3 → handler → 4 → 5 → 6

顺序对业务逻辑的影响

注册顺序决定中间件在洋葱中的位置,也决定它能看到哪些上游数据:

// 日志中间件需要记录用户信息时,必须注册在认证中间件之后(更内层)
app.use('/api/*', auth())     // 外层:先解析 token,设置 c.set('user', ...)
app.use('*', logger())        // 内层:日志可以看到 c.get('user')
 
// 反过来则日志拿不到用户信息
app.use('*', logger())        // 外层:此时还没有 user
app.use('/api/*', auth())     // 内层:解析 token

如果需要错误处理中间件捕获认证中间件的异常,错误处理就应该注册在认证之前(更外层)。

5. 常见路由级中间件模式

以下是实际项目中高频出现的路由级中间件用法。

认证:只在受保护路由上启用

公开路由不挂载认证中间件,受保护路由才挂载:

// src/routes/index.ts
const app = new Hono()
 
app.route('/auth', authRoutes) // 登录、注册不需要认证
 
const protected_ = new Hono().use('*', authMiddleware)
protected_.route('/users', usersRoutes)
protected_.route('/posts', postsRoutes)
app.route('/', protected_)

限流:在敏感端点上启用

登录、注册等接口容易被暴力破解,按路由单独配置限流阈值:

// src/routes/auth.ts
const auth = new Hono()
 
auth.post('/login', rateLimiter({ window: 60, max: 5 }), async (c) => {
  const { email, password } = await c.req.json()
  return c.json({ token: '...' })
})
 
auth.post('/register', rateLimiter({ window: 60, max: 3 }), registerHandler)

请求体大小限制:上传路由专用

全局限制通常比较小(如 1MB),文件上传需要更大限制但不应该放宽全局设置:

// src/routes/upload.ts
const upload = new Hono()
 
upload.post('/avatar', bodyParserLimit('5mb'), async (c) => {
  const formData = await c.req.formData()
  return c.json({ url: '/avatars/xxx.png' })
})
 
upload.post('/documents', bodyParserLimit('50mb'), async (c) => {
  return c.json({ message: 'uploaded' })
})

CORS 差异化配置

管理接口比公开接口需要更严格的 CORS 策略。注意同一路径匹配到多个 CORS 中间件时,后注册的会覆盖前面的响应头,避免把全局 CORS 和作用域更小的 CORS 挂在同一路径上:

// src/index.ts
app.use('/api/*', cors())                                  // 宽松
app.use('/admin/*', cors({                                 // 严格
  origin: ['https://admin.example.com'],
  credentials: true,
}))

分级日志

开发调试接口输出详细信息,生产接口只记录方法和路径。按路径分别挂载不同详细程度的日志中间件即可:

// src/routes/index.ts
app.use('/api/*', logger())           // 生产:简洁日志
 
if (env.NODE_ENV !== 'production') {
  app.use('/dev/*', debugLogger)      // 开发:详细日志
}

6. Context 数据传递

中间件和 handler 之间通过 Context 传递数据。03.04 讲过 c.set() / c.get() 的机制。

典型用法:认证中间件解析 token 后存入用户信息,请求追踪中间件生成唯一 ID 存入 Context,下游的 handler、日志、错误处理中间件直接通过 c.get() 读取:

// src/middleware/auth.ts
export const authMiddleware = createMiddleware<Env>(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) return c.json({ error: 'Missing token' }, 401)
 
  const user = await verifyToken(token)
  c.set('user', user)
  await next()
})
// src/middleware/request-id.ts
import { createMiddleware } from 'hono/factory'
import { randomUUID } from 'crypto'
 
export const requestIdMiddleware = createMiddleware<Env>(async (c, next) => {
  const id = c.req.header('x-request-id') ?? randomUUID()
  c.set('requestId', id)
  c.header('x-request-id', id) // 回传给客户端,方便全链路追踪
  await next()
})

通过 hono/factory 的泛型参数声明 Context 变量类型,让 c.get('user') 推导出 &#123; id: string; role: 'admin' | 'user' &#125;

// src/types/context.ts
export type Env = {
  Variables: {
    user: { id: string; role: 'admin' | 'user' }
    requestId: string
  }
}

中间件和 handler 都声明相同的 Env 类型,c.get() 就能在编译期获得正确的类型。

7. 条件中间件

同一路径下,根据请求特征选择不同的中间件逻辑:

// src/middleware/conditional-auth.ts
import { createMiddleware } from 'hono/factory'
 
export const conditionalAuth = createMiddleware(async (c, next) => {
  if (c.req.method === 'GET') {
    await next()   // 读操作不需要认证
    return
  }
  const token = c.req.header('Authorization')
  if (!token) {
    return c.json({ error: 'Authentication required' }, 401)
  }
  await next()
})
 
app.use('/api/data/*', conditionalAuth)
app.get('/api/data/list', listHandler)      // 不需要 token
app.post('/api/data/create', createHandler)  // 需要 token

条件逻辑简单时,中间件内部 if 判断即可。条件复杂(三种以上分支),建议拆成独立中间件分别挂载到不同路径。按 API 版本分开挂载也是类似思路:app.use('/api/v1/*', authV1)app.use('/api/v2/*', authV2)

8. 性能考量

路由级中间件的性能影响来自两个方面:中间件自身的计算开销,以及在不需要的路径上白白执行的开销。

每个中间件本质上是一个异步函数。读取 header / Context 的开销极低;JWT 验证(纯 CPU)中等;数据库查询较高;外部 API 调用最高。认证中间件如果需要查数据库,可能消耗 5-50ms。优化方向是缓存验证结果——JWT 本身无状态,验证只需要 CPU 计算;如果必须查数据库,用 Redis 缓存并设置合理 TTL。

中间件数量本身不是瓶颈。5 个只做 header 读写的中间件,比 1 个包含 Redis 查询的中间件更快。

全局中间件在不需要的路径上也会执行。健康检查、静态资源、公开文档不需要认证——04.08 的分层架构已经处理了这个问题,把不需要认证的路由放在独立实例上即可。

9. 测试路由级中间件

Hono 的 app.request() 可以在内存中模拟请求,不需要启动 HTTP 服务器。创建一个只挂载待测中间件的临时 app,验证各种输入条件下的行为:

// tests/middleware/auth.test.ts
import { describe, it, expect } from 'vitest'
import { Hono } from 'hono'
import { authMiddleware } from '@/middleware/auth'
 
describe('authMiddleware', () => {
  const app = new Hono()
  app.use('*', authMiddleware)
  app.get('/test', (c) => c.json({ user: c.get('user') }))
 
  it('无 token 时返回 401', async () => {
    const res = await app.request('/test')
    expect(res.status).toBe(401)
  })
 
  it('有效 token 时放行', async () => {
    const token = createTestToken({ id: '1', role: 'user' })
    const res = await app.request('/test', {
      headers: { Authorization: `Bearer ${token}` },
    })
    expect(res.status).toBe(200)
  })
 
  it('无效 token 时返回 401', async () => {
    const res = await app.request('/test', {
      headers: { Authorization: 'Bearer invalid' },
    })
    expect(res.status).toBe(401)
  })
})

测试路由组中间件时,把整个子 app 导入即可。app.request('/admin/users', ...) 会自动经过路由组上挂载的所有中间件,验证组合行为。

延伸阅读

总结

路由级中间件解决的是「哪些请求需要经过哪些处理逻辑」的问题。Hono 提供三个层级的控制粒度:

  1. 全局中间件app.use('*', mw) 对所有请求生效,适合日志、CORS、错误处理
  2. 路由组中间件:子实例的 subApp.use('*', mw) 通过 app.route() 的挂载点限定作用范围,适合按模块配置认证策略
  3. 路由内联中间件app.get('/path', mw1, mw2, handler) 精确到单条路由的单个方法,适合限流、校验等特定需求

执行顺序遵循洋葱模型的嵌套规则:全局 → 路由组 → 路由内联 → handler,返回时反向穿过。注册顺序决定了每个中间件在洋葱中的位置,也决定了它能看到哪些上游数据。

中间件通过 c.set() 向下游传递数据,handler 通过 c.get() 读取。用 hono/factorycreateMiddleware&lt;Env&gt;() 可以获得编译期的类型检查。

下一篇讲 404 处理——当请求没有匹配到任何路由时,Hono 如何生成响应,以及如何自定义 404 行为。