Skip to content

拦截器与管道

一、Interceptor(拦截器)

核心原理

拦截器基于 RxJS Observable,通过 next.handle() 获取 Handler 执行的 Observable,然后用 RxJS 操作符在其前后插入逻辑:

typescript
@Injectable()
export class ExampleInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    // ── 进入阶段(Handler 执行前)──
    console.log('Before handler');

    return next.handle().pipe(
      // ── 返回阶段(Handler 执行后)──
      tap(() => console.log('After handler')),
    );
  }
}

next.handle() 返回一个 Observable,订阅它才真正执行 Handler。在 pipe() 中可以使用任何 RxJS 操作符。

常用 RxJS 操作符

操作符用途
map转换返回值(统一响应格式)
tap副作用(日志、统计)
catchError捕获错误(在拦截器级别处理异常)
timeout设置超时
of短路,直接返回值(缓存命中)
switchMap切换到新 Observable(缓存刷新)

实战一:统一响应格式

typescript
export interface ApiResponse<T> {
  code: number;
  data: T;
  message: string;
  timestamp: string;
}

@Injectable()
export class ResponseInterceptor<T>
  implements NestInterceptor<T, ApiResponse<T>>
{
  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<ApiResponse<T>> {
    return next.handle().pipe(
      map(data => ({
        code: 0,
        data,
        message: 'success',
        timestamp: new Date().toISOString(),
      })),
    );
  }
}

// 注册为全局拦截器
// app.module.ts
providers: [{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor }]

实战二:接口耗时与日志

typescript
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest<Request>();
    const { method, url } = request;
    const start = Date.now();

    return next.handle().pipe(
      tap({
        next: () => {
          this.logger.log(`${method} ${url} - ${Date.now() - start}ms`);
        },
        error: (err) => {
          this.logger.error(
            `${method} ${url} - ${Date.now() - start}ms - Error: ${err.message}`,
          );
        },
      }),
    );
  }
}

实战三:响应缓存拦截器

typescript
@Injectable()
export class CacheInterceptor implements NestInterceptor {
  constructor(private cacheManager: Cache) {}  // @nestjs/cache-manager

  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest<Request>();
    // 只缓存 GET 请求
    if (request.method !== 'GET') return next.handle();

    const cacheKey = `cache:${request.url}`;
    const cached = await this.cacheManager.get(cacheKey);

    if (cached) {
      return of(cached);  // 短路:直接返回缓存,不执行 Handler
    }

    return next.handle().pipe(
      tap(async data => {
        await this.cacheManager.set(cacheKey, data, 60_000);  // 缓存 60 秒
      }),
    );
  }
}

实战四:请求超时

typescript
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  constructor(@Inject('TIMEOUT') private timeoutMs: number = 5000) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      timeout(this.timeoutMs),
      catchError(err => {
        if (err instanceof TimeoutError) {
          throw new RequestTimeoutException('请求超时');
        }
        throw err;
      }),
    );
  }
}

实战五:在拦截器中处理异常

typescript
@Injectable()
export class ErrorTransformInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError(err => {
        // 将数据库错误转换为业务异常
        if (err.code === '23505') {  // PostgreSQL 唯一约束
          throw new ConflictException('数据已存在');
        }
        throw err;  // 其他错误继续传播
      }),
    );
  }
}

二、Pipe(管道)

核心职责

管道在 Handler 方法参数绑定时执行(Guard 之后),可以:

  1. 转换(Transform):将输入值转换为期望类型
  2. 验证(Validate):验证值是否合法,不合法则抛出 BadRequestException

内置管道

NestJS 提供了一组开箱即用的管道:

typescript
// ParseIntPipe:字符串 → 整数
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
  // id 已是 number 类型,不是字符串 '123'
}

// 失败时自定义错误信息
@Get(':id')
findOne(
  @Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
  id: number,
) {}

// ParseUUIDPipe:验证 UUID 格式
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {}

// ParseBoolPipe:'true'/'false' → boolean
@Get()
find(@Query('active', ParseBoolPipe) active: boolean) {}

// ParseArrayPipe:'1,2,3' → [1, 2, 3]
@Get()
findMany(
  @Query('ids', new ParseArrayPipe({ items: Number, separator: ',' }))
  ids: number[],
) {}

// ParseEnumPipe:验证枚举值
enum SortOrder { ASC = 'asc', DESC = 'desc' }
@Get()
findAll(@Query('order', new ParseEnumPipe(SortOrder)) order: SortOrder) {}

// DefaultValuePipe:提供默认值
@Get()
findAll(
  @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
  @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) {}

ValidationPipe(最重要的管道)

结合 class-validatorclass-transformer 实现 DTO 验证:

安装:

bash
npm install class-validator class-transformer

DTO 定义:

typescript
import { IsString, IsEmail, IsInt, Min, Max, IsOptional, MinLength, IsEnum } from 'class-validator';
import { Transform } from 'class-transformer';

export class CreateUserDto {
  @IsString()
  @MinLength(2, { message: '姓名至少 2 个字符' })
  name: string;

  @IsEmail({}, { message: '邮箱格式不正确' })
  email: string;

  @IsInt()
  @Min(1)
  @Max(120)
  age: number;

  @IsEnum(UserRole)
  role: UserRole;

  @IsOptional()
  @IsString()
  @Transform(({ value }) => value?.trim())  // 自动去除首尾空格
  bio?: string;
}

全局注册(推荐):

typescript
// main.ts
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,             // 过滤掉 DTO 中没有声明的字段
    forbidNonWhitelisted: true,  // 有未知字段时抛出 400 错误(不是静默忽略)
    transform: true,             // 自动将路径/查询参数转换为 DTO 声明的类型
    transformOptions: {
      enableImplicitConversion: true,  // 根据 TS 类型自动推断转换
    },
  }),
);

whitelist vs forbidNonWhitelisted

typescript
// 请求体: { name: 'Alice', email: 'a@a.com', hack: 'injected' }
// DTO 只有 name 和 email

// whitelist: true, forbidNonWhitelisted: false(默认)
// → 静默过滤 hack 字段,DTO 只有 { name, email }

// whitelist: true, forbidNonWhitelisted: true
// → 抛出 400:'property hack should not exist'

自定义管道

typescript
@Injectable()
export class TrimPipe implements PipeTransform {
  transform(value: any): any {
    if (typeof value === 'string') return value.trim();
    if (typeof value === 'object' && value !== null) {
      return Object.fromEntries(
        Object.entries(value).map(([k, v]) => [
          k,
          typeof v === 'string' ? v.trim() : v,
        ]),
      );
    }
    return value;
  }
}

// 使用(参数级)
@Post()
create(@Body(TrimPipe) dto: CreateUserDto) {}

// 使用(全局)
app.useGlobalPipes(new TrimPipe());

PartialType、OmitType、PickType(DTO 继承)

typescript
import { PartialType, OmitType, PickType, IntersectionType } from '@nestjs/mapped-types';

// 更新 DTO:所有字段变为可选
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// 省略某些字段
export class UserResponseDto extends OmitType(CreateUserDto, ['password'] as const) {}

// 只选某些字段
export class LoginDto extends PickType(CreateUserDto, ['email', 'password'] as const) {}

// 合并两个 DTO
export class CompleteUserDto extends IntersectionType(CreateUserDto, AddressDto) {}

三、管道 vs 守卫:选哪个做参数校验?

守卫负责"这个请求是否被允许"(认证/授权)。

管道负责"这个请求的参数是否合法"(格式验证)。

typescript
// ✅ 正确分工
@UseGuards(JwtAuthGuard)      // 守卫:验证 Token,确认身份
@Post()
create(
  @Body(new ValidationPipe()) dto: CreateUserDto,  // 管道:验证 DTO 格式
) {}

不要在守卫里做参数格式验证,也不要在管道里做权限检查。


可运行 Demo: practice/02-request-lifecycle — TracingInterceptor + TracingPipe 全链路计时


常见错误

错误原因解决
拦截器 next.handle() 未调用 → 请求挂起忘记 return next.handle()拦截器必须返回 Observable,pipe() 之前先调用 next.handle()
Pipe 转换后数据类型不对ParseIntPipe 但参数是可选的new ParseIntPipe({ optional: true })
ValidationPipe 不校验嵌套对象默认不深度校验全局配置 { transform: true },嵌套 DTO 加 @ValidateNested() + @Type(() => NestedDto)

NestJS 深度学习体系