Middlewares 层设计
要点
- 第 5 篇讲过中间件的基本用法——
app.use()、await next()、c.set()/c.get()。这篇换一个问题切入:当中间件从三五个变成十几个,怎么组织才不会乱 - Middlewares 层的核心职责是处理「横切关注点」——日志、鉴权、CORS、错误兜底,这些逻辑和具体业务无关,但每个请求都可能用到
- 全局中间件和路由级中间件要分层挂载。全局层处理所有请求共有的逻辑,路由层处理特定接口的额外约束
- 中间件之间通过
c.set()/c.get()传递数据,需要在Variables类型里统一定义字段,避免各处写any - 中间件的注册顺序直接决定执行顺序。CORS 要在鉴权之前,错误兜底要包住所有业务逻辑
- 自定义中间件建议用
createMiddleware()创建,能拿到完整的类型推导,也能让c.set()/c.get()的 key 有编辑器提示 - 错误处理中间件放在所有中间件和路由的最后面,用
app.onError()统一兜底,避免每个路由都写 try-catch
1. 从一个文件到一层的演进
第 5 篇的示例里,中间件都是直接写在 index.ts 里的。项目小的时候这种做法够用——三五个中间件,一眼扫完,没什么认知负担。
但当你开始拆分路由模块(第 2 篇讲过),中间件也会跟着膨胀:
- 认证中间件要拆出「JWT 验证」和「API Key 验证」两个版本
- 日志中间件想在本地开发时打印更多字段,线上只记关键信息
- CORS 配置在不同环境有不同的 allowed origins
- 新加了限流中间件、请求 ID 中间件、权限校验中间件……
这些中间件如果继续散落在 index.ts 和各个路由文件里,会出现几个问题:
- 同一个逻辑在多处重复。比如
authMiddleware在 users 路由和 posts 路由里各写了一份,后续改一处忘了另一处 index.ts又变回了三四百行,光中间件注册就占了大半- 新成员不知道该去哪里找可用的中间件,只能全局搜
到了这个阶段,把中间件独立成一层(src/middleware/)就是自然的选择。
2. Middlewares 层的职责边界
在进入具体代码之前,先明确 Middlewares 层管什么、不管什么。
管的——请求经过路由 handler 之前或之后,需要统一执行的逻辑:
- 请求日志、请求计时
- 身份认证(解析 JWT、验证 API Key)
- 权限校验(是不是管理员、有没有操作权限)
- 跨域处理(CORS)
- 安全响应头
- 请求 ID 注入(方便链路追踪)
- 错误兜底(把异常统一转成 JSON 响应)
不管的——某个具体接口的业务逻辑:
- 参数校验(放在路由 handler 里,或者用单独的 validator)
- 数据库 CRUD(放在 repositories 层)
- 业务规则判断(放在 services 层)
一句话概括:Middlewares 层处理的是「所有请求(或某一类请求)都要经过的公共逻辑」,不处理「某个接口特有的业务」。
3. 目录结构:按职责一个文件一个中间件
拆分之后,src/middleware/ 目录大致是这样:
// directory
src/
middleware/
auth.ts 认证中间件(JWT 验证)
api-key.ts API Key 验证中间件
logger.ts 请求日志中间件
error.ts 错误处理(onError 注册)
request-id.ts 请求 ID 注入
permissions.ts 权限校验中间件每个文件导出一个或一组中间件函数。入口文件 index.ts 只负责按顺序挂载,不关心中间件内部的实现细节。
4. 全局中间件 vs 路由级中间件
中间件按作用范围分两级。
全局中间件
挂在 app.use('*', ...) 上,所有请求都会经过。适合放「不管什么接口都要执行」的逻辑:
// src/index.ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
import type { AppEnv } from './types'
import { requestIdMiddleware } from './middleware/request-id'
import { authMiddleware } from './middleware/auth'
import { registerErrorHandler } from './middleware/error'
const app = new Hono<AppEnv>()
// 1. 日志——最先注册,确保所有请求(包括被后续中间件拦截的)都有日志
app.use('*', logger())
// 2. 请求 ID——在日志之后,这样日志里就能带上请求 ID
app.use('*', requestIdMiddleware())
// 3. CORS——在鉴权之前,因为浏览器跨域的 OPTIONS 预检请求不带 token
app.use('*', cors({
origin: ['https://example.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
}))
// 4. 安全响应头
app.use('*', secureHeaders())
// 5. 认证——只有 /api/* 下的接口需要
app.use('/api/*', authMiddleware())
// 6. 错误兜底——注册在最外层
registerErrorHandler(app)推荐的全局中间件注册顺序:
- logger — 最先,保证所有请求都被记录
- requestId — 给每个请求打一个唯一 ID,后续日志都能带上
- cors — 在鉴权之前,否则 OPTIONS 预检会被拦截
- secureHeaders — 设置安全响应头
- auth — 身份认证(通常只挂在
/api/*下) - error handler — 通过
app.onError()注册,兜底所有未捕获的异常
路由级中间件
只对特定路由或路由组生效。有两种挂载方式。
方式一:在子 app 上挂
// src/routes/admin.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { adminOnlyMiddleware } from '../middleware/permissions'
export const adminApp = new Hono<AppEnv>()
// admin 组下的所有接口都要先过 adminOnly
adminApp.use('*', adminOnlyMiddleware())
adminApp.get('/stats', async (c) => {
// 能走到这里,说明已经是管理员了
return c.json({ totalUsers: 1024 })
})
adminApp.delete('/users/:id', async (c) => {
const id = c.req.param('id')
// 删除操作
return c.json({ message: `User ${id} deleted` })
})adminApp.use('*', ...) 只对这个子 app 内的路由生效。入口文件里 app.route('/api/admin', adminApp) 挂载后,实际作用范围是 /api/admin/*。
方式二:直接写在路由参数里
// src/routes/posts.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { authMiddleware } from '../middleware/auth'
import { ownerOnlyMiddleware } from '../middleware/permissions'
export const postsApp = new Hono<AppEnv>()
// 获取文章列表——公开接口,不需要认证
postsApp.get('/', async (c) => {
// ...
return c.json(posts)
})
// 删除文章——需要认证 + 作者本人
postsApp.delete('/:id', authMiddleware(), ownerOnlyMiddleware(), async (c) => {
const id = c.req.param('id')
// ...
return c.json({ message: `Post ${id} deleted` })
})这种方式适合「只有个别接口需要额外校验」的场景。中间件按从左到右的顺序依次执行,全部通过之后才进入 handler。
5. 认证中间件:把鉴权逻辑独立出来
第 5 篇里写过一个简单的认证中间件,直接把逻辑写在 app.use() 里。到了工程化阶段,把认证逻辑抽成独立文件,方便测试和复用:
// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
const header = c.req.header('Authorization')
if (!header || !header.startsWith('Bearer ')) {
return c.json({ error: 'Missing or invalid Authorization header' }, 401)
}
const token = header.slice(7)
try {
const secret = c.env.JWT_SECRET
// 这里省略了具体的 JWT 验证逻辑
// 假设 verifyJWT 返回解码后的 payload
const payload = await verifyJWT(token, secret)
// 把用户信息写入 context,后面的路由可以直接取
c.set('user', {
id: payload.sub,
email: payload.email,
role: payload.role,
})
await next()
} catch {
return c.json({ error: 'Invalid or expired token' }, 401)
}
})几个值得注意的点:
- 用
createMiddleware<AppEnv>()创建,c.env和c.set()/c.get()都有类型推导 - JWT 密钥从
c.env.JWT_SECRET读,不硬编码在代码里 - 验证通过之后用
c.set('user', ...)把用户信息存进去,后续路由用c.get('user')取出 - 验证失败直接
return c.json(..., 401),不调用next(),请求在这里被拦截
verifyJWT 是一个独立的工具函数,放在 src/lib/ 或 src/utils/ 里。中间件只负责「从请求里取 token → 调用验证 → 存结果或拦截」这个流程,不关心 JWT 本身的编解码细节。
6. 错误处理中间件:用 onError 统一兜底
如果每个路由 handler 里都写 try-catch 然后返回统一的错误格式,代码会非常冗余:
// anti-pattern.ts
// 不要这样写
app.get('/api/users', async (c) => {
try {
const users = await db.select().from(usersTable)
return c.json(users)
} catch (error) {
console.error(error)
return c.json({ error: 'Internal Server Error' }, 500)
}
})Hono 提供了 app.onError() 来统一处理所有未捕获的异常。把它理解成一个「全局 catch 块」:
// src/middleware/error.ts
import type { AppEnv } from '../types'
import type { Hono } from 'hono'
// 自定义错误基类,方便区分业务异常和系统异常
export class AppError extends Error {
constructor(
public statusCode: number,
public message: string,
public code?: string,
) {
super(message)
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(404, `${resource} not found`, 'NOT_FOUND')
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(401, message, 'UNAUTHORIZED')
}
}
// 注册全局错误处理器
export function registerErrorHandler(app: Hono<AppEnv>) {
app.onError((err, c) => {
// 业务异常:有明确的状态码和消息
if (err instanceof AppError) {
return c.json(
{ error: err.message, code: err.code },
err.statusCode,
)
}
// 系统异常:不暴露具体错误信息给客户端
console.error(`[Unhandled Error] ${err.message}`, err.stack)
return c.json(
{ error: 'Internal Server Error', code: 'INTERNAL_ERROR' },
500,
)
})
}路由里只需要抛出自定义错误,不需要自己 catch:
// src/routes/users.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
import { NotFoundError } from '../middleware/error'
export const usersApp = new Hono<AppEnv>()
usersApp.get('/:id', async (c) => {
const id = c.req.param('id')
const user = await db.select().from(usersTable).where(eq(usersTable.id, id))
if (!user) {
// 直接抛错,onError 会统一处理响应格式
throw new NotFoundError('User')
}
return c.json(user)
})这样做的好处:
- 所有错误响应的格式一致——
{ error: string, code?: string } - 路由 handler 只关心正常流程,异常处理集中在一个地方
- 系统异常的具体堆栈只在服务端日志里打印,不会泄露给客户端
7. 中间件之间怎么传递数据
前面反复用到的 c.set() / c.get(),是中间件之间、中间件与路由之间传递数据的唯一通道。
基本用法
// src/middleware/request-id.ts
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../types'
export const requestIdMiddleware = createMiddleware<AppEnv>(async (c, next) => {
// 生成一个请求 ID,优先用客户端传过来的(方便前端追踪)
const requestId = c.req.header('X-Request-ID') || crypto.randomUUID()
// 写入 context
c.set('requestId', requestId)
await next()
// 在响应头里也带上,方便客户端排查问题
c.header('X-Request-ID', requestId)
})在 Variables 类型里声明字段
c.set() / c.get() 的 key 需要在 AppEnv 的 Variables 里声明,否则 TypeScript 会报错:
// src/types.ts
export type AppEnv = {
Bindings: {
JWT_SECRET: string
DB: D1Database
}
Variables: {
// 每个字段对应一个 c.set() 的 key
user: {
id: number
email: string
role: string
}
requestId: string
db: DrizzleD1Database
}
}声明之后,c.get('user') 会自动推导出 { id: number; email: string; role: string } 类型,c.get('unknown') 则会在编辑阶段就提示错误。
数据流向
一个请求经过多个中间件后,context 里可能积累了这些数据:
// flow
请求进入
→ logger 中间件:(不写入 context,只打印日志)
→ requestId 中间件:c.set('requestId', 'abc-123')
→ auth 中间件:c.set('user', { id: 1, email: '...', role: 'admin' })
→ 路由 handler:c.get('requestId'), c.get('user')每条数据只在当前请求的生命周期内有效,不同请求的 context 互相隔离。不用担心中间件 A 设置的用户信息会泄露到另一个用户的请求里。
8. 执行顺序:为什么顺序很重要
中间件按注册顺序依次执行,顺序直接影响功能的正确性。第 4 节的代码里已经按推荐顺序排列了,这里解释为什么是这个顺序。
考虑一个反例——如果把鉴权放在 CORS 之前:
// wrong-order.ts
// 鉴权中间件在前,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('/api/*', cors({ origin: 'http://localhost:3000' }))前端从 http://localhost:3000 跨域发 PUT 请求时,浏览器会先发一个 OPTIONS 预检请求。这个 OPTIONS 请求不带 Authorization 头,直接被鉴权中间件拦截返回 401,真正的 PUT 请求根本不会发出去。
把 CORS 调到鉴权之前,CORS 中间件先处理 OPTIONS 请求并返回正确的响应头,预检通过之后浏览器才发真正的请求。
记住一条原则:越通用的越靠前,越业务相关的越靠后。
9. createMiddleware 的好处
前面第 5 节的认证中间件已经用了 createMiddleware<AppEnv>()。这里汇总一下它相比直接写 async (c, next) => { ... } 的好处:
c.env的字段有编辑器补全,不怕拼错环境变量名c.set()的 key 只能是Variables里定义过的,写错了 TypeScript 直接报错c.get()的返回值有类型推导,不用手动断言- 中间件本身有明确的类型标注,作为参数传给
app.use()时不会有类型冲突
在工程化项目里,建议所有自定义中间件都通过 createMiddleware 创建。写起来多一行 import,换来的是后续维护和重构时的类型安全。
总结
Middlewares 层在 Hono 工程化项目里承担的角色:
- 把横切关注点(日志、认证、CORS、错误兜底)从业务逻辑里抽离出来,集中放在
src/middleware/目录下 - 全局中间件挂在入口文件上,处理所有请求共有的逻辑;路由级中间件挂在子 app 或单个路由上,处理特定接口的额外约束
- 中间件之间通过
c.set()/c.get()传递数据,字段在Variables类型里统一定义 - 注册顺序直接影响正确性:logger → requestId → cors → secureHeaders → auth → error handler
- 用
createMiddleware<AppEnv>()创建自定义中间件,拿到完整的类型安全 - 用
app.onError()统一处理异常,路由里不需要每个都写 try-catch
下一篇看 Routes 层的设计——路由模块怎么组织、子 app 怎么挂载、路径前缀怎么管理。