本节目标:理解中间件的洋葱模型与 Fastify hooks 的执行顺序;用
AsyncLocalStorage建立请求级上下文,让日志、追踪、审计自动带上请求标识;把认证结果类型安全地挂到请求对象上;并把校验、限流、错误收口串成一条可维护的链。
5.2 中间件与请求上下文
上一节我们把路由打通了,但一个真实的请求进入服务后,真正被执行的业务代码往往只占很小一部分:前面要鉴权、要限流、要记日志、要开事务,后面要序列化、要记耗时、要清理资源。这些逻辑如果写进每个 handler,重复代码会迅速失控。
中间件就是用来装这些横切逻辑的。但它的难点从来不是「怎么写一个」,而是顺序、作用域、类型三件事。
5.2.1 洋葱模型与 Fastify hooks
先看最经典的心智模型——洋葱。Express 与 Hono 都是这个模型:请求穿过一层层中间件到达业务代码,响应再原路穿回来。
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
const timing: MiddlewareHandler = async (c, next) => {
const start = performance.now()
console.log('→ 进入', c.req.path)
await next() // 交出控制权,等待下游全部执行完
const ms = (performance.now() - start).toFixed(2)
c.header('x-response-time', `${ms}ms`)
console.log('← 离开', c.req.path, `${ms}ms`)
}
const app = new Hono()
app.use('*', timing)
await next() 是关键:它之前的代码在「下行」阶段执行,之后的代码在「上行」阶段执行。若忘记 await,上行逻辑会在下游完成前就运行,x-response-time 会变成一个接近 0 的假值。
Fastify 没有用洋葱,而是用分阶段的生命周期钩子,语义更精确:
app.addHook('onRequest', async (req) => {
// 最早执行:请求已到达,body 尚未解析
// 适合:请求 ID 注入、限流、IP 黑名单
})
app.addHook('preParsing', async (req) => {
// body 流已就绪但尚未解析
})
app.addHook('preHandler', async (req, reply) => {
// body / query / params 均已解析并校验通过
// 适合:鉴权、权限校验、事务开启
})
app.addHook('onSend', async (req, reply, payload) => {
// 响应即将发出,可改写 header 或 payload
return payload
})
app.addHook('onResponse', async (req, reply) => {
// 响应已发出,适合记录耗时
})
为什么这个划分重要?因为鉴权必须放在 body 解析之后还是之前,直接决定性能与安全。把鉴权放在 onRequest 看起来更快(未授权请求不解析 body),但你就拿不到 req.body,无法做「只能改自己资源」这类基于内容的授权判断。
| 钩子 | body 已解析 | 可中止请求 | 典型用途 |
|---|---|---|---|
onRequest | 否 | 是 | 请求 ID、限流、IP 过滤 |
preParsing | 否(流就绪) | 是 | 压缩解压、签名校验 |
preValidation | 否 | 是 | 自定义预校验 |
preHandler | 是 | 是 | 鉴权、授权、开事务 |
onSend | 是 | 是 | 响应头、敏感字段脱敏 |
onResponse | 是 | 否 | 耗时统计、指标上报 |
中止请求的方式是 reply.code(401).send(...) 后 return reply;在 preHandler 里若只写 reply.send() 而不 return,后续 handler 仍会执行——这是 Fastify 新手最常见的错误。
5.2.2 请求上下文:AsyncLocalStorage
有了请求 ID,接下来要解决的是「怎么让深处几十层的业务代码也拿到它」。层层传参显然不现实,Node 的答案是 AsyncLocalStorage。
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'
export interface RequestContext {
requestId: string
userId?: string
startedAt: number
}
export const als = new AsyncLocalStorage<RequestContext>()
export function currentContext(): RequestContext | undefined {
return als.getStore()
}
// Fastify 插件:为每个请求建立独立上下文
app.addHook('onRequest', async (req) => {
const ctx: RequestContext = {
requestId: req.headers['x-request-id'] as string ?? randomUUID(),
startedAt: Date.now(),
}
// run 之后的整个异步调用链都能读到这个 store
return als.run(ctx, async () => {
req.log = req.log.child({ requestId: ctx.requestId })
})
})
这里有一个必须讲清的机制:als.run(store, callback) 只对回调内部同步启动的异步链生效。如果你在 run 之外预先创建了一个 Promise 或定时器,它们不会继承 store。
上下文一旦建立,日志与追踪就都活了。把它接到日志封装里,业务代码无需再传参:
import { als } from './context'
export function log(level: 'info' | 'error', msg: string, extra: object = {}) {
const ctx = als.getStore()
const line = JSON.stringify({
level,
msg,
requestId: ctx?.requestId ?? '-',
userId: ctx?.userId ?? '-',
...extra,
})
process.stdout.write(line + '\n')
}
在 OpenTelemetry 场景下,requestId 还会与 traceId 关联,形成「日志—链路—指标」三者可互相跳转的观测体系;具体接线方式见 17.1 OpenTelemetry 追踪
,日志字段规范见 3.3 结构化日志与脱敏
。
5.2.3 认证中间件与请求类型的收窄
鉴权最典型的问题不是逻辑,而是类型:req.user 从哪来?Fastify 的答案是 decorateRequest 配合声明合并。
import type { FastifyRequest } from 'fastify'
interface AuthUser {
id: string
roles: Array<'admin' | 'user'>
}
// 声明合并:让 req.user 在所有 handler 里都有类型
declare module 'fastify' {
interface FastifyRequest {
user?: AuthUser
}
}
app.decorateRequest('user', undefined)
app.addHook('preHandler', async (req, reply) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '')
if (!token) {
return reply.code(401).send({ message: '缺少凭证' })
}
const payload = verifyToken(token) // 失败会抛出,交给错误处理器
req.user = { id: payload.sub, roles: payload.roles }
})
app.get('/me', async (req) => {
// 类型上 user 是可选,需要收窄;用断言函数把它变成必需
const user = req.user
if (!user) throw new Error('unreachable: 鉴权钩子应已拦截')
return user
})
注意 user 被声明为可选是有意的:类型系统无法表达「这个路由挂了鉴权钩子」,所以它只能诚实地告诉你「可能没有」。工程上有两种收窄方案:
第一种是写一个断言函数 assertAuth(req): asserts req is FastifyRequest & { user: AuthUser },在每个需要鉴权的 handler 开头调用;第二种是把鉴权做成一个带 schema 的封装函数,让处理函数直接接收 user 参数:
function authed<P extends Record<string, unknown>>(
schema: P,
handler: (req: FastifyRequest<{ Params: P }>, user: AuthUser) => Promise<unknown>,
) {
return {
schema,
handler: async (req: FastifyRequest<{ Params: P }>) => {
if (!req.user) throw new Error('unreachable')
return handler(req, req.user)
},
}
}
第二种写法把「有没有鉴权」从运行期约定变成了函数签名的一部分,是更 TypeScript 的做法。凭证本身怎么签发、刷新、吊销,可参考 Node.js JWT 认证 。
5.2.4 校验、限流与错误收口
入参校验交给 schema(见上一节),限流则适合放在 onRequest,因为它要在最便宜的位置挡住流量:
import rateLimit from '@fastify/rate-limit'
await app.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
keyGenerator: (req) => req.user?.id ?? req.ip,
errorResponseBuilder: (req, ctx) => ({
message: `请求过于频繁,请 ${ctx.after} 后重试`,
retryAfter: ctx.after,
}),
})
keyGenerator 用 req.user?.id ?? req.ip 是刻意为之:已登录用户按用户维度限流,未登录回退到 IP。若直接用 IP,同一 NAT 后的用户会互相拖累;若直接用 userId,则未登录请求全都落到 undefined 这个同一个桶里,限流形同虚设。
错误收口是最后一环。所有抛出的异常都应该在同一个地方被翻译成 HTTP 响应,而不是散落在各个 handler:
app.setErrorHandler((err, req, reply) => {
req.log.error({ err }, '请求处理失败')
// 业务错误:显式分类,对外暴露细节
if (err instanceof AppError) {
return reply.code(err.status).send({
code: err.code,
message: err.message,
requestId: currentContext()?.requestId,
})
}
// 校验错误:Fastify 自带 statusCode 400
if (err.validation) {
return reply.code(400).send({ code: 'VALIDATION', message: err.message })
}
// 未知错误:不泄漏堆栈,只回 requestId 便于对账
return reply.code(500).send({
code: 'INTERNAL',
message: '服务器内部错误',
requestId: currentContext()?.requestId,
})
})
返回 requestId 是这一节最实用的一个约定:用户报障时提供这串 ID,你就能在日志系统里精确定位那一次请求的全部上下文,而不必靠时间戳猜。类型化错误的设计思路见 3.1 Result/Either 与类型化错误
。
5.2.5 常见坑
第一个坑是上下文丢失。在中间件里启动一个「不等待」的后台任务(void doSomething())时,该任务虽然能读到 store,但如果它内部再创建独立的事件循环阶段(例如 setImmediate 之外的第三方回调),getStore() 可能返回 undefined。稳妥做法是把需要的字段在任务启动时快照出来,而不是在任务内部再去取。
第二个坑是重复执行。Fastify 中 register 的插件默认是封装的,同一个插件在父子作用域各注册一次会执行两遍钩子;用 fastify-plugin 包过的全局插件则会在每次 register 时都跑一遍,务必确认只注册一次。
第三个坑是顺序错配。限流必须在鉴权之前还是之后?如果限流按 userId 计数,就必须在鉴权之后;如果按 IP 计数,放在最前面更省资源。这类决策不要靠试,直接写进表格与注释里。
第四个坑是在 onSend 里做重活。onSend 处在响应关键路径上,任何同步阻塞都会直接拉高延迟,脱敏这类字符串处理务必用简单的正则或字段剔除。
横切逻辑齐了,服务就「正常」了。但一个成熟的服务还要能「不正常地退出」——进程收到终止信号时如何不丢请求、如何让编排系统正确判断它是否可用,这是下一节 5.3 优雅关闭与健康检查 要解决的问题。并发控制与资源竞态的更多模式可延伸阅读 TypeScript 异步并发控制 。
小结
本节的核心是把横切关注点从业务代码里彻底剥离,并让它们具备类型与顺序的确定性。
- 洋葱模型(Hono / Express)用
await next()划分上下行阶段,忘记 await 会导致上行逻辑时序错误; - Fastify 用分阶段钩子替代洋葱,
onRequest适合限流与请求 ID,preHandler适合鉴权与事务; AsyncLocalStorage是请求级上下文的标准方案,als.run之后的异步链自动继承 store,但预创建的 Promise 不会;decorateRequest+ 声明合并让req.user有类型,但类型上它必然是可选的,更严谨的做法是把鉴权结果作为处理函数的显式参数;- 限流的
keyGenerator必须同时覆盖登录与未登录两种身份维度; - 错误收口统一到
setErrorHandler,并在响应里返回requestId,让线上问题可对账。
下一节我们把视角从「请求」拉高到「进程」:如何响应终止信号、如何让健康检查真实反映依赖状态、如何在容器编排下不丢请求地完成发布。
阅读导航:上一节:5.1 HTTP 服务与路由(Fastify / Hono) · 下一节:5.3 优雅关闭与健康检查 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。