《TypeScript编程实战》5.2 中间件与请求上下文

本节讲解 HTTP 服务的横切关注点如何组织:从洋葱模型与 Fastify hooks 的执行顺序,到用 AsyncLocalStorage 建立请求级上下文并贯穿日志与追踪,再到把认证结果类型安全地挂到请求对象上。你将掌握认证、校验、限流、错误收口四类中间件的写法与顺序约束,并学会避免重复执行与上下文丢失两类高频故障。

本节目标:理解中间件的洋葱模型与 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 优雅关闭与健康检查 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes