译文:nest 的中间件、异常过滤器和管道(三)

学习一个技术最好的方式就是看他的官网,大家好,我是云牧,这次翻译的是 nest 最新官网。

翻译方式是 chatgpt 主力翻译 + 人工每行校对。错误的地方,欢迎大家语雀评论纠正。

语雀文档地址:nest 官网译文 (yuque.com)

过程中我会对一些感觉不恰当内容有少量的增加修改删除。

希望内容会对大家有所帮助。

中间件

中间件是在路由处理程序之前调用的函数。中间件函数可以访问 request and response 对象。以及应用程序的请求-响应周期中的 next() 中间件函数。下一个中间件函数通常用一个名为 next 的变量表示。

Nest 中间件默认与 express 中间件等价。以下来自官方 express 文档的描述,介绍了中间件的功能:

中间件函数可以执行以下任务:

  • 执行任何代码
  • 对请求和响应对象进行更改。
  • 结束请求-响应循环。
  • 在堆栈中调用下一个中间件函数。
  • 如果当前的中间件函数没有结束请求-响应周期,它必须调用 next() 来将控制权传递给下一个中间件函数。否则,请求将被搁置。

您可以使用函数或带有 @Injectable() 装饰器的类来实现自定义的 Nest 中间件。类应该实现 NestMiddleware 接口,而函数则没有特殊要求。让我们首先使用类方法来实现一个简单的中间件功能。

**警告:**Express 和 fastify 以不同的方式处理中间件,并提供不同的方法签名,read more here.

typescript 复制代码
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

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

Dependency injection 依赖注入

Nest 中间件可以像提供者和控制器一样,通过构造函数来注入同一模块中可用的依赖项。这意味着在编写 Nest 中间件时,可以声明中间件类的构造函数,并在构造函数参数中指定所需的依赖项。Nest.js 将负责解析和注入这些依赖项,使得在中间件中可以方便地访问和使用这些依赖项。

Applying middleware 应用中间件

在 @Module() 装饰器中没有中间件的位置。相反,我们使用模块类中的 configure() 方法来设置它们。包含中间件的模块必须实现 NestModule 接口。让我们在 AppModule 级别设置 LoggerMiddleware 。

typescript 复制代码
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 。

我们还可以通过将包含路由 path 和请求 method 的对象传递给配置中间件时的 forRoutes() 方法,进一步限制中间件只适用于特定的请求方法。在下面的示例中,请注意我们导入 RequestMethod 枚举以引用所需的请求方法类型。

typescript 复制代码
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 });
  }
}

提示:使用 configure() 方法可以使用 async/await 使其变为异步(例如,您可以在 configure() 方法体内等待异步操作的完成)。
**警告:**使用 express 适配器时,NestJS 应用程序将默认注册来自 body-parser 包的 json 和 urlencoded 。这意味着如果您想通过 MiddlewareConsumer 自定义该中间件,您需要通过在使用 NestFactory.create() 创建应用程序时将 bodyParser 标志设置为 false 来关闭全局中间件。

Route wildcards 路由通配符

还支持基于模式的路由。例如,星号被用作通配符,可以匹配任意字符的组合:

typescript 复制代码
forRoutes({ path: 'ab*cd', method: RequestMethod.ALL });

路由路径将匹配 'ab*cd' , abcd , ab_cd ,以及其他类似的内容。

字符 ? , + , * ,和 () 可以在路由路径中使用,并且是它们正则表达式对应项的子集。

  • ?:在路由路径中,**? **表示前面的字符是可选的。例如,路由路径 **/users/:id? **可以匹配 **/users **和 **/users/1 **两种路径。
  • + :在路由路径中,**+ 表示前面的字符可以重复一次或多次。例如,路由路径 /users/:id+ 可以匹配 /users/1/users/1/2/users/1/2/3 **等多级路径。
  • :在路由路径中, *** *表示前面的字符可以重复零次或多次。例如,路由路径 /users/ 可以匹配 /users/users/1、**/users/1/2 **等任意多级路径。
  • ():在路由路径中,**() **用于分组字符和创建子模式。它们可以用于定义复杂的路由路径匹配规则。

连字符(-)和点号(.)。在基于字符串的路径中,连字符和点号会被解释为字面值,而不会被视为特殊字符进行匹配。

**警告:**fastify 包使用最新版本的 path-to-regexp 包,不再支持通配符星号 * 。相反,您必须使用参数(例如 (.) , :splat )。

Middleware consumer 中间件消费者

MiddlewareConsumer 是一个辅助类。它提供了几个内置方法来管理中间件。所有这些方法都可以以流畅的方式简单地链接在一起。 forRoutes() 方法可以接受单个字符串、多个字符串、一个 RouteInfo 对象、一个控制器类甚至多个控制器类。在大多数情况下,您可能只需传递由逗号分隔的控制器列表。下面是一个使用单个控制器的示例:

typescript 复制代码
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
import { CatsController } from './cats/cats.controller';

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

**提示:**apply() 方法可以接受单个中间件,也可以接受多个参数来指定多个中间件( multiple middlewares)。

Excluding routes 排除路由

有时我们希望排除某些路由不应用中间件。我们可以使用 exclude() 方法轻松地排除某些路由。该方法可以接受单个字符串、多个字符串或一个 RouteInfo 对象来标识要排除的路由,如下所示:

typescript 复制代码
consumer
  .apply(LoggerMiddleware)
  .exclude(
    { path: 'cats', method: RequestMethod.GET },
    { path: 'cats', method: RequestMethod.POST },
    'cats/(.*)',
  )
  .forRoutes(CatsController);

**提示:**该方法使用 path-to-regexp 包支持通配符参数。

根据上面的例子, LoggerMiddleware 将绑定到 CatsController 内定义的所有路由,除了传递给 exclude() 方法的三个路由。

Functional middleware 功能中间件

我们一直在使用的 LoggerMiddleware 类非常简单。它没有成员,没有额外的方法,也没有依赖关系。为什么我们不能只在一个简单的函数中定义它,而不是一个类呢?实际上,确实可以。这种类型的中间件被称为函数式中间件。让我们将基于类的日志记录中间件转换为函数式中间件,以说明它们之间的区别:

typescript 复制代码
import { Request, Response, NextFunction } from 'express';

export function logger(req: Request, res: Response, next: NextFunction) {
  console.log(`Request...`);
  next();
};

在 AppModule 内使用它:

typescript 复制代码
consumer
  .apply(logger)
  .forRoutes(CatsController);

**提示:**考虑在中间件不需要任何依赖项时使用更简单的功能中间件替代方案。

Multiple middleware 多个中间件

如上所述,为了绑定按顺序执行的多个中间件,只需在 apply() 方法中提供逗号分隔的列表即可:

typescript 复制代码
consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);

Global middleware 全局中间件

如果我们想要一次性将中间件绑定到所有已注册的路由上,我们可以使用 use() 方法,该方法由 INestApplication 实例提供:

typescript 复制代码
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(3000);

**提示:**在全局中间件中无法访问 DI 容器。当使用 app.use() 时,您可以使用功能中间件代替。或者,您可以使用类中间件,并在 AppModule (或任何其他模块)中使用 .forRoutes('*') 来消费它。

应用场景

  1. 身份验证和授权:中间件经常用于处理身份验证和授权。例如,它可以检查每个请求中的令牌或 cookie,以确定用户是否已经登录,以及他们是否有权访问请求的特定资源。
  2. 日志记录:中间件也可以用于记录日志。例如,可以创建一个中间件来记录所有传入的请求,包括请求的 URL,请求方法,请求头,请求体等。这可以帮助追踪和调试问题。
  3. 错误处理:中间件可以用于全局错误处理。当应用程序发生错误时,可以使用中间件来捕获错误,并将其记录下来或发送给开发者。
  4. 请求和响应处理:中间件可以处理或修改请求和响应。例如,可以创建一个中间件来处理跨域资源共享(CORS)问题,或者在将响应发送给客户端之前对其进行格式化。
  5. 服务限流:中间件可以用来限制特定 IP 或用户在一定时间内的请求次数,以防止恶意攻击或滥用。
  6. 数据校验:中间件可以用于校验请求数据的格式,确保其符合预期的格式。

异常过滤器

Nest 内置了一个异常处理层 ,负责处理应用程序中的所有未处理异常。当一个异常没有被应用程序代码处理时,它会被这个异常处理层捕获,然后自动发送一个适当的用户友好响应。

开箱即用,此操作由内置的全局异常过滤器执行,处理类型为 HttpException (及其子类)的异常。每个发生的异常都由全局异常过滤器处理。

当异常未被识别(既不是 HttpException ,也不是继承自 HttpException 的类),内置异常过滤器会生成以下默认的 JSON 响应:

json 复制代码
{
  "statusCode": 500,
  "message": "Internal server error"
}

**提示:**全局异常过滤器部分支持 http-errors 库。基本上,任何包含 statusCode 和 message 属性的抛出异常将被正确填充并作为响应发送回去(而不是对于未识别异常的默认 InternalServerErrorException )。

Throwing standard exceptions 抛出标准异常

Nest 提供了一个内置的 HttpException 类,它来自于 @nestjs/common 包。对于基于 HTTP REST/GraphQL API 的典型应用程序,在发生某些错误条件时,最佳实践是发送标准的 HTTP 响应对象。

例如,在 CatsController 中,我们有一个 findAll() 方法(一个 GET 路由处理程序)。假设这个路由处理程序由于某种原因抛出了一个异常。为了演示这一点,我们将硬编码如下:

typescript 复制代码
@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}

**提示:**我们在这里使用了 HttpStatus 。这是从 @nestjs/common 包中导入的辅助枚举。

当客户端调用此端点时,响应如下:

typescript 复制代码
{
  "statusCode": 403,
  "message": "Forbidden"
}

构造函数接受两个必需的参数,这些参数决定了响应

  • response 参数定义了 JSON 响应体。它可以是 string 或如下所述的 object 。
  • status 参数定义了 HTTP status code

默认情况下,JSON 响应体包含两个属性:

  • statusCode :默认为 status 参数中提供的 HTTP 状态码
  • message :基于 status 的 HTTP 错误的简短描述

要仅覆盖 JSON 响应体中的消息部分,请在 response 参数中提供一个字符串。要覆盖整个 JSON 响应体,请在 response 参数中传递一个对象。Nest 将序列化该对象并将其作为 JSON 响应体返回。

第二个构造函数参数 - status - 应该是一个有效的 HTTP 状态码。最佳实践是使用从 @nestjs/common 导入的 HttpStatus 枚举。

第三个构造函数参数(可选)- options - 可以用来提供错误原因。这个 cause 对象不会被序列化到响应对象中,但它可以用于记录目的,提供有关导致 HttpException 抛出的内部错误的有价值信息。

下面提供一个错误的完整示例:

typescript 复制代码
@Get()
async findAll() {
  try {
    await this.service.findAll()
  } catch (error) { 
    throw new HttpException({
      status: HttpStatus.FORBIDDEN,
      error: 'This is a custom message',
    }, HttpStatus.FORBIDDEN, {
      cause: error
    });
  }
}

使用上述内容,这就是响应的样子:

json 复制代码
{
  "status": 403,
  "error": "This is a custom message"
}

Custom exceptions 自定义异常

在 Nest.js 中,通常情况下不需要编写自定义异常,可以使用内置的 Nest HTTP 异常,如下一节所述。

但如果确实需要创建自定义异常,最佳实践是创建自己的异常层次结构,其中自定义异常继承自基类 HttpException。采用这种方式,Nest 将能够识别你的异常,并自动处理错误响应。让我们实现一个这样的自定义异常:

typescript 复制代码
export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}

由于 ForbiddenException 扩展了基本 HttpException ,它将与内置的异常处理程序无缝配合,因此我们可以在 findAll() 方法中使用它。

typescript 复制代码
@Get()
async findAll() {
  throw new ForbiddenException();
}

Built-in HTTP exceptions 内置的 HTTP 异常

Nest 提供了一组从基本 HttpException 继承的标准异常。这些异常从 @nestjs/common 包中暴露出来,代表了许多常见的 HTTP 异常。

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • HttpVersionNotSupportedException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableEntityException
  • InternalServerErrorException
  • NotImplementedException
  • ImATeapotException
  • MethodNotAllowedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException
  • PreconditionFailedException

所有内置的异常也可以使用 options 参数提供错误 cause 和错误 description

typescript 复制代码
throw new BadRequestException('Something bad happened', { cause: new Error(), description: 'Some error description' })

使用上述内容,这就是响应的样子:

json 复制代码
{
  "message": "Something bad happened",
  "error": "Some error description",
  "statusCode": 400,
}

Exception filters 异常过滤器

虽然基本(内置)异常过滤器可以自动处理许多情况,但您可能希望对异常层有完全的控制 。例如,您可能希望添加日志记录,或根据某些动态因素使用不同的 JSON 模式。异常过滤器就是为了这个目的而设计的。它们允许您控制精确的控制流程和发送回客户端的响应内容。

让我们创建一个异常过滤器,负责捕获属于 HttpException 类的异常,并为其实现自定义的响应逻辑。为了做到这一点,我们需要访问底层的平台 Request 和 Response 对象。我们将访问 Request 对象,以便从中提取出原始的 url 并将其包含在日志信息中。我们将使用 Response 对象来直接控制发送的响应,使用 response.json() 方法。

typescript 复制代码
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,
      });
  }
}

**提示:**所有的异常过滤器都应该实现泛型接口 ExceptionFilter 。这要求您提供具有指定签名的 catch(exception: T, host: ArgumentsHost) 方法。 T 表示异常的类型。
**警告:**如果您正在使用 @nestjs/platform-fastify ,您可以使用 response.send() 代替 response.json() 。不要忘记从 fastify 导入正确的类型。

@Catch(HttpException) 装饰器将所需的元数据绑定到异常过滤器,告诉 Nest,这个特定的过滤器正在寻找类型为 HttpException 的异常,而不是其他类型。 @Catch() 装饰器可以接受一个参数或逗号分隔的列表。这使您可以一次设置多种类型的异常过滤器。

Arguments host 参数 host

让我们来看一下 catch() 方法的参数。 exception 参数是当前正在处理的异常对象。 host 参数是一个 ArgumentsHost 对象。 ArgumentsHost 是一个强大的实用对象,我们将在执行上下文章节中进一步讨论它。在这个代码示例中,我们使用它来获取对 Request 和 Response 对象的引用,这些对象被传递给原始请求处理程序(在异常发生的控制器中)。在这个代码示例中,我们使用 ArgumentsHost 上的一些辅助方法来获取所需的 Request 和 Response 对象。Learn more about ArgumentsHost here.

*这种抽象级别的原因是 ArgumentsHost 在所有上下文中都起作用(例如,我们现在正在使用的 HTTP 服务器上下文,但也包括微服务和 WebSockets)。在执行上下文章节中,我们将看到如何使用 ArgumentsHost 及其辅助函数访问任何执行上下文的适当底层参数。这将使我们能够编写适用于所有上下文的通用异常过滤器。

Binding filters 绑定过滤器

让我们把我们的新 HttpExceptionFilter 绑定到 CatsController 的 create() 方法上。

typescript 复制代码
@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

**提示:**装饰器从 @UseFilters() 包中导入。

我们在这里使用了 @UseFilters() 装饰器。与 @Catch() 装饰器类似,它可以接受一个过滤器实例,或者一个逗号分隔的过滤器实例列表。在这里,我们直接创建了 HttpExceptionFilter 的实例。或者,您可以传递类(而不是实例),将实例化的责任留给框架,并启用依赖注入。

typescript 复制代码
@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

**提示:**尽可能使用类而不是实例来应用过滤器。这样做可以减少内存使用,因为 Nest 可以在整个模块中轻松地重用相同类的实例。

在上面的示例中, HttpExceptionFilter 仅应用于单个 create() 路由处理程序,使其具有方法范围。异常过滤器可以在不同的级别上进行范围设置:控制器/解析器/网关的方法范围,控制器范围或全局范围。

例如,要将过滤器设置为控制器范围,您需要执行以下操作:

typescript 复制代码
@UseFilters(new HttpExceptionFilter())
export class CatsController {}

此构造为在 HttpExceptionFilter 内定义的每个路由处理程序设置了 CatsController 。

要创建全局范围的过滤器,您需要执行以下操作:

typescript 复制代码
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

**警告:**useGlobalFilters() 方法不会为网关或混合应用程序设置过滤器。

全局范围的过滤器在整个应用程序中使用,适用于每个控制器和每个路由处理程序。就依赖注入而言,从任何模块外部注册的全局过滤器(如上例中的 useGlobalFilters() )无法注入依赖项,因为这是在任何模块的上下文之外进行的。为了解决这个问题,您可以使用以下结构直接从任何模块注册全局范围的过滤器:

typescript 复制代码
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

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

**提示:**使用这种方法来执行过滤器的依赖注入时,请注意,无论在哪个模块中使用这种构造方式,过滤器实际上都是全局的。应该在哪里进行这个操作?应该选择定义过滤器,即上面的 HttpExceptionFilter 示例中的模块。此外, useClass 并不是处理自定义提供程序注册的唯一方式。Learn more here.

您可以根据需要使用此技术添加任意数量的过滤器;只需将每个过滤器添加到提供者数组中。

Catch everything 捕获所有异常

为了捕获每个未处理的异常(无论异常类型如何),请将 @Catch() 装饰器的参数列表保留为空,例如, @Catch() 。

在下面的示例中,我们有一个代码,它是平台无关的,因为它使用 HTTP 适配器来传递响应,并且不直接使用任何特定于平台的对象( Request 和 Response )

typescript 复制代码
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    // In certain situations `httpAdapter` might not be available in the
    // constructor method, thus we should resolve it here.
    const { httpAdapter } = this.httpAdapterHost;

    const ctx = host.switchToHttp();

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

    const responseBody = {
      statusCode: httpStatus,
      timestamp: new Date().toISOString(),
      path: httpAdapter.getRequestUrl(ctx.getRequest()),
    };

    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }
}

警告:当将一个捕获所有异常的异常过滤器与一个绑定到特定类型的过滤器结合时,应该先声明"捕获任何异常"过滤器,以便让特定过滤器正确处理绑定的类型。

Inheritance 继承

通常情况下,您会创建完全定制的异常过滤器,以满足您的应用程序需求。然而,在某些情况下,您可能希望简单地扩展内置的默认全局异常过滤器,并根据特定因素覆盖其行为。

为了将异常处理委托给基本过滤器,您需要继承 BaseExceptionFilter 并调用继承的 catch() 方法。

typescript 复制代码
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 进行实例化。相反,应该让框架自动实例化它们。

上述实现只是一个示例,展示了这种方法。您对扩展异常过滤器的实现将包括您量身定制的业务逻辑(例如处理各种条件)。

全局过滤器可以扩展基础过滤器。有两种方法可以实现。

第一种方法是在实例化自定义全局过滤器时注入 HttpAdapter 引用:

typescript 复制代码
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 标记,as shown here.

管道

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

管道有两个典型的用例:

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

在这两种情况下,管道都在控制器路由处理程序处理的 arguments 上操作。Nest 会在调用这个方法之前插入一个管道,并且管道接收要传递给方法的参数并对其进行操作。任何转换或验证操作都在此时进行,之后路由处理程序将使用任何(可能)转换后的参数进行调用。

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

**提示:**管道在异常区域内运行。这意味着当管道抛出异常时,它将由异常层(全局异常过滤器和应用于当前上下文的任何异常过滤器)处理。根据上述情况,可以清楚地看到,当在 Pipe 中发生异常,controller 不会继续执行任何方法。这为您提供了一种验证从外部来源进入应用程序的数据的最佳实践技术。

Built-in pipes 内置管道

Nest 自带九个开箱即用的管道,即

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

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

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

Binding pipes 绑定管道

使用管道时,我们需要将管道类的一个实例绑定到适当的上下文中。在我们的 ParseIntPipe 示例中,我们希望将管道与特定的路由处理方法关联起来,并确保在调用该方法之前运行。我们可以通过以下结构来实现,我们将其称为在方法参数级别绑定管道:

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

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

例如,假设路由的调用方式如下:

Nest 会抛出这样的异常:

json 复制代码
{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

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

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

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

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

例如,使用查询字符串参数:

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

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

typescript 复制代码
@Get(':uuid')
async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string) {
  return this.catsService.findOne(uuid);
}

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

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

**提示:**此外,查看验证管道的详细示例以了解验证技术(Validation techniques)。

Custom pipes 自定义管道

正如前面提到的,您可以构建自己的自定义管道。虽然 Nest 提供了一个强大的内置 ParseIntPipe 和 ValidationPipe ,但让我们从头开始构建简单的自定义版本,以了解如何构建自定义管道。

我们从一个简单的 ValidationPipe 开始。最初,它只是接收一个输入值并立即返回相同的值,就像一个恒等函数一样。

typescript 复制代码
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 是当前处理的方法参数的元数据。元数据对象具有以下属性:

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

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

type 告诉我们参数是一个 body @Body(), query @Query(), param @Param(), 还是自定义参数(read more here
metatype 参数的元类型,例如, String 。注意:如果在路由处理程序方法签名中省略类型声明或使用原始 JavaScript,则该值为 undefined 。
data 传递给装饰器的字符串,例如 @Body('string') 。如果将装饰器括号留空,则为 undefined 。

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

Schema based validation 基于结构的验证

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

typescript 复制代码
@Post()
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

让我们聚焦在 createCatDto body 参数上。它的类型是 CreateCatDto :

typescript 复制代码
export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

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

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

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

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

Object schema validation 对象模式验证

有几种方法可以以简洁、DRY 的方式进行对象验证。其中一种常见的方法是使用基于结构的验证。让我们继续尝试这种方法。

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

首先安装所需的包:

bash 复制代码
$ npm install --save zod

在下面的代码示例中,我们先创建一个简单的 class,在构造函数中传递 schema 参数。然后,我们使用 schema.parse() 方法,该方法根据提供的 schema 验证我们的传入参数。

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

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

typescript 复制代码
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;
  }
}

Binding validation pipes 绑定验证管道

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

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

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

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

Zod 结构示例:

typescript 复制代码
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() 装饰器来实现这一点:

typescript 复制代码
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

**提示:**装饰器从 @UsePipes() 包中导入。
**警告:**zod 库需要在您的 tsconfig.json 文件中启用 strictNullChecks 配置。

Class validator 验证器类

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

让我们来看一种替代的验证技术实现。

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

bash 复制代码
$ npm i --save class-validator class-transformer

一旦安装了这些,我们可以给 CreateCatDto 类添加一些装饰器。在这里,我们可以看到这种技术的一个重要优势:对于我们的 post body, CreateCatDto 类仍然是唯一的来源(而不是需要创建一个单独的验证类)。

typescript 复制代码
import { IsString, IsInt } from 'class-validator';

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

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

**提示:**Read more about the class-validator decorators here.

现在我们可以创建一个 ValidationPipe 类。

typescript 复制代码
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);
  }
}

**通知:**我们在上面使用了与 class-validator 库相同作者开发的 class-transformer 库,它们之间的配合非常出色。

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

接下来请注意,我们使用解构来提取元类型字段(仅从 ArgumentMetadata 中提取此成员)到我们的 metatype 参数中。这只是一种简写方式,用于获取完整的 ArgumentMetadata ,然后再添加一个语句来赋值元类型变量。

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

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

最后,正如前面所提到的,由于这是一个验证管道,它要么返回原始值,要么抛出异常。

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

typescript 复制代码
@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

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

Global scoped pipes 全局作用域的管道

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

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

通知: 在混合应用的情况下, useGlobalPipes() 方法不会为网关和微服务设置管道。对于"标准"(非混合)微服务应用程序, useGlobalPipes() 会全局挂载管道。

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

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

typescript 复制代码
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

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

**提示:**请注意使用上述方式依赖注入时,请牢记无论哪种模块采用了该结构,管道都是全局的。那么它应该放在哪里呢?答案是选择管道(例如上面例子中的 ValidationPipe)被定义的模块。另外,useClass 并不是处理自定义提供者注册的唯一方法。Learn more here.

The built-in ValidationPipe 内置的 ValidationPipe

作为提醒,你不必自己构建一个通用的验证管道,因为 Nest 已经提供了 ValidationPipe 。内置的 ValidationPipe 提供了比本章中我们构建的示例更多的选项,本章中的示例仅为了说明自定义管道的机制而保持基本代码。你可以在这里找到完整的细节和大量的示例( here.)。

Transformation use case 转型使用案例

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

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

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

typescript 复制代码
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;
  }
}

我们可以将这个管道绑定到所选参数,如下所示:

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

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

typescript 复制代码
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;
}

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

以下是一个可能的实现示例:

typescript 复制代码
import { Injectable, PipeTransform, BadRequestException } from '@nestjs/common';
import { UserEntity } from './user.entity';
import { UserService } from './user.service';

@Injectable()
export class UserByIdPipe implements PipeTransform {
  constructor(private readonly userService: UserService) {}

  async transform(value: any): Promise<UserEntity> {
    const userId = parseInt(value, 10);

    if (isNaN(userId)) {
      throw new BadRequestException('Invalid user ID');
    }

    const user = await this.userService.findById(userId);

    if (!user) {
      throw new NotFoundException('User not found');
    }

    return user;
  }
}

在上面的示例中,UserByIdPipe 是一个实现了 PipeTransform 接口的可注入的管道类。它接收一个 value 参数,该参数是从路由参数中提取的用户 ID。首先,我们将用户 ID 转换为整数,如果无法转换,就抛出一个 BadRequestException 异常。然后,我们使用 userService 的 findById 方法来查找对应的用户实体。如果找不到用户,就抛出一个 NotFoundException 异常。最后,如果一切正常,我们返回找到的用户实体。

Providing defaults 提供默认值

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

typescript 复制代码
@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 });
}
相关推荐
fishmemory7sec几秒前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
2401_854391082 分钟前
高效开发:SpringBoot网上租赁系统实现细节
java·spring boot·后端
Cikiss11 分钟前
微服务实战——SpringCache 整合 Redis
java·redis·后端·微服务
Cikiss12 分钟前
微服务实战——平台属性
java·数据库·后端·微服务
OEC小胖胖26 分钟前
Spring Boot + MyBatis 项目中常用注解详解(万字长篇解读)
java·spring boot·后端·spring·mybatis·web
豆豆44 分钟前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
2401_857617621 小时前
SpringBoot校园资料平台:开发与部署指南
java·spring boot·后端
计算机学姐1 小时前
基于SpringBoot+Vue的在线投票系统
java·vue.js·spring boot·后端·学习·intellij-idea·mybatis
twins35202 小时前
解决Vue应用中遇到路由刷新后出现 404 错误
前端·javascript·vue.js
Yvemil72 小时前
MQ 架构设计原理与消息中间件详解(二)
开发语言·后端·ruby