Skip to content
NestJS 关键组件:中间件、守卫、管道与拦截器
概述
NestJS 的请求处理并非由控制器直接完成,而是经过一个分阶段的管线(pipeline)。在请求到达控制器之前,中间件、守卫、管道和拦截器依次介入。中间件最先接触请求,可以操作原始的 req/res 对象;守卫负责回答“当前用户是否能访问该路由”;管道对参数做校验和转换;拦截器则在方法执行前后插入横切逻辑。这四个组件的协作关系可以用一张简图描述:
请求 → 中间件 → 守卫 → 管道 → 控制器 → 拦截器(后) → 响应
↑ 拦截器(前) ↓拦截器的“前逻辑”在守卫通过之后、管道之前运行,“后逻辑”在控制器返回结果之后运行。异常过滤器运行在另一纬度,不在本篇讨论范围。
基本概念
中间件
中间件与 Express 中间件概念一脉相承,是一个在路由处理之前执行的函数。它可以访问原始的请求对象、响应对象以及 next 函数,适合做日志记录、CORS 头设置、Session 初始化等与底层平台强相关的操作。
NestJS 支持两种形态:函数中间件和类中间件。类中间件可以实现 NestMiddleware 接口,从而利用 Nest 的依赖注入。
守卫
守卫实现 CanActivate 接口,核心方法是 canActivate,返回布尔值(或 Promise<boolean> / Observable<boolean>)来决定是否继续处理请求。守卫在路由明确之后、控制器方法执行之前运行,用于认证和授权。它能够通过 ExecutionContext 获取当前路由处理器信息,例如读取自定义的元数据。
管道
管道实现 PipeTransform 接口,其 transform 方法接收原始值并返回处理后的值。管道在守卫放行之后、控制器方法执行之前运行。典型场景包括参数类型转换(如把字符串 "2" 转为数字 2)、数据校验(结合 class-validator 对 DTO 做完整验证)。校验失败时抛出异常,请求不会进入控制器。
拦截器
拦截器实现 NestInterceptor 接口,核心方法是 intercept,接收 ExecutionContext 和 CallHandler,返回 Observable(或 Promise)。next.handle() 返回的 Observable 代表控制器方法执行的结果流,通过 pipe 操作符可以在这条流上附加逻辑。因此拦截器能在方法执行前后执行代码,常用于记录耗时、统一包装返回值、缓存等场景。
工作原理
一次完整的请求按以下顺序经过各个组件:
- 中间件:全局中间件先执行,然后按模块内部的
apply顺序执行模块中间件。 - 守卫:先全局,再控制器,最后方法级别的守卫。
- 拦截器前逻辑:全局 → 控制器 → 方法依次执行
intercept中next.handle()之前的代码。 - 管道:全局 → 控制器 → 方法 → 参数管道,对每个参数依次执行转换和校验。
- 控制器方法:实际业务逻辑执行。
- 拦截器后逻辑:控制器的返回值流经过拦截器的 Observable 管道,顺序与前面相反——方法拦截器先处理,然后是控制器,最后全局。
- 若中途有异常抛出,后续阶段不再执行,异常将由异常过滤器接管。
拦截器的前后逻辑运行机制类似中间件栈:intercept 被调用时执行前逻辑,next.handle() 触发下层拦截器或控制器,pipe 操作符定义后逻辑。因此多个拦截器的后逻辑是“先进后出”。
基本用法
Middleware
函数形态中间件就是一个接收 req、res、next 的函数:
ts
const loggerMiddleware = (req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
};类形态中间件可以实现 NestMiddleware 接口,以便注入其他服务:
ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
next();
}
}类中间件通过模块的 configure 方法注册,并使用 MiddlewareConsumer 指定应用路径:
ts
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
@Module({})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('cats'); // 仅对 "cats" 路由生效
}
}forRoutes 可以接收路径字符串、控制器类,或者带有 { path: 'cats', method: RequestMethod.GET } 的对象来限制 HTTP 方法。apply 可以同时挂载多个中间件,按数组顺序依次执行。
要排除某些路由,可以使用 exclude:
ts
consumer
.apply(LoggerMiddleware)
.exclude({ path: 'cats/(.*)', method: RequestMethod.GET })
.forRoutes(CatsController);函数形态中间件编写简单,但无法注入 Nest 服务。当中间件内部需要访问提供者时,应使用类形态。
Guard
守卫通过实现 CanActivate 接口来定义:
ts
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const roles = this.reflector.get<string[]>('roles', context.getHandler());
if (!roles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
return roles.includes(user?.role);
}
}ExecutionContext 提供了获取当前路由处理器和方法元数据的能力,switchToHttp() 可以拿到平台原生的 Request、Response 对象。上例通过 Reflector 读取自定义的 roles 元数据,再与请求中携带的用户角色进行比对。
在控制器或方法上通过 @UseGuards() 绑定:
ts
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {
@Get()
@Roles('admin') // 自定义装饰器设置角色元数据
findAll() {
return [];
}
}全局守卫可以通过 app.useGlobalGuards(new RolesGuard(new Reflector())) 设置,但这种方式不能使用依赖注入。更推荐的做法是使用令牌 APP_GUARD 在任何模块的 providers 中注册:
ts
@Module({
providers: [
{
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}这样守卫既是全局有效的,也能正常注入 Reflector 等其他提供者。
Pipe
管道实现 PipeTransform 接口,transform 方法签名通常为:
ts
transform(value: any, metadata: ArgumentMetadata): any校验场景中最常用的是内置的 ValidationPipe,它依赖 class-validator 和 class-transformer 对 DTO 进行校验和转换:
ts
import { IsString, IsInt, Min } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
@Min(0)
age: number;
}
@Post()
@UsePipes(new ValidationPipe())
create(@Body() createCatDto: CreateCatDto) {
// createCatDto 已经是校验通过的 DTO 实例
}ValidationPipe 会自动将请求体构造为 CreateCatDto 的实例,并根据装饰器规则校验每个字段。校验失败时抛出 BadRequestException,阻止控制器方法执行。
纯转换管道如 ParseIntPipe 可将路由参数中的字符串转为数字:
ts
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id 已经是 number 类型
}自定义转换管道只需实现 PipeTransform:
ts
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseObjectIdPipe implements PipeTransform<string, string> {
transform(value: string, metadata: ArgumentMetadata): string {
const isValid = /^[a-fA-F0-9]{24}$/.test(value);
if (!isValid) {
throw new BadRequestException('Invalid ObjectId');
}
return value;
}
}管道的绑定方式有三种:
- 参数级别:
@Param('id', ParseIntPipe),仅作用于该参数。 - 控制器或方法级别:
@UsePipes(new ValidationPipe()),作用于该控制器所有路由的参数。 - 全局:
app.useGlobalPipes()或通过APP_PIPE令牌注册。
Interceptor
拦截器实现 NestInterceptor 接口:
ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const now = Date.now();
console.log('Before...');
return next
.handle()
.pipe(
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}在控制器或方法上使用 @UseInterceptors(LoggingInterceptor) 绑定。全局注册同样有两种途径:app.useGlobalInterceptors() 或 APP_INTERCEPTOR 令牌。
拦截器常用于响应映射。例如统一将返回值包装为 { code: 0, data: ... }:
ts
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(data => ({ code: 0, data })),
);
}map 操作符会修改 Observable 流上发出的值,即控制器的返回结果。如果控制器返回的是原始类型,也会被这个 map 包装。
组件绑定方式与作用范围
四种组件的作用范围与绑定方法归纳如下:
| 作用范围 | 中间件 | 守卫 | 管道 | 拦截器 |
|---|---|---|---|---|
| 全局 | 在 configure 中调用 consumer.apply(...).forRoutes('*') | app.useGlobalGuards() 或 APP_GUARD | app.useGlobalPipes() 或 APP_PIPE | app.useGlobalInterceptors() 或 APP_INTERCEPTOR |
| 控制器 | 通过 forRoutes 指定控制器类 | @UseGuards() 装饰控制器 | @UsePipes() 装饰控制器 | @UseInterceptors() 装饰控制器 |
| 方法 | 无法直接单独绑定方法,可通过路径精确匹配 | @UseGuards() 在方法上 | @UsePipes() 在方法上 | @UseInterceptors() 在方法上 |
| 参数 | 不适用 | 不适用 | 参数装饰器第二个参数直接传入管道实例 | 不适用 |
中间件的绑定单位是路径而不是装饰器,这与 Express 中间件的使用习惯一致。全局组件如果直接通过 app.useGlobalXxx() 实例化,则无法享受依赖注入;通过 APP_GUARD、APP_PIPE、APP_INTERCEPTOR 令牌注册可以保证全局范围的同时保留依赖注入能力。
综合示例:订单模块
下面用一个“订单模块”把四个组件组合起来,展示它们各自的注册和使用方式。
准备 DTO:
ts
// create-order.dto.ts
import { IsString, IsInt, Min } from 'class-validator';
export class CreateOrderDto {
@IsString()
item: string;
@IsInt()
@Min(1)
quantity: number;
}日志中间件(类形态):
ts
@Injectable()
export class OrderLoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`[OrderModule] ${req.method} ${req.originalUrl}`);
next();
}
}角色守卫:
ts
@Injectable()
export class AdminGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const { user } = context.switchToHttp().getRequest();
return user?.role === 'admin';
}
}计时拦截器:
ts
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const start = Date.now();
return next.handle().pipe(
tap(() =>
console.log(
`[${context.getClass().name}.${context.getHandler().name}] ${Date.now() - start}ms`,
),
),
);
}
}订单控制器:
ts
@Controller('orders')
@UseGuards(AdminGuard)
@UseInterceptors(TimingInterceptor)
export class OrdersController {
@Post()
@UsePipes(new ValidationPipe())
create(@Body() dto: CreateOrderDto) {
return { orderId: 1, ...dto };
}
}模块配置:
ts
@Module({})
export class OrderModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(OrderLoggerMiddleware).forRoutes(OrdersController);
}
}所有组件协同工作时的请求处理流程:
- 中间件
OrderLoggerMiddleware为所有/orders请求打印日志,在守卫之前运行。 - 守卫
AdminGuard检查user.role是否为admin,若不满足则请求被拒绝,返回 403。 - 管道
ValidationPipe对CreateOrderDto校验,确保item是字符串、quantity是大于等于 1 的数字。 - 拦截器
TimingInterceptor在方法执行前后计时,后逻辑中打印耗时。
假设发起 POST /orders,请求体为 { "item": "book", "quantity": 2 },且用户角色为 admin:
- 日志中间件输出
[OrderModule] POST /orders。 - 守卫放行。
- 验证管道校验通过。
- 拦截器的前逻辑记录开始时间。
- 控制器方法执行,返回
{ orderId: 1, item: "book", quantity: 2 }。 - 拦截器后逻辑打印耗时,如
[OrdersController.create] 12ms。
若用户角色为普通用户(role: "user"),请求会在守卫阶段被拦截,直接返回 403,后续管线不再执行。
注意点与限制
- 中间件的依赖注入边界:类中间件可以注入依赖,但它运行在 Nest 上下文外层,不能直接获取守卫或拦截器的能力。如果需要访问请求上下文以外的 Nest 服务,只能通过中间件自身的构造函数注入。
- 全局组件的依赖注入:直接使用
app.useGlobalGuards()等方式注册组件时,构造函数参数需要手动传入(例如new RolesGuard(new Reflector()))。若希望全局组件也享受依赖注入,应改用APP_GUARD、APP_PIPE、APP_INTERCEPTOR令牌。 - 拦截器与
@Res()的冲突:如果在控制器方法中使用了@Res()手动发送响应,拦截器中对 Observable 的map等操作将失效,因为响应已脱离框架控制。若设置@Res({ passthrough: true }),则拦截器仍可对返回的数据进行处理。 - 管道和
@Body()选项:ValidationPipe默认会触发whitelist行为,即丢弃 DTO 中未被class-validator装饰器标记的属性,可通过whitelist: false关闭。 - 执行顺序的固有逻辑:方法上同时使用
@UseGuards()和@UseInterceptors()时,拦截器的前逻辑在守卫通过后才执行,而不是之前。因此拦截器里的前置逻辑不能替代权限判断。 - 中间件路径匹配规则:
forRoutes使用path-to-regexp风格的匹配,而非简单的字符串前缀匹配。配置'cats'会匹配/cats、/cats/1、/cats/anything,但不会匹配/cats-extra。
应用:根据需求选择处理阶段
四种组件各有擅长的领域,选错阶段会让代码组织变得别扭。可以按照以下思路决定逻辑应该放置的位置:
- 需要直接操作
req/res对象(如设置 Cookie、CORS 头、解析 Body):只能使用中间件。这是因为中间件运行在 Nest 抽象之外,能接触到平台原生的请求和响应。 - 判断请求能否到达控制器(认证令牌校验、角色权限判断):守卫是最合适的组件。守卫可以访问
ExecutionContext,读取路由处理器上的元数据,天然适合声明式授权。 - 对方法参数进行校验或转换(检查必填字段、字符串转数字、JSON 反序列化到 DTO):管道是唯一选择。管道在参数被方法消费前执行,并且能通过抛出标准异常终止请求。
- 在方法执行前后附加横切逻辑(计时、缓存、统一响应格式):使用拦截器。拦截器能拿到
CallHandler,在 Observable 流上操作,可以同时访问请求上下文和最终的返回值。
如果某个逻辑很难归类,可以问三个问题:需要原始请求吗?→ 中间件;只对参数动手吗?→ 管道;只关心“放行/拒绝”吗?→ 守卫;要在方法执行前后都做事吗?→ 拦截器。
