14 · NestJS ExecutionContext 执行上下文:守卫、拦截器、过滤器拿到的"同一个 context",为什么能力不一样?

承接上篇 :上一篇《ExceptionFilters 异常过滤器》收官了 AOP 五件套------但回看 09--13 期,有个东西五篇都在用、却始终没单独讲:context.getHandler()、context.getClass()、context.switchToHttp()、过滤器里的 host.switchToHttp()。 这篇就拆它:为什么守卫/拦截器收到的是 ExecutionContext、过滤器收到的却是 ArgumentsHost?为什么不直接把 Express 的 req 传进来?这层"看起来多余的一跳"到底买了什么?
定位 :本篇讲 AOP 组件们的"通用货币":ArgumentsHost 的托盘与换挡器、ExecutionContext 多出来的两个"我是谁"、过滤器只拿宿主版的根本原因,以及 rag-server 三份真实代码的并排对照。不讲自定义装饰器怎么消费 context(15 期)。读完你能一眼判断"这层代码能拿到 context 的哪一半",并解释给不同组件发不同钥匙的机制。

一、一句话回答

Execution Context(执行上下文)是 Nest 横切组件手里的"上下文把手",分两层:

  • ArgumentsHost(参数宿主) :把"这次请求传了哪些底层参数"包起来,给你 switchToHttp()/getRequest()/getResponse() 这类换挡器------把"参数位置随传输层变化"的差异挡在后面;
  • ExecutionContext = ArgumentsHost + 两个"我是谁" :getClass()(所属 Controller 的类)和 getHandler()(即将被调用的路由方法)。守卫/拦截器贴着某个具体 handler 运行,框架给完整版;异常过滤器要对"可能发生在任何 handler 被选中之前"的异常兜底,只给宿主版。
scss 复制代码
                      这次请求 (req, res, next)
                              │
              Nest 包成 ExecutionContextHost
              ┌──────────────────────────────┐
              │  args      = [req, res, next] │  ← getArgs() 就是这仨
              │  换挡器 switchToHttp()        │  ← getRequest() === args[0]
              │  class     = RagController    │  ← getClass()
              │  handler   = query()          │  ← getHandler()
              └──────────────────────────────┘
       守卫/拦截器 收到完整版(ExecutionContext)  → 能喊"在守谁" + 能摸 req/res
       异常过滤器 收到宿主版(ArgumentsHost)     → 只能摸 req/res

对 HTTP-only 的项目,这层看起来像"多余的一跳"(直接传 req 不香吗?)------但正是这一跳,让守卫/拦截器/过滤器写一套、跨 HTTP / WebSocket / RPC 复用。

前端移植锚点:React 合成事件包一层原生事件,跨浏览器/跨端逻辑写一份,e.nativeEvent 才是真身 ------ArgumentsHost 包 [req,res,next],switchToHttp() 就是那个掏真身的动作。
记忆锚点:ExecutionContext 是 AOP 组件手里共同握着的那把"可切换的钥匙串":钥匙能开哪些门,取决于 Nest 发给你的是完整版还是宿主版。

二、先对齐三个真实签名

rag-server 里正好各一份(都在 src/common/),同一次 POST /v1/api/query 请求里它们都会跑:

ts 复制代码
// auth.guard.ts ------ 守卫:收 ExecutionContext
canActivate(context: ExecutionContext): boolean {
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
        context.getHandler(),          // ← 方法级
        context.getClass(),            // ← 类级
    ]);
    ...
    const request = context.switchToHttp().getRequest<Request>();
    const header = request.headers["authorization"];
    ...
}

// logging.interceptor.ts ------ 拦截器:收 ExecutionContext
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const http = context.switchToHttp();
    const req = http.getRequest<Request & { requestId?: string }>();
    const res = http.getResponse<Response>();
    ...
}

// exception.filter.ts ------ 异常过滤器:只收 ArgumentsHost
catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const req = ctx.getRequest<Request & { requestId?: string }>();
    ...
}

三个文件都要读 req/res,签名却分成两类。先记住这个不对称,全文就为解释它:

组件 收的类型 能不能喊出"在守哪个 handler/类"
守卫 AuthGuard ExecutionContext ✅ getHandler()/getClass()
拦截器 LoggingInterceptor ExecutionContext ✅(本项目没用)
过滤器 AllExceptionsFilter ArgumentsHost ❌ 类型里就没有

前端直觉:同一个回调,有的地方给你传整个"路由匹配结果"(知道当前是哪个页面组件),有的地方只给你 event------摸得到 DOM,不知道在处理哪个页面。先记住"有的给得多、有的给得少",下面拆为什么。

三、ArgumentsHost:一个"原始参数托盘 + 换挡器"

3.1 运行时真相:[req, res, next] 原样打包

Nest 骑在 Express 之上,Express 调路由处理器天然就是 (req, res, next) 三个参数。Nest 没有重新发明一套,而是把这三个原样打包传给 AOP 层 。证据在本机 @nestjs/core@11.2.3 的 router/router-execution-context.js:

js 复制代码
// 调用守卫:把 [req, res, next] 作为参数数组传给 canActivate
fnCanActivate && (await fnCanActivate([req, res, next]));

// 调用拦截器:同样传 [req, res, next],再加 instance/callback/contextType
this.interceptorsConsumer.intercept(interceptors, [req, res, next], instance, callback, ...);

所以对 HTTP 请求,host.getArgs() 返回的就是 [req, res, next]:

ts 复制代码
host.getArgByIndex(0)   // === req
host.getArgByIndex(1)   // === res
host.getArgByIndex(2)   // === next

3.2 switchToHttp() 的实现,干脆到有点好笑

@nestjs/core 的 helpers/execution-context-host.js:

js 复制代码
switchToHttp() {
    return Object.assign(this, {
        getRequest:  () => this.getArgByIndex(0),
        getResponse: () => this.getArgByIndex(1),
        getNext:     () => this.getArgByIndex(2),
    });
}

没有魔法 :switchToHttp().getRequest() 就是 args[0]。它存在的意义不是"获取"本身,而是把"第 0 个参数是请求"这个位置约定,翻译成有语义的方法名,并且只暴露 HTTP 那套。

ArgumentsHost 类型上的完整方法(@nestjs/common 的 arguments-host.interface.d.ts):

方法 作用 说明
getArgs<T>() 拿整个参数数组 HTTP 下 = [req, res, next]
getArgByIndex(i) 按下标拿一个 等于 args[i]
switchToHttp() 切到 HTTP 适配器 getRequest/getResponse/getNext
switchToRpc() 切到 RPC/微服务适配器 getContext/getData(gRPC 等)
switchToWs() 切到 WebSocket 适配器 getClient/getData/getPattern
getType() 当前上下文类型 `'http'

3.3 为什么要"换挡",而不是直接传 req?

因为参数形状随传输层而变:

传输层 底层参数形状
HTTP(Express) [req, res, next]
WebSocket [client, data](data 里还带 event 名)
RPC(微服务) [data, ...](payload 不一定是请求对象)

如果守卫直接写死"第一个参数是 req",这套代码就只能跑在 HTTP 。Nest 想让你写一次守卫、同时跑在 HTTP / WebSocket / 微服务上,于是给一个 host,由你按需 switchToXxx()。getArgs()/getArgByIndex() 之所以"能用但别常用",就是因为位置数字换一个传输层就全错位。

前端直觉:这就是"横切逻辑只依赖抽象,不依赖某个传输层实现"------写通用拦截器时不摸浏览器 event 的私有字段,只用合成事件统一暴露的 API。axios 拦截器签名不随 adapter(xhr/fetch)变,同一个道理。

3.4 HTTP 项目用得到哪个?

rag-server 是 HTTP-only 项目,永远只走 switchToHttp() 这一行 ------switchToWs/switchToRpc 平时不会触发(26 期"平台无关性"会把这条线拉满)。但看懂"为什么存在",你才不会再对着 context.switchToHttp() 问"这不就是 req 吗,为什么要包一层"。

四、ExecutionContext = ArgumentsHost + 两个"我是谁"

类型定义一句话说清(execution-context.interface.d.ts):

ts 复制代码
export interface ExecutionContext extends ArgumentsHost {
    getClass<T = any>(): Type<T>;   // 所属 Controller 的类(构造函数,不是实例)
    getHandler(): Function;         // 即将被调用的路由方法的引用
}

ExecutionContext 只是 ArgumentsHost 多了两个方法,其余能力(getArgs / switchToHttp / getType...)全部继承。

这两个方法返回的是"引用",不是"去调用它"

ts 复制代码
context.getClass()    // → RagController 这个类(构造函数),不是实例
context.getHandler()  // → 当前要被调用的方法(如 query 的引用)

核心用途只有一个:拿引用去读"贴在这个类/方法上的元数据"------正是 10 期那套"装饰器写暗号、守卫读暗号":

ts 复制代码
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
    context.getHandler(),   // 先在方法上找 is_public
    context.getClass(),     // 找不到再去类上找
]);

Reflector.getAllAndOverride 之所以要"方法 + 类"两个目标,正是 getHandler() 和 getClass() 给的。守卫能"按接口分流鉴权",根子就在多出来的这两个方法------没有它们,守卫和中间件就没区别(09 期说过:中间件签名里根本没有"当前是哪个 handler"这个信息)。

前端移植锚点:Vue Router 的 to.meta 把"这个路由标了什么"直接喂到你手里;Nest 没那么"甜",给你的是两个引用 ,要你自己拿去 Reflector 里查。记住:拿到引用 ≠ 调用它,引用是用来"查档案(metadata)"的。

五、三个真实文件并排:同一个请求,各拿各的

把一次到 RagController.query 的请求摊开,各层拿到什么:

less 复制代码
RequestContextMiddleware(req, res, next)      ← 裸参,连 ArgumentsHost 都不是(09 期)
   → AuthGuard.canActivate(context)           ← ExecutionContext
   → LoggingInterceptor.intercept(context)    ← ExecutionContext
   → TrimBodyPipe / ValidationPipe            ← 只有值 + 参数元数据(11 期)
   → RagController.query(@Body dto)           ← 参数装饰器拆出来的 dto
   → (抛错时) AllExceptionsFilter.catch(exception, host)   ← 只有 ArgumentsHost
文件 用 context 干了什么 用的哪半能力
auth.guard.ts getHandler()/getClass() 查 @Public()/@RequireAdmin();switchToHttp().getRequest() 读 Authorization 头 两半都用了
logging.interceptor.ts switchToHttp() 拿 req/res,记 method/path/状态码/耗时/rid 只用继承的宿主半(没碰"我是谁")
exception.filter.ts host.switchToHttp() 拿 res 写 JSON、拿 req 读 requestId 只有宿主半,想碰也碰不到

一个有意思的对照 :LoggingInterceptor 和 AllExceptionsFilter 都只用了"宿主那半",但 Nest 给拦截器的是完整版、给过滤器的是宿主版。拦截器就坐在"会读暗号"的守卫隔壁,却完全没用 getHandler()------说明"有能力"和"用不用"是两回事:ExecutionContext 的能力是"给你、你随时可以要";ArgumentsHost 是"根本没给你"。

六、过滤器为什么只有宿主版:异常可能根本没有"当前 handler"

这是最容易被忽略、但最能帮你记住边界的一条:过滤器收到的对象天生就该少一半,不是 Nest 小气。

回想 08/13 期的生命周期:过滤器不是主链路的一步,它是兜底层------任何位置抛异常都会进它,包括:

scss 复制代码
AuthGuard 抛 401  → 还没到任何 handler,就被拦下
中间件抛错        → 发生在路由处理器被选中之前(08 期:只有全局过滤器能接)
管道抛 400        → handler 即将被调用但还没调
RagService 抛错   → handler 已跑完,在出站途中

尤其中间件抛错 那条:异常发生在"路由处理器还没被选出来"的阶段,此时 Nest 根本不知道 这次请求对应哪个 Controller 的哪个方法------getClass()/getHandler() 没有值可给。既然过滤器要为"没有当前 handler"的情况兜底,它的签名就只能承诺"有 req/res"(ArgumentsHost),不能承诺"知道在守谁"。

一句话记忆:守卫/拦截器"贴着某个 handler 运行",所以知道自己在守谁;过滤器"对任何异常兜底",异常可能发生在连 handler 都还没有的时刻。"给不给 getClass/getHandler",由"这层代码是否总在一个具体 handler 的上下文里运行"决定。

这也顺手解释了 13 期的一个现象:想在过滤器里"按路由定制错误响应"做不到------它读不到路由暗号。要做,得在守卫/拦截器那层先处理,或让守卫把需要的信息塞进 req、过滤器读 req(后者的活例子:中间件把 requestId 塞进 req,过滤器隔着两层照样读到)。

七、DebugGuard:从"练习题"变成"项目现役"

10 期我们已经见过它------src/common/debug.guard.ts,这里从 context 的视角再看一遍,它就是 getClass() + getHandler() 的最小消费样本:

ts 复制代码
export class DebugGuard implements CanActivate {          // 注意:没写 @Injectable()(10 期讲过边界)
    private readonly logger = new Logger(DebugGuard.name);

    canActivate(context: ExecutionContext): boolean {
        const controller = context.getClass().name;          // 如 "HealthController"
        const handler = context.getHandler().name;           // 如 "health"
        this.logger.log(`guarding ${controller}#${handler}`);
        return true;                                         // 只观察,不拦截
    }
}

五行代码,把"我是谁"那半能力用到了极致:getClass().name + getHandler().name,每个被放行的请求喊一声 guarding XxxController#yyy------10 期它被当作"守卫顺序探针"(401 时这行不出现)用的就是这份输出。

再往前一步就是角色鉴权的原型 :把"报名字"换成"读暗号"------@RequireAdmin()/@Roles() 都是 SetMetadata 写在方法上的,守卫拿 getHandler()+getClass() 去 Reflector 查。一个完整的 RolesGuard,和这个 DebugGuard 的骨架只差一行 Reflector 查询(10 期第八节已给全码)。

隐藏消费者提醒 :createParamDecorator 的工厂函数收到的也是 ExecutionContext------自定义参数装饰器想读 req/当前 handler,不必进拦截器,直接在装饰器里 ctx.switchToHttp().getRequest() 就行。ExecutionContext 的消费者不止守卫/拦截器两个,参数装饰器是第三个------15 期就拆它。

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

前端概念 对应 Nest Execution Context 本质
React 合成事件,e.nativeEvent 掏真身 ArgumentsHost 包 [req,res,next],switchToHttp() 掏 req/res 横切逻辑只依赖抽象
路由守卫里 to 告诉你当前路由 getHandler()/getClass() 告诉你当前 handler/类 AOP 层要知道"在守谁"
arguments / rest 参数数组 getArgs() = [req, res, next] 底层就是 Express 原样参数
事件系统不管 click 还是 submit,统一收 event 同一套守卫跨 HTTP/WS/RPC 换挡器屏蔽参数形状差异
有的回调只给 event、不给"当前组件" 过滤器只有 ArgumentsHost 给不给"我是谁"取决于是否贴着 handler

一条最重要的对应 :React 的合成事件你已经用了多年------不为兼容性谁会多包一层?包一层就是为了"写一份、处处跑" 。ArgumentsHost 是同一决策:多一跳的代价,换回 AOP 组件与传输层解耦。理解了这个取舍,这层抽象从"多余"变成"值得"。

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

  1. ExecutionContext 和 ArgumentsHost 的区别,一句话? → ExecutionContext extends ArgumentsHost,多 getClass()(所属 Controller 的类)和 getHandler()(即将被调用的路由方法)两个方法。守卫/拦截器因此能读暗号按接口分流,过滤器不能。

  2. 为什么过滤器只有宿主版?提示:中间件抛错时,有"当前 handler"吗? → 没有。中间件在路由处理器被选中之前运行,那时 Nest 还不知道这次请求对应哪个方法------getHandler()/getClass() 无值可给。过滤器要对"任何位置"的异常兜底,签名只能承诺"有 req/res"。

  3. host.switchToHttp().getRequest() 和 host.getArgByIndex(0) 有什么区别? → 运行时没区别(getRequest 的实现就是 getArgByIndex(0))。但前者把位置约定翻译成语义化方法名、只暴露 HTTP 那套;换到 WS/RPC 参数形状变了,getArgByIndex(0) 拿到的就不是 req 了。能用 ≠ 该用。

  4. 想写一个"按当前路由读元数据"的横切逻辑,守卫、拦截器、过滤器、参数装饰器四个位置,哪些能做? → 守卫、拦截器、参数装饰器(三者都收 ExecutionContext,有 getHandler()/getClass())。过滤器不行(只有 ArgumentsHost);管道也不行(transform(value, metadata) 里连 host 都没有)。

九、常见坑与边界

  1. 管道想读 req/res → 拿不到。 transform(value, metadata) 里既没有 ArgumentsHost 也没有 ExecutionContext(只有"参数值 + 它是 body/query/param 的描述")。想按请求头/当前路由处理参数,去参数装饰器或守卫/拦截器;
  2. 过滤器里用 getHandler() → 编译都过不去。 类型是 ArgumentsHost,没有这个方法。按路由定制错误响应,把信息塞 req 传过去;
  3. 用 getArgs()[0] 当 req 而不是 switchToHttp().getRequest()。 能用,但脆弱且零语义------换传输层全错位,读代码的人也看不懂下标 0 是什么;
  4. getClass() 返回的是类(构造函数),不是实例。 别拿它调 Controller 的方法------实例是 DI 管理的。它只用来查元数据和拿 .name;
  5. 拿 handler 引用和字符串比(getHandler().name === "query" 才放行)。 能用但脆:方法改名就静默失效 。该做的是 @SetMetadata 写暗号 + Reflector 读------用元数据,别用方法名字符串;
  6. 拦截器前置阶段读"校验后的 body" → 读不到。 拦截器前置在管道之前,req.body 还是原始 JSON;校验/转换过的 DTO 要等管道跑完进 handler 才有(11/12 期的时序);
  7. 把 context 存下来跨异步缝隙用。 req/res 是存活对象,晚读 res.statusCode 没问题(12 期 LoggingInterceptor 就在 tap() 里晚读);但"深处 Service 要归口 rid"这种需求,别抱着 context 不放------09 期的 AsyncLocalStorage 才是正解,context 给的是"这层的把手",不是"全链路的通道"。

十、本篇收束与下一篇

执行上下文这一站拆完:它是一个两层的把手------宿主层把 [req, res, next] 托盘化、用换挡器屏蔽传输层差异;执行层多出 getClass()/getHandler() 两个"我是谁",给了守卫/拦截器"贴着 handler 运行"的资格,而过滤器因为要对"连 handler 都还没有的时刻"兜底,只拿宿主版。同一个请求、三份代码、各拿各的钥匙------09--13 期散落各篇的 context.xxx() 调用,至此全部对上号。

下一篇(15 期《自定义装饰器》)拆这套体系的"第三个消费者":@Public()/@RequireAdmin() 这类自定义装饰器是怎么用 SetMetadata + 参数装饰器工厂造出来的、createParamDecorator 的工厂函数为什么也收 ExecutionContext、以及 10 期那句"装饰器写暗号"的写这一侧,到底发生了什么。


相关推荐
小小张说故事1 小时前
Python 多线程为什么跑不快?asyncio 入门指南:异步并发从零上手
后端·python
明月_清风1 小时前
数据进入平台后怎么处理?一文搞懂 ETL
大数据·后端·数据分析
初学AI的小高1 小时前
LangGraph断点恢复与幂等执行实战
后端·架构
欧西1 小时前
AI全栈安全Agent平台(九):自动化测试用例生成引擎实战
后端
程序员Flycan1 小时前
🚀 跨域终结者:前端代理服务器(Proxy)原理解析与配置总结
前端
怕浪猫1 小时前
顶级模型一句话,AI 写出了能玩的 QQ飞车
前端·面试·github
huakoh1 小时前
MCP 报错分不清?先看响应里是 result 还是 error
前端
呃呃呃呃ex1 小时前
10. 现代前端工程化:ES6 React 项目实战与原理解析
前端·javascript
独泪了无痕1 小时前
Hutool之RandomUtil:随机数生成的终极利器
java·后端