Overview

管道

学习如何在 NestJS 中使用管道进行数据转换和验证

管道是一个用 @Injectable() 装饰器注解的类,它实现了 PipeTransform 接口。

管道有两个典型的用例:

  • 转换:将输入数据转换为所需的形式(例如,从字符串转换为整数)
  • 验证:评估输入数据,如果有效,则简单地将其原样传递;否则,抛出异常

在这两种情况下,管道都对控制器路由处理程序正在处理的 arguments 进行操作。Nest 在方法调用之前插入一个管道,管道接收发送给方法的参数并对其进行操作。任何转换或验证操作都在那时进行,之后路由处理程序被调用,并带有任何(可能)转换后的参数。

Nest 提供了许多开箱即用的内置管道。您也可以构建自己的自定义管道。在本章中,我们将介绍内置管道并展示如何将它们绑定到路由处理程序。然后我们将检查几个自定义构建的管道,以展示如何从头开始构建一个。

info 提示 管道在异常区域内运行。这意味着当管道抛出异常时,它由异常层(全局异常过滤器和应用于当前上下文的任何异常过滤器)处理。鉴于上述情况,应该清楚的是,当在管道中抛出异常时,随后不会执行控制器方法。这为您提供了一种在系统边界验证来自外部源的数据的最佳实践技术。

内置管道

Nest 提供了几个开箱即用的管道:

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe
  • ParseDatePipe

它们从 @nestjs/common 包导出。

让我们快速看一下使用 ParseIntPipe。这是转换用例的一个示例,其中管道确保方法处理程序参数被转换为 JavaScript 整数(如果转换失败则抛出异常)。在本章后面,我们将展示 ParseIntPipe 的简单自定义实现。下面的示例技术也适用于其他内置转换管道(ParseBoolPipeParseFloatPipeParseEnumPipeParseArrayPipeParseDatePipeParseUUIDPipe,我们在本章中将其称为 Parse* 管道)。

绑定管道

要使用管道,我们需要将管道类的实例绑定到适当的上下文。在我们的 ParseIntPipe 示例中,我们想要将管道与特定的路由处理程序方法关联,并确保它在方法被调用之前运行。我们使用以下构造来做到这一点,我们将其称为在方法参数级别绑定管道:

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

这确保以下两个条件之一为真:要么我们在 findOne() 方法中接收到的参数是一个数字(如我们调用 this.catsService.findOne() 时所期望的),要么在路由处理程序被调用之前抛出异常。

例如,假设路由被这样调用:

GET localhost:3000/abc

Nest 将抛出这样的异常:

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

异常将阻止 findOne() 方法的主体执行。

在上面的示例中,我们传递了一个类(ParseIntPipe),而不是实例,将实例化的责任留给框架并启用依赖注入。与管道和守卫一样,我们也可以传递就地实例。如果我们想通过传递选项来自定义内置管道的行为,传递就地实例是有用的:

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

绑定其他转换管道(所有 Parse* 管道)的工作方式类似。这些管道都在验证路由参数、查询字符串参数和请求体值的上下文中工作。

例如,对于查询字符串参数:

@Get()
async findOne(@Query('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

这是使用 ParseUUIDPipe 解析字符串参数并验证它是否为 UUID 的示例。

@@filename()
@Get(':uuid')
async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string) {
  return this.catsService.findOne(uuid);
}
@@switch
@Get(':uuid')
@Bind(Param('uuid', new ParseUUIDPipe()))
async findOne(uuid) {
  return this.catsService.findOne(uuid);
}

info 提示 当使用 ParseUUIDPipe() 时,您正在解析版本 3、4 或 5 的 UUID,如果您只需要特定版本的 UUID,可以在管道选项中传递版本。

上面我们已经看到了绑定各种 Parse* 系列内置管道的示例。绑定验证管道有点不同;我们将在下一节中讨论这一点。

info 提示 另外,请参阅验证技术以获取验证管道的详细示例。

自定义管道

如前所述,您可以构建自己的自定义管道。虽然 Nest 提供了强大的内置 ParseIntPipeValidationPipe,但让我们从头开始构建每个的简单自定义版本,以了解自定义管道是如何构造的。

我们从一个简单的 ValidationPipe 开始。最初,我们将让它简单地接受一个输入值并立即返回相同的值,表现得像一个恒等函数。

@@filename(validation.pipe)
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}
@@switch
import { Injectable } from '@nestjs/common';

@Injectable()
export class ValidationPipe {
  transform(value, metadata) {
    return value;
  }
}

info 提示 PipeTransform<T, R> 是一个泛型接口,任何管道都必须实现它。泛型接口使用 T 来指示输入 value 的类型,使用 R 来指示 transform() 方法的返回类型。

每个管道都必须实现 transform() 方法来履行 PipeTransform 接口契约。此方法有两个参数:

  • value
  • metadata

value 参数是当前处理的方法参数(在被路由处理方法接收之前),metadata 是当前处理的方法参数的元数据。元数据对象具有以下属性:

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

这些属性描述了当前处理的参数。

属性描述
type指示参数是 body @Body()、query @Query()、param @Param(),还是自定义参数(阅读更多这里)。
metatype提供参数的元类型,例如 String。注意:如果您在路由处理方法签名中省略类型声明,或使用原生 JavaScript,则该值为 undefined
data传递给装饰器的字符串,例如 @Body('string')。如果您将装饰器括号留空,则为 undefined

warning 警告 TypeScript 接口在转译过程中会消失。因此,如果方法参数的类型声明为接口而不是类,则 metatype 值将为 Object

基于模式的验证

让我们使验证管道更有用一些。仔细看看 CatsControllercreate() 方法,我们可能希望在尝试运行服务方法之前确保 post body 对象是有效的。

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

让我们关注 createCatDto body 参数。它的类型是 CreateCatDto

@@filename(create-cat.dto)
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

我们希望确保对 create 方法的任何传入请求都包含有效的 body。因此我们必须验证 createCatDto 对象的三个成员。我们可以在路由处理方法内部执行此操作,但这样做并不理想,因为它会破坏单一职责原则(SRP)。

另一种方法可能是创建一个验证器类并将任务委托给它。这样做的缺点是我们必须记住在每个方法的开头调用此验证器。

创建验证中间件怎么样?这可能有效,但不幸的是,无法创建可在整个应用程序的所有上下文中使用的通用中间件。这是因为中间件不知道执行上下文,包括将要调用的处理程序及其任何参数。

当然,这正是管道设计的用例。所以让我们继续完善我们的验证管道。

对象模式验证

有几种方法可以以干净、DRY 的方式进行对象验证。一种常见的方法是使用基于模式的验证。让我们尝试这种方法。

Zod 库允许您以直接的方式创建模式,具有可读的 API。让我们构建一个使用基于 Zod 的模式的验证管道。

首先安装所需的包:

$ npm install --save zod

在下面的代码示例中,我们创建了一个简单的类,它将模式作为 constructor 参数。然后我们应用 schema.parse() 方法,该方法根据提供的模式验证我们的传入参数。

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

在下一节中,您将看到我们如何使用 @UsePipes() 装饰器为给定的控制器方法提供适当的模式。这样做使我们的验证管道可以跨上下文重用,正如我们设定的目标。

@@filename()
import { PipeTransform, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ZodSchema  } from 'zod';

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown, metadata: ArgumentMetadata) {
    try {
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }
}
@@switch
import { BadRequestException } from '@nestjs/common';

export class ZodValidationPipe {
  constructor(private schema) {}

  transform(value, metadata) {
    try {
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }
}

绑定验证管道

之前,我们看到了如何绑定转换管道(如 ParseIntPipe 和其余的 Parse* 管道)。

绑定验证管道也非常简单。

在这种情况下,我们希望在方法调用级别绑定管道。在我们当前的示例中,我们需要执行以下操作来使用 ZodValidationPipe

  1. 创建 ZodValidationPipe 的实例
  2. 在管道的类构造函数中传递特定于上下文的 Zod 模式
  3. 将管道绑定到方法

Zod 模式示例:

import { z } from 'zod';

export const createCatSchema = z
  .object({
    name: z.string(),
    age: z.number(),
    breed: z.string(),
  })
  .required();

export type CreateCatDto = z.infer<typeof createCatSchema>;

我们使用 @UsePipes() 装饰器来实现,如下所示:

@@filename(cats.controller)
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}
@@switch
@Post()
@Bind(Body())
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(createCatDto) {
  this.catsService.create(createCatDto);
}

info 提示 @UsePipes() 装饰器从 @nestjs/common 包中导入。

warning 警告 zod 库需要在您的 tsconfig.json 文件中启用 strictNullChecks 配置。

类验证器

warning 警告 本节中的技术需要 TypeScript,如果您的应用程序使用原生 JavaScript 编写,则不可用。

让我们看看验证技术的另一种实现。

Nest 与 class-validator 库配合得很好。这个强大的库允许您使用基于装饰器的验证。基于装饰器的验证非常强大,特别是与 Nest 的管道功能结合使用时,因为我们可以访问处理属性的 metatype。在开始之前,我们需要安装所需的包:

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

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

@@filename(create-cat.dto)
import { IsString, IsInt } from 'class-validator';

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

info 提示这里阅读更多关于 class-validator 装饰器的信息。

现在我们可以创建一个使用这些注解的 ValidationPipe 类。

@@filename(validation.pipe)
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) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }

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

info 提示 提醒一下,您不必自己构建通用验证管道,因为 Nest 开箱即用地提供了 ValidationPipe。内置的 ValidationPipe 提供了比我们在本章中构建的示例更多的选项,为了说明自定义管道的机制,我们保持了基本的实现。您可以在这里找到完整的详细信息和大量示例。

warning 注意 我们在上面使用了 class-transformer 库,它与 class-validator 库是同一作者制作的,因此它们配合得很好。

让我们来看看这段代码。首先,注意 transform() 方法被标记为 async。这是可能的,因为 Nest 支持同步和异步管道。我们使这个方法 async 是因为一些 class-validator 验证可以是异步的(使用 Promises)。

接下来注意我们使用解构来提取 metatype 字段(从 ArgumentMetadata 中只提取这个成员)到我们的 metatype 参数中。这只是获取完整 ArgumentMetadata 然后有一个额外语句来分配 metatype 变量的简写。

接下来,注意辅助函数 toValidate()。它负责在当前处理的参数是原生 JavaScript 类型时绕过验证步骤(这些不能附加验证装饰器,所以没有理由让它们通过验证步骤)。

接下来,我们使用 class-transformer 函数 plainToInstance() 将我们的普通 JavaScript 参数对象转换为类型化对象,以便我们可以应用验证。我们必须这样做的原因是,传入的 post body 对象在从网络请求反序列化时,没有任何类型信息(这是底层平台(如 Express)的工作方式)。Class-validator 需要使用我们之前为 DTO 定义的验证装饰器,所以我们需要执行这种转换,将传入的 body 视为适当装饰的对象,而不仅仅是普通的原生对象。

最后,如前所述,由于这是一个验证管道,它要么返回未更改的值,要么抛出异常。

最后一步是绑定 ValidationPipe。管道可以是参数作用域、方法作用域、控制器作用域或全局作用域。之前,使用我们基于 Zod 的验证管道,我们看到了在方法级别绑定管道的示例。 在下面的示例中,我们将管道实例绑定到路由处理程序 @Body() 装饰器,以便调用我们的管道来验证 post body。

@@filename(cats.controller)
@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

当验证逻辑只涉及一个指定参数时,参数作用域管道很有用。

全局作用域管道

由于 ValidationPipe 被创建为尽可能通用,我们可以通过将其设置为全局作用域管道来实现其完整的实用性,以便它应用于整个应用程序的每个路由处理程序。

@@filename(main)
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

warning 注意混合应用的情况下,useGlobalPipes() 方法不会为网关和微服务设置管道。对于"标准"(非混合)微服务应用,useGlobalPipes() 确实会全局挂载管道。

全局管道在整个应用程序中使用,用于每个控制器和每个路由处理程序。

请注意,在依赖注入方面,从任何模块外部注册的全局管道(如上面示例中的 useGlobalPipes())无法注入依赖项,因为绑定已在任何模块的上下文之外完成。为了解决这个问题,您可以使用以下构造直接从任何模块设置全局管道:

@@filename(app.module)
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

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

info 提示 当使用这种方法为管道执行依赖注入时,请注意,无论在哪个模块中使用这种构造,管道实际上都是全局的。应该在哪里完成这个操作?选择定义管道(上面示例中的 ValidationPipe)的模块。另外,useClass 不是处理自定义提供者注册的唯一方法。在这里了解更多。

内置的 ValidationPipe

提醒一下,你不必自己构建通用验证管道,因为 Nest 开箱即用地提供了 ValidationPipe。内置的 ValidationPipe 提供了比我们在本章中构建的示例更多的选项,为了说明自定义管道的机制,我们的示例保持了基本的形式。你可以在这里找到完整的详细信息和大量示例。

转换用例

验证不是自定义管道的唯一用例。在本章开始时,我们提到管道也可以将输入数据转换为所需的格式。这是可能的,因为从 transform 函数返回的值完全覆盖了参数的先前值。

这什么时候有用?考虑有时从客户端传递的数据需要经过一些更改 - 例如将字符串转换为整数 - 然后才能被路由处理程序方法正确处理。此外,一些必需的数据字段可能缺失,我们希望应用默认值。转换管道可以通过在客户端请求和请求处理程序之间插入处理函数来执行这些功能。

这里是一个简单的 ParseIntPipe,负责将字符串解析为整数值。(如上所述,Nest 有一个更复杂的内置 ParseIntPipe;我们将此作为自定义转换管道的简单示例)。

@@filename(parse-int.pipe)
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;
  }
}
@@switch
import { Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntPipe {
  transform(value, metadata) {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

然后我们可以将此管道绑定到选定的参数,如下所示:

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

另一个有用的转换案例是使用请求中提供的 id 从数据库中选择现有用户实体:

@@filename()
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;
}
@@switch
@Get(':id')
@Bind(Param('id', UserByIdPipe))
findOne(userEntity) {
  return userEntity;
}

我们将此管道的实现留给读者,但请注意,像所有其他转换管道一样,它接收输入值(id)并返回输出值(UserEntity 对象)。这可以通过将样板代码从处理程序抽象到通用管道中,使你的代码更具声明性和 DRY

提供默认值

Parse* 管道期望参数的值被定义。它们在接收到 nullundefined 值时会抛出异常。为了允许端点处理缺失的查询字符串参数值,我们必须提供一个默认值,在 Parse* 管道对这些值进行操作之前注入。DefaultValuePipe 就是为此目的而服务的。只需在相关的 Parse* 管道之前在 @Query() 装饰器中实例化一个 DefaultValuePipe,如下所示:

@@filename()
@Get()
async findAll(
  @Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
  @Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
  return this.catsService.findAll({ activeOnly, page });
}