Nestjs入门学习之搭建七牛云工具系列二

248 阅读10分钟

截屏2022-06-09 上午10.15.32.png 上一讲我们分享了Nestjs项目的搭建以及项目结构的介绍,也实现了一个简单的接口雏形,通过上一讲的学习应该对nestjs有了大致的认识,这一讲我们继续分享nestjs基础核心概念内容中间件、守卫、拦截器、管道、过滤器,使用过express、koa、egg的同学应该对中间件比较熟悉,其他几个概念可能就比较陌生了。个人认为这几个核心的东西必须搞清楚,它可以模块化的分割公共逻辑业务,结构清晰,非常有利于后续迭代,这篇文章我们就来好好讲讲它!

为什么我们要把这几个东西合到一起来讲?因为他们有执行的先后顺序,功能不同,放到一起具有可以性。以下就是它们执行的时序图。

截屏2022-06-01 下午4.02.03.png

中间件

Middlewares_1.png 中间件是在路由处理程序 之前 调用的函数。 中间件函数可以访问请求和响应对象,以及应用程序请求响应周期中的 next()中间件函数。next() 中间件函数通常由名为 next 的变量表示。它和express中的中间件其实是等价的,只是在nestjs中做了一层封装,执行时机可参照上面的时序图,下面我们来体验一下。

  1. 新建中间件
nest g mi auth
import { Injectable, NestMiddleware } from '@nestjs/common';

@Injectable()
export class AuthMiddleware implements NestMiddleware {
  use(req: any, res: any, next: () => void) {
    next();
  }
}
  1. 配置中间件 配置中间件需要注意的点:1.继承NestMiddleware的class中间件,需要配置在对应的模块文件内,如下代码所示,一般不太推荐配置到指定的模块内,而是直接配置在AppModule中,如有自定义的需求可通过forRoutes来指定对应的路由即可。2.配置在main内的中间件,我们称为全局中间件,它的要求一定是函数式中间件,就和express的全局中间件一致。

继承Middleware的class中间件

export class AppModule {
  configure(consumer) {
    // `forRoutes()` 可接受一个字符串、多个字符串、对象、一个控制器类甚至多个控制器类
    // 配置路由
    consumer.apply(AuthMiddleware).forRoutes('');
    // 配置path、method
    // consumer.apply(LoggerMiddleware).forRoutes({ path: 'cats', method:RequestMethod.GET});
    // 通配符的形式配置
    // consumer.apply(LoggerMiddleware).forRoutes({ path: 'ab*cd', method:RequestMethod.ALL });
    // 配置控制器
    // consumer.apply(LoggerMiddleware).forRoutes(CatsController);
    // 排除路由
    consumer
        .apply(LoggerMiddleware)
        .exclude(
            { path: 'cats', method: RequestMethod.GET },
            { path: 'cats', method: RequestMethod.POST },
            'cats/(.*)',
        )
        .forRoutes(CatsController);
    // 多个中间件的配置
    consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);

全局中间件(函数式)

const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);

守卫

Guards_1.png

路由守卫本质上也是中间件的一种,koa或者express开发中接口鉴权就是基于中间件开发的,如果当前请求是不被允许的,当前中间件将不会调用后续中间件,达到阻断请求的目的。

但是中间件的职责是不明确的,中间件可以干任何事(数据校验,格式转化,响应体压缩等等),这导致只能通过名称来识别中间件,项目迭代比较久以后,有比较高的维护成本。

由于单一职责的关系,路由守卫只能返回true和false来决定放行/阻断当前请求,不可以修改request/response对象,因为一旦破坏单一职责的原则,排查问题比较麻烦。

如果需要修改request对象,可以结合中间件一起使用。

执行时机:守卫在每个中间件之后执行,但在任何拦截器或管道之前执行。

通过继承CanActive接口即可定义一个路由守卫

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; 
import { Observable } from 'rxjs'; 
@Injectable() 
export class AuthGuard implements CanActivate { 
    canActivate( context: ExecutionContext ): boolean | Promise<boolean> | bservable<boolean>{ 
        const request = context.switchToHttp().getRequest(); 
        return validateRequest(request); 
    } 
}
控制器范围守卫

意思是对整个控制器产生作用,控制器内的每个请求都会通过这个守卫

@Controller('user')
@UseGuards(UserGuard)
export class UserController {
  // 查看当前用户信息
  @Get('info')
  info() {
    return {username: 'fake_user'};
  }
}
方法范围守卫

方法范围的守卫只对这个这个方法产生作用

@Get('info') 
@UseGuards(UserGuard) 
info() { 
    return {username: 'fake_user'}; 
}
全局范围守卫

该级别对所有控制器的所有路由方法生效。该方法与全局异常过滤器一样不会对WebSocket和GRPC生效。

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 由于main.ts启动时并未初始化依赖注入容器,所以依赖必须手动传入,一般情况下不建议使用全局守卫,因为依赖注入得自己解决。
  app.useGlobalGuards(new UserGuard(new UserService()));
  await app.listen(3000);
}

bootstrap();

我们发现上面的方式使用全局守卫特别麻烦,是因为它不属于任何一个模块,所以依赖项需要手动注入。不推荐使用,但是如果非要使用,还可以有别的更友好的方式,可以在任意模块注册(下面是全局注册)

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

以module的方式注册的全局守卫,需要注入依赖就方便多了,正常引用就OK了,如下代码

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { QiniuService } from '../qiniu/qiniu.service';
@Injectable()
export class TestGuard implements CanActivate {
  constructor(private qiniuService: QiniuService) {}
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const time = this.qiniuService.getTime();
    console.log('全局守卫=============', time);
    return true;
  }
}

如果需要在对应的某个模块注册守卫,那只需要在对应模块的module内注册就OK

注意:如果是在非AppModule模块注册,需要注意模块的引用关系,并且被依赖的服务需要在自己的module导出
举个🌰:test守卫依赖了 A 模块的service,在 B 模块注册,这个引用关系怎么管理?

# A module
@module({
  ...
  exports: [AService]
})



# B module 
@Module({
 imports: [
   AModule, // 手动引入A模块
 ],
 controllers: [],
 providers: [
   {
     provide: APP_GUARD,
     useClass: TestGuard,
   },
 ],
})

上面的依赖关系整好了,就可以在守卫里面通过construct正常注入依赖关系了

constructor(private aService: AService) {}
执行上下文

canActivate() 函数接收单个参数,即 ExecutionContext 实例。ExecutionContext 继承自 ArgumentsHost 。ArgumentsHost 是传递给原始处理程序的参数的包装器,在上面的示例中,我们只是使用了之前在 ArgumentsHost上定义的帮助器方法来获得对请求对象的引用。有关此主题的更多信息。你可以在异常过滤器一章的了解到更多。
通过扩展ArgumentsHostExecutionContext还添加了几个新的辅助方法,这些方法提供了有关当前执行过程的更多详细信息。这些细节有助于构建更通用的防护,这些防护可以在广泛的控制器、方法和执行上下文中工作。ExecutionContext在此处了解更多信息。

export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

getHandler()方法返回对将要调用的处理程序的引用。getClass()方法返回这个特定处理程序所属的 Controller 类的类型。例如,如果当前处理的请求是 POST 请求,目标是 CatsController上的 create() 方法,那么 getHandler() 将返回对 create() 方法的引用,而 getClass()将返回一个CatsControllertype(而不是实例)。

拦截器

Interceptors_1.png AOP(Aspect Oriented Programming),即面向切面编程,是NestJS框架中的重要内容之一。

利用AOP可以对业务逻辑的各个部分例如:权限控制,日志统计,性能分析,异常处理等进行隔离,从而降低各部分的耦合度,提高程序的可维护性。

NestJS框架中体现AOP思想的部分有:Middleware(中间件), Guard(守卫器),Pipe(管道),Exception filter(异常过滤器)等,当然还有我们今天的主角:Interceptor(拦截器)。

示例

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> { 
        console.log('Before...'); 
        const now = Date.now(); 
        return next.handle().pipe(tap(() => console.log(`After... ${Date.now() - now}ms`)), );
    } 
}

绑定拦截器
为了设置拦截器, 我们使用从 @nestjs/common 包导入的 @UseInterceptors() 装饰器。与守卫一样, 拦截器可以是控制器范围内的, 方法范围内的或者全局范围内的。

  1. 控制器范围
@UseInterceptors(LoggingInterceptor)
export class CatsController {}
  1. 方法范围
export class SomeController { 
    @UseInterceptors(LoggingInterceptor) 
    @Get() 
    routeHandler(){ 
        // 执行路由函数 
    } 
}
  1. 全局范围
const app = await NestFactory.create(ApplicationModule); 
app.useGlobalInterceptors(new LoggingInterceptor());

全局拦截器用于整个应用程序、每个控制器和每个路由处理程序。在依赖注入方面, 从任何模块外部注册的全局拦截器 (如上面的示例中所示) 无法插入依赖项, 因为它们不属于任何模块。为了解决此问题, 您可以使用以下构造直接从任何模块设置一个拦截器:

import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

@Module({
 providers: [
   {
     provide: APP_INTERCEPTOR,
     useClass: LoggingInterceptor,
   },
 ],
})
export class AppModule {}

类型守卫的依赖注入方式

管道

Pipe_1.png

熟悉Linux命令的伙伴应该对“管道运算符”不陌生。

ls -la | grep demo

"|" 就是管道运算符,它把左边命令的输出作为输入传递给右边的命令,支持级联,如此一来,便可以通过管道运算符进行复杂命令的交替运算。

NestJs中的管道有着类似的功能,也可以级联处理数据。NestJs管道通过**@Injectable()装饰器装饰,需要实现PipeTransform**接口。

NestJs中管道的主要职责如下:

  • 数据转换 将输入数据转换为所需的输出
  • 数据验证 接收客户端提交的参数,如果通过验证则继续传递,如果验证未通过则提示错误

参数验证管道

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

@Injectable()
export class ValidatorPipe implements PipeTransform {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);

    const errors = await validate(object);
    if (errors.length > 0) {
      const msg = Object.values(errors[0].constraints)[0];
      throw new BadRequestException(`Validation failed: ${msg} `);
    }
    return value;
  }
  private toValidate(metatype): boolean {
    const types = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

ArgumentMetadata

export interface ArgumentMetadata {
  readonly type: 'body' | 'query' | 'param' | 'custom';
  readonly metatype?: Type<any>;
  readonly data?: string;
}

这个接口大家可能看不明白,没关系,等下会有具体示例来进行解读。

  • type 输入数据的来源
  • metatype 注入数据的类型
  • data <string|undefined>传递给装饰器的数据类型

使用示例

@Post('create')
@UsePipes(ValidatorPipe) // 表单校验管道 也可以传入 new ValidatorPipe() 实例
async create(@Body() createUserDto: CreateUserDto) {
    const { email, username } = createUserDto;
    // 校验邮箱是否存在
    if (await this.userService.findByEmail(email)) {
      throw new BadRequestException('Validation failed: 用户邮箱已存在');
    }
    // 校验用户名是否存在
    if (await this.userService.findByUsername(username)) {
      throw new BadRequestException('Validation failed: 用户名已存在');
    }
    return await this.userService.create(createUserDto);
}

大概解释一下,@Body()代表是以body形式入参,管道ArgumentMetadata中的type ’body‘,CreateUserDto代表ArgumentMetadata中的metatype,createUserDto代表传入transform中的value

类验证器

本节中的技术需要 TypeScript ,如果您的应用是使用原始 JavaScript编写的,则这些技术不可用。

让我们看一下验证的另外一种实现方式

Nest 与 class-validator 配合得很好。这个优秀的库允许您使用基于装饰器的验证。装饰器的功能非常强大,尤其是与 Nest 的 Pipe 功能相结合使用时,因为我们可以通过访问 metatype 信息做很多事情,在开始之前需要安装一些依赖。

$ npm i --save class-validator class-transformer

一旦安装了这些,我们就可以在CreateCatDto类中添加一些装饰器。在这里,我们看到了这种技术的一个显着优势:CreateCatDto该类仍然是我们的 Post body 对象的唯一真实来源(而不是必须创建一个单独的验证类)。

create-cat.dto.ts

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

export class CreateCatDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}
全局管道

由于 ValidationPipe 被创建为尽可能通用,所以我们将把它设置为一个全局作用域的管道,用于整个应用程序中的每个路由处理器。

main.ts

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

全局管道用于整个应用程序、每个控制器和每个路由处理程序。请注意,就依赖注入而言,从任何模块外部注册的全局管道(如上例所示)无法注入依赖,因为它们不属于任何模块。为了解决这个问题,可以使用以下构造直接为任何模块设置管道:

app.module.ts

import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe
    }
  ]
})
export class AppModule {}
转换管道

验证不是管道唯一的用处。在本章的开始部分,我已经提到管道也可以将输入数据转换为所需的输出。这是可以的,因为从 transform 函数返回的值完全覆盖了参数先前的值。

在什么时候使用?有时从客户端传来的数据需要经过一些修改(例如字符串转化为整数),然后处理函数才能正确的处理。还有种情况,比如有些数据具有默认值,用户不必传递带默认值参数,一旦用户不传就使用默认值。转换管道可以通过在客户端请求和请求处理程序之间插入处理功能来执行这些功能。
这是一个ParseIntPipe负责将字符串解析为整数值的简单程序。(如上所述,Nest 具有ParseIntPipe更复杂的内置功能;我们将其作为自定义转换管道的简单示例包含在内)。

parse-int.pipe.ts

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

如下所示, 我们可以很简单的配置管道来处理所参数 id:

@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return await this.catsService.findOne(id);
}