NestJS 的请求到底经过了什么:装饰器、守卫、拦截器、管道与中间件如何配合

一句话结论:NestJS 的扩展点各有边界------装饰器声明规则或读取上下文,Middleware 做路由前预处理,Guard 决定是否放行,Interceptor 包围调用过程,Pipe 负责参数,Exception Filter 负责最后兜底。

刚开始写 NestJS 时,我也把 Guard、Interceptor、Middleware 当成几种差不多的"前置钩子":反正都能拿到 Request,代码放在哪里似乎都能跑。项目变大之后,这种做法很快就会失控。认证藏在拦截器里,参数转换散落在 Controller,日志中间件又不知道接口上的元数据,最后每一层都在做别人的工作。

真正好用的判断方式不是背定义,而是先回答三个问题:这段逻辑发生在请求的哪个阶段?它是否依赖具体 Handler 的元数据?它只关心输入,还是需要同时观察返回值和异常?

先把一条请求走完

下面这张图足够覆盖大多数 HTTP 应用的主流程:

flowchart LR A[Request] --> B[Middleware] B --> C[Guard] C --> D[Interceptor 前置] D --> E[Pipe / 参数解析] E --> F[Controller / Service] F --> G[Interceptor 后置] G --> H[Response] B -. 未捕获异常 .-> X[Exception Filter] C -. 未捕获异常 .-> X D -. 未捕获异常 .-> X E -. 未捕获异常 .-> X F -. 未捕获异常 .-> X

Middleware 最早进入,随后 Guard 判断请求能否继续。Interceptor 的前置逻辑包住 Pipe 和 Handler,Handler 返回后再经过 Interceptor 的后置逻辑。未被处理的异常最终交给 Exception Filter。

同一种组件还可能注册在全局、Controller 和方法三个作用域。范围越大,越应该只承载稳定、通用的规则。要特别注意 Interceptor 是洋葱模型:进入时按全局到局部执行,返回时顺序相反。假设注册顺序是性能统计、慢请求监控、限流、响应包装,响应会先被最内层包装,再逐层返回。顺序不是形式问题,它会直接影响计时范围、缓存内容和错误处理。

注册方式也表达了设计意图。认证基线可以用 APP_GUARD 注册为全局 Provider,操作审计可用 APP_INTERCEPTOR 覆盖全局,参数基线通常由 app.useGlobalPipes() 建立;只服务于单个业务域的能力则用 @UseGuards()@UseInterceptors() 放到 Controller 或方法上。通过 Provider 注册的全局组件可以正常注入依赖,比在启动文件里直接 new 一个实例更容易测试和维护。方法级配置最精确,但若几十个接口都在重复同一行装饰器,就应重新判断它是否已经是 Controller 或全局规则。

装饰器:不做重活,只负责"声明"与"取值"

自定义装饰器最常见的误解,是把它当成一段隐藏的业务逻辑。实际项目里,我更愿意把它限制为两类。

第一类是参数装饰器,从执行上下文中取出前面环节已经准备好的数据:

typescript 复制代码
export const CurrentUser = createParamDecorator(
  (_data, context: ExecutionContext) => {
    const request = context.switchToHttp().getRequest();
    return request.user;
  },
);

@Get('profile')
profile(@CurrentUser() user: UserContext) {}

它消除了 @Req() 和 Request 字段名在 Controller 中的重复,也让参数意图更明确。但 @CurrentUser() 不应该自己解析 Token;request.user 应由认证 Guard 提前写入。类似做法还适合当前租户、操作者、角色、数据范围和标准化日期等上下文。

第二类是元数据装饰器,为 Guard 或 Interceptor 留下声明:

typescript 复制代码
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

export const RequirePermission = (...codes: string[]) =>
  SetMetadata(PERMISSION_KEY, codes);

export const LogOperation = (options: LogOptions) =>
  SetMetadata(LOG_OPERATION_KEY, options);

这些函数本身既不认证,也不查权限,更不写日志。它们只把规则贴在类或方法上,真正的执行者通过 Reflector 读取。装饰器和消费者应该成对设计:@Public() 对应认证 Guard,@RequirePermission() 对应权限 Guard,@LogOperation() 对应操作日志 Interceptor。只有装饰器而没有消费者,声明不会产生任何效果。

当一组声明总是一起出现,可以用 applyDecorators() 组合,例如把权限、操作日志和 API 文档组合成一个业务动作装饰器。但组合装饰器仍应保持可推断:看到名称就能知道它声明了什么,不能把数据库查询或外部调用藏进去。元数据 Key 最好集中定义为常量或 Symbol,并明确方法级配置是覆盖还是合并类级配置;getAllAndOverride()getAllAndMerge() 的选择应该由这条语义决定,而不是随手使用。

Middleware:与具体 Handler 无关的入口预处理

Middleware 运行在 Nest 路由处理链之前,适合处理协议和入口层问题。例如某组路由第一段路径必须是支持的平台类型:

typescript 复制代码
@Injectable()
export class PlatformMiddleware implements NestMiddleware {
  use(request: Request, _response: Response, next: NextFunction) {
    if (!SUPPORTED_PLATFORMS.includes(request.params.platform)) {
      throw new BadRequestException('Unsupported platform');
    }
    next();
  }
}

它在模块中按路由范围注册,而不是依赖 Controller 方法上的装饰器:

typescript 复制代码
configure(consumer: MiddlewareConsumer) {
  consumer
    .apply(PlatformMiddleware)
    .forRoutes(':platform/*');
}

我通常把原始 Body 或 XML 解析、通用 Header 归一化、路径参数预检、请求 ID 初始化放在这里。它们有一个共同点:不需要知道最终会执行哪个 Handler。

Middleware 能看到 URL,却不适合读取 @RequirePermission() 之类的方法元数据,也不天然拥有 Nest 的 ExecutionContext。如果规则表达的是"这个接口允许谁访问",Guard 更合适。另一个边界是原始 Body:签名校验若依赖未经转换的字节,应在 Body Parser 消耗数据之前保存原文,不能等进 Controller 后再设法还原。

XML 回调是另一个典型例子:只对指定路由使用文本解析器,在 Middleware 中保存原文或完成一次安全解析,再把结构化结果交给后续 Pipe 或 Controller。解析时要限制 Body 大小、禁用危险的外部实体,并把解析失败转换为参数错误。Middleware 的挂载范围应尽量窄;通配路由的写法可能随 HTTP Adapter 或底层路由版本变化,框架升级后要用端到端测试验证实际命中范围。

Guard:把"能不能进入"说清楚

Guard 的职责很直接:返回 true 放行,否则抛出异常或返回 false。认证和授权都属于这个阶段,但两者不要混为一谈。

@Public() 与全局认证 Guard

生产服务更稳妥的默认值是全局保护,只对少量公开接口显式放行:

typescript 复制代码
@Public()
@Post('login')
login() {}

Global Auth Guard 读取方法和类上的元数据,并在认证成功后准备用户上下文:

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

const request = context.switchToHttp().getRequest();
const token = this.extractToken(request);
if (!token) throw new UnauthorizedException();

request.user = await this.tokenService.verify(token);
return true;

这里的关键不是 JWT 还是 Session,而是认证只有一个权威入口。Token 缺失、过期、签名错误或用户状态不可用时都应失败关闭,不能因为依赖异常就临时放行。

@RequirePermission() 与权限 Guard

认证解决"你是谁",授权解决"你能否访问这个 Handler"。接口只负责声明需要的权限:

typescript 复制代码
@RequirePermission('report:read', 'report:export')
@Get('reports')
findReports() {}

权限 Guard 读取声明,并结合认证 Guard 写入的用户信息判断:

typescript 复制代码
const required = this.reflector.getAllAndOverride<string[]>(
  PERMISSION_KEY,
  [context.getHandler(), context.getClass()],
);
if (!required?.length) return true;

const request = context.switchToHttp().getRequest();
if (!request.user) throw new UnauthorizedException();

const allowed = await this.permissionService.hasAny(
  request.user.id,
  required,
);
if (!allowed) throw new ForbiddenException();
return true;

示例采用"满足任意一个权限",实际也可以是全部满足,但语义必须写进命名和测试。认证信息不存在应该返回未认证,身份有效但权限不足才是禁止访问;这两个状态不要用同一个业务码含混处理。

公开接口也要克制使用。@Public() 表达的是完全绕过全局身份认证,不等于"登录用户和匿名用户都可以访问"。如果接口需要可选身份,可以单独设计 Optional Auth Guard:有 Token 就严格校验并写入用户,没有 Token 才按匿名处理。把 Token 校验失败也当匿名,会让过期凭证和攻击流量悄悄穿过认证边界。

Interceptor:同时站在调用的两侧

Interceptor 最特别的地方是它既能在 Handler 前运行,也能通过 RxJS 流观察成功结果、异常和结束时刻。因此它适合上下文注入、响应包装、缓存、性能指标和操作审计,但不适合承担"是否允许进入"的主判断。

数据权限 Interceptor 与参数装饰器

功能权限只决定能否打开门,数据权限还要决定进门后能看到什么。认证和功能授权通过后,Interceptor 可以加载用户的数据范围:

typescript 复制代码
const request = context.switchToHttp().getRequest();
request.dataPermission =
  await this.permissionService.loadDataScope(request.user.id);

return next.handle();

Controller 再通过参数装饰器显式接收:

typescript 复制代码
export const DataPermission = createParamDecorator(
  (_data, context: ExecutionContext) =>
    context.switchToHttp().getRequest().dataPermission,
);

@Get()
find(@DataPermission() permission: DataScope) {}

这组配对的价值是把"如何加载范围"和"业务如何使用范围"分开。装饰器没有凭空产生权限,它只是读取 Interceptor 准备的数据;后面的 Service/DAO 仍必须把范围落实成 QueryBuilder 条件、SQL 参数或下游请求参数。

操作日志装饰器与审计 Interceptor

操作日志依赖具体业务语义,单看 URL 往往不够,因此由接口声明场景更清楚:

typescript 复制代码
@LogOperation({ scene: 'report', description: '导出报表' })
@Post('export')
exportReport() {}

Interceptor 用 Reflector 取出配置,在流的成功和错误分支异步落日志:

typescript 复制代码
const options = this.reflector.get<LogOptions>(
  LOG_OPERATION_KEY,
  context.getHandler(),
);

return next.handle().pipe(
  tap({
    next: result => void this.audit.writeSuccess(options, request, result),
    error: error => void this.audit.writeFailure(options, request, error),
  }),
);

审计日志不是把 Request、Response 原样序列化。密码、Token、手机号等字段要按路径脱敏,大对象需要截断,还要避免日志写入失败反过来阻塞主请求。对于必须可靠送达的审计,可将事件写入消息队列或本地事务外盒,而不是只做一次不受控的异步调用。

响应包装、性能与缓存

响应结构和耗时统计可以用一段很短的组合说明:

typescript 复制代码
const startedAt = Date.now();

return next.handle().pipe(
  map(data => ({ data, statusCode: 200 })),
  finalize(() => this.metrics.observe(Date.now() - startedAt)),
);

清理资源和结束计时应使用 finalize(),否则异常请求不会进入只处理成功值的 map()。缓存也常放在 Interceptor,但缓存 Key 必须包含租户、操作者以及所有影响结果的路径、查询和请求参数;缺一项都可能造成越权或脏数据。进程内的锁、限流 Map 和本地缓存只对单实例有效,需要集群一致性时应使用具备原子操作的共享存储。

缓存放在响应包装 Interceptor 的内侧还是外侧也有差别:内侧通常缓存业务数据,外侧可能缓存统一响应壳。两种方案都能工作,但读取缓存时必须返回与未命中路径相同的类型。写操作后的失效策略、空值是否缓存、并发回源和 TTL 抖动需要一起设计,否则"加一个缓存装饰器"只是把偶发慢请求换成偶发旧数据和缓存击穿。

Pipe:让参数在进入业务前变得可信

Pipe 只关心当前参数的转换与校验。比如查询字符串 ids=a,b,c 要变成数组,与其让每个 Service 重复拆分,不如声明一个可复用的 Pipe:

typescript 复制代码
@Injectable()
export class CsvArrayPipe implements PipeTransform<string, string[]> {
  transform(value?: string) {
    if (!value) return [];

    return value
      .split(',')
      .map(item => item.trim())
      .filter(Boolean);
  }
}

@Get()
find(@Query('ids', CsvArrayPipe) ids: string[]) {}

同类转换还包括字符串到数字、布尔值和日期。转换不能只写一个 Number(value)new Date(value) 就结束:NaNInvalid Date、连续逗号产生的空元素、重复值和超长数组都要有明确约定。若参数错误,应在 Pipe 中抛出稳定的参数异常,而不是把坏值带入数据库层。

对象参数则更适合 DTO 和 ValidationPipe

typescript 复制代码
@Post()
create(
  @Body(new ValidationPipe({ whitelist: true, transform: true }))
  dto: CreateDto,
) {}

whitelist 可以移除 DTO 未声明的字段;安全要求更高时可配合 forbidNonWhitelisted 直接拒绝。格式、长度、枚举和嵌套结构属于输入边界,跨字段业务规则则可以由自定义校验器或 Service 表达。Pipe 不负责判断当前用户能否创建资源,也不负责包装响应。

全局开启 transform 前还要检查隐式类型转换是否符合预期。例如字符串 'false' 不能因为 JavaScript 的真值规则变成 true,超出安全整数范围的 ID 也不应直接转成 number。对于分页、日期区间和数组长度等频繁出现的约束,统一 Pipe 或 DTO 能保证所有入口使用同一口径;业务含义不同的参数则不要为了复用强塞进一个"万能转换管道"。

Exception Filter:给未捕获异常一个稳定出口

即使每层职责清楚,数据库、网络和业务代码仍可能抛错。全局 Filter 的作用是记录内部诊断信息,并返回不会泄露堆栈、SQL 或下游地址的外部结构:

typescript 复制代码
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(error: unknown, host: ArgumentsHost) {
    const response = host.switchToHttp().getResponse<Response>();
    const status = getHttpStatus(error);

    this.logger.error(toInternalLog(error));
    response.status(status).json(toPublicError(error));
  }
}

Interceptor 可以观察自己所包围调用链里的正常结果和异常,Filter 则专门接住最终未处理的异常。Filter 不是万能的 try/catch:调用外部服务时,如果业务需要超时、重试、降级或把对方错误转换成本域错误,就应在调用处处理;等到全局 Filter 才处理,已经失去了业务上下文。

HTTP 状态码、业务错误码和响应结构也要形成团队契约。普通 HTTP API 应让状态码保留协议语义:成功使用 2xx,客户端错误使用 4xx,服务端错误使用 5xx。若历史客户端协议被"始终返回 HTTP 200、再由业务码表达结果"绑定,应把它明确标为兼容约束,并让网关监控、客户端重试和告警统计同时识别业务码;不要让不同 Controller 各自选择一套语义。

内部日志至少应包含请求 ID、路由、耗时和原始异常链,外部响应只保留客户端可以行动的信息。未知异常统一映射为通用错误,已知业务异常才暴露经过审核的消息。这样既方便排查,也避免把堆栈、数据库字段和第三方响应泄露给调用方。

权限不是一个开关,而是三层约束

把前面的组件串起来,一套清晰的权限链通常是这样:

text 复制代码
AuthGuard
  → 校验身份并写入 request.user

PermissionGuard
  → 读取 @RequirePermission 元数据
  → 判断功能权限

DataPermissionInterceptor
  → 加载数据范围并写入 Request
  → @DataPermission() 参数装饰器读取
  → Service/DAO 将范围落实到查询条件

这三层分别回答"你是谁""你能调用什么""你能看到哪些数据"。前两层通过不代表第三层自动安全。数据范围如果只传到 Controller,SQL 却没有追加租户、组织或资源条件,权限实际上没有生效。

数据权限还应采用参数绑定。QueryBuilder 中使用命名参数或展开参数,raw SQL 使用数据库驱动的预处理语句或参数化查询;只做字符串格式化或转义不是等价防线,更不能把用户输入直接拼进条件。对于调用下游服务的场景,也要把范围转换为明确、可审计的请求契约,而不是默认相信下游会猜出当前用户的边界。

配置与基础设施:Apollo 不是微服务

个人项目观察:在所梳理的 NestJS 项目里,Apollo 只承担配置读取,数据库地址、功能开关、超时或 SQL 模板由它提供。Apollo 官方也把自身定义为集中管理应用与集群配置的配置管理系统;是否通过网络访问并不改变这一职责。Redis 用于缓存、分布式锁或限流时同样属于基础设施,不能因为"多个服务都在用"就称为微服务。

配置客户端适合封装为 DynamicModule,让业务模块只依赖抽象的配置服务:

typescript 复制代码
ConfigCenterModule.forRootAsync({
  inject: [ConfigService],
  useFactory: config => ({
    endpoint: config.get('CONFIG_CENTER_ENDPOINT'),
    namespace: config.get('CONFIG_NAMESPACE'),
  }),
});

forRoot() 适合调用时已经拿到的静态选项;forRootAsync() 适合选项还依赖环境变量、Secret 或其他 Provider。初始化完成后,业务代码通过 DI 获取配置,不需要知道底层是 Apollo、环境变量还是本地文件。

配置读取也有输入边界。JSON/YAML 字符串应先解析再做结构校验;缺少关键配置时应阻止应用启动,而不是带着 undefined 继续运行;非关键配置可以有经过评审的默认值。敏感配置不得打印到启动日志,动态更新还要考虑旧值、新值切换期间的一致性。

微服务与服务间通信:先区分传输,再讨论治理

Nest 原生微服务通常指 @nestjs/microservices 提供的消息模式与 transporter。调用方可以通过 ClientsModule 注入 ClientProxy,底层选择 TCP、gRPC、Kafka 或 Redis transporter:

typescript 复制代码
ClientsModule.register([
  {
    name: 'REPORT_CLIENT',
    transport: Transport.TCP,
    options: { host: 'service-host', port: 4000 },
  },
]);

这里的 Redis 是消息传输方式,和"用 Redis 做接口缓存"是两件事。gRPC 也不自动带来完整的服务治理,它首先解决的是通信协议和类型契约。

另一种常见模式仍然使用 HTTP:服务启动时向 Eureka 或 Nacos 注册,调用方通过注册中心发现健康实例,再由 HTTP Client 发起请求。此时 Eureka/Nacos 负责注册与发现,HTTP 才是实际 RPC 通道。配置中心提供的固定地址可以作为直连或兜底,但直连、发现和本地调试覆盖项的优先级必须写清楚。

无论选择哪种模式,工程上都绕不开这些问题:启动时注册、关闭时注销;健康实例和泳道/标签选择;连接、请求与整体超时;哪些错误可以重试;内部错误如何归一化;请求和响应如何维持稳定类型;应用关闭时如何释放客户端、定时器和监听器。把客户端包进 DynamicModule 只是第一步,生命周期和失败语义才决定它能否长期稳定运行。

重试尤其不能只设一个次数。查询类幂等请求可以在明确的超时和退避策略下重试,创建订单、扣减库存一类写请求则必须依赖幂等键或服务端去重。调用链还应透传请求 ID、截止时间和必要的身份上下文,但不要把入口 Token 不加区分地转发给所有下游。无论 HTTP、gRPC 还是消息队列,契约都应版本化,并在调用端设置比上游剩余时间更短的超时预算。

我会怎样约束这些扩展点

首先,每个扩展点只做一个阶段的事。Guard 不包装响应,Pipe 不查用户权限,参数装饰器不发远程请求,Filter 不吞掉所有诊断信息。其次,元数据装饰器必须有明确消费者,Request 上的自定义字段必须有统一类型,不能靠字符串字段名在各处碰运气。

测试也应按边界写。Pipe 用表格测试合法值、空值和异常值;Guard 用模拟 ExecutionContext 验证公开、未认证和权限不足;Interceptor 同时覆盖成功、异常与 finalize();再用少量端到端测试确认全局注册顺序和最终响应。真正容易出问题的通常不是单个类,而是这些类接在一起之后的执行次序。

最后可以用一张表快速决定代码放在哪里:

需求 首选机制
路由前通用预处理 Middleware
判断是否允许进入 Handler Guard
请求前后包装或上下文注入 Interceptor
参数转换与校验 Pipe
声明元数据或读取上下文 Decorator
未捕获异常统一响应 Exception Filter

当一段代码看起来放在哪里都可以时,我会回到请求生命周期再看一遍:它最早需要什么信息,最终影响哪个阶段,是否依赖 Handler 元数据。位置选对之后,NestJS 的这些扩展机制不是额外复杂度,反而是把横切逻辑从业务代码里拿走的秩序。

相关推荐
Gopher_HBo1 小时前
moby-client客户端
后端
杨运交2 小时前
[055][调度模块]Spring动态任务调度框架的设计与实现
java·后端·spring
卷福同学3 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
Csvn4 小时前
📊 SQL 入门 Day 11:CASE 表达式:SQL 里的 if-else 魔法
后端·sql
QQ_21696290964 小时前
Spring Boot 养老院管理系统:从入住、护理到费用结算的全流程实现(源码可领)
java·spring boot·后端
万少6 小时前
DeepSeek-V4-Flash 正式版上线了,但这 3 个坑我帮你提前踩了
前端·javascript·后端
明月_清风6 小时前
🚀 Palantir Foundry 本体论实战:当 Ontology 从"知识图谱"进化为"企业操作系统"
前端·后端
明月_清风6 小时前
从概念到代码:用 Ontology 构建你的第一个知识图谱
前端·后端
Python私教7 小时前
Django 6.1 邮件配置大改:旧项目如何平稳升级?
后端·python·django