NestJS 🧑‍🍳 厨子必修课(七):管道

535 阅读11分钟

1. 前言

在上一节中提到了 dto 文件,它用于定义请求参数的类型,这是对数据输入的一种验证保护,使其符合 API 规范。在 NestJS 中提供了管道的特性,允许路由处理器通过管道对输入参数进行验证或是转换:

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

具体来说,管道将会在路由处理器的 arguments 上运行,Nest 在调用方法之前插入一个管道,管道接收指定给该方法的参数并对它们进行操作,然后使用验证或转换过的参数去调用路由处理程序。

⚠️ 注意:管道虽然类似于中间件,但是只负责处理输入参数!因此,提到管道的地方就要理解是要处理输入参数了。

假设有路由 /example,下面是验证管道的工作原理:

验证管道工作原理.png

欢迎加入技术交流群

image.png

  1. NestJS 🧑‍🍳  厨子必修课(一):后端的本质
  2. NestJS 🧑‍🍳 厨子必修课(二):项目创建
  3. NestJS 🧑‍🍳 厨子必修课(三):控制器
  4. NestJS 🧑‍🍳 厨子必修课(四):服务类
  5. NestJS 🧑‍🍳 厨子必修课(五):Prisma 集成(上)
  6. NestJS 🧑‍🍳 厨子必修课(六):Prisma 集成(下)
  7. NestJS 🧑‍🍳 厨子必修课(七):管道
  8. NestJS 🧑‍🍳 厨子必修课(八):异常过滤器

2. 内置管道

NestJS 内置了一些常见的管道。

2.1 ValidationPipe

ValidationPipe:这是一个通用的管道,用于验证传入的数据。它可以与 class-validator 包一起使用,自动验证 DTO 对象的属性,确保它们符合定义的验证规则。

import { Body, Controller, Post, UsePipes } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { ValidationPipe } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Post()
  @UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
  createUser(@Body() createUserDto: CreateUserDto) {
    // 业务逻辑
  }
}
  • whitelist:忽略 DTO 中未定义的属性。
  • forbidNonWhitelisted:如果请求体中包含未定义的属性,抛出异常。

curl 请求示例:

curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john.doe@example.com", "password": "securepassword123"}'

2.2 ParseIntPipe

ParseIntPipe:将传入的字符串转换为整数,如果转换失败,它将返回一个错误。

import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { ParseIntPipe } from '@nestjs/common';

@Controller('items')
export class ItemsController {
  @Get()
  getItems(@Query('page', ParseIntPipe) page: number) {
    // 业务逻辑,使用 page
  }
}

curl 请求示例:

curl -G http://localhost:3000/items?page=2

2.3 ParseFloatPipe

ParseFloatPipe:类似于 ParseIntPipe,但用于将字符串转换为浮点数。

import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { ParseFloatPipe } from '@nestjs/common';

@Controller('orders')
export class OrdersController {
  @Get()
  getOrders(@Query('price', ParseFloatPipe) price: number) {
    // 业务逻辑,使用 price
  }
}

curl 请求示例:

curl -G http://localhost:3000/orders?price=19.99

2.4 ParseBoolPipe

ParseBoolPipe:将传入的值转换为布尔值,可以处理字符串(如 "true", "false", "1", "0")和其他表示真值或假值的输入。

import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { ParseBoolPipe } from '@nestjs/common';

@Controller('users')
export class UsersController {
  @Get()
  getUsers(@Query('isActive', ParseBoolPipe) isActive: boolean) {
    // 业务逻辑,使用 isActive
  }
}

curl 请求示例:

curl -G http://localhost:3000/users?isActive=true

2.5 ParseArrayPipe

ParseArrayPipe:将传入的字符串转换为数组。

import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { ParseArrayPipe } from '@nestjs/common';

@Controller('products')
export class ProductsController {
  @Get()
  getProducts(@Query('categories', ParseArrayPipe({ items: ParseIntPipe })) categories: number[]) {
    // 业务逻辑,使用 categories 数组
  }
}

curl 请求示例:

curl -G http://localhost:3000/products?categories=1,2,3

2.6 ParseUUIDPipe

ParseUUIDPipe:将传入的字符串转换为 UUID 对象,如果字符串不是有效的 UUID,它将返回一个错误。

import { Controller, Get, Param, UsePipes } from '@nestjs/common';
import { ParseUUIDPipe } from '@nestjs/common';

@Controller('products')
export class ProductsController {
  @Get(':id')
  getProduct(@Param('id', ParseUUIDPipe) id: string) {
    // 业务逻辑,使用 id
  }
}

curl 请求示例:

curl -G http://localhost:3000/products/123e4567-e89b-12d3-a456-426614174000

2.7 ParseEnumPipe

ParseEnumPipe:将传入的字符串转换为枚举值。它需要一个枚举类作为参数,如果传入的值不是枚举中的有效成员,它将返回一个错误。

import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { ParseEnumPipe } from '@nestjs/common';

enum Status {
  ACTIVE = 'active',
  INACTIVE = 'inactive',
  PENDING = 'pending',
}

@Controller('statuses')
export class StatusesController {
  @Get()
  getStatuses(@Query('status', new ParseEnumPipe({ enum: Status })) status: Status) {
    // 业务逻辑,使用 status
  }
}

curl 请求示例:

curl -G http://localhost:3000/statuses?status=active

2.8 DefaultValuePipe

DefaultValuePipe:用于为没有提供的数据设置默认值。它可以接受一个对象,其中定义了参数名和相应的默认值。

import { Controller, Get, Query, UsePipes } from '@nestjs/common';
import { DefaultValuePipe } from '@nestjs/common';

@Controller('settings')
export class SettingsController {
  @Get()
  getSettings(@Query('limit', new DefaultValuePipe(10)) limit: number) {
    // 业务逻辑,使用 limit,默认值为 10
  }
}

curl 请求示例:

curl -G http://localhost:3000/settings?limit=5

2.9 ParseFilePipe

ParseFilePipe:用于处理上传的文件。它通常用于处理 multipart/form-data 请求中的文件上传。

import { Controller, Post, UseInterceptors } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';

@Controller('uploads')
export class UploadsController {
  @Post('profile')
  @UseInterceptors(FileInterceptor('file'))
  uploadProfile(@UploadedFile() file) {
    // 业务逻辑,处理上传的文件
  }
}

⚠️ 注意:ParseFilePipe 实际上是一个示例名称,NestJS 中用于处理文件上传的管道是 FileInterceptor

curl 请求示例:

curl -X POST -F "file=@/path/to/profile/image.jpg" http://localhost:3000/uploads/profile

上面的内置管道都使用于参数级别,当然还可以用于以下级别:

  • 全局应用:所有传入请求都会经过指定的管道。
  • 控制器级别:特定控制器中的所有请求会应用到指定的管道。
  • 路由处理程序级别:仅应用于某个具体的路由处理函数。

可以看到是非常灵活的,但是一个成熟的项目推荐上全局应用,所有的请求都应该接受管道验证。另外,管道的执行顺序是在路由处理程序执行之前,因此可以确保传入的请求数据在进入业务逻辑之前已经过了验证和转换。

3. ValidationPipe

ValidationPipe 验证管道提供了很多开箱即用的选项,不用我们自己手动去构建验证逻辑。现在,我们在全局应用验证管道。

ValidationPipe 需要搭配 class-validator 包中的装饰器来声明验证规则:

npm install class-validator class-transformer

3.1 class-validator

class-validator 用于验证对象属性的装饰器库,能集成到任何基于类的框架中,如 NestJS。可以验证对象、数组甚至嵌套对象;支持各种验证规则,包括自定义规则;能够与 class-transformer 配合使用,以自动转换和验证数据。

3.2 class-transformer

class-transformer 是一个用于转换(序列化和反序列化)和复制对象的库,它可以与 class-validator 无缝集成。它允许将普通的 JavaScript 对象转换为类实例,并在转换过程中进行验证。支持对象的深度复制;可以与 class-validator 结合使用,以在转换过程中进行验证。

3.3 全局使用验证管道

简而言之,class-validator 专注于数据验证,提供了丰富的验证规则和装饰器;class-transformer 专注于数据转换,能够将普通对象转换为类的实例,并支持深度复制。

在入口文件中设置全局管道:

// main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
+ import { ValidationPipe } from '@nestjs/common';

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

3.4 添加验证规则

以 users 资源下的 dto 文件 create-user.dto.ts 为例,添加验证规则:

import { IsNotEmpty, MinLength, IsEmail, IsString } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  @MinLength(3)
  name: string;

  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @IsNotEmpty()
  @MinLength(8)
  password: string;
}

class-validator 提供的装饰器很好理解:

  • @IsString():验证输入字段是否为字符串类型,如果不是,则验证失败。
  • @IsNotEmpty():验证字段是否已定义且不是空字符串。
  • @MinLength(3):确保字段长度至少为 3 个字符,否则验证失败。
  • @IsEmail():验证字段为一个有效的电子邮箱地址,它使用正则表达式来验证格式。

users.controller.ts 如下:

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

在调用 this.usersService.create(createUserDto) 方法之前,由于全局使用了验证管道,因此会先对输入参数进行验证。

现在改写 users.service.ts 中创建用户的 create 方法如下:

@Injectable()
export class UsersService {
  constructor(private readonly prisma: PrismaService) {}

  async create(createUserDto: CreateUserDto) {
    const existingUser = await this.prisma.user.findUnique({
      where: { email: createUserDto.email },
    });
    if (existingUser) {
      throw new ConflictException('邮箱已被注册');
    }
    const hashedPassword = await bcrypt.hash(createUserDto.password, 10);
    return this.prisma.user.create({
      data: {
        ...createUserDto,
        password: hashedPassword,
        role: UserRole.USER,
      },
    });
  }

简单来说,会先去检查是否存在指定电子邮件地址的用户,如果存在就返回“邮箱已被注册”的异常提示;否则,使用 bcrypt.hash 方法来创建关于 createUserDto.password 的加密密码,最后返回创建完成的 user 实体数据。(创建的用户 roleUserRole.USER

当前的 Prisma 模型文件已经发变化:

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
+   password  String
+   role      UserRole @default(USER)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  orders    Order[]
}

model Order {
  id        Int      @id @default(autoincrement())
  userId    Int
  user      User     @relation(fields: [userId], references: [id])
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

+ enum UserRole {
+   USER
+   ADMIN
+ }

User 模型多了 password 用于记录用户密码,role 用于记录用户角色。

3.5 测试一下验证管道是否有效

example1:首先输入一个正确的数据格式。

curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john.doe@example.com", "password": "securepassword123"}'

example1 结果.png

✅ 成功了。

example2:再输入一个错误的格式(email 格式是错误的,密码为空)。

curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "David King", "email": "john.doe", "password": ""}'

example2 结果.png

✅ 这是一个很好的预期。

example3:再使用第一个成功创建的用户的邮箱再次创建,看看是否会告诉我们有重复的。

curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john.doe@example.com", "password": "securepassword123"}'

example3 结果.png

✅ 符合预期,告诉我们邮箱已被注册。

example4:传递不必要的属性给路由处理器(在保证参数有效的前提下,试着多传递一个 title 属性)。

curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "Mike Poter", "email": "mike.poter@example.com", "password": "securepassword123","title":"多余的属性"}'

example4 结果.png

这就直接报出 500 的服务器错误了,虽然但是——不是很优雅。

3.6 删除不必要的属性

ValidationPipe 提供了选项 whitelist: true 来删除 DTO 中未定义的属性。

修改 main.ts:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

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

现在再尝试 example5,结果如下:

example5 结果.png

✅ 正确。

如果想要更加严格地控制输入参数,比如 title 在 dto 文件中并未定义,应该报错。通过添加 forbidNonWhitelisted: true 选项来实现这种效果:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
+     new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
  );
  await app.listen(3000);
}
bootstrap();

拦截多余属性然后报错.png

✅ 从结果看,多余的 title 确实被检查出来了,新的 user 也不会被创建出来。

4. 转换输入参数的数据类型

在内置管道章节已经列举了很多有关输入参数类型转换的示例,回到项目代码 users.controller.ts 中:

@Get(':id')
findOne(@Param('id') id: string) {
  return this.usersService.findOne(+id);
}

@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
  return this.usersService.update(+id, updateUserDto);
}

@Delete(':id')
remove(@Param('id') id: string) {
  return this.usersService.remove(+id);
}

需要传递 id 的路由处理程序在调用服务类方法时都传递了 +id 以使 id 转换为数字类型,这是 JS 提供的自动转换特性,现在可以使用管道(ParseIntPipe)来实现自动转换:

import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
  Query,
+ ParseIntPipe,
} from '@nestjs/common';

// 其他引入...

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  // 其他路由处理器...

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

  @Patch(':id')
+ update(
+   @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
+    return this.usersService.update(id, updateUserDto);
  }

  @Delete(':id')
+ remove(@Param('id', ParseIntPipe) id: number) {
+   return this.usersService.remove(id);
  }
}

5. 自定义管道

先前在用户注册时,我们只对 email 做了检查,其实还要检查用户名是否唯一,我们用自定义管道来实现这个需求。

5.1 定义管道

在 users 目录下创建 pipes 目录,其下新建管道文件 unique-username.pipe.ts:

import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';

@Injectable()
export class UniqueUsernamePipe implements PipeTransform {
  constructor(private prisma: PrismaService) {}

  async transform(value: string) {
    const user = await this.prisma.user.findFirst({
      where: { name: value },
    });

    if (user) {
      throw new BadRequestException('用户名已存在');
    }

    return value;
  }
}

自定义的 UniqueUsernamePipe 类实现了 PipeTransform 的接口,必须实现 transform 方法来履行 PipeTransform 的接口契约。transform 方法的参数 value 表示当前路由处理器接收的输入参数。

因此,上述代码首先使用 prisma 客户端实例去查找对应名称的用户,如果存在则抛出错误:“用户名已存在”,否则将输入参数返回出去。

5.2 使用管道

在 users.controller.ts 中使用定义好的自定义管道:

+ import { UniqueUsernamePipe } from './pipes/unique-username.pipe';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
  
  @Post()
  create(
+    @Body('name', UniqueUsernamePipe) name: string,
    @Body() createUserDto: CreateUserDto,
  ) {
    return this.usersService.create(createUserDto);
  }
}

curl 请求示例(John Doe 是 User 表中已经存在的对象):

curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name": "John Doe", "email": "john.doe@example.com", "password": "securepassword123"}'

自定义管道 UniqueUsernamePipe 使用结果.png

✅ 管道起作用了!

6. 总结

在本节中,我们探讨了如何在 NestJS 框架中利用管道(Pipes)对传入的请求参数执行验证和转换。通过这种方式,管道不仅增强了数据的安全性,确保了接收到的数据符合预期格式和标准,而且还通过自定义管道提供了高度的灵活性,使得对参数的校验可以根据具体业务需求进行定制。