Skip to content

自定义装饰器

一、为什么需要自定义装饰器

NestJS 的内置装饰器(@Param@Body@Headers 等)已经很强大,但实际项目中常有重复的提取逻辑:

typescript
// ❌ 每个接口都重复这段代码
@Get('profile')
getProfile(@Req() req: Request) {
  const user = req.user;  // 到处重复
  return user;
}

// ✅ 封装为自定义装饰器
@Get('profile')
getProfile(@CurrentUser() user: User) {
  return user;
}

自定义装饰器让代码更语义化复用性更高、测试更简单。


二、参数装饰器(createParamDecorator)

createParamDecorator 用于创建提取请求数据的装饰器,取代 @Req() 后的手动提取。

基础用法

typescript
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

// 提取当前登录用户
export const CurrentUser = createParamDecorator(
  (data: string | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;  // 由 JwtAuthGuard 注入

    // data 是传入装饰器的参数
    return data ? user?.[data] : user;
  },
);

// 使用方式一:获取完整 user 对象
@Get('profile')
getProfile(@CurrentUser() user: User) {
  return user;
}

// 使用方式二:获取 user 的某个字段
@Get('me')
getMyId(@CurrentUser('id') id: number) {
  return { id };
}

提取请求 IP

typescript
export const ClientIp = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest<Request>();
    // 支持代理(nginx、CDN)透传的真实 IP
    return (
      (request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ||
      request.headers['x-real-ip'] as string ||
      request.socket.remoteAddress ||
      '0.0.0.0'
    );
  },
);

@Post('login')
login(@ClientIp() ip: string, @Body() dto: LoginDto) {
  this.authService.recordLoginAttempt(ip, dto.email);
}

提取 traceId

typescript
export const TraceId = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest<
      Request & { traceId?: string }
    >();
    return request.traceId ?? '';
  },
);

@Get('data')
getData(@TraceId() traceId: string) {
  this.logger.log(`Processing request ${traceId}`);
}

带类型安全的参数装饰器

typescript
// 声明 data 参数的类型,提供 IDE 提示
export const CurrentUser = createParamDecorator(
  (data: keyof User | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user: User = request.user;
    return data ? user[data] : user;
  },
) as {
  (): ParameterDecorator;
  (data: keyof User): ParameterDecorator;
};

三、元数据装饰器(SetMetadata)

用于向路由处理方法或控制器类附加元数据,配合 Guard/Interceptor 中的 Reflector 读取。

基础模式

typescript
import { SetMetadata } from '@nestjs/common';

// 简单版本
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);

// 带类型安全的工厂函数版本(推荐)
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

公开接口装饰器

typescript
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

// 在 JwtAuthGuard 中读取
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
  context.getHandler(),
  context.getClass(),
]);
if (isPublic) return true;

操作权限装饰器

typescript
export enum Action { Read = 'read', Create = 'create', Update = 'update', Delete = 'delete' }
export enum Resource { User = 'user', Post = 'post', Comment = 'comment' }

export interface Permission {
  action: Action;
  resource: Resource;
}

export const RequirePermission = (action: Action, resource: Resource) =>
  SetMetadata('permission', { action, resource });

// 使用
@RequirePermission(Action.Delete, Resource.Post)
@Delete(':id')
deletePost(@Param('id') id: string) {}

四、组合装饰器(applyDecorators)

applyDecorators 将多个装饰器合并为一个,消除重复代码:

认证守护装饰器

typescript
import { applyDecorators, UseGuards, SetMetadata, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';

export function Auth(...roles: Role[]) {
  return applyDecorators(
    SetMetadata('roles', roles),
    UseGuards(JwtAuthGuard, RolesGuard),
    ApiBearerAuth(),                                       // Swagger 文档
    ApiUnauthorizedResponse({ description: '未授权' }),    // Swagger 文档
  );
}

// 使用:替代 4 行代码
@Auth(Role.ADMIN)
@Delete(':id')
remove(@Param('id') id: string) {}

API 路由标准套件

typescript
export function ApiRoute(options: {
  summary: string;
  roles?: Role[];
  isPublic?: boolean;
}) {
  const decorators: MethodDecorator[] = [
    ApiOperation({ summary: options.summary }),
  ];

  if (options.isPublic) {
    decorators.push(Public());
  } else {
    decorators.push(
      SetMetadata('roles', options.roles ?? []),
      UseGuards(JwtAuthGuard, RolesGuard),
      ApiBearerAuth(),
    );
  }

  return applyDecorators(...decorators);
}

// 使用
@Get()
@ApiRoute({ summary: '获取用户列表', roles: [Role.ADMIN] })
findAll() {}

@Get('health')
@ApiRoute({ summary: '健康检查', isPublic: true })
health() {}

五、属性装饰器

用于 DTO 类的属性标注(class-validator 中大量使用此模式):

typescript
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';

// 自定义:两个密码字段必须一致
export function IsEqualTo(property: string, validationOptions?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      name: 'isEqualTo',
      target: object.constructor,
      propertyName,
      constraints: [property],
      options: validationOptions,
      validator: {
        validate(value: any, args: ValidationArguments) {
          const [relatedPropertyName] = args.constraints;
          const relatedValue = (args.object as any)[relatedPropertyName];
          return value === relatedValue;
        },
        defaultMessage(args: ValidationArguments) {
          return `${args.property} 与 ${args.constraints[0]} 不一致`;
        },
      },
    });
  };
}

// 使用
export class RegisterDto {
  @IsString()
  @MinLength(8)
  password: string;

  @IsEqualTo('password')
  confirmPassword: string;
}

六、在自定义装饰器中使用管道

参数装饰器可以组合管道进行数据转换:

typescript
// 自动将参数转换为 ParsedQuery 对象并验证
export const PaginationQuery = () =>
  Query(
    new ValidationPipe({
      transform: true,
      transformOptions: { enableImplicitConversion: true },
      whitelist: true,
    }),
  );

export class PaginationDto {
  @IsOptional()
  @IsInt()
  @Min(1)
  @Type(() => Number)
  page?: number = 1;

  @IsOptional()
  @IsInt()
  @Min(1)
  @Max(100)
  @Type(() => Number)
  limit?: number = 10;
}

// 使用
@Get()
findAll(@PaginationQuery() pagination: PaginationDto) {
  return this.service.findPaginated(pagination.page, pagination.limit);
}

七、最佳实践

  1. 集中管理装饰器:在 src/common/decorators/ 目录统一存放
  2. 导出常量 Key:避免字符串字面量散落各处
    typescript
    export const ROLES_KEY = 'roles';
    export const IS_PUBLIC_KEY = 'isPublic';
  3. 优先用组合装饰器:将常见的 Guard + Roles + Swagger 组合封装为 @Auth()
  4. 类型安全createParamDecorator 中声明 data 参数的类型
  5. 测试装饰器行为:通过 Reflector.get() 在单元测试中验证元数据是否正确设置

可运行 Demo: practice/04-auth-system — @CurrentUser() 和 @Roles() 自定义装饰器实现


常见错误

错误原因解决
createParamDecorator 无法注入 Service参数装饰器无法使用 DI改用 Guard + @SetMetadata 组合,或用 ModuleRef 手动解析
组合装饰器中 Guard 顺序错误applyDecorators 顺序与执行顺序不同Guard 先于 Interceptor 执行;装饰器应用顺序从下到上
元数据 key 冲突多个装饰器用了同名 string keySymbol 作为 metadata key 避免命名冲突

NestJS 深度学习体系