Nestjs 学习记录:(四)Exception Filter & Pipes

一、Exception Filter

(一)Intro

Nestjs 拥有一个内置的Exception Layer,负责捕获并处理应用中所有未处理的异常。当你没有在项目代码中手动处理程序抛出的异常时,该异常会被Exception Layer捕获并自动向客户端发送一个规范且可读性强的异常响应。

该操作由内置的全局异常过滤器执行,开箱即用,默认会处理所有类型为 HttpException 及其子类的异常,当异常无法识别时(既不是 HttpException 也不是继承自 HttpException 的类),内置异常过滤器会生成以下默认 JSON 响应

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

(二)Throwing standard exceptions

Nestjs 提供了内置的 HttpException 类,在常见的基于 REST / GraphQL API 风格的应用中,它是在特定的错误条件出现时,向客户端返回标准的 HTTP 响应对象的最佳实践。

HttpException 构造函数需要传递两个参数来决定响应结果

response:定义响应体内容,可以是字符串,也可以是对象

status:定义 HTTP 状态码,必须是合法的 HTTP 状态码【使用 HttpStatus 类】

options:可选参数,可以用来传递异常发生的原因,该原因不会被序列化到结果中,但是它在打印日志来记录异常的详细信息时会非常有帮助

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

statusCode:根据 status 参数生成的 HTTP 状态码

message:基于 response 生成的一个简短的描述信息

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

如果希望只覆盖 JSON 响应体中的 message 部分,传递一个字符串到 response 参数中即可

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

如果需要重写完整的响应体对象,可以传递一个对象给到 response 参数,Nestjs 会自动序列化该对象并将其返回给客户端。

ts 复制代码
@Get()
async findAll() {
  try {
    // 执行 Service 中用来读写数据库的函数
    await this.service.findAll()
  } catch (error) { 
    // 报错则抛出异常
    throw new HttpException({
      status: HttpStatus.FORBIDDEN,
      error: 'This is a completely custom message',
    }, HttpStatus.FORBIDDEN, {
      cause: error
    });
  }
}

上述示例中,我们定义了一个 findAll 方法,当客户端发送请求并调用到该方法时,执行出错则抛出 Forbidden 异常。

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

(三)Exception Filters

虽然内置的异常过滤器可以自动处理很多应用场景,但是你在开发时可能会希望能够完全掌控异常层的行为,例如,我们在前后端对接过程中,通常都会约定一个统一的接口响应规范,每个公司的规范可能都不尽相同,此时就无法直接把所有工作全部推给 Nestjs 内置的异常层了。

为了满足这一需求,Nestjs 也提供了相应的解决方案 -- Exception Filters

它负责捕捉 HttpException 类实例的异常,并为它们实现自定义响应逻辑。为此,我们需要访问底层平台的 RequestResponse 对象。从 Request 对象中提取原始 url 并将其包含在日志信息中,使用 Response 对象直接控制接口的响应内容。

ts 复制代码
// 告诉 Nestjs 仅捕获类型为 HttpException 的异常
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  // 注入 LoggerService 以提供日志服务
  constructor(private readonly logger: LoggerService) {}
  // exception:当前处理的异常对象
  // host:ArgumentsHost 类型的对象,我们可以从中获取到传递给路由函数【出现异常的位置】的 Request 和 Response 对象
  catch(exception: HttpException, host: ArgumentsHost) {
    console.log('error', exception)
    // 切换到 Http 上下文
    const ctx = host.switchToHttp()
    // 拿到所有需要的对象
    const response = ctx.getResponse()
    const request = ctx.getRequest()
    const status = exception.getStatus()
​
    // 打印日志
    this.logger.error(
      null,
      JSON.stringify({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url
      })
    )
​
    // 根据项目组约定的规范返回响应结果
    response.status(status).json({
      Code: status,
      Message: exception.getResponse(),
      ReturnData: {
        timestamp: new Date().toISOString(),
        path: request.url
      }
    })
  }
}

(四)Binding Filters

局部绑定:使用 @UseFilters 装饰器,并接收一个或多个 Exception Filter 类作为参数绑定到路由函数或控制器上。

ts 复制代码
@Post()
// 可以仅传入类而非实例,将实例化的工作交给 Nestjs 执行
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}
ts 复制代码
@UseFilters(new HttpExceptionFilter())
export class CatsController {}

全局绑定:使用 app.useGlobalFiltersProvider注入 的形式将过滤器绑定到全局

ts 复制代码
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();
ts 复制代码
import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
​
@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule {}

(五)Catch Everything

上述的 Exception Filter 仅针对 HTTP 异常做了捕获,但是实际开发中可能会有其他类型的异常,刚才定义的异常过滤器就无法处理了。

下述示例中,我们将使用 HttpAdapterHost,而不是特定平台的功能,比如 Express 的 Request 和 Response 对象来发送响应,藉此来忽视代码运行平台的影响。

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

需要注意的是,如果你在项目中将捕获所有内容的异常过滤器绑定到特定类型的过滤器 组合在一起时,应该首先声明Catch anything过滤器,以允许特定的过滤器正确处理绑定的类型。

二、Pipes

Nestjs 的管道主要有两个应用场景:

  • 转换【transformation】:将输入的内容转换为期望的格式

  • 校验【validation】:判断输入的内容是否合法,合法则放行,非法则抛出异常

Nestjs 可以在路由处理器函数被调用之前插入 Pipes

Pipes 可以接收用于该路由处理函数的参数,并对其执行转换校验逻辑。

(一)Built-in Pipes

Nestjs 提供了很多开箱即用的内置管道,同时也支持开发者编写自定义管道。

  • ValidationPipe
  • ParseIntPipe:保证参数会被转换为 JS 数字类型,如果转换失败,则会抛出异常
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe

(二)Binding Pipes

为了使用管道功能,我们需要将一个管道类绑定到合适的上下文中

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

上述示例中,我们将 ParseIntPipe 类绑定到路由处理函数 findOne 上,并在调用函数之前处理传入的参数 id。那么,假设客户端请求指定路由触发了上面的路由处理函数:

dash 复制代码
GET localhost:3000/abc

由于字符串 'abc' 并不能转换为合法的数字类型,Nestjs 会抛出下述异常信息,并阻止 findOne 函数的执行

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

在上述示例中,我们仅将 ParseIntPipe 类本身作为 @Param 参数传入,实例化的工作则依靠 Nestjs 的依赖注入机制全部交给框架实现。当然,和 Guards 一样,我们也可以手动实例化 ParseIntPipe 类来自定义其行为。

ts 复制代码
@Get(':id')
async findOne(
    @Param(
        'id',
        // 修改抛出异常时的状态码
        new ParseIntPipe({
            errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE
        }
    )
)
    id: number,
) {
    return this.catsService.findOne(id);
}

(三)Custom Pipes

每个管道都必须实现 transform() 方法来实现 PipeTransform 接口,基本结构如下:

ts 复制代码
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common';
​
@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

transform 方法需要传递两个参数:

  • value:当前处理的方法参数(在被路由处理方法接收之前)

  • metadata:当前处理的方法参数的元数据,基本格式如下:

ts 复制代码
export interface ArgumentMetadata {
  // 指明参数用的哪个装饰器:@Body(), @Query(), @Param(), 或是自定义参数
  type: 'body' | 'query' | 'param' | 'custom';
  // 参数的元类型,例如 String。需要注意的是:如果你在路由处理函数的参数签名中省略了类型声明,或者使用普通JavaScript,该值为 undefined。
  metatype?: Type<unknown>;
  // 传递给装饰器的字符串,例如 @Body('string'),如果圆括号内未传值,则为 undefined
  data?: string;
}

(四)Schame Based Validation

在实际项目场景中,我们可能会需要校验复杂结构的参数对象,以保证客户端发送的请求体是合法的,虽然我们可以将校验逻辑放到路由处理函数内执行,但这并不是个理想的解决方案,因为它会破坏单一责任原则。

Nestjs 提供了多个方法帮助我们实现这一需求。

方法一:Zod

第一步,安装 Zod 库

bash 复制代码
npm install --save zod

第二步,使用 Zod 提供的方法创建 Schema

ts 复制代码
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 绑定校验管道,并传入先前定义好的 Schema

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

第四步,使用 this.schema.parse() 方法校验管道收到的 value 参数,报错则抛出异常

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

方法二:Class Validator

第一步,安装 class-validator 和 class-transformer 库

dash 复制代码
npm i --save class-validator class-transformer

第二步,使用 class-validator 暴露的装饰器来进行校验。这种方法的好处在于:CreateCatDto 类仍然是 Post 主体对象的唯一事实来源(不必创建单独的验证类)。

ts 复制代码
import { IsString, IsInt } from 'class-validator';
​
export class CreateCatDto {
  @IsString()
  name: string;
​
  @IsInt()
  age: number;
​
  @IsString()
  breed: string;
}

第三步,编写校验管道

ts 复制代码
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;
    }
    // 由于通过网络请求传递的参数是没有任何类型信息的
    // class-validator 需要根据开发者在 DTO 中使用的装饰器,将接收的原始 JS 对象转换为类型化对象,以方便我们应用校验逻辑
    const object = plainToInstance(metatype, value);
    // 开始校验,并判断校验结果是否合法,非法则抛出异常
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    return value;
  }
​
  // 跳过原始 JS 对象的校验过程
  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}
相关推荐
zzzll111134 分钟前
SpringBoot 入门与实践指南
java·spring boot·后端
newerp1 小时前
Go :结构体嵌入、sort.Interface 与 io.Reader/Writer
后端
SamDeepThinking1 小时前
如何维持缓存的一致性?
后端·面试·程序员
泡海椒1 小时前
还在手动把 Postman 和浏览器的 curl 转成 Java 代码?这个框架让你一键粘贴直接用
后端
用户8181870627461 小时前
第13章 死锁诊断实战
后端
晨曦中的暮雨1 小时前
Redis 有哪些常见数据结构?它们的底层实现和使用场景是什么?
java·后端·八股
枫叶V1 小时前
Prompt Injection 防不住怎么办?从 Source-Sink 模型设计 Agent 安全边界
后端·agent
用户8181870627461 小时前
第14章 线程池 RejectedExecutionException 与拒绝策略源码
后端
Leo2821 小时前
LangGraph:把 Agent 从“会回答”推进到“可控执行”的工程架构
后端
死神6542 小时前
Vibe Coding 实战:我用 Nuxt 3+NestJS 做了一个电商运营 SaaS
saas·nestjs·nuxt.js·电商运营·vibe coding