Nestjs 核心功能实践

395 阅读2分钟

本文内容指南

  • 异常拦截
  • 返回体封装
  • class-validator 参数校验管道
  • typeorm 数据库操作
  • ...

异常过滤

过滤分为全局过滤器、控制器过滤器、路由过滤器,它们用来更友好地返回服务端的错误响应。

@Catch(HttpException)捕获Http异常进行处理,统一返回状态码和错误信息。

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        message:exception.message,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }

绑定过滤器

在入口文件通过useGlobalFilters方式绑定全局的过滤器,当然也可以在Controller类或者方法通过@UseFilters()装饰器使用。

import { HttpExceptionFilter } from './filter/http-exception.filter'
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

使用map方法改写返回值,封装data、statusCode、message返回结构。

拦截器封装响应体

拦截器主要用于在请求处理之前和之后对请求进行修改、干预或拦截。它们可以修改请求和响应的数据、转换数据格式、记录日志等,以及处理全局任务。这里通过拦截器封装响应体:

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

export interface Response<T> {
  data: T;
}

@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
    return next.handle().pipe(map(data => {
        return {
            statusCode: 200,
            message: '请求成功!',
            data
        }
    }));
  }
}

绑定拦截器

在入口文件通过useGlobalInterceptors方式绑定全局的拦截器,@UseInterceptor()装饰器可以绑定类或者方法的拦截器。

import { HttpExceptionFilter } from './filter/http-exception.filter'
import { TransformInterceptor } from './interceptor/transform.interceptor'
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  app.useGlobalInterceptors(new TransformInterceptor());
  await app.listen(3000);
}
bootstrap();

class-validator 参数校验管道

安装依赖:

pnpm add class-validator class-transformer

创建数据模型类:

验证修饰符有IsString,MinLength,MaxLength,IsInt,Min,Max,IsDate等,message参数可以定义错误提示信息。

import { IsString, IsInt,MinLength,MaxLength} from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2, { message: "最小长度2" })
  @MaxLength(20, { message: "最大长度20" })
  name: string;

  @IsInt()
  age: number;
}

class-validator 装饰器方法见 wdk-docs.github.io/nestjs-docs…

创检验管道类:

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToInstance(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      const message=errors.map(e=> Object.values(e.constraints));
      throw new BadRequestException(`字段校验失败:${message}`);
    }
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

绑定校验管道

管道可以是参数、方法、控制器、全局范围。 这里绑定到@Body装饰器上校验post body。

@Post()
async create(
  @Body(new ValidationPipe()) createUserDto: CreateUserDto,
) {
  this.usersService.create(CreateUserDto);
}

typeorm 操作数据库

需要安装 typeorm相关依赖:

@nestjs/typeorm typeorm mysql2

创建实体

import { Entity, Column, PrimaryColumn,CreateDateColumn } from 'typeorm';

@Entity('user_info')
export class User {
  @PrimaryColumn()
  id: number;

  @Column()
  userName: string;

  @CreateDateColumn()
  createTime: Date;
}
  • Entity 传参可以指定表名
  • PrimaryColumn 是主键列,每个实体必须至少有一个主键列。
  • PrimaryGeneratedColumn 是自动生成的主键列。

@ Column可以指定列选项:

  • type: ColumnType - 列类型。其中之一在上面.
  • name: string - 数据库表中的列名。
  • length: number - 列类型的长度。 例如,如果要创建varchar(150)类型,请指定列类型和长度选项。
  • default: string - 添加数据库级列的DEFAULT值。
  • primary: boolean - 将列标记为主要列。 使用方式和@ PrimaryColumn相同。
  • unique: boolean - 将列标记为唯一列(创建唯一约束)。
  • comment: string - 数据库列备注,并非所有数据库类型都支持。

还有有几种特殊的列类型可以使用:

  • @CreateDateColumn 是一个特殊列,自动为实体插入日期。无需设置此列,该值将自动设置。
  • @UpdateDateColumn 是一个特殊列,在每次调用实体管理器或存储库的save时,自动更新实体日期。无需设置此列,该值将自动设置。
  • @VersionColumn 是一个特殊列,在每次调用实体管理器或存储库的save时自动增长实体版本(增量编号)。无需设置此列,该值将自动设置。

实体管理器Repository可以对实体进行增删改查,nestjs中通过InjectRepository向Service注入Repository:


import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  findAll(): Promise<User[]> {
    return this.usersRepository.find();
  }

  findOne(id: number): Promise<User | null> {
    return this.usersRepository.findOneBy({ id });
  }

  async remove(id: number): Promise<void> {
    await this.usersRepository.delete(id);
  }
}

Repository API

find

  • select - 表示必须选择对象的哪些属性
  • relations - 关系需要加载主体。 也可以加载子关系(join 和 leftJoinAndSelect 的简写)
  • join - 需要为实体执行联接,扩展版对的"relations"。
  • where -查询实体的简单条件。OR 运算符查询可以用数组
  • order - 选择排序
  • skip - 偏移(分页)
  • take - limit (分页) - 得到的最大实体数。
  • cache 启用或禁用查询结果缓存
  • lock - 启用锁查询。 只能在findOne方法中使用。 

find方法示例:

userRepository.find({  
    select: ["id", "userName"], 
    relations: ["photos"],  
    where: { 
        firstName: "Timber",  
        lastName: "Saw"  
    },  
    order: {  
        name: "ASC",  
        id: "DESC"  
    },  
    skip: 1, 
    take: 10, 
    cache: true 
});

Repository 其它API:

  • create - 创建新实例。 接受具有用户属性的对象文字,该用户属性将写入新创建的用户对象(可选)。
  • save - 保存给定实体或实体数组。    如果该实体已存在于数据库中,则会更新该实体。    如果数据库中不存在该实体,则会插入该实体。
  • insert - 插入新实体或实体数组。
  • remove - 删除给定的实体或实体数组。
  • update - 通过给定的更新选项或实体 ID 部分更新实体。
  • delete -根据实体 id, ids 或给定的条件删除实体
  • count - 符合指定条件的实体数量。对分页很有用。
  • findOne - 查找匹配某些 ID 或查找选项的第一个实体。
  • query - 执行原始 SQL 查询。

命名策略:驼峰命转下划线

实体中的列名用的是驼峰命名法,但是数据库里一般是下划线命名,通过@Columns({name:'user_name'})指定每个字段数据库列名又太繁琐。

sequelize-typescript可以通过underscored选项开启转换,typeorm中可以通过namingStrategy指定命名策略。

import { DefaultNamingStrategy } from "typeorm";
 
export class MyNamingStrategy extends DefaultNamingStrategy {
    tableName(targetName: string, userSpecifiedName: string | undefined): string {
        if (userSpecifiedName) return userSpecifiedName;
        return parseName(targetName);
    }
    columnName(propertyName: string, customName: string, embeddedPrefixes: string[]): string {
        if (customName) return customName;
        return parseName(propertyName);
    }
}
function parseName(targetName: string): string {
    if(!targetName) return "";
    let str : string = "";
    for(let i = 0; i < targetName.length; ++i) {
        let code = targetName[i];
        if (code >= "A" && code <= "Z") {
            if(i != 0 ) {
                str = str.concat("_");
            }
            str = str.concat(code.toLocaleLowerCase());
        }else {
            str = str.concat(code);
        }
    }
    return str;
}

初始化typeorm是提供namingStrategy选项:

 TypeOrmModule.forRoot({
  type: 'mysql',
  namingStrategy: new MyNamingStrategy()
  ...
})

JWT 认证

JWT认证流程:

  • 客户端使用用户名密码登录
  • 登录成功后服务端下发JWT
  • 客户端后续请求携带JWT headers 实现身份认证
  • 服务的中还需创建受保护的路由守卫,只有携带JWT才能访问路由
  1. 创建auth 模块
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true,
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

Controller 中添加登录方法


import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }
}
  1. auth service 检查用户名密码密码是否正确
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async signIn(username, pass) {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }
    const payload = { sub: user.userId, username: user.username };
    return {
      access_token: await this.jwtService.signAsync(payload),
    };
  }
}
  1. 创建登录守卫
import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException();
    }
    try {
      const payload = await this.jwtService.verifyAsync(
        token,
        {
          secret: jwtConstants.secret
        }
      );
      // 💡 We're assigning the payload to the request object here
      // so that we can access it in our route handlers
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }
    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

Controller 中使用守卫

 @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }

RBAC角色访问控制

RBAC是基于角色的权限访问控制

首先创建Role枚举系统的角色:

export enum Role {
  User = 'user',
  Admin = 'admin',
}

@Roles装饰器

然后创建一个装饰器@Roles,表示哪些角色可以访问相应的资源


import { SetMetadata } from '@nestjs/common';
import { Role } from '../enums/role.enum';

export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

SetMetadata 是 Nest.js 内置的一个装饰器方法,它用于为路由方法添加元数据,它接收两个参数,分别是描述元数据的 key 和元数据值。

@Roles装饰器通过SetMetadata给类添加了角色信息的数据。之后在守卫中取数据时需要用Reflector检索和解析元数据。

  • Reflector.getMetadata(metadataKey: string, target: object):获取指定目标上的元数据。

创建后,就可以在Controller中使用:

@Post()
@Roles(Role.Admin)
create(@Body() createUserDto: CreateUserDto) {
  this.usersService.create(createUserDto);
}

创建角色守卫

最好,创建守卫来拦截路由,用户角色和路由需要的角色匹配时才让请求通过。

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 requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    if (!requiredRoles) {
      return true;
    }
    const { user } = context.switchToHttp().getRequest();
    return requiredRoles.some((role) => user.roles?.includes(role));
  }
}

在守卫canActivate方法中比较角色,通过则返回true,处理用户请求;否则返回false,忽略用户请求。

this.reflector.get 可以通过key 和 类上下文获取SetMetadata存储在类中的数据,getAllAndOverride 可以覆盖多层守卫中的默认值,比如下面的用例:

@Roles(['user'])
@Controller('users')
export class UsersController {
  @Post()
  @Roles(['admin'])
  async create(@Body() createUserDto: CreateUserDto) {
    this.usersService.create(createUserDto);
  }
}

注册守卫

守卫分为全局守卫、控制器守卫、方法守卫,执行顺序:全局守卫>控制器守卫>方法守卫.

这里将 RolesGuard 注册为全局守卫,根据@Role装饰器提供的角色信息对所有请求进行角色权限校验.

import { APP_GUARD } from '@nestjs/core'
@Module({
    providers: [
      {
        provide: APP_GUARD,
        useClass: RolesGuard,
      },
    ],
})

接口频率限制

接口频率限制可以用 @nestjs/throttler

pnpm add @nestjs/throttler
@Module({
  imports: [
    ThrottlerModule.forRoot([{
      ttl: 60000, // 超时时间
      limit: 10,  // ttl内的最大请求数
    }]),
  ],
  providers:[
    {
      provide: APP_GUARD,
      useClass: ThrottlerGuard // 全局注册
    }
  ]
})
export class AppModule {}

热更新

  1. 安装依赖
pnpm add -D webpack-node-externals run-script-webpack-plugin webpack
  1. 根目录下新建配置文件webpack-hmr.config.js
const nodeExternals = require('webpack-node-externals');
const { RunScriptWebpackPlugin } = require('run-script-webpack-plugin');

module.exports = function (options, webpack) {
  return {
    ...options,
    entry: ['webpack/hot/poll?100', options.entry],
    externals: [
      nodeExternals({
        allowlist: ['webpack/hot/poll?100'],
      }),
    ],
    plugins: [
      ...options.plugins,
      new webpack.HotModuleReplacementPlugin(),
      new webpack.WatchIgnorePlugin({
        paths: [/.js$/, /.d.ts$/],
      }),
      new RunScriptWebpackPlugin({ name: options.output.filename, autoRestart: false }),
    ],
  };
};
  1. main.ts启用热更新

declare const module: any;

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);

  if (module.hot) {
    module.hot.accept();
    module.hot.dispose(() => app.close());
  }
}
bootstrap();
  1. packge.json启动命令:
"start:dev": "nest build --webpack --webpackPath webpack-hmr.config.js --watch"

其它功能

设置API前缀

app.setGlobalPrefix('/api')

开启cors

app.enableCors()

swagger 接口文档

import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
function enableSwagger(app){
  const config = new DocumentBuilder()
    .setTitle('Cats example')
    .setDescription('The cats API description')
    .setVersion('1.0')
    .addTag('cats')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  // 第一个参数是swagger文档路径
  SwaggerModule.setup('docs', app, document);
}
enableSwagger(app)

swc 编译

pnpm add -D @swc/cli @swc/core

nest-cli.json添加选项:

{
  "compilerOptions": {
    "builder": "swc"
  }
}

注册bodyParser

nestjs支持解析json 和 urlencoded,useBodyParser可以解析其他格式数据:

app.useBodyParser('text');