跳到主要内容

管道

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

Nest.js 管道

管道有两个典型用例:

  • transformation(转换): 将输入数据转换为所需形式(例如,从字符串到整数)
  • validation(验证): 验证数据,如果有效,则只需按将其原样传递;否则,抛出异常

在这两种情况下,管道对控制器路由处理程序正在处理的参数进行操作Nest在调用方法之前插入一个管道该管道接收指定给该方法的参数并对它们进行操作任何转换或验证操作都会在此时发生之后使用任何(可能)转换的参数调用路由处理程序。

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

提示

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

内建管道

Nest附带九个开箱即用的管道

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe
提示

它们是从@nestjs/common包导出的。

让我们快速了解一下ParseIntPipe的使用。 这是转换用例的示例,其中管道确保方法处理程序参数转换为JavaScript整数(或者如果转换失败则引发异常)。 在本章后面,我们将展示ParseIntPipe的简单自定义实现。 下面的示例技术也适用于其他内置转换管道(ParseBoolPipe,ParseFloatPipe, ParseEnumPipe,ParseArrayPipeParseUUIDPipe,在本章中我们将其称为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的示例。

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

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

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

自定义管道

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

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

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

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

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(),查询Query(),参数@Param()还是自定义参数
  • metatype:提供参数的元数据,例如String。注意:如果您在路由处理程序方法签名中省略类型声明,或者使用普通JavaScript,则该值未定义。
  • data:传递给装饰器的字符串,例如@Body('string')。如果将装饰器括号留空,则它是未定义(undefined)的。
注意

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

基于模式的验证

让我们让验证管道变得更有用一点。仔细看看CatsControllercreate()方法,我们可能希望在 尝试运行我们的服务方法之前确保帖子主题对象有效。

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

让我们关注createCatDtobody参数。它的类型是CreateCatDto:

export class CreateCatDto {
name: string;
age: number;
breed: string;
}

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

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

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

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

对象模式验证

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

Zod库允许您以一种简单易读的API方式创建模式。 让我们构建一个使用基于Zod的模式的验证管道。

首先,安装所需的包。

npm install --save zod

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

正如前面提到的,验证管道要么返回不变的值,要么抛出异常。

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

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

export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodObject<any>) {}

transform(value: unknown, metadata: ArgumentMetadata) {
try {
this.schema.parse(value);
} catch (error) {
throw new BadRequestException('Validation failed');
}
return value;
}
}

绑定验证管道

之前,我们看到了如何绑定转换管道(比如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()装饰器来做到这一点,如下所示:

cats.controller.ts
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
提示

@UsePipes()装饰器是从@nestjs/common包导入的

注意

zod库需要再tsconfig.json文件中启用strictNullChecks配置

类验证器

注意

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

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

npm i --save class-validator class-transformer

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

create-cat.dto.ts
import { IsString, IsInt } from 'class-validator';

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

@IsInt()
age: number;

@IsString()
breed: string;
}

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

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

@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 (error.length > 0) {
throw new BadRequestException('Validation failed');
}
return value;
}

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

作为提醒,您无需自己构建通用验证管道,因为Nest已经提供了ValidationPipe。 内置的ValidationPipe提供了比本章中构建的示例更多的选项,为了说明自定义管道的机制,本章将其保持基本。 您可以在这里找到完整的详细信息,以及许多示例。

注意

我们在上面使用了由与class-validator库相同作者制作的class-transformer库, 因此它们之间协同运作得非常好。

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

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

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

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

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

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

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

当验证逻辑仅涉及一个指定参数时,参数范围管道非常有用。

全局范围的管道

由于ValidationPipe被创建为尽可能通用,我们可以通过将其设置为全局范围的管道来充分发挥其效用, 使其应用于整个应用程序的每个路由处理程序。

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

在混合应用程序的情况下,useGlobalPipes() 方法不会为网关和微服务设置管道。 对于“标准”(非混合)微服务应用程序,useGlobalPipes()确实会在全局范围内挂载管道。

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

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

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

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

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

内置的验证管道

作为提醒,您无需自己构建通用的验证管道,因为Nest内置了ValidationPipe。 内置的ValidationPipe提供了比本章中构建的示例更多的选项,本章保持基本只是为了说明自定义管道的机制。 您可以在这里找到完整的详细信息以及大量的示例。

转换用例

验证并不是自定义管道的唯一用例。在本章的开头,我们提到过管道还可以将输入数据转换为所需的格式。 这是因为从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;
}
}

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

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

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

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

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

提供默认值

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

@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})
}