拦截器与管道
一、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 之后),可以:
- 转换(Transform):将输入值转换为期望类型
- 验证(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-validator 和 class-transformer 实现 DTO 验证:
安装:
bash
npm install class-validator class-transformerDTO 定义:
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) |