09 · NestJS Middleware 中间件:链路最外层那个"最像 Express"的家伙

项目开源地址(本专栏实证代码的教程仓) https://gitee.com/yanjinqiang/corp-rag-tutorial (复制到浏览器打开)
承接上篇 :上一篇《请求生命周期总览》画出了七站全景图,并指出中间件是链路最外层 、先于守卫,从中间件抛的错只有全局过滤器能接。 这篇就拆这一站:它为什么是 Nest 里唯一签名还停留在 (req, res, next) 时代的组件?它和守卫的分界到底在哪?------以及 rag-server 用一个中间件同时干了"requestId 透传"和"AsyncLocalStorage 播种上下文"两件事,后者是怎么贯穿整条管线的?
定位 :本篇讲中间件这一站:签名与身份、绑定方式(configure + forRoutes)、requestId 全链路、中间件与守卫的分界决策表,以及 rag-server 真实的 ALS 落地。不讲守卫内部实现(10 期)、不讲拦截器与过滤器细节(12/13 期)。读完你能判断一个横切需求该不该做成中间件,并能说清"req.requestId"与"ALS 上下文"两条链路的分工。

一、一句话回答

Middleware 是 Nest 链路最外层的一层:它在守卫之前运行,签名就是最原始的 (req, res, next),用"改 req / 回写 res / 调 next 放行"的方式,对匹配到的请求做预处理。

它解决的是"AOP 四件套管不到的那类事 "------那些只需要碰底层请求/响应对象、不需要知道路由元数据的横切逻辑:请求 ID、CORS、日志头、静态资源、请求体预处理等。

scss 复制代码
客户端请求
   │
   ▼
┌─ Middleware (req,res,next) ──┐   ← 本文主角:最外层,能碰 req/res
│  RequestContextMiddleware      │
│  rid 复用/生成 + ALS 播种上下文 │
└──────────────┬───────────────┘
               ▼ next()
        Guards → Interceptors → Pipes → Controller(后面几篇的主角们)

记忆锚点:02 期说 Controller 是"路由翻译层"、06 期说 Provider 是"能力单元"、07 期说 Module 是"收纳盒"------中间件则是"请求进门后的第一道闸门",而且是一道用 Express 老姿势写的闸门。

二、先抓那条真实中间件:RequestContextMiddleware

rag-server 全部中间件就一个(src/common/request-context.middleware.ts),核心逻辑不到 20 行:

ts 复制代码
import { Injectable, NestMiddleware } from "@nestjs/common";
import type { NextFunction, Request, Response } from "express";
import { REQUEST_ID_HEADER, newRequestId } from "@corprag/core/resilience";
import { runWithRequestContext } from "./request-context";

@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
    use(req: Request & { requestId?: string }, res: Response, next: NextFunction): void {
        const incoming = req.headers[REQUEST_ID_HEADER];        // ① 请求头里带 x-request-id 了吗
        const requestId =
            typeof incoming === "string" && incoming.length > 0
                ? incoming                                          // 有 → 复用(跨服务链路追踪)
                : newRequestId();                                   // 没有 → 生成一个新的
        req.requestId = requestId;                                  // ② 挂到 req 上(显式兜底)
        res.setHeader(REQUEST_ID_HEADER, requestId);                // ③ 回写响应头,调用方拿得到
        runWithRequestContext({ requestId, startedAt: Date.now(),   // ④ ALS 播种隐形上下文
            method: req.method, originalUrl: req.originalUrl }, next);
    }
}

逐块拆:

  1. implements NestMiddleware + 实现 use(req, res, next) :Nest 中间件必须实现 use 方法,签名三件套直接来自 Express。这里的 Request/Response/NextFunction 都是从 express 导入的类型;
  2. @Injectable() 是必须的 :类中间件要能被 DI 容器实例化、能被 consumer.apply 接收(函数式中间件则不需要,见第七节);
  3. 逻辑三件事 :读请求头 → 有就复用/没有就生成 → 同时挂到 req 和写回 res。这正是链路追踪里"request id 透传"的标准姿势:上游传来的 id 别丢弃,没有才新造,这样跨服务能把同一次调用串起来;
  4. 最后用 runWithRequestContext(..., next) 代替裸 next() :它在 AsyncLocalStorage.run() 里包住 next------这一步是 rag-server 的增量,第五节专讲。

触发器:中间件的方法名叫 use 、参数是 (req, res, next) ------这三个字就是它的身份证明:它活在"Express 中间件"的语法世界里,跟守卫的 canActivate(context)、拦截器的 intercept(context, next) 不是一套上下文。看到 use(req,res,next) 就知道这是中间件。

三、为什么它"最像 Express":没有 ExecutionContext 的一层

这是理解中间件和其余组件最大不同的关键。对比签名:

组件 方法签名 拿到的是什么 能碰路由元数据吗
Middleware use(req, res, next) 裸的 Express req/res ❌
Guard canActivate(context: ExecutionContext) 包了一层 ExecutionContext ✅ 用 Reflector 读
Interceptor intercept(context: ExecutionContext, next: CallHandler) ExecutionContext + 可观察流 ✅ 用 Reflector 读
Pipe transform(value, metadata) 参数值 + 参数元数据 部分

为什么中间件拿不到 ExecutionContext?因为 Nest 骑在 Express/Fastify 之上 ,中间件这一层为了兼容底层平台,保留的是平台的原始姿势------req、res 就是 Express 的原生对象(02 期说的"平台原生对象"就是它们)。

这个差异带来三个实际后果

  1. 中间件不能做"按路由元数据分流" :守卫能用 Reflector 读 @Public()/@RequireAdmin() 决定放不放行;中间件读不到 任何装饰器写的元数据,它只认识 req/res 和绑定的路径。所以鉴权这种"要按接口语义区分"的事,放守卫,不放中间件;
  2. 中间件可以干"裸 req/res"才能干的活 :直接改请求头、改写响应头、提前 res.end() 短路(CORS 预检直接回)、读原始 body 流;
  3. 中间件的"作用域"是路径,不是控制器/方法:绑定时指定的是"哪些 URL 路径",这跟守卫(可绑控制器、可绑方法)是不同维度。

一句话:Nest 里越靠外越"平台化",越靠里越"框架化"。 中间件是最外层所以最像 Express;到 Controller 已经是纯框架声明式了。这条"由外到内从命令式走向声明式"的光谱,是理解整个 Nest 的一把钥匙。

四、绑定方式:consumer.apply().forRoutes(),而不是装饰器

中间件不用装饰器挂在 Controller 上(守卫有 @UseGuards、管道有 @UsePipes,中间件没有 对应的路由装饰器)。它挂在模块的 configure() 里:

ts 复制代码
@Module({
    imports: [RagModule, HealthModule],
    providers: [
        { provide: APP_GUARD, useClass: AuthGuard },
        { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
        { provide: APP_FILTER, useClass: AllExceptionsFilter },
    ],
})
export class AppModule implements NestModule {            // ← ① 模块要实现 NestModule
    configure(consumer: MiddlewareConsumer): void {        // ← ② 才能拿到 consumer
        consumer.apply(RequestContextMiddleware).forRoutes("*");  // ← ③ 绑定:这个中间件跑这些路径
    }
}

拆开这行绑定:

ts 复制代码
consumer
    .apply(RequestContextMiddleware)   // 给哪些中间件:类(可多个 apply(MW1, MW2))
    .forRoutes("*");                   // 管哪些路由:这里 "*" = 所有路由
  1. 模块类要实现 NestModule 接口、写 configure(consumer) ------这是 Nest 给模块开的"中间件配置口"。只有根模块/需要绑中间件的模块才实现它;RagModule/HealthModule 没实现;
  2. .apply(...) 接收中间件类,.forRoutes(...) 指定路径 ;"*" 是通配符;
  3. 路径可以写得很细 :forRoutes("api/query") 只对一个接口生效,还能传 Controller 类 forRoutes(RagController)。项目用最宽的 "*",因为 requestId 是"每个请求都要有"。

为什么放根模块,而不是子模块?

绑在 AppModule(根模块)上,意味着它是整条链路最外层 ------比所有子模块里可能绑的中间件都先跑。08 期那张执行序里 RequestContextMiddleware 排第一,正是因为它绑在根模块 + forRoutes("*") 。中间件的执行顺序由"绑定层级"决定:全局(app.use)先于根模块,根模块先于 import 进来的子模块。 想给"全站每个请求都盖章",放根模块最自然。

机制层小结:守卫/拦截器靠"装饰器 + 类绑定",中间件靠"模块 configure + 路径绑定"。前者以 Controller/方法为锚点、可读元数据;后者以路径为作用域、只碰 req/res。两种绑定哲学,对应两类不同问题。

五、requestId 的全链路故事:中间件写,后面层读

这条中间件最大的价值不是"生成了一个 id",而是它写下的信息被后面几层读到了 。而且 rag-server 里存在两条并行的通道 :显式的 req.requestId 和隐形的 ALS 上下文。

第一通道:显式 req.requestId(谁都能读,但要拿到 Request)

① 中间件写入(最外层)

ts 复制代码
req.requestId = requestId;                    // 挂到 req
res.setHeader(REQUEST_ID_HEADER, requestId);  // 回写响应头

② LoggingInterceptor 读取(日志归口):拦截器从请求对象取出 requestId 拼进日志------

ts 复制代码
const requestId = req.requestId ?? "-";
return next.handle().pipe(
    tap({
        next: () => this.logger.log(`${method} ${originalUrl} ${res.statusCode} ${cost}ms rid=${requestId}`),
        error: (err) => this.logger.warn(`... ${status} ${cost}ms rid=${requestId} - ${err?.message ?? err}`),
    }),
);

你在服务端日志看到的 POST /v1/api/query 200 823ms rid=xxx 里那个 rid=,源头就是中间件写的那个值。拦截器自己不管生成,只管读。

③ AllExceptionsFilter 读取(错误归口):过滤器处理异常时也取同一个 id,打进错误日志并带回客户端响应里。

ini 复制代码
RequestContextMiddleware  →   LoggingInterceptor   →   AllExceptionsFilter
  生成/复用 requestId            日志 rid=xxx           错误响应 requestId=xxx
  req.requestId = ...           (成功/失败都读)          (打日志 + 回给客户端)

于是一条请求从头到尾只有一个 id:前端拿到错误响应里的 requestId,就能去服务端日志精确捞出那一条。

第二通道:ALS 隐形上下文(拿不到 Request 的深处也能读)

req.requestId 有个天然局限:你得先拿到 Request 对象 。但业务越深越拿不到------RagService 里没有 @Req() 可用,难道为了打日志把 Request 一路透传下去?

rag-server 的解法是在中间件用 AsyncLocalStorage 播种一个"隐形上下文":

ts 复制代码
// common/request-context.ts ------ store + 读接口
export const runWithRequestContext = (ctx, fn) => storage.run(ctx, fn);
export const getRequestContext = () => storage.getStore();
export const getRequestId = () => storage.getStore()?.requestId;
scss 复制代码
RequestContextMiddleware: runWithRequestContext({requestId, startedAt, method, originalUrl}, next)
   │  ALS.run() 之内 → guard → interceptor → pipe → handler → service → filter 全在里面
   ▼
RagService(深处、拿不到 Request): this.logger.log(`[rid=${getRequestId()}] ...`)  ← 照样读得到

两个设计要点值得记:

  • 隐形 ≠ 唯一 :req.requestId 保留为显式兜底------不依赖 ALS 的层(比如某些拦截器)照旧读它,两边值一致,互不冲突;
  • ALS 沿异步链存活 :异步操作不会丢上下文,所以 service 内部 await 多次之后 getRequestId() 依然有效(13 期讲事件监听器时会再用到这条性质)。

触发器:中间件最常见、也最正确的用途,就是"在最早处给请求『盖章』,让后面的层共用" 。requestId、开始计时时间戳、鉴权后的用户信息(req.user)都是这种"中间件写入 → 后续 AOP 读取"的模式。判断一个横切逻辑适不适合做中间件,就问一句:"它是不是要在最外层给请求加点什么,让后面所有人都能读?" 是 → 中间件;不是 → 往下考虑守卫/拦截器。

六、中间件 vs 守卫:都"拦截请求",分界在哪

两者都在 Controller 之前跑,都能"拦下请求",到底用谁?看项目自己怎么选就是最好的答案:RequestContextMiddleware 是中间件;鉴权的 AuthGuard 是守卫。

维度 中间件 守卫(AuthGuard)
能不能读路由元数据 ❌ 读不到 @Public()/@RequireAdmin() ✅ Reflector.getAllAndOverride 读得到
怎么决定放不放行 只能看 req/res(如请求头有没有 token) 能按"这个接口标了 @Public 还是 @RequireAdmin"分流
项目用它干什么 给每个请求盖 rid + 播种上下文(不看路由是谁) 按接口语义区分 public / 普通 / 管理员

关键在 AuthGuard 的判据 ------它必须知道"当前这个接口是 public 还是要管理员",这个信息存在路由方法的元数据 里(@Public()/@RequireAdmin() = SetMetadata,02 期拆过)。守卫能通过 ExecutionContext + Reflector 读到:

ts 复制代码
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),
    context.getClass(),
]);

而中间件拿不到 ExecutionContext,根本读不到这些标记 ------如果鉴权用中间件做,就只能"所有路由一视同仁地查 token",做不到 /health 放行、/reindex 要管理员这种按接口分流。所以鉴权必须放守卫。

决策表

场景 用谁 为什么
给所有请求盖 requestId / 计时 / 透传头 中间件 只看 req/res,不看路由是谁
CORS 预检、静态资源、body 解析 中间件 平台层能力,越靠外越好
鉴权,且要区分 public / 角色 守卫 要读路由元数据(Reflector)
参数校验、类型转换 管道(不是这两者) 生命周期更靠里

决策口诀:"要不要知道『这是哪个接口』?要知道 → 守卫/拦截器;不 care、只想碰所有请求的 req/res → 中间件。" 中间件是"无差别预处理",守卫是"按接口裁决"。

七、项目没用到的中间件形态:多个绑定 / exclude / 函数式

项目只用最简的 apply(...).forRoutes("*"),这些扩展形态识认即可:

7.1 一次绑多个

ts 复制代码
consumer.apply(RequestContextMiddleware, CorsMiddleware).forRoutes("*");  // 按顺序依次执行

7.2 排除某些路径

ts 复制代码
consumer
    .apply(AuthMiddleware)
    .exclude(
        { path: "health", method: RequestMethod.GET },
        { path: "docs", method: RequestMethod.GET },
    )
    .forRoutes("*");

注意:exclude 用路径 + 方法 排除,跟守卫里 @Public() 的"按元数据放行"是两种思路------一个是绑定期的路径排除,一个是运行期读元数据。

7.3 函数式中间件

无依赖注入需求时可以直接写函数,省掉 @Injectable 类和 implements:

ts 复制代码
export function requestId(req, res, next) {
    req.requestId = newRequestId();
    next();
}
consumer.apply(requestId).forRoutes("*");   // 直接传函数

类 vs 函数怎么选 ?类中间件能构造注入;函数式不能。rag-server 用 @Injectable 类------将来要注入依赖不用改绑定处。这也呼应 06 期那句:别为了用能力而用,形态跟着"需不需要被管理"走。 (不过 RequestContextMiddleware 目前构造器为空,严格说这里函数式也够------它的"必须用类"理由在于第五条那个 ALS 上下文后续可能扩展成可注入的 store。)

八、中间件常见坑

  1. 忘了调 next() → 请求永远挂起、超时。所有分支都要保证最终能 next()(或显式短路返回)。runWithRequestContext(..., next) 这种包装写法尤其要检查 next 真被传进去执行了;
  2. 在中间件里做鉴权、却想读 @Public() 元数据 → 读不到。鉴权按接口分流请用守卫;
  3. 从中间件 throw 业务异常,指望路由级过滤器接 → 接不到。中间件在路由处理器被选中之前运行,中间件抛的错只有全局过滤器能接(08 期已证)。中间件不是抛业务错误的地方;
  4. 把中间件绑在子模块,却以为它全局生效 → 只有它所在模块 + 依赖路径内的请求才过它;
  5. 多个中间件顺序写反 → apply(A, B) 按 A → B 执行;跨模块时根模块先于子模块。requestId 这种"别人要依赖它"的中间件必须排最前;
  6. 混淆中间件和拦截器 → 中间件签名 (req, res, next) 碰裸对象、绑路径;拦截器签名 intercept(context, next) 拿 ExecutionContext + 可观察流、绑控制器/方法。看签名就知道是谁;
  7. 以为 ALS 上下文能跨进程/跨请求 → 它是"每个请求一份"的隐形变量,不在同一个请求的异步链里读不到。这也是为什么 req.requestId 的显式兜底不能省。

九、前端心智一眼记 + 自测

前端概念 对应 Nest Middleware 本质
Next.js 的 middleware.ts Nest consumer.apply(...) 最外层统一预处理
Redux 中间件链(store => next => action) Express 式 (req,res,next) 链 洋葱式 next 放行
前端埋点给请求/会话生成 traceId 中间件生成 requestId + ALS 播种 链路追踪透传
请求拦截器统一往 header 塞 token 中间件改 req.headers 无差别预处理
路由守卫(Vue Router beforeEach)按 meta 分流 更像 Nest 守卫,不是中间件 需要"知道是哪个路由"→ 守卫
React Context 让深层组件不用层层传 props ALS 让深层 Service 不用透传 Request 隐形上下文

一条最重要的对应 :前端常把"鉴权路由守卫"和"请求预处理"都叫"拦截器",但在 Nest 里它们被拆成了**中间件(碰 req/res、不看路由)和守卫(读路由元数据、按接口裁决)**两层。理解这个分界,比记 API 有用得多。而 ALS 那一步,则相当于给整条异步调用链装了一个"不用手传的 React Context"。

3 个自测题(先自己答,再看答案)

  1. RequestContextMiddleware 为什么实现 NestMiddleware、写 use(req,res,next),而不是像守卫那样写 canActivate(context)? → 因为中间件是链路最外层、"最像 Express"的一层,拿的是平台原生 req/res,不需要也不该拿到 ExecutionContext。签名 use(req,res,next) 就是身份标志。

  2. 鉴权 AuthGuard 为什么做成守卫而不是中间件? → 守卫能通过 ExecutionContext + Reflector 读路由方法元数据(@Public()/@RequireAdmin())从而按接口语义分流;中间件读不到元数据,只能对所有请求一视同仁。口诀:要不要知道"这是哪个接口"?要 → 守卫。

  3. req.requestId 和 ALS 上下文这两条通道,为什么不能只留一条? → req.requestId 显式、任何拿到 Request 的层都能读,但深处(Service)拿不到 Request;ALS 隐形、贯穿整条异步链,但只能在同一个请求的异步上下文里读、且有隐式依赖。两条互补,rag-server 保留显式兜底以免"不依赖 ALS 的层"读不到 id。

十、本篇收束与下一篇

中间件这一站拆完:它是链路最外层、平台化的那一层,靠"路径绑定 + 裸 req/res"干活,典型的正确用法是"在最外层盖章,后面所有人共用"------rag-server 用它在 req.requestId 和 ALS 上下文两条通道上同时落了 rid 透传。而"哪些事不该给它做"的答案,恰好指向下一站。

下一篇(10 期《Guards 守卫》)拆链路第二站:AuthGuard 怎么用 Reflector 读元数据、@Public()/@RequireAdmin() 这套"暗号链"完整闭环、以及守卫能拿到 ExecutionContext 到底多给了它什么能力------02 期埋的那条线索,在这里收口。


相关推荐
Bughandler42 分钟前
FastAPI 路由操作数据库 + 服务启动:main.py 全拆解
python·ai编程
Gopher_HBo42 分钟前
zap日志 整体架构与数据流
后端
创新技术阁42 分钟前
FastapiAdmin 实战:演示模式开关失效的排查记录
前端·后端·fastapi
归鹭42 分钟前
spring外部化配置
后端
拖孩42 分钟前
一个人 + AI 做的小程序,一个半月把服务器钱赚回来一半了
前端·后端·微信小程序
风骏时光牛马42 分钟前
后端服务接口开发与业务逻辑实现
前端
吴佳浩42 分钟前
Multi-Agent 通信协议与编排中枢:状态机、DAG 与事件总线
人工智能·agent·ai编程
Flynt42 分钟前
Jev 爆火一周后,我装了它的开源平替 Laya:路由 0.3 毫秒是真的,推理先把我机器干爆了
开源·agent·ai编程
京东云开发者43 分钟前
从零构建一个生产级记忆型 AI Agent —— AgentScope 项目全景技术与学习指南
后端·架构·ai编程