深入了解Nest的异常过滤器

3,676 阅读11分钟

上文中间件章节中在介绍Express时,提到了异常处理中间件。Express有自己的异常中间件格式(参数)。Nest为此扩展了相关的功能,使之更强大和灵活。

异常

JS异常及其特性

在开始了解Nest的异常处理过程之前,先来了解一下JS处理异常的特性。当JS代码中发生异常情况时,程序会沿着程序的调用堆栈逐层向上冒泡传递异常信息。这个过程会被一个try... catch...代码捕获并处理,或直至传递到JS引擎。

main();
function main() {
  try {
    foo();
  } catch (err) {
    console.log(`An error occurred in foo.`);
  } finally {
    console.log(`Done!`);
  }
}
function foo() {
  console.log('inner foo');
  try {
    bar();
  } catch (err) {
    console.log(`An error occurred in bar.`);
  } finally {
    console.log(`finish in foo!`);
  }
}

function bar() {
  console.log('inner bar');
  throw Error('Error In bar');
}
PS C:\Users\django\source\nest-notes\projects\process_exception_filter\src> node .\exception.js
inner foo
inner bar
An error occurred in bar.
finish in foo!
Done!

如果将foo代码中的try...catch...代码注释,输出内容则会变为:

inner foo
inner bar
An error occurred in foo.
Done!

如果将程序中所有的异常捕获代码都去掉,那么输出一大段错误信息,并提示出是由哪个代码抛出的异常信息。

Nest标准异常

Nest内建了异常层,用于处理没有被程序try...catch...捕获的异常。这样至少使得Nest应用程序在发生异常时,不至于停止运行。例如,在Service中直接throw

import { Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    throw '异常';
  }
}
curl -X GET "http://localhost:3000/"
{
  "statusCode": 500,
  "message": "Internal server error"
}

内建的异常过滤器识别HttpException类型以及扩展类型的错误。如果无法识别错误类型,就会返回上面的500错误信息。

HttpException

Nest的异常基础类,构造函数必须传入两个对象:

  • response 定义JSON的响应实体,默认情况下含有两个属性:
    • statusCode HTTP状态码;
    • message 关于异常的简要描述;
  • status 定义HTTP的状态码,官方建议引入 nestjs/common中的HttpStatus的枚举值;
// /src/app.service.ts
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';

@Injectable()
export class AppService {
  getHello(): string {
    throw new HttpException('异常', HttpStatus.FORBIDDEN);
  }
}
curl -X GET "http://localhost:3000/"
# {"statusCode":403,"message":"异常"}

以上是标准异常抛出动作,Nest将信息序列后输出给请求。

内建扩展异常类

Nest有如下异常(类)

异常(类)错误类型状态码
BadRequestException无效请求400
UnauthorizedException未授权401
NotFoundException未找到资源404
ForbiddenException禁止访问指定资源403
NotAcceptableExceptionaccept头中未在列的响应内容406
RequestTimeoutException请求超时408
ConflictException冲突异常409
GoneException请求资源不再可用410
HttpVersionNotSupportedExceptionHTTP版本不支持505
PayloadTooLargeException提交请求的载荷(数据)过大413
UnsupportedMediaTypeException不支持的媒体类型415
UnprocessableEntityException无法处理的实体422
InternalServerErrorException内部服务器错误(一般错误)500
NotImplementedException不支持的功能501
ImATeapotExceptionTeapot418
MethodNotAllowedExceptionaccept头中不可接受的请求方式405
BadGatewayException上游服务的异常导致502
ServiceUnavailableException服务不可用503
GatewayTimeoutException网关相应超时504
PreconditionFailedException请求头中给定的前提条件false412

随意查看一个内建的异常源代码:

export class UnauthorizedException extends HttpException {
    constructor(
    objectOrError?: string | object | any,
    description = 'Unauthorized',
  ) {
    super(
      HttpException.createBody(
        objectOrError,
        description,
        HttpStatus.UNAUTHORIZED,
      ),
      HttpStatus.UNAUTHORIZED,
    );
  }
}

内容比较简单,预设了一个描述信息和状态码的枚举值传入父类HttpException中,得到某一个具体的子类型异常对象。

自定义异常

只要不是非常简单的应用都会有自己定义的异常架构。Nest的基本异常类HttpException扩展自Error对象,也只有HttpException或者继承自它的异常对象,才能被Nest识别并处理之。除了上述预设的那些异常类外,开发者也可以自己定义专门的异常。HttpException还提供了一个静态方法createBody用于自定义的输出信息。先了解一下源代码:

  public static createBody(
    objectOrError: object | string,
    description?: string,
    statusCode?: number,
  ) {
    if (!objectOrError) {
      return { statusCode, message: description };
    }
    return isObject(objectOrError) && !Array.isArray(objectOrError)
      ? objectOrError
      : { statusCode, message: objectOrError, error: description };
  }

经过长时候发现:objectOrError可以是一个JSON对象,也可以是字符串;

  • 如果是字符串,输出的message字段就是objectOrError,error字段对应description,并带有statusCode
  • 如果是JSON,则完全由开发者决定输出的信息内容;
throw new HttpException({ Title: '我是王者' }, HttpStatus.UNAUTHORIZED);
// {"Title":"我是王者"}
// throw new HttpException(HttpException.createBody({ Title: '我是王者' }), HttpStatus.UNAUTHORIZED); 同上
throw new HttpException(HttpException.createBody("我是王者","我错了"), HttpStatus.UNAUTHORIZED)
// {"message":"我是王者","error":"我错了"}

HttpException.createBody第三个状态码参数可选,如果加了则会体现在返回的信息中(JSON),但是与请求的HTTP状态码无关。例如:

throw new HttpException(HttpException.createBody("我是王者","我错了",HttpStatus.TOO_MANY_REQUESTS), HttpStatus.UNAUTHORIZED)

得到的反馈为:

curl -X GET "http://localhost:3000/a" -i
HTTP/1.1 401 Unauthorized
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 63
ETag: W/"3f-CkWpT4oA6UTSUc1stvBVr6qrvZo"
Date: Fri, 05 Feb 2021 03:45:06 GMT
Connection: keep-alive

{"statusCode":429,"message":"我是王者","error":"我错了"}

异常过滤器

异常过滤器可以在让开发者在异常处理层中接管Nest的工作,完全自由的设定返回内容和程序流程。

创建一个过滤器

nest g f unauthorized

import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';

@Catch()
export class UnauthorizedFilter<T> implements ExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {}
}

ExceptionFilter是一个接口,只有一个方法:catch。方法有两个传入参数:

  • 异常对象:被抛出的异常对象实例;
  • 服务上下文对象:包含request和response;当进入到过滤器中,response必须要有返回(给请求)的信息,否则整个过程将会停止,最终请求方会得不到响应而引发超时异常。

@Catch()装饰器是用于告诉Nest,当前的类是一个异常过滤器。可以带有异常类型的参数,如果不设参数,则所有的异常都会经此过滤器。

稍微将异常过滤器补充完整一下会是这样:

import {
  ArgumentsHost,
  Catch,
  ExceptionFilter,
  HttpException,
} from '@nestjs/common';
import { Response, Request } from 'express';

@Catch(HttpException)
export class UnauthorizedFilter 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)
    // 响应返回征文
      .send(
        `[${
          request.url
        }] \t  -${new Date().toLocaleDateString()} \t  ${JSON.stringify(
          exception.getResponse(),
        )}`,
      );
  }
}

执行上下文 ArgumentsHost

早先提到过,但还是要重申关于Nest的定位:严格意义上来说,它就是一个IoC容器,本身不具备什么功能。是存粹的以一种程序架构理念实现的框架。在服务低层(注意,是高低的低),它可以是Express或者Fastify的HTTP服务,也可以是微服务或者是WebSocket应用。无论高层逻辑是什么样的,都与低层松耦合。当需要调取低层的某些功能时,就需要通过执行上下文(Execution context)来获取对象和请求参数。例如守卫、过滤器和中断,三者需要对整个请求-响应过程做出一定的干预和操作(例如提前终止响应过程)。

在Nest框架中,有两个类提供了这样的操作途径:ArgumentsHost和ExecutionContext。ExecutionContext主要应用在守卫和中断中。这里我们详细介绍前者。

Nest的源代码如下:

export interface ArgumentsHost {
  getArgs<T extends Array<any> = any[]>(): T;
  getArgByIndex<T = any>(index: number): T;
  switchToRpc(): RpcArgumentsHost;
  switchToHttp(): HttpArgumentsHost;
  switchToWs(): WsArgumentsHost;
  getType<TContext extends string = ContextType>(): TContext;
}

以上相关ContextTypeRpcArgumentsHostHttpArgumentsHostWsArgumentsHost和的源码:

export type ContextType = 'http' | 'ws' | 'rpc';
export interface HttpArgumentsHost {
  getRequest<T = any>(): T;
  getResponse<T = any>(): T;
  getNext<T = any>(): T;
}
export interface WsArgumentsHost {
  getData<T = any>(): T;
  getClient<T = any>(): T;
}
export interface RpcArgumentsHost {
  getData<T = any>(): T;
  getContext<T = any>(): T;
}

从源码中很容易推断出:switchTo**是获取上下文对象(接口),getArgs**获取上下文变量,实际上两者功能几乎一致。getType则是当前上下文的类型。

获取上下文

如果你写的程序可能会应用在多个服务场景中,那么需要根据不同的上下文来获取对象,这样就需要先利用getType()方法来判断上下文类型:

if (host.getType() === 'http') {
  // HTTP服务
} else if (host.getType() === 'rpc') {
  // rpc(微服务)
} else if (host.getType() === 'ws') {
  // WebSocket
}

例如异常过滤器catch代码中,就会将ArgumentsHost类型的host变量传入。再根据上面源代码可以得知:

  • host.switchToHttp()对应HTTP服务
  • host.switchToRpc()对应微服务
  • host.switchToWs()对应WebSocket服务

当获取上下文对象(Host)后,进而获取更详细的子实例,例如HTTP有:

  • getRequest() 请求实例
  • getResponse()响应实例
  • getNext()下一跳函数

除此以外,还可以使用getArgs()直接一次性提取所需要的关键对象

if (host.getType() === 'http'){
  const [req, res, next] = host.getArgs();
  /** 等同:
  const req = host.getArgByIndex(0);
  const res = host.getArgByIndex(1);
  const next = host.getArgByIndex(2);**/
} else if (host.getType() === 'rpc') {
  // rpc(微服务)
  const [root, args, context, info] = host.getArgs();
  // ...
} else if (host.getType() === 'ws') {
  // WebSocket
  const [data, client] = host.getArgs();
}

过滤器绑定

编写完过滤器逻辑之后,还需要设定将这个过滤器应用在哪个地方,例如:

  @Get('tea')
  @UseFilters(UnauthorizedFilter)
  teapot(): string {
    throw new ImATeapotException('I am a teapot');
  }

@UserFilters()装饰器可以接受单个或多个过滤器类,也可以是过滤器的实例。建议以类的方式传递参数,因为Nest会依赖注入来统一管理实例,在需要使用同一类过滤器的场景下,Nest会重用实例,以节省内存。我们来了解一下绑定过滤器的装饰器源代码:

export const UseFilters = (...filters: (ExceptionFilter | Function)[]) =>
  addExceptionFiltersMetadata(...filters);

function addExceptionFiltersMetadata(
  ...filters: (Function | ExceptionFilter)[]
): MethodDecorator & ClassDecorator {
  return (
    target: any,
    key?: string | symbol,
    descriptor?: TypedPropertyDescriptor<any>,
  ) => {
    const isFilterValid = <T extends Function | Record<string, any>>(
      filter: T,
    ) =>
      filter &&
      (isFunction(filter) || isFunction((filter as Record<string, any>).catch));

    if (descriptor) {
      validateEach(
        target.constructor,
        filters,
        isFilterValid,
        '@UseFilters',
        'filter',
      );
      extendArrayMetadata(
        EXCEPTION_FILTERS_METADATA,
        filters,
        descriptor.value,
      );
      return descriptor;
    }
    validateEach(target, filters, isFilterValid, '@UseFilters', 'filter');
    extendArrayMetadata(EXCEPTION_FILTERS_METADATA, filters, target);
    return target;
  };
}

从源码中可以看出,装饰器支持输入多个过滤器,依次通过addExceptionFiltersMetadata方法验证输入对象(过滤器)的有效性,然后存放到方法和类的元数据中。

关于绑定的范围有三种,分别是:

  • 单个方法绑定(上面的例子);
  • 控制器绑定:将装饰器放在控制器类的注解中;
  • 全局绑定;

全局绑定有两种方式,一种是将过滤器以Provider的形式注册到任意一个Module中:

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';
import { UnauthorizedFilter } from 'src/unauthorized.filter';
import { UserController } from './user.controller';

@Module({
  controllers: [UserController],
  providers: [{ provide: APP_FILTER, useClass: UnauthorizedFilter }],
})
export class UserModule {}

另外一种是在main.ts中设置

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { UnauthorizedFilter } from './unauthorized.filter';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new UnauthorizedFilter());
  await app.listen(3000);
}
bootstrap();

两者的差别在于,是否由Nest来管理依赖关系,特别是在过滤器(作为Provider的一种类型)需要依赖注入的时候。如果需要Nest来处理依赖注入,则采用Module的方式。并且,除了以类方式来指明过滤器之外,同样还可以以Provider的其他方式来注册过滤器。更多内容参考动态模块章节。

**需要注意:**如果要接管中间件抛出的异常,则必须使用全局异常过滤,控制器层面的过滤器是不对中间件起作用的。

异常过滤器的继承与架构

一个架构清晰的应用,同样应该有结构清晰的异常类。不同的异常类应该有不同的处理过程和逻辑。关于Nest的异常过滤器,到这里才是最有意思的地方。

结合先前提到@Catch()装饰器的参数(异常类型)以及过滤器的作用域,可以设计出复杂的处理异常的逻辑结构。假设某个系统采用了如下的异常体系:

执行逻辑

注册过滤器时,Nest会生成一个异常过滤器栈:后加入先比对。只要异常类型符合@Catch()的设定,就执行过滤器逻辑。且不会执行类似于中间件那样的控制权传递过程。以上图为例,如果先注册了A应用,再注册应用程序异常,那么当发生A应用类型异常时,Nest框架会执行应用程序异常

如果希望程序执行时,优先对子类异常执行对应过滤器,允许当没有对某个或某些子类设置过滤器时执行其父类过滤器则注册的顺序必须先注册父类,后注册其子类。

轻逻辑过滤器

往往一些小型项目处理过滤器时,需求较为简单,比如的只做一个记录,并无其他关键逻辑。将基本的异常处理依旧交由Nest处理,可以利用BaseExceptionFilter类:

import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';

@Catch()
export class AllexceptionFilter<T> extends BaseExceptionFilter {
  catch(exception: T, host: ArgumentsHost) {
    console.log('some exceptions occurred');
    super.catch(exception, host);
  }
}

其使用方法与一般的过滤器相同,也可以在装饰其中制定或不指定异常类型。super.catch()则是将当前的控制权又还给Nest框架,程序会继续执行。