1.异常与异常过滤器

对于互联网项目来说,没有处理的异常对用户而言是很困惑的,就像Windows 98电脑每次蓝屏时弹出的无法理解的故障和错误代码一样令人无所适从。因此,对应用程序来说,需要捕捉并且处理大部分异常,并以易于阅读和理解的形式反馈给用户,NestJs项目内置了一个全局的异常过滤器(Exception filter)来处理所有的Http异常(HttpException),对于无法处理的异常,则向用户返回“服务器内部错误”的JSON信息,这也是一般互联网项目的标准做法。本文以Nestjs官方文档为基础并提供部分完善与补充。

  1. {
  2. "statusCode":500,
  3. "message":"Internal server error"
  4. }

2. 异常的抛出与捕获

互联网项目中的异常,可能是无意或者有意产生的,前者往往来自程序设计中的bug,后者则多属于针对特殊条件需要抛出的异常(比如,当没有权限的用户试图访问某个资源时)。在程序中直接抛出异常的方法有以下几种(以官方文档cats.controller.ts为例)。

要在NestJs中使用HttpException错误,可能需要从@nestjs/common模块中导入HttpException,HttpStatus模块,如果(下节)自定义了异常抛出模块和异常捕捉模块,还需要导入相应模块并导入UseFilter装饰器。

  1. import { Controller,Get } from '@nestjs/common';
  2. import {HttpException,HttpStatus,UseFilters} from '@nestjs/common';
  3. import {CatsService} from './cats.service';
  4. import {Cat} from './interfaces/cat.interface';
  5. import {CreateCatDto} from './dto/create-cat.dto';
  6. import {Observable,of} from 'rxjs';
  7. import {ForbiddenException} from '../shared/exceptions/forbidden.exception';
  8. import { HttpExceptionFilter } from 'src/shared/filters/http-exception.filter';
  9. @Controller('cats')
  10. // @UseFilters(new HttpExceptionFilter) //异常过滤器可以作用域模块,方法或者全局
  11. export class CatsController {
  12. constructor(private readonly catsService:CatsService){}
  13. @Get()
  14. //在下节定义完HttpExceptionFilter后,可通过该装饰器调用
  15. // @UseFilters(new HttpExceptionFilter)
  16. findAll():Observable<Cat[]>{
  17. //注释掉默认的返回代码
  18. //return of(this.catsService.findAll());
  19. //可以直接通过throw new 抛出标准的HttpException错误
  20. throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
  21. //也可以自定义抛出错误的内容,注意最后的http标准代码403
  22. /*
  23. throw new HttpException({
  24. status:HttpStatus.FORBIDDEN,
  25. error:'Custom fibidden msg: you cannot find all cats'
  26. },403);
  27. */
  28. 在下节定义ForbiddenException后可以导入并抛出预定义的异常
  29. //throw new ForbiddenException();
  30. }
  31. }

3.自定义异常模块与异常过滤器

对于频繁使用的功能,在上节代码中以硬编码的方式写入并不是一个好习惯,不利于程序的模块和功能划分,也可能额外引入不确定的问题。因此,在实际项目中,可以将自定义的异常模块单独放置,例如forbidden.exception.ts文件,在需要抛出该自定义的位置,引入并抛出该自定义模块即可。

  1. import {HttpException,HttpStatus} from '@nestjs/common';
  2. export class ForbiddenException extends HttpException{
  3. constructor(){
  4. super('Forbidden',HttpStatus.FORBIDDEN);
  5. //super('Unauthorized',HttpStatus.UNAUTHORIZED);
  6. }
  7. }

和自定义异常模块相比,异常过滤器的使用更为广泛,通过异常过滤器,可以捕捉并处理不同类型的异常,并根据不同异常进行处理。官方文档中提供了一个完整的异常过滤器示例。在github的[NEST-MEAN](https://github.com/nartc/nest-mean)项目中,也使用异常过滤器针对未授权的异常访问进行过滤并处理。一个典型的http-exception.filter.ts文件,需要导入@nestjs/common模块中的ExceptionFilter,Catch,ArgumentsHost以及HttpException等模块。其中,ctx变量将请求上下文转换为Http请求,并从中获取与Express一致的Response和Request上下文。

@Catch()装饰器中的HttpException参数限制了要捕获的异常类型,如果要处理所有类型的异常,则应该保持@Catch()装饰器的参数为空,并在代码中处理捕获的不同类型的异常。status变量从HttpException中读取异常状态。可以通过status来区分处理不同的异常。下文代码段中注释掉的代码,用于判断并处理未授权访问的异常。

在需要使用异常过滤器的地方,使用装饰器@UseFilters(new HttpExceptionFilter)以调用异常过滤器,经过如下的异常过滤器后,返回给用户的异常结构成为如下的JSON格式。如果将forbidden.exceptions.ts中的HttpStatusForbidden修改为UNAUTHORIZED,并取消以下程序代码中处理UNAUTHORIZED异常类型的部分代码,则针对该类异常,返回的是自定义的’You do not have permission to access this resource`消息而不是默认的消息。

  • 返回的消息
  1. {
  2. "statusCode": 403,
  3. "timestamp": "2020-04-04T09:00:56.129Z",
  4. "message": "Forbidden",
  5. "path": "/cats"
  6. }
  • 异常过滤器文件
  1. import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
  2. import { Request, Response } from 'express';
  3. @Catch(HttpException)
  4. //@Catch() //如果要捕获任意类型的异常,则此处留空
  5. export class HttpExceptionFilter implements ExceptionFilter {
  6. catch(exception: HttpException, host: ArgumentsHost) {
  7. //catch(exception:unknown, host:ArgumentsHost){//如果要捕获任意类型的异常,则异常类型应为any或unkown
  8. const ctx = host.switchToHttp();
  9. const response = ctx.getResponse<Response>();
  10. const request = ctx.getRequest<Request>();
  11. const status = exception.getStatus();
  12. //如果要捕获的是任意类型的异常,则可能需要对此做如下判断来区分不同类型的异常
  13. /*
  14. const exceptionStatus=
  15. exception instanceof HttpException
  16. ? exception.getStatus()
  17. :HttpStatus.INTERNAL_SERVER_ERROR;
  18. */
  19. //如果要区分不同的异常状态,则可能需要做类似如下判断
  20. /*
  21. if (status=== HttpStatus.UNAUTHORIZED) {
  22. if (typeof response !== 'string') {
  23. response['message'] =
  24. response['message'] || 'You do not have permission to access this resource';
  25. }
  26. }
  27. */
  28. response
  29. .status(status)
  30. .json({
  31. statusCode: status,
  32. timestamp: new Date().toISOString(),
  33. message: response['message'] || exception.message,
  34. path: request.url,
  35. });
  36. }
  37. }

4.异常过滤器的调用

异常过滤器可以作用域方法、模块或者全局。在第二节的代码中,@UseFilters()装饰器位于在@Controller之后时,针对整个Controller使用异常过滤器,而位于@Get()之后时则仅针对Get方法使用异常过滤器。

如果要在全局调用异常过滤器,则需要在项目main.ts文件中使用useGlobalFilters方法,如下:

  1. import { NestFactory } from '@nestjs/core';
  2. import { AppModule } from './app.module';
  3. import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
  4. async function bootstrap() {
  5. const app = await NestFactory.create(AppModule);
  6. app.useGlobalFilters(new HttpExceptionFilter());
  7. await app.listen(3000);
  8. }
  9. bootstrap();

类似地,也可以在模块中注册全局过滤器,由于之前的useGlobalFilters方法不能注入依赖(不属于任何模块),因此需要注册一个全局范围的过滤器来为模块设置过滤器。这需要在app.module.ts文件中做如下定义:

  1. import { Module } from '@nestjs/common';
  2. import { APP_FILTER } from '@nestjs/core';
  3. import { HttpExceptionFilter } from './shared/filters/http-exception.filter';
  4. @Module({
  5. providers: [
  6. {
  7. provide: APP_FILTER,
  8. useClass: HttpExceptionFilter,
  9. },
  10. ],
  11. })
  12. export class AppModule {}

5.系统内置的Http异常类型

NestJs系统内置的Http异常包括了如下类型。

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

6.ArgumentsHost与应用上下文

在异常过滤器中,用到了ArgumentsHost参数,这是NestJs内置的一个应用上下文类,用于快速在不同类型的网络应用中切换(例如Http服务器、微服务或者WebSocket服务),上下文(Context)是程序设计中较难理解的概念之一,可以简单的理解为应用程序的运行环境,例如(一个不太严谨的比喻),运行在Windows中的程序和MacOS中的程序,由于操作环境的不同,因此所包含的运行环境上下文就是不一样的。

6.1 ArgumentsHost

前文HttpExceptionFilter的catch方法中,使用了ArgumentsHost。ArgumentsHost在NestJs中用来提供处理器(handler)的参数,例如在使用Express平台(@nestjs/platform-express)时,ArgumentsHost就是一个数组,包含Express中的[request,response,next],前两个作为对象(Object)而最后一个作为函数。在其他使用场景中,例如GraphQL时,ArguimentsHost则表示[root,args,context,info]数组。在NestJs使用惯例中,一般用host变量调用ArgumentsHost,即host:ArgumentsHost

  • getType()
    在NestJs的守卫(guards),过滤器(filters)和拦截器(interceptors)中,会用到ArgumentsHostgetType()方法来获取ArgumentsHost的类型,例如:
  1. if (host.getType() === 'http') {
  2. // do something that is only important in the context of regular HTTP requests (REST)
  3. } else if (host.getType() === 'rpc') {
  4. // do something that is only important in the context of Microservice requests
  5. } else if (host.getType<GqlContextType>() === 'graphql') {
  6. // do something that is only important in the context of GraphQL requests
  7. }
  • getArgs()
    可以使用ArgumentsHost的getArgs()方法获取所有参数:
  1. const [req, res, next] = host.getArgs();
  • getArgByIndex()//不推荐使用

使用getArgByIndex()通过索引获取ArgumentsHost数组中的参数:

  1. const request=host.getArgByIndex(0);
  2. const response=host.getArgByIndex(1);
  • 转换参数上下文

通过Arguments的方法可以将上下文转换为不同类型如Rpc,Http或者Websocket,例如前文示例中:

  1. const ctx = host.switchToHttp();
  2. const request = ctx.getRequest<Request>();
  3. const response = ctx.getResponse<Response>();

其他几个转换参数上下文的实例包括:

  1. /**
  2. * Switch context to RPC.
  3. */
  4. switchToRpc(): RpcArgumentsHost;
  5. /**
  6. * Switch context to HTTP.
  7. */
  8. switchToHttp(): HttpArgumentsHost;
  9. /**
  10. * Switch context to WebSockets.
  11. */
  12. switchToWs(): WsArgumentsHost;
  • WsArgumentsHost和RpcArgumentsHost
    与ArgumentsHost类似地,还有WsArgumentsHost和RpcArgumentsHost。官方文档中也提供了简单的示例
  1. export interface WsArgumentsHost {
  2. /**
  3. *返回data对象.
  4. */
  5. getData<T>(): T;
  6. /**
  7. * 返回客户端client对象.
  8. */
  9. getClient<T>(): T;
  10. }
  11. export interface RpcArgumentsHost {
  12. /**
  13. * 返回data对象
  14. */
  15. getData<T>(): T;
  16. /**
  17. *返回上下文对象
  18. */
  19. getContext<T>(): T;
  20. }

6.2 ExecutionContext运行上下文

ExecutionContext类从ArgumentsHost类继承而来,主要用于提供一些当前运行进程中更细节的参数,例如,在守卫中的canActivate()方法和拦截器中的intercept()方法。

  1. export interface ExecutionContext extends ArgumentsHost {
  2. /**
  3. *返回当前调用对象所属的controller类
  4. */
  5. getClass<T>(): Type<T>;
  6. /**
  7. * Returns a reference to the handler (method) that will be invoked next in the
  8. * request pipeline.
  9. * 返回管道中下一个将被调用的处理器对象的引用
  10. */
  11. getHandler(): Function;
  12. }
  13. //官方示例
  14. const methodKey = ctx.getHandler().name; // "create"
  15. const className = ctx.getClass().name; // "CatsController"
  • 反射和元数据(Reflection and metadata)

NestJs提供了@SetMetadata()装饰器方法来将用户自定义的元数据添加到路径处理器(handler)中。如下示例通过SetMetadata方法添加了roles键和[‘admin’]值对。

  1. import {SetMetadata} from '@nestjs/common';
  2. @Post()
  3. //@Roles('admin') //官方推荐使用本行的装饰器方法而不是下一行的直接采用SetMetadata方法
  4. @SetMetadata('roles', ['admin'])
  5. async create(@Body() createCatDto: CreateCatDto) {
  6. this.catsService.create(createCatDto);
  7. }

但官方文档不建议直接通过SetMetadata方法添加元数据,针对以上操作,可以通过如下方法新建一个roles装饰器并在controller中导入,示例的roles.decorator.ts文件如下:

  1. import { SetMetadata } from '@nestjs/common';
  2. export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

要访问自定义的元数据,需要从@nestjs/core中导入并使用Reflector.例如在roles.guard.ts中:

  1. @Injectable()
  2. export class RolesGuard {
  3. constructor(private reflector: Reflector) {}
  4. }
  5. //有了如上定义之后,就可以用get方法读取自定义元数据
  6. //const roles = this.reflector.get<string[]>('roles', context.getHandler());
  7. //自定义元数据也可以应用在整个controller中,这时需要使用context.getClass()来调用
  8. /*
  9. **在cats.controller.ts中定义
  10. @Roles('admin')
  11. @Controller('cats')
  12. export class CatsController {}
  13. */
  14. /*
  15. **在roles.guard.ts中调用
  16. const roles = this.reflector.get<string[]>('roles', context.getClass());
  17. */