[NestJS] 大前端Node层应用框架 之 NestJS英文官方文档解读笔记 (持续跟新ing

3,136 阅读23分钟

前言

公司的项目使用了大前端技术,大前端作为中间层,整合了服务端的接口,也缩短了前端的开发等待接口的空窗期。所有的mock数据在node层生成供调试,使前端开发形成了闭环。而node层所运用的框架中,nest是一个非常高效、好用的框架。

  • 本文作为学习nest框架的一篇笔记,对英文官方文档进行了直译、意译和理解。
  • 如有错误欢迎及时指正并修改。

介绍

  • NestJS是一个为了构建高效、可扩展的服务端应用框架。它运用了渐进式javascript,使用并且完全支持Typescript,结合了面向对象编程OOP、函数式编程FP、函数式响应编程FRP。
  • 在Nest底层也运用了强大的HTTP服务框架例如Express(默认),和Fastify(可配置)。
  • Nest在这些常用的HTTP框架之上提供了一个抽象层,但也直接暴露了他们自己的API给开发者。这也允许开发者自由地使用无数原先平台就可以使用的第三方模块(中间件)。

安装

  • 开始一个nest,可以使用脚手架Nest CLI,也可以clone一个初始项目
$ npm i -g @nestjs/cli
$ nest new project-name

or

$ git clone https://github.com/nestjs/typescript-starter.git project
$ cd project
$ npm install
$ npm run start
  • 手动创建
$ npm i --save @nestjs/core @nestjs/common rxjs reflect-metadata

控制器

控制器

  • 控制器负责处理收到的请求并且返回响应给客户端
  • 控制器的目的是接受应用的具体请求。路由决定了哪一个控制器接受哪些请求。通常控制器都会至少两个路由,不同的路由可以执行不同的动作。
  • 创建一个控制器需要使用类class和装饰器decorator


路由

  • 创建一个基本的控制器需要使用装饰器@Controller,下例规定了路由前缀cat传入控制器。如此一来我们可以轻松地将一系列路由归为一组,减少了重复代码。后面所有路由都会在/cats之下。
import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}
  • 创建控制器可以使用脚手架指令$ nest g controller cats
  • @Get()告诉Nest需要为HTTP的get请求创建一个处理函数。
  • 此例中,Nest路由了一个get请求到findAll方法,而此方法名完全是随意的(显然我们必需为路由绑定一个处理函数,然而Nest从来不关心这个函数的名字到底叫什么)。并且这个方法将会返回一个200的状态码和对应的响应数据(此例为字符串)。

    Nest使用了两个不同的配置来操作处理函数的响应

standard(标准的) 此配置使用内置的方法,当函数返回了一个对象或数组会自动转为JSON格式,而返回字符时不会转换。这使得返回值处理十分简单,而Nest只关注剩余的部分。
Library-specific(规定库的) 使用此配置可以通过在函数签名中注入@Res()装饰器来获取库的(Express)request对象(findAll(@Res() response)),这样也就可以使用原生的request对象暴露的API进行处理。例如response.status(200).send()

不可以同时使用上述两种方法,当Nest监测到你使用了@Res()或者@Next()时,那么表明你选择了Library-specific项。两者同时使用时Nest会自动禁用standard项,而造成不可预期的后果。


request对象

  • 处理函数通常需要获取客户端请求的详细信息,Nest提供了底层库的request对象(默认Express)。
  • 方法是在处理函数签名中注入@Req()装饰器
import { Controller, Get, Req } from '@nestjs/common';
import { Request } from 'express';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(@Req() request: Request): string {
    return 'This action returns all cats';
  }
}
ps: 为了使用Express的类型可以安装`@types/express`包
  • request对象代表了Http请求,包含许多属性:query string、parameters、HTTP headers。大多是情况下这些属性不用我们手动去调用,可以使用专门的装饰器。例如@Body()@Query()
@Request() req
@Response() res*
@Next() next
@Session() req.session
@Param(key?: string) req.params / req.params[key]
@Body(key?: string) req.body / req.body[key]
@Query(key?: string) req.query / req.query[key]
@Headers(name?: string) req.headers / req.headers[name]

* 由于Library-specific的存在,会有两种风格的Resquest对象。standard模式使用@Request()装饰器,而底层库的request对象使用@Req()装饰器获取,并确保理解他们的不同。


资源

  • 前面我们通过Get请求来获取了cat的资源,现在我们也希望去创建一个新的cat记录。使用post。
import { Controller, Get, Post } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Post()
  create(): string {
    return 'This action adds a new cat';
  }

  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}
  • Nest提供了同样风格的其他HTTP请求方式,@Put()@Delete()@Patch()@Options()@Head()@All()

路由通配符

  • *号通配符可以在路由中使用,可以匹配任意字符的结合
@Get('ab*cd')
findAll() {
  return 'This route uses a wildcard';
}
  • 上例可以匹配abcd, ab_cd, abecd等等
  • 正则表达式,以及其他符号?+*()均可使用
  • The hyphen ( -) and the dot (.) are interpreted literally by string-based paths.


状态码

  • 处理函数响应的状态码总是默认为200,post例外为201
  • 可以使用装饰器@HttpCode(...)指定状态码
@Post()
@HttpCode(204)
create() {
  return 'This action adds a new cat';
}
  • 通常状态码不是静态,而是由多种因素来决定的。在这种情况下你可以使用Library-specific模式的response对象

请求头

  • 想要规定一个自定义的响应头,可以使用装饰器@Header()或者Library-specific模式的res对象
@Post()
@Header('Cache-Control', 'none')
create() {
  return 'This action adds a new cat';
}

路由参数

  • 如果需要接受动态的数据作为请求地址的一部分,静态地址的路由将不会工作。(动态路由参数)
  • 例如Get /cats/1 来获取id=1的Cat资源
  • 使用装饰器@Param()来获取动态路由参数
@Get(':id')
findOne(@Param() params): string {
  console.log(params.id);
  return `This action returns a #${params.id} cat`;
}
  • 也可以传入特定的参数给装饰器,来直接通过名称获取路由参数。
@Get(':id')
findOne(@Param('id') id): string {
  return `This action returns a #${id} cat`;
}

路由顺序

  • 请注意路由顺序,当之前使用了动态路由参数进行截获,则之后就不会命中不使用动态参数的静态同级路由。
@Controller('cats')
export class CatsController {
  @Get(':id')
  findOne(@Param('id') id: string) {
    return `This action returns a #${id} cat`;
  }

  @Get()
  findAll() {
    // 此节点将不会命中,因为/cats请求将被/cats:id请求所截获
  }
}

作用域

  • 由于读者来自不同的编程语言背景,学习此块内容是不被期望的,几乎所有的东西都通过收到的request请求共享了。我们有一个连接池,连接到了数据库、具有全局状态的单例服务等。记住Node并没有遵循多线程无状态的请求/响应模式,所有的请求都为单线程处理。因此使用单例对象对于应用来更加安全。

异步性

  • 数据获取几乎都是异步的,而Nest支持异步async函数。
  • 每个异步async函数都返回一个Promise对象,也就是你可以返回一个延迟的值,Nest将会自己处理。
@Get()
async findAll(): Promise<any[]> {
  return [];
}
  • 也可以使用RxJS

请求的负载

  • 如果使用了Typescript,那么需要定义一个DTO文件(Data Transfer Object)。DTO文件定义了数据在网络中的传输形式。可以使用Typescript的interface或者简单的一个类来定义DTO文件。建议使用类来定义,因为类是ES6的一部分,他们在js编译的过程中是受保护的实体信息,而interface在转译为js的过程中会被移除,Nest在运行过程中获取不到它们。这很重要,因为一些特性,例如管道,在运行时可能会使变量的类型改变。
export class CreateCatDto {
  readonly name: string;
  readonly age: number;
  readonly breed: string;
}
@Post()
async create(@Body() createCatDto: CreateCatDto) {
  return 'This action adds a new cat';
}

完整的例子

import { Controller, Get, Query, Post, Body, Put, Param, Delete } from '@nestjs/common';
import { CreateCatDto, UpdateCatDto, ListAllEntities } from './dto';

@Controller('cats')
export class CatsController {
  @Post()
  create(@Body() createCatDto: CreateCatDto) {
    return 'This action adds a new cat';
  }

  @Get()
  findAll(@Query() query: ListAllEntities) {
    return `This action returns all cats (limit: ${query.limit} items)`;
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return `This action returns a #${id} cat`;
  }

  @Put(':id')
  update(@Param('id') id: string, @Body() updateCatDto: UpdateCatDto) {
    return `This action updates a #${id} cat`;
  }

  @Delete(':id')
  remove(@Param('id') id: string) {
    return `This action removes a #${id} cat`;
  }
}
  • 控制器通常都属于一个模块,我们要在装饰器@Module()中包含controllers数组
  • 启动时,需要在模块上挂载定义的控制器,否则Nest根本不知道他们的存在。。。
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';

@Module({
  controllers: [CatsController],  // 数组
})
export class AppModule {}


供应者

供应者Provider(依赖)

  • 供应者在Nest中一是一个基本的概念。许多基础的Nest类都可以看作供应者——服务、库、工厂、工具等等。供应者主要的目的是注入依赖。这意味着对象之间可以创建多种依赖关系,对象上实例方法的预编译可以委托给Nest的运行系统。供应者Provider是一个简单的使用了装饰器@Injectable()的类。

  • 在前面的章节,我们创建了一个简单的控制器CatController。控制器应只处理HTTP请求,而将其他复杂的业务交给供应者来处理。

    ps:尽管Nest支持用一种更加面向对象的方式来设计组织依赖,但我们强烈建议遵循SOLID原则。(面向对象设计的五大原则)

服务

  • 创建一个简单的服务,来负责数据的存储和检索,最后由控制器来调用。
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

ps: 可以使用脚手架命令nest g service cat来创建服务。

  • 我们的CatService是拥有一个属性两个方法的基本的类,新的特征就是使用了装饰器@Injectable()。附带了元数据来告诉Nest这个类是一个Nest的提供者。顺带一提,此例也使用了一个Cat接口,也许长这样。
export interface Cat {
  name: string;
  age: number;
  breed: string;
}
  • 现在我们拥有了一个服务的类来检索Cats,将它应用到控制器上
import { Controller, Get, Post, Body } from '@nestjs/common';
import { CreateCatDto } from './dto/create-cat.dto';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

  @Get()
  async findAll(): Promise<Cat[]> {
    return this.catsService.findAll();
  }
}
  • CatService服务通过类的构造器来注入,注意private readonly语法,这种表达式可以在立刻这个位置同时声明并初始化成员(TS语法糖)。

依赖注入

  • Nest是围绕大家所熟知的依赖注入而构建的,关于其概念我们建议阅读Auglar的官方文档。
  • 在Nest中,由于Typescript的存在,管理依赖是非常简单的,因为他们通过类型来解析。在下面的例子中,Nest将通过创建并返回一个CatService实例来解析CatService(正常情况下是一个单例对象,如果在别的地方中被请求过,则返回已经存在实例)。依赖解析成功并传入控制器的构造函数(或者传给已经声明的属性)。
constructor(private readonly catsService: CatsService) {}

作用域(生命周期)

  • 一个提供者Provider的生命周期(作用域)与应用的声明周期同步。当应用创建时,所有的依赖必须被解析,因此每个提供者也必须被实例化。同理,当应用关闭时,每个提供者将会销毁。当然你也可以使你提供者的生命周期在你的所需范围内。点击这里来阅读此技巧

自定义化提供者

  • Nest有一个内置的控制反转(面向对象设计原则,降低耦合性)的容器来处理两个提供者间的关系。该特性是构成上述的依赖注入的基础,但实际上它比我们所描述的还要强大得多。装饰器@Injectable()只是冰山一角,并且也不是定义供应者的唯一方式。实际上你可以使用简单的值、类、甚至是同步或异步的工厂。点击这里阅读更多案例

可选的提供者

  • 有时候你可能有一些依赖并不需要去解析他。举个🌰,你的类也许依赖于一个配置对象,但是如果没有任何入参,应使用默认值。这种情况下提供者变为可选的,而且缺少配置的提供者也不会报错。
import { Injectable, Optional, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  constructor(
    @Optional() @Inject('HTTP_OPTIONS') private readonly httpClient: T
  ) {}
}

以属性为基础的注入

  • 目前为止我们使用的都是以构造器为基础的注入,提供者通过构造函数被注入。在一些特定情况下,以属性为基础的注入会更加有用。例如,一个顶层的类依赖于一个或多个提供者,而子类始终通过super来继承父类引用的Provider是非常"单调乏味"的。为了避免这种情况,你可以在属性层使用装饰器@Inject()
import { Injectable, Inject } from '@nestjs/common';

@Injectable()
export class HttpService<T> {
  @Inject('HTTP_OPTIONS')
  private readonly httpClient: T;
}

如果你的类中没有使用另外的提供者,你应该始终使用以构造器为基础的注入


注册提供者

  • 现在我们有了一个提供者CatService,我们需要将它注册到控制器上才能实现注入。在@Module()模块中加入你的服务CatService到提供者Providers数组。
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class AppModule {}
  • 这样一来,Nest就可以处理控制器的提供者们之间的依赖关系了。


模块

模块

  • 模块是一个使用装饰器@Module()的类,装饰器提供元数据给Nest来组织应用结构。

  • 每个应用至少有一个根模块。根模块是Nest绘制应用结构的起点,Nest利用内部的数据结构来解析模块、依赖、以及提供者间的关系。理论上来说,非常小的应用也许只由一个根模块,但这不是典型例子。我们想要强调,模块是强烈推荐的一个高效的方式去组织你的组件。所以绝大多数应用将使用多个模块,每个模块涵盖了一系列相关的功能。
  • 使用@Module()装饰一个对象,属性用来描述这个模块。
供应者Provider 供应者将会被Nest实例化,并且这些供应者至少在当前模块中是共享的
控制器Controller 这个属性定义了在当前模块中哪些控制器将会被实例化
输入imports 引入模块的列表,引入了其他模块导出的在此模块中需要使用的提供者
输出exports 此模块所使用的提供者的子集,导出的提供者在导入此模块的其他模块中可用
  • 模块默认包含了提供者Provider,这意味着你如果想注入一个既不是当前模块一部分的提供者,也不是导入的模块所导出的提供者,是不可能的。这样一来,你就要考虑从一个模块中导入一些提供者,作为模块的公共接口或者API。(一个公共模块)

特征模块

  • 控制器CatsController和服务CatsService是应用的同一块区域,他们应该放入特征模块中。
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

ps: 可以使用脚手架指令nest g module cats来创建模块。

  • 上面,我们定义了CatsModule在cats.module.ts文件中。然后将所有相关的文件放统一目录下。最后将它导出到根模块就可以了。
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule {}
  • 然后结构目录看起来像这样


共享模块

  • 在Nest中,模块默认是一个单例对象,并且你可以轻松的在任何提供者之间共享同一个实例。

  • 每个模块自动的成为共享模块,只要创建就可以被任意其他模块重用。想像一下如果需要在其他几个模块中共享一个CatService服务的实例,我们需要通过在模块中增加exports属性来导出提供者CatService
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService]
})
export class CatsModule {}
  • 现在任何其他模块只要引入CatModule模块,就能共享同一个服务实例。

模块重导出

  • 如上所述,模块可以导出他们内部的提供者,此外他们还可以将引入的模块重新导出。那么其他模块引入了该模块时,同时可以使用该模块重导出的模块共享的提供者。
@Module({
  imports: [CommonModule],
  exports: [CommonModule],
})
export class CoreModule {}

依赖注入

  • 模块的类也可以引入提供者

import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {
  constructor(private readonly catsService: CatsService) {}
}
  • 然而模块的类自身不能作为提供者被自己引入形成循环依赖(自己依赖自己)

全局模块

  • 如果你到处引入同一个模块是“单调乏味的”。不像在Nest中,Augular的提供者注册在全局作用域下,只要定义,哪里都可以用。然而Nest将提供者包含在模块的作用域下,别的地方无法使用没有导出的提供者。
  • 如果你想在哪里都可以使用一些提供者,使用装饰器@Global()
import { Module, Global } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

@Global()
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],
})
export class CatsModule {}
  • 装饰器@Global()使该模块在全局作用域下。全局模块应只被注册一次在核心模块或者根模块当中。在上例中,其他模块使用服务CatService时,不用再引入该模块。

ps: 不要到处使用全局模块,他只是为了减少大量的必需引入。大多数情况下更好的方式是使用imports


动态模块

  • Nest的模块系统中包含了一个特性叫做动态模块,它能轻易地使你创建一个自定义模块。下面是一个动态模块的例子,DatabaseModule
import { Module, DynamicModule } from '@nestjs/common';
import { createDatabaseProviders } from './database.providers';
import { Connection } from './connection.provider';

@Module({
  providers: [Connection],
})
export class DatabaseModule {
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities);
    return {
      module: DatabaseModule,
      providers: providers,
      exports: providers,
    };
  }
}

ps:forRoot()方法可能会同步或异步地返回一个动态模块。(通过Promise

  • 这个模块定义了一个默认的Provider叫做Connection,依赖于传入的entitiesoptions对象,暴露了一个提供者provider的集合,比如库。记住动态模块扩展(而非重载)了基础模块的元数据。这就是从模块导出静态声明的提供者Connection和动态配置库的提供者的方式。
  • 当你需要动态地注册并配置你的提供者Providers时,这个重要的特性是非常有用的。只要用这种方式定义,DatabaseModule模块就能以如下的方式导出并配置。
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { User } from './users/entities/user.entity';

@Module({
  imports: [DatabaseModule.forRoot([User])],
})
export class AppModule {}
  • 如果你想反过来重导出一个动态模块,你可以在导出数组中,删除forRoot()函数的调用
import { Module } from '@nestjs/common';
import { DatabaseModule } from './database/database.module';
import { User } from './users/entities/user.entity';

@Module({
  imports: [DatabaseModule.forRoot([User])],
  exports: [DatabaseModule],
})
export class AppModule {}

中间件

中间件

  • 中间件是一个函数,在路由处理函数之前被调用。中间件函数接收了requestresponse对象还有管道函数next()

  • 中间件函数可以完成以下任务

    1. 运行任何代码
    2. 修改requestresponse对象
    3. 结束request-response循环
    4. 栈中调用next函数
    5. 如果当前中间件没有结束request-response循环,即必须调用next函数将控制权交给下个中间件函数。否则请求会一直处于挂起状态
  • 你可以用任何一个函数或者使用了装饰器@Injectable的类实现中间件。如果是类应当实现NestMiddleware接口,而函数不需要任何特殊的需求。让我们使用类来实现一个简单的中间件。

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response } from 'express';

@Injectable()
export class LoggerMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: Function) {
    console.log('Request...');
    next();
  }
}

依赖注入

  • Nest中间件完全支持依赖注入,就像提供者或控制器,他们可以注入在同一个模块中可用的依赖。通常是通过constructor来实现。

应用中间件

  • 在装饰器@Modules()中没有中间件的位置,取而代之我们使用模块类中的configure()函数来设置他们。包含中间件的模块必须实现NestModule的接口。让我们在根模块上设置一个LoggerMiddleware的中间件。
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes('cats');
  }
}
  • 在上面的例子中我们为在CatsController中定义的路由/cats的处理函数之前设置了中间件LoggerMiddleware。 当我们在配置中间件时,通过传入一个包含路由pathrequest方法的对象给forRoutes()来将中间件函数限制为一个特定的路由。在下面的例子中,注意我们引入了枚举类型RequestMethod,断言请求方法的类型。
import { Module, NestModule, RequestMethod, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes({ path: 'cats', method: RequestMethod.GET });
  }
}


路由通配符

  • 支持基于模式的路由。举个例子,*作为通配符可以匹配任意字符结合。
forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

中间件Consumer

  • MiddlewareConsumer是一个辅助类,它提供了一些内置的方法来管理中间件。他们都可以简单地使用用链式风格链接起来。forRoutes()方法可以使用单个字符串,多字符串,一个RouteInfo对象,一个控制器Controller的类,或者甚至是多个控制器的类。大多数情况下你可能会传入一个用逗号分割的控制器的列表。下面的例子是单个控制器。
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(LoggerMiddleware)
      .forRoutes(CatsController);
  }
}

ps:apply()方法接受一个中间件,或者多个中间件参数。

  • 我们经常想要在中间件应用中排除某个特定的路由。当用类来定义中间件时(正如我们到目前为止所做的,而不是使用可替代的函数中间件),我们可以使用exclude()方法来排除某个特定的路由。这个方法接受一个一个或多个对象来匹配需要排除路由path和方法。
consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST }
  )
  .forRoutes(CatsController);
  • 通过上面的例子,中间件LoggerMiddleware会绑定在CatsController中所有的路由,除了传入exclude()方法的两个对象。请记住exclude()方法在函数中间件中没有作用。此外,exclude方法不会排除那些来自更通用的路由。(比如说通配符)。如果你需要那种级别上的控制,你应当把你路由限制的逻辑直接放在中间件中。例如:接受路由请求并且有条件地应用中间件的逻辑。

函数中间件

  • 我们用的LoggerMiddleware类还是非常简单。他没有成员,没有额外的方法,没有依赖。为什么我们不定义一个简单的函数而不是一个类呢。让我们将它从类中间件转换为函数中间件。
export function logger(req, res, next) {
  console.log(`Request...`);
  next();
};
  • 然后在根路由中去使用它。
consumer
  .apply(logger)
  .forRoutes(CatsController);

ps: 考虑只在不需要任何依赖的情况下使用函数中间件。


多个中间件

  • 上面提到的,为了绑定多个中间件并依次执行,只需要提供一个逗号分割的apply()方法的调用。
consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);

全局中间件

  • 如果我们想立即在每个注册的路由上绑定中间件,我们可以使用由INestApplication接口提供的use()方法。
const app = await NestFactory.create(AppModule);
app.use(logger);  // 就像Express
await app.listen(3000);

异常过滤器

异常过滤器

  • Nest有一个异常层,负责处理整个应用中所有未处理过的异常。当你一个异常没有被你的应用的代码处理,他就会被这一层捕获。然后自动发送合适的用户友好型响应。

  • 这个动作由一个内置的全局异常过滤器完成,处理了HttpException类型的异常(及其子类)。当一个异常未被识别(既不是HttpException也不是继承自HttpException的类),客户端会接受到如下的JSON的响应。
{
  "statusCode": 500,
  "message": "Internal server error"
}

底层异常

  • 内置的Http异常的类通过@nestjs/common包暴露。
  • CatsController中,我们有一个findAll()函数(一个Get路由请求)。假设这个函数因为一些原因抛出了一个异常。
@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}

ps: 我们这里使用Http状态码,是由@nestjs/common导入的辅助枚举类型。

  • 当客户端请求这个节点,响应看起来像这样
{
  "statusCode": 403,
  "message": "Forbidden"
}
  • HttpException的构造函数接受两个参数,决定了JSON格式的响应体和各自Http响应的状态码。第一个参数是一个类型:string | object。传入一个字符来自定义错误信息。传入一个属性为statuserror的字面量对象为第一个参数而不是字符,来完全重载响应体。第二个参数应该是一个实际的Http状态码。
@Get()
async findAll() {
  throw new HttpException({
    status: HttpStatus.FORBIDDEN,
    error: 'This is a custom message',
  }, 403);
}

然后响应像这样

{
  "statusCode": 403,
  "error": "This is a custom message"
}

异常结构

  • 创建自己的异常结构是一个很好的实践。这意味着你的Http异常应当继承自基础的HttpException类。结果是Nest将辨识你的异常,自动处理异常的响应。
export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}
  • 由于ForbiddenException继承自HttpException,他将和内置的异常处理函数无缝衔接,所以我们可以在findAll()函数中使用
@Get()
async findAll() {
  throw new ForbiddenException();
}

Http Exceptions

  • 为了减少我们所写的重复的代码,Nest提供了一系列有用的继承自HttpException的异常。他们都由@nestjs/common包暴露
    • BadRequestException
    • UnauthorizedException
    • NotFoundException
    • ForbiddenException
    • NotAcceptableException
    • RequestTimeoutException
    • ConflictException
    • GoneException
    • PayloadTooLargeException
    • UnsupportedMediaTypeException
    • UnprocessableEntityException
    • InternalServerErrorException
    • NotImplementedException
    • BadGatewayException
    • ServiceUnavailableException
    • GatewayTimeoutException

异常过滤器

  • 内置的异常过滤器可以自动地为你处理大部分情况,但是您可能想要完全控制异常层。例如你想加入logging或者由于一些动态的因素返回不同的JSON结构的响应。异常过滤器为这个目的而设计。
  • 让我们创建一个异常过滤器来捕获继承自HttpException的异常,并且为他们实现了自定义响应的逻辑。
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,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

ps: 所有异常过滤器都必须实现ExceptionFilter<T>接口。这需要你提供catch(exception: T, host: ArgumentsHost)方法和签名。T表明了异常的类型。

  • @Catch(HttpException)装饰器为一场过滤器绑定了所需的元数据,告诉Nest这个过滤器正在寻找HttpException类型的异常。@Catch()装饰器接受一个参数,或者一个逗号分割的列表。这使你可以一次性设置多种不同类型的异常。

接口Arguments host

  • 让我们看一看catch()方法的参数,exception参数是当前正在处理的异常对象,而host是一个ArgumentsHost对象。ArgumentsHost是传递给原始请求处理函数的参数的包装器(异常初始化的地方),包含了一个基于应用类型的特定的数组。
export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgByIndex<T = any>(index: number): T;
  switchToRpc(): RpcArgumentsHost;
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
}
  • ArgumentsHost提供了一系列便利的方法来帮助我们在不同的应用环境下从基础数组中选择正确的参数,ArgumentsHost只不过是一组参数。举个例子,当过滤器使用的是Http应用环境,ArgumentsHost将会提供一个[request, response]数组。然而当当前环境是WebSocket应用环境,它包含一个[client, data]数组。这种方法使你能够访问在自定义catch()方法中最终传递给原始处理函数的任何参数。

绑定过滤器

  • 让我们将HttpExceptionFilterCatsControllercreate()函数联系起来。
@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

ps: 装饰器@UseFilters()@nestjs/common包导出。

  • 我们这里使用了装饰器@UseFilters()。类似于@Catch()装饰器,它接受一个过滤器实例,过着一个逗号分割的过滤器实例的列表。这里我们创建了HttpExceptionFilter的实例。或者你也可以传入类(而不是实例),将实例化的责任交给框架,并且启用依赖注入。
@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

ps: 尽可能地使用类而不是实例来注册过滤器。它减少了内存使用,因为Nest在你的模块中可以轻易地重用同一个类的实例。

  • 在上面的例子中,HttpExceptionFilter仅应用于单个create()路由处理函数,使其具有方法范围。异常过滤器可以在不同的层确定作用域:方法作用域、控制器作用域或全局作用域。例如,设置一个控制器作用域的过滤器:
@UseFilters(new HttpExceptionFilter())
export class CatsController {}
  • 此构造函数为在CatsController中定义的每个路由处理函数设置了HttpExceptionFilter
  • 创建一个全局作用域的过滤器:
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

`useGlobalFilters()`方法不为网关或混合应用程序设置过滤器。

  • 全局作用域的过滤器在整个应用当中被使用,每个控制器每个路由处理函数。按照依赖注入,从任何模块外部注册的全局过滤器(如上例所示,使用useGlobalFilters())不能注入依赖项,因为这是在任何模块的环境之外完成的。你可以使用以下构造直接从任何模块注册一个全局范围的过滤器
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

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

ps: 当使用此方法为过滤器执行依赖项注入时,请注意,无论使用此构造的模块是什么,过滤器实际上都是全局的。这应该在哪里进行?选择定义过滤器(上面示例中的HttpExceptionFilter)地方的模块。并且,useClass不是处理自定义提供者注册的唯一方法。

  • 你可以根据需要使用这种方法添加尽可能多的过滤器,只需将每个组件添加到provider数组。

捕获所有

  • 为了捕获未处理的异常(不管异常的类型是什么)。装饰器@Catch()传空就可以了。
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status).json({
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}
  • 在上面的例子中,过滤器将捕获所有抛出的异常,不管类型是什么。

继承

  • 通常,你将创建完全自定义的异常过滤器,以满足应用程序需求。然而,在某些例子中,你可能希望简单地扩展内置的默认全局异常过滤器,并基于某些因素覆盖行为。
  • 为了将异常处理委托给基础过滤器,你需要扩展BaseExceptionFilter并调用继承的catch()方法。
import { Catch, ArgumentsHost } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    super.catch(exception, host);
  }
}

扩展`BaseExceptionFilter`的方法范围和控制范围的过滤器不应该用new实例化,让框架自动实例化它们。

  • 上面的实现只是展示了该方法的结构。扩展异常过滤器的实现将包括你的定制的业务逻辑(例如,处理各种条件)。
  • 全局过滤器可以扩展基础过滤器,有两种方法。
  • 第一种方法是在实例化自定义的全局过滤器时注入HttpServer参数
async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const { httpAdapter } = app.get(HttpAdapterHost);
  app.useGlobalFilters(new AllExceptionsFilter(httpAdapter));

  await app.listen(3000);
}
bootstrap();
  • 第二种方法是使用APP_FILTER标识像这样


管道

管道

  • 管道是使用装饰器@Injectable()的类,管道应该实现PipeTransform接口

  • 管道由两个典型的应用
    1. 转换 —— 将输入的数据转换为所需的数据输出
    2. 校验 —— 校验输入的数据,通过验证则原封不动地传递,否则当数据不正确时抛出异常
  • 在这两种情况中,管道对控制器的路由处理函数正在处理的参数进行操作。Nest插入一个在函数调用前插入一个管道,并且管道接受函数指定的参数。任何转换或验证操作都在那时发生,然后使用任何(潜在的)转换后的参数调用路由处理函数。

ps:管道运行在异常区域内。这意味这当管道抛出异常时,由异常层(全局异常过滤器和应用于当前环境的任何异常过滤器)处理。根据上面的说明,当异常在管道中抛出时,显然不会执行控制器方法。


内置管道

  • Nest提供了三个开箱即用的管道:ValidationPipeParseIntPipeParseUUIDPipe。他们从@nestjs/common导出,为了更好地理解它们是如何工作的,让我们从头开始构建它们。
  • 让我们从ValidationPipe开始。起先我们简单地传入一个值并立刻返回相同的值,就像一个确认函数。
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

ps: PipeTransform<T, R>是一个泛型接口,其中T表示输入值的类型,R表示transform()方法的返回类型。

  • 每个管道提供了tranform()方法,由两个参数:
    1. value
    2. metadata
  • value是当前处理的参数(在路由处理方法接收它之前),而metadata是它的元数据。meata对象具有这些属性
export interface ArgumentMetadata {
  readonly type: 'body' | 'query' | 'param' | 'custom';
  readonly metatype?: Type<any>;
  readonly data?: string;
}
  • 这些属性描述了当前正在处理的参数
type 表明了参数是@body()@Query()@Param()或者是一个自定义参数
metatype 为参数提供了元数据,例如字符。注意:如果在路由处理函数的方法签名中省略类型声明,或者使用普通JavaScript,则该值为undefined
data 传入装饰器的字符。例如:@Body('string'),传空即为undefined

TypeScript的接口(Interface)在转换期间会消失。因此,如果方法参数的类型声明为接口而不是类,则metatype值将为Object。


验证的例子

  • 让我们仔细看看CatsControllercreate()方法。
@Post()
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
  • 让我们关注createCatDtobody参数。它的类型是CreateCatDto
export class CreateCatDto {
  readonly name: string;
  readonly age: number;
  readonly breed: string;
}
  • 我们希望确保对create方法来说任何传入的请求都包含一个有效的请求体。因此,我们必须验证createCatDto对象的三个成员。我们当然可以在路由处理函数中做这件事情,但我们将会打破单一责任规则(SRP)。另一种方法是创建一个验证器类并在其中委托任务,但是我们必须在每个方法的开头使用这个验证器。那创建一个验证中间件怎么样?这可能是一个好主意,但是这没有办法创建可以跨整个应用程序使用的通用的中间件(因为中间件不知道执行环境,包括将要调用的处理函数及其任何参数)。事实证明,这种情况非常适合于管道。所以我们来做一个。

对象模式验证

  • 有几种可用的方法来验证对象,一种常见的方法是使用基于模式的验证。Joi库允许你使用可读的API以非常简单的方式创建模式。让我们看看一个使用基于joi模式的管道。
  • 先安装
$ npm install --save @hapi/joi
$ npm install --save-dev @types/hapi__joi
  • 在下面的代码示例中,我们创建了一个简单的类,它接受模式作为构造函数参数。然后我们应用jo .validate()方法,该方法根据提供的模式验证传入参数。

  • 如上所述,验证的管道要么返回未更改的值,要么抛出异常。

  • 在下一节中,你将看到我们如何使用@UsePipes()装饰器为给定的控制器方法提供适当的模式。

import * as Joi from '@hapi/joi';
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class JoiValidationPipe implements PipeTransform {
  constructor(private readonly schema: Object) {}

  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = Joi.validate(value, this.schema);
    if (error) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }
}

绑定管道

  • 绑定管道(将它们绑定到适当的控制器或处理函数)非常简单。我们使用@UsePipes()装饰器并创建一个管道实例,将一个Joi验证模式传递给它。
@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

类验证器

本节中的技术需要TypeScript,如果应用程序是使用普通JavaScript编写的,则不可用

  • 让我们看看验证技术的另一种可替代的实现。
  • Nest可以很好地与类验证器库一起工作。这个惊人的库允许您使用基于装饰器的验证。基于装饰器的验证功能非常强大,尤其是与Nest的管道功能结合使用时,因为我们可以访问处理属性的元类型。在开始之前,我们需要安装所需的软件包
$ npm i --save class-validator class-transformer
  • 安装了这些之后,我们可以向CreateCatDto类添加一些装饰器。
import { IsString, IsInt } from 'class-validator';

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

  @IsInt()
  readonly age: number;

  @IsString()
  readonly breed: string;
}

ps:更多关于类验证器请阅读这里

  • 现在我们可以创建一个ValidationPipe
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } 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 = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }

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

ps: 上面,我们使用了类转换器库。它是由同一个作者编写的,与类验证器库是同一个作者编写的,因此,它们在一起运行得非常好。

  • 让我们看看这段代码。首先,注意transform()函数是异步的。这是可能的,因为Nest同时支持同步和异步管道。我们这样做是因为一些类验证器验证可以是异步的(利用promise)。
  • 接下来注意,我们使用解构来提取metatype字段(仅从ArgumentMetadata中提取此成员)到metatype参数中。这只是获取完整ArgumentMetadata的简写,然后使用附加语句来分配元类型变量。
  • 接下来,注意辅助函数toValidate()。当当前处理的参数是原生JavaScript类型时,它负责绕过验证步骤(这些参数不能附加模式,因此没有理由在验证步骤中运行它们)。
  • 接下来,我们使用类转换器函数plain toclass()将普通JavaScript参数对象转换为类型化对象,以便应用验证。从网络请求反序列化传入的主体时,会没有任何类型的信息。类验证器需要使用前面为DTO定义的验证装饰器,因此需要执行此转换。
  • 最后,如前所述,由于这是一个验证管道,它要么返回未更改的值,要么抛出异常。
  • 最后一步是绑定ValidationPipe。管道类似于异常过滤器,可以是方法范围的、控制范围的或全局范围的。此外,管道可以是参数作用域的。在下面的示例中,我们将直接将管道实例绑定到路由参数的@Body()装饰器。
@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}
  • 当验证逻辑只涉及一个指定参数时,参数作用域的管道非常有用。

  • 或者,要用方法级别设置管道,可以使用@UsePipes()装饰器。

@Post()
@UsePipes(new ValidationPipe())
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
  • @UesPipe()装饰器由@nestjs/common导出
  • 在上面的示例中,立即就地创建了ValidationPipe实例。或者,传递类(而不是实例),从而将实例化留给框架,并启用依赖项注入。
@Post()
@UsePipes(ValidationPipe)
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
  • 由于ValidationPipe被创建为尽可能通用,所以让我们将它设置为全局范围的管道,应用于整个应用程序中的每个路由处理程序。
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

ps: 在混合应用程序的情况下,useGlobalPipes()方法不会为网关和微服务设置管道。对于“标准”(非混合)微服务应用程序,useGlobalPipes()在全局安装管道。

  • 全局管道用于整个应用程序,用于每个控制器和每个路由处理程序。在依赖项注入方面,从任何模块外部注册的全局管道(如上例中使用useGlobalPipes())不能注入依赖项,因为这是在任何模块的环境之外完成的。为了解决这个问题,您可以使用以下构造从任何模块直接设置全局管道
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

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

ps: 当使用此方法为管道执行依赖项注入时,请注意,无论使用此构造的模块是什么,管道实际上都是全局的。这应该在哪里进行?选择定义管道的模块(上面示例中的ValidationPipe)。而且,useClass不是处理自定义提供者注册的唯一方法。了解更多


转换例子

  • 验证不是管道的唯一的案例。在本章的开头,我们提到管道还可以将输入数据转换为所需的输出。这是可能的,因为transform函数返回的值完全覆盖了参数的前一个值。什么时候有用?考虑到有时从客户端传递的数据需要进行一些更改(例如将字符串转换为整数),然后才能由route处理方法正确处理。此外,一些必需的数据字段可能会丢失,我们希望应用默认值,Transformer pipes可以通过在客户端请求和请求处理程序之间插入一个处理函数来执行这些功能。
  • 这是一个ParseIntPipe,它负责将字符串解析为整数值。
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;
  }
}
  • 我们可以简单地将这个管道绑定到所选的参数,如下所示
@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
  return await this.catsService.findOne(id);
}
  • 如果您愿意,可以使用ParseUUIDPipe,它负责解析字符串并验证是否为UUID。
@Get(':id')
async findOne(@Param('id', new ParseUUIDPipe()) id) {
  return await this.catsService.findOne(id);
}

ps: 当使用ParseUUIDPipe()时,您在版本3、4或5中解析UUID,如果您只需要特定版本的UUID,那么您可以在管道选项中传递一个版本。

  • 有了这一点,ParseIntPipeParseUUIDPipe将在请求到达相应的处理函数之前执行,确保它总是会收到id参数的整数或uuid(根据使用的管道)。另一种有用的情况是通过id从数据库中选择一个现有的用户实体
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;
}
  • 请注意,与所有其他转换管道一样,它接收一个输入值(一个id)并返回一个输出值(一个UserEntity对象)。通过将样板代码从处理函数抽象到公共管道中,这可以使您的代码更具声明性和干爽性。

内置的验证管道

  • 幸运的是,您不必自己构建这些管道,因为ValidationPipeParseIntPipe是由Nest开箱即用提供的。(请记住ValidationPipe需要同时安装类验证器和类转换器包)。
  • 内置的ValidationPipe提供了比我们在本章中构建的示例更多的选项,为了说明管道的基本机制,本章保持了基本的验证。你可以在这里找到很多例子。
  • 其中一个选择就是转换。回想一下前面关于反序列化体对象是普通JavaScript对象的讨论(即,没有我们的DTO类型)。到目前为止,我们已经使用管道验证了负载。你可能还记得,在这个过程中,我们使用class-transform将普通对象临时转换为类型化对象,以便进行验证。内置的ValidationPipe还可以选择性地返回这个转换后的对象。我们通过向管道传递配置对象来启用此行为。对于这个选项,传递一个带有字段转换和值true的配置对象
@Post()
@UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

ps: ValidationPipe@nestjs/common package导出。

  • 因为这个管道基于类验证器和类转换器库,所以有许多额外的选项可用。与上面的transform选项一样,您可以通过传递给管道的配置对象来配置这些设置。以下是内置选项
export interface ValidationPipeOptions extends ValidatorOptions {
  transform?: boolean;
  disableErrorMessages?: boolean;
  exceptionFactory?: (errors: ValidationError[]) => any;
}
  • 除此之外,所有类验证器选项(继承自ValidatorOptions接口)都是可用的
配置 类型 描述
skipMissingProperties boolean 如果设置为true,验证器将跳过验证对象中缺少的所有属性的验证。
whitelist boolean 如果设置为true,validator将剥离不使用任何验证修饰符的任何属性的已验证(返回)对象。
forbidNonWhitelisted boolean 如果设置为true,则抛出异常而不是剥离非白名单属性验证器。
forbidUnknownValues boolean 如果设置为true,验证未知对象的尝试将立即失败。
disableErrorMessages boolean 如果设置为true,验证错误将不会返回给客户端。
exceptionFactory Function 获取验证错误数组,并返回要抛出的异常对象。
groups string[] 在对象验证期间使用的数组。
dismissDefaultMessages boolean 如果设置为true,验证将不使用默认信息。如果没有明确设置错误信息,则该信息始终是undefined
validationError.target boolean 表示目标是否应在ValidationError中暴露
validationError.value boolean 表示验证的值是否应在ValidationError中暴露

ps: 在其存储库中查找有关类验证器包的更多信息



守卫

守卫

  • 守卫是一个使用装饰器@Injectable()的类,守卫应该继承CanActivate接口

  • 守卫只有一个责任。他们决定了一个请求是否要交予路由函数处理,取决于在运行时出现的某些情况(例如权限、角色、ACL等)。这通常叫做授权。授权(通常和他的兄弟——身份验证——一起合作)在传统的Express当中一贯是由中间件来处理。中间件对于身份验证是一个好的选择,因为像令牌验证或是在request对象上增加属性和一个特定的路由环境(还有他的元数据)并没有很大的关联。
  • 但是中间件本身是愚蠢的,他并不知道调用next()方法后哪一个路由处理函数将被执行。另一方面,守卫可以访问ExecutionContext的实例,这样就能准确地知道下一个执行的函数。它们的设计很像异常过滤器、管道和拦截器,让你在request/response循环中在正确的位置插入处理逻辑,并用声明的方式来实现。

ps:守卫会在每个中间件之后执行,但是会在拦截器和管道之前。


授权守卫

  • 正如所说的,授权是守卫的一个很好的案例,因为只有当调用者(通常是经过身份验证的特定用户)具有足够的权限时,才应该使用特定的路由。
  • 我们现在要构建的AuthGuard假设有一个经过身份验证的用户(因此,请求头附加了一个令牌)。它将提取和验证令牌,并使用提取的信息来确定请求是否可以继续。
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();
    return validateRequest(request);
  }
}
  • validateRequest()函数中的逻辑可以根据需要简单或复杂。本例的主要目的是展示守卫是如何适应request/response循环
  • 每个守卫必须实现canActivate()函数。函数应该返回一个布尔值,表示了当前请求是否允许通过。他可以同步或异步地返回响应(通过Promise或者Observable)。Nest根据返回值来控制下一个动作:
    • 如果返回true,请求将被处理
    • 如果返回false,Nest将拒绝该请求
  • canActivate()函数接受一个参数,ExecutionContext的实例。ExecutionContext继承自ArgumentsHost。我们在之前异常过滤器的章节中看到过ArgumentsHost。在那里,它是传递给原始处理函数的参数的包装器,并且包含了基于应用类型的不同的参数数组。有关此主题的更多信息,请参阅异常过滤器。

执行环境

  • 通过扩展ArgumentsHostExecutionContext提供了关于当前执行过程的更多细节。
export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}
  • getHandler()方法返回对将要调用的处理函数的引用。getClass()方法返回这个特定处理函数所属的控制器类的类型。例如,如果当前处理的请求是一个POST请求,目标是CatsController上的create()方法,getHandler()将返回对create()方法的引用,getClass()将返回一个CatsController类型(而不是实例)。

基于角色的身份验证

  • 让我们构建一个功能更强的守卫,它只允许具有特定角色的用户访问。我们将从一个基本的守卫模板开始,并在接下来的部分中构建它。目前,它允许所有请求继续进行
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class RolesGuard implements CanActivate {
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    return true;
  }
}

绑定守卫

  • 与管道和异常过滤器一样,守卫可以是控制器范围的、方法范围的或全局范围的。下面,我们使用@ useguard()装饰器设置了一个控制器范围的守卫。这个装饰器可以使用单个参数,也可以使用逗号分隔的参数列表。‘
@Controller('cats')
@UseGuards(RolesGuard)
export class CatsController {}

ps: UseGuards()装饰器由@nestjs/common导出

  • 上面,我们传递了RolesGuard类型(而不是实例),将实例化的责任留给框架并启用依赖项注入。与管道和异常过滤器一样,我们也可以就地传递一个实例。
@Controller('cats')
@UseGuards(new RolesGuard())
export class CatsController {}
  • 上面的构造将守卫附加到此控制器声明的每个处理程序。如果我们希望守卫只应用于一个方法,我们将在方法级别应用@useguard()装饰器。
  • 要设置全局警卫,请使用Nest应用程序实例的useglobalguard()方法
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new RolesGuard());

ps: 在混合应用程序的情况下,useglobalguard()方法不会为网关和微服务设置保护。对于“标准”(非混合)微服务应用程序,useglobalguard()确实在全球安装了这些警卫。

  • 全局守卫用于整个应用程序,用于每个控制器和每个路由处理函数。在依赖项注入方面,从任何模块外部注册的全局警卫(如上例中使用useglobalguard())不能注入依赖项,因为这是在任何模块的环境之外完成的。为了解决这个问题,您可以使用以下构造从任何模块直接设置一个守卫
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';

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

ps: 当使用此方法为守卫执行依赖项注入时,请注意,无论使用此构造的模块是什么,守卫实际上都是全局的。这应该在哪里进行?选择定义守卫的模块(在上面的例子中是RolesGuard)。而且,useClass不是处理自定义提供者注册的唯一方法。了解更多


映射

  • 我们的RolesGuard工作正常,但还不是很智能。我们还没有利用最重要的守卫特性——执行环境。它还不知道角色,或者每个处理程序允许哪些角色。例如,CatsController可以为不同的路由提供不同的权限方案。其中一些可能只对管理员用户可用,而另一些则可以对所有人开放。我们如何以灵活和可重用的方式将角色匹配到路由?
  • 这就是定制元数据发挥作用的地方。Nest提供了通过@SetMetadata()装饰器将定制元数据附加到路由处理程序的能力。这个元数据提供了我们丢失的角色数据,智能守卫需要这些数据来做出决策。让我们看看如何使用@SetMetadata()
@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

ps: 装饰器@SetMetadata()nestjs/common包导出

  • 通过上述构造,我们将角色元数据(角色是一个键,而['admin']是一个特定值)附加到create()方法。虽然这样做是有效的,但是在路由中直接使用@SetMetadata()不是很好的做法。相反,创建您自己的装饰器,如下所示
import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
  • 这种方法更简洁、可读性更强,而且是强类型的。现在我们有了一个自定义的@Roles()装饰器,我们可以使用它来装饰create()方法。
@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

融会贯通

  • 现在让我们回去把它和我们的RolesGuard绑在一起。目前,它只是在所有情况下返回true,允许每个请求继续。我们希望将分配给当前用户的角色与正在处理的当前路由所需的实际角色进行比较,从而使返回值具有条件。为了访问路由的角色(自定义元数据),我们将使用Reflector辅助类,它由框架提供,并从@nestjs/common公开。
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
import { Reflector } from '@nestjs/core';

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    }
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const hasRole = () => user.roles.some((role) => roles.includes(role));
    return user && user.roles && hasRole();
  }
}

ps: 在node.js中,通常将授权用户附加到请求对象。因此,在上面的示例代码中,我们假设该请求。user包含用户实例和允许的角色。在您的应用程序中,您可能会在自定义身份验证守卫(或中间件)中创建关联。

  • Reflector类允许我们通过指定的键轻松地访问元数据(在本例中,键是“roles”;返回到roles.decorator.ts文件和在那里发出的SetMetadata()调用。在上面的示例中,为了提取当前处理的请求方法的元数据,我们传递了context.getHandler()。记住,getHandler()提供了对路由处理函数的引用。
  • 我们可以通过提取控制器元数据并使用该元数据来确定当前用户角色,从而使这个守卫更加通用。为了提取控制器元数据,我们传递context.getClass()而不是context.getHandler()
const roles = this.reflector.get<string[]>('roles', context.getClass());
  • 当权限不足的用户请求端点时,Nest自动返回以下响应
{
  "statusCode": 403,
  "message": "Forbidden resource"
}

注意,在后台,当一个守卫返回false时,框架抛出一个ForbiddenException。如果希望返回不同的错误响应,应该抛出自己的特定异常。例如

throw new UnauthorizedException();
  • 由守卫引发的任何异常都将由异常层(全局异常过滤器和应用于当前环境的任何异常过滤器)处理。


拦截器

拦截器

  • 拦截器是使用装饰器@injectable的类,拦截器应该实现NestInterceptor接口

  • 拦截器具有一组有用的功能,这些功能受到面向方面编程(AOP)技术的启发。他们使之成为可能:
    1. 在方法执行前/后绑定额外的逻辑
    2. 转换函数返回的结果
    3. 转换函数抛出的异常
    4. 扩展基础函数的行为
    5. 根据特定条件完全重载函数

基础

  • 每个拦截器实现了intercept()方法,接受两个参数。第一个是ExecutionContext的实例(和守卫一样)。ExecutionContext继承自ArgumentsHost。我们在前面的异常过滤器一章中看到了ArgumentsHost。在那里,我们看到它是传递给原始处理函数的参数的包装器,并根据应用程序的类型包含不同的参数数组。

Execution context 执行环境

  • 通过扩展ArgumentsHost, ExecutionContext提供了关于当前执行过程的更多细节。这是它的样子
export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}
  • getHandler()方法返回对将要调用的路由处理程序的引用,getClass()方法返回这个特定处理程序所属的控制器类的类型。例如,如果当前处理的请求是一个POST请求,目标是CatsController上的create()方法,getHandler()将返回对create()方法的引用,getClass()将返回一个CatsControllertype(而不是实例)。

调用处理函数

  • 第二个参数是CallHandlerCallHandler接口实现handle()方法,你可以使用该方法在拦截器中的某个点调用路由处理函数。如果在拦截器intercept()方法的实现中不调用handle()方法,则根本不会执行路由处理函数。

部分拦截

  • 第一个用例是使用拦截器来记录用户交互(例如,存储用户调用、异步调度事件或计算时间戳)。我们在下面展示了一个简单的日志拦截器
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`)),
      );
  }
}

ps: NestInterceptor<T, R>是一个泛型接口,其中T表示一个Observable<T>(支持响应流)的类型,RObservable<R>封装的值的类型。

ps: 拦截器,如控制器、提供者、守卫等,可以通过它们的构造函数注入依赖项。

  • 由于handle()返回一个RxJs的Observable,所以我们可以使用多种操作符来操作流。在上面的例子中,我们使用了tap()操作符,在observable流优雅或异常终止时调用匿名日志函数,但不会在其他方面干扰响应循环。

绑定拦截器

  • 为了设置拦截器,我们使用从@nestjs/common导入的@UseInterceptors()装饰器,与管道和守卫一样,拦截器可以是控制器范围的、方法范围的或全局范围的。
@UseInterceptors(LoggingInterceptor)
export class CatsController {}
  • 使用上述构造,CatsController中定义的每个路由处理函数都将使用LoggingInterceptor。当有人调用GET /cats端点时,您将在标准输出中看到以下输出
Before...
After... 1ms
  • 注意,我们传递了LoggingInterceptor类型(而不是实例),将实例化的责任留给框架并启用依赖项注入。与管道、守卫和异常过滤器一样,我们也可以传递一个就地实例。
@UseInterceptors(new LoggingInterceptor())
export class CatsController {}
  • 如上所述,上面的构造将拦截器附加到此控制器声明的每个处理函数。如果我们想将拦截器的范围限制为一个方法,我们只需在方法级别应用装饰器。

  • 为了设置全局拦截器,我们使用了Nest应用程序实例的useGlobalInterceptors()方法

const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
  • 全局拦截器用于整个应用程序,用于每个控制器和每个路由处理函数 。在依赖项注入方面,从任何模块外部注册的全局拦截器(使用useGlobalInterceptors(),如上例所示)不能注入依赖项,因为这是在任何模块的环境之外完成的。为了解决这个问题,您可以使用以下构造直接从任何模块设置拦截器
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';

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

ps: 当使用此方法为拦截器执行依赖项注入时,请注意,无论使用此构造的模块是什么,拦截器实际上都是全局的。这应该在哪里进行?选择定义拦截器(上例中的LoggingInterceptor)的模块。而且,useClass不是处理自定义提供者注册的唯一方法。了解更多


响应映射

  • 我们已经知道handle()返回一个Obseverable对象。流包含路由处理函数返回的值,因此我们可以使用RxJS的map()操作符轻松地对其进行修改。

响应映射特性与指定库library-specific的响应策略不兼容(禁止直接使用`@Res()`对象)。

  • 让我们创建TransformInterceptor,它将以一种简单的方式修改每个响应,以演示流程。它将使用RxJS的map()操作符将响应对象分配给新创建对象的data属性,并将新对象返回给客户端。
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 => ({ data })));
  }
}

ps: 嵌套拦截器同时使用同步和异步intercept()方法。如果需要,您可以简单地将方法切换到async

  • 使用上述构造,当有人调用GET /cats端点时,响应将如下所示(假设路由处理程序返回一个空数组[])
{
  "data": []
}
  • 拦截器在为跨整个应用程序发生的需求创建可重用的解决方案方面具有很大的价值。例如,假设我们需要将每次出现的空值转换为空字符串。我们可以使用一行代码来实现这一点,并全局绑定拦截器,这样每个注册的处理程序都会自动使用它。
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ExcludeNullInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(map(value => value === null ? '' : value ));
  }
}

异常映射

  • 另一个有趣的用例是利用RxJS的catchError()操作符覆盖抛出的异常
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  BadGatewayException,
  CallHandler,
} from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';

@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next
      .handle()
      .pipe(
        catchError(err => throwError(new BadGatewayException())),
      );
  }
}

流重载

  • 我们有时可能希望完全避免调用处理函数,而是返回一个不同的值,这有几个原因。一个明显的例子是实现缓存以加速响应时间。让我们来看一个简单的缓存拦截器,它从缓存返回响应。在实际的示例中,我们希望考虑其他因素,如TTL、缓存失效、缓存大小等,但这超出了本文的讨论范围。这里我们将提供一个基本示例来演示主要概念。
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';

@Injectable()
export class CacheInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const isCached = true;
    if (isCached) {
      return of([]);
    }
    return next.handle();
  }
}
  • 我们的缓存拦截器有一个硬编码的isCached变量和一个硬编码的响应数组。需要注意的关键点是,我们在这里返回一个由RxJSof()操作符创建的新流,因此根本不会调用路由处理程序。当有人调用使用CacheInterceptor的端点时,响应(硬编码的空数组)将立即返回。为了创建一个通用的解决方案,您可以利用Reflector并创建一个自定义装饰器。Reflector在守卫一章中有很好的描述。

更多的操作

  • 使用RxJS操作符操作流的可能性为我们提供了许多功能。让我们考虑另一个常见的用例。假设您希望处理路由请求上的超时。当端点在一段时间之后没有返回任何内容时,您希望使用错误响应终止。下面的构造实现了这一点
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { timeout } from 'rxjs/operators';

@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(timeout(5000))
  }
}
  • 5秒后,请求进程将被取消。


自定义装饰器

自定义路由装饰器

  • Nest是围绕一种名为decorator的语言特性构建的。装饰器在许多常用的编程语言中都是一个众所周知的概念,但在JavaScript世界中,它们仍然相对较新。为了更好地理解decorator是如何工作的,我们建议阅读本文。这里有一个简单的定义

An ES2016 decorator is an expression which returns a function and can take a target, name and property descriptor as arguments. You apply it by prefixing the decorator with an @ character and placing this at the very top of what you are trying to decorate. Decorators can be defined for either a class or a property.


参数装饰器

  • Nest提供了一组有用的参数装饰器,可以与HTTP路由处理程序一起使用。下面是所提供的装饰器和它们所表示的普通Express(或Fastify)对象的列表
@Request() req
@Response() res*
@Next() next
@Session() req.session
@Param(key?: string) req.params / req.params[key]
@Body(key?: string) req.body / req.body[key]
@Query(key?: string) req.query / req.query[key]
@Headers(name?: string) req.headers / req.headers[name]
  • 此外,您还可以创建自己的自定义装饰器。为什么这个有用?
  • 在node.js世界中,通常将属性附加到请求对象。然后在每个路由处理程序中手动提取它们,使用如下代码
const user = req.user;
  • 为了使代码更具可读性和透明性,您可以创建一个@User()装饰器,并在所有控制器之间重用它。
import { createParamDecorator } from '@nestjs/common';

export const User = createParamDecorator((data, req) => {
  return req.user;
});
  • 然后,您可以简单地在任何适合需要的地方使用它。
sync findOne(@User() user: UserEntity) {
  console.log(user);
}

传入data参数

  • 当装饰器的行为取决于某些条件时,可以使用data参数将参数传递给装饰器的工厂函数。一个用例是自定义装饰器,它按键从请求对象中提取属性。例如,假设我们的身份验证层验证请求并将用户实体附加到请求对象。经过身份验证的请求的用户实体可能如下所示
{
  "id": 101,
  "firstName": "Alan",
  "lastName": "Turing",
  "email": "alan@email.com",
  "roles": ["admin"]
}
  • 让我们定义一个装饰器,它接受属性名作为键,如果属性名存在,则返回关联的值(如果不存在,则返回未定义的值,或者如果用户对象没有创建,则返回未定义的值)。
import { createParamDecorator } from '@nestjs/common';

export const User = createParamDecorator((data: string, req) => {
  return data ? req.user && req.user[data] : req.user;
});
  • 下面是如何通过控制器中的@User()装饰器访问特定的属性
@Get()
async findOne(@User('firstName') firstName: string) {
  console.log(`Hello ${firstName}`);
}
  • 您可以使用具有不同键的相同装饰器来访问不同的属性。如果用户对象是深度或复杂的,这可以使请求处理程序实现更容易、更可读。

和管道一起用

  • Nest以与内置参数装饰器(@Body()@Param()@Query()相同的方式处理定制的参数装饰器。这意味着管道也将为自定义带注释的参数执行(在我们的示例中,是用户参数)。此外,您还可以将管道直接应用于自定义装饰器
@Get()
async findOne(@User(new ValidationPipe()) user: UserEntity) {
  console.log(user);
}

overview结束




关于本文

  • 本文作为学习nest框架的一篇笔记,对英文官方文档进行了直译、意译和理解。
  • 翻译多有变扭之处,是比较大量的工作,后续应该持续会纠正。
  • 用作记录自己曾经学习、思考过的问题的一种笔记。
  • 用作前端技术交流分享。
  • 阅读本文时欢迎随时质疑本文的准确性,将错误的地方告诉我。本人会积极修改,避免文章对读者的误导。

关于我

  • 是一只有梦想的肥柴。
  • 觉得算法、数据结构、函数式编程、js底层原理等十分有趣的小前端。
  • 志同道合的朋友请关注我,一起交流技术,在前端之路上共同成长。
  • 如对本人有任何意见建议尽管告诉我哦~ 初为肥柴,请多多关照~
  • 前端路漫漫,技术学不完。今天也是美(diao)好(fa)的一天( 跪了...orz