项目开源地址(本专栏实证代码的教程仓)
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);
}
}
逐块拆:
implements NestMiddleware+ 实现use(req, res, next):Nest 中间件必须实现use方法,签名三件套直接来自 Express。这里的Request/Response/NextFunction都是从express导入的类型;@Injectable()是必须的 :类中间件要能被 DI 容器实例化、能被consumer.apply接收(函数式中间件则不需要,见第七节);- 逻辑三件事 :读请求头 → 有就复用/没有就生成 → 同时挂到
req和写回res。这正是链路追踪里"request id 透传"的标准姿势:上游传来的 id 别丢弃,没有才新造,这样跨服务能把同一次调用串起来; - 最后用
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 期说的"平台原生对象"就是它们)。
这个差异带来三个实际后果
- 中间件不能做"按路由元数据分流" :守卫能用
Reflector读@Public()/@RequireAdmin()决定放不放行;中间件读不到 任何装饰器写的元数据,它只认识req/res和绑定的路径。所以鉴权这种"要按接口语义区分"的事,放守卫,不放中间件; - 中间件可以干"裸 req/res"才能干的活 :直接改请求头、改写响应头、提前
res.end()短路(CORS 预检直接回)、读原始 body 流; - 中间件的"作用域"是路径,不是控制器/方法:绑定时指定的是"哪些 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("*"); // 管哪些路由:这里 "*" = 所有路由
- 模块类要实现
NestModule接口、写configure(consumer)------这是 Nest 给模块开的"中间件配置口"。只有根模块/需要绑中间件的模块才实现它;RagModule/HealthModule没实现; .apply(...)接收中间件类,.forRoutes(...)指定路径 ;"*"是通配符;- 路径可以写得很细 :
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。)
八、中间件常见坑
- 忘了调
next()→ 请求永远挂起、超时。所有分支都要保证最终能next()(或显式短路返回)。runWithRequestContext(..., next)这种包装写法尤其要检查next真被传进去执行了; - 在中间件里做鉴权、却想读
@Public()元数据 → 读不到。鉴权按接口分流请用守卫; - 从中间件
throw业务异常,指望路由级过滤器接 → 接不到。中间件在路由处理器被选中之前运行,中间件抛的错只有全局过滤器能接(08 期已证)。中间件不是抛业务错误的地方; - 把中间件绑在子模块,却以为它全局生效 → 只有它所在模块 + 依赖路径内的请求才过它;
- 多个中间件顺序写反 →
apply(A, B)按 A → B 执行;跨模块时根模块先于子模块。requestId 这种"别人要依赖它"的中间件必须排最前; - 混淆中间件和拦截器 → 中间件签名
(req, res, next)碰裸对象、绑路径;拦截器签名intercept(context, next)拿 ExecutionContext + 可观察流、绑控制器/方法。看签名就知道是谁; - 以为 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 个自测题(先自己答,再看答案)
-
RequestContextMiddleware为什么实现NestMiddleware、写use(req,res,next),而不是像守卫那样写canActivate(context)? → 因为中间件是链路最外层、"最像 Express"的一层,拿的是平台原生 req/res,不需要也不该拿到ExecutionContext。签名use(req,res,next)就是身份标志。 -
鉴权 AuthGuard 为什么做成守卫而不是中间件? → 守卫能通过
ExecutionContext+Reflector读路由方法元数据(@Public()/@RequireAdmin())从而按接口语义分流;中间件读不到元数据,只能对所有请求一视同仁。口诀:要不要知道"这是哪个接口"?要 → 守卫。 -
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 期埋的那条线索,在这里收口。