承接上篇 :上一篇《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 个自测题(先自己答,再看答案)
-
ExecutionContext和ArgumentsHost的区别,一句话? →ExecutionContext extends ArgumentsHost,多getClass()(所属 Controller 的类)和getHandler()(即将被调用的路由方法)两个方法。守卫/拦截器因此能读暗号按接口分流,过滤器不能。 -
为什么过滤器只有宿主版?提示:中间件抛错时,有"当前 handler"吗? → 没有。中间件在路由处理器被选中之前运行,那时 Nest 还不知道这次请求对应哪个方法------
getHandler()/getClass()无值可给。过滤器要对"任何位置"的异常兜底,签名只能承诺"有 req/res"。 -
host.switchToHttp().getRequest()和host.getArgByIndex(0)有什么区别? → 运行时没区别(getRequest的实现就是getArgByIndex(0))。但前者把位置约定翻译成语义化方法名、只暴露 HTTP 那套;换到 WS/RPC 参数形状变了,getArgByIndex(0)拿到的就不是 req 了。能用 ≠ 该用。 -
想写一个"按当前路由读元数据"的横切逻辑,守卫、拦截器、过滤器、参数装饰器四个位置,哪些能做? → 守卫、拦截器、参数装饰器(三者都收
ExecutionContext,有getHandler()/getClass())。过滤器不行(只有ArgumentsHost);管道也不行(transform(value, metadata)里连 host 都没有)。
九、常见坑与边界
- 管道想读 req/res → 拿不到。
transform(value, metadata)里既没有 ArgumentsHost 也没有 ExecutionContext(只有"参数值 + 它是 body/query/param 的描述")。想按请求头/当前路由处理参数,去参数装饰器或守卫/拦截器; - 过滤器里用
getHandler()→ 编译都过不去。 类型是ArgumentsHost,没有这个方法。按路由定制错误响应,把信息塞req传过去; - 用
getArgs()[0]当 req 而不是switchToHttp().getRequest()。 能用,但脆弱且零语义------换传输层全错位,读代码的人也看不懂下标 0 是什么; getClass()返回的是类(构造函数),不是实例。 别拿它调 Controller 的方法------实例是 DI 管理的。它只用来查元数据和拿.name;- 拿 handler 引用和字符串比(
getHandler().name === "query"才放行)。 能用但脆:方法改名就静默失效 。该做的是@SetMetadata写暗号 +Reflector读------用元数据,别用方法名字符串; - 拦截器前置阶段读"校验后的 body" → 读不到。 拦截器前置在管道之前,
req.body还是原始 JSON;校验/转换过的 DTO 要等管道跑完进 handler 才有(11/12 期的时序); - 把
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 期那句"装饰器写暗号"的写这一侧,到底发生了什么。