乐趣区

NestJs学习之旅6异常处理

欢迎持续关注 NestJs 之旅 系列文章,关注公众号可以获得最新的教程!

传统的异常处理

在前面的内容中我们介绍了 NestJs 的几大常用组件,但是有一点没有做出说明,当我们的应用需要中断此次请求且输出错误信息时,我们需要怎么做?

这个问题有两种解决办法:

  1. services 层直接返回中断请求的响应对象,controller 直接输出该对象即可

    if(!this.allowLogin()) {return {errcode: 403, errmsg: '不允许登录'};
    }
  2. services 层抛出异常,controller 捕获该异常,然后输出响应对象

以上两种方法都有一定的缺点:

  1. controller 调用多个 services 时,需要依据 services 层的返回值来进行错误判断,要是漏了判断的话会导致原本需要中断的请求处理继续运行,导致不可预料的后果
  2. 如果每个 controller 都需要 try/catch 掉 services 层抛出的异常的话,会多了很多“重复”代码

那有没有一个像 SpringBoot 的 ExceptionHandler 相似的解决办法呢?

NestJs 的异常处理

NestJs 提供了统一的异常处理器,来集中处理运行过程中 未捕获的异常,可以自定义响应参数,非常灵活。

默认响应

NestJs 内置了默认的 全局异常过滤器 ,该过滤器处理HttpException(及其子类) 的异常。如果抛出的异常不是上述异常,则会响应以下默认 JSON:

{
  "statusCode": 500,
  "message": "Interval server error"
}

内置异常过滤器

由于 NestJs 内置了默认的异常过滤器,如果在应用内抛出 HttpException,是可以被 NestJs 自动捕获的。

比如在 services 层抛出一个 HttpException:

@Injectable()
export class UserService {login(username: string, password: string) {if(!this.allowLogin()) {throw new HttpException('您无权登录', HttpStatus.FORBIDDEN);
        }
    return {user_id:1, token: 'fake token'}
  }
}

controller 正常调用该 services 即可:

@Controller('users')
export class UserController {constructor(private readonly userService: UserService) {}
  
  @Post('login')
  login(@Body('username') username: string, @Body('password') password: string) {return this.userService.login(username, password);
  }
}

客户端访问 /user/login 时,如果不允许登录,会收到以下响应:

{
  "statusCode": 403,
  "message": "您无权登录"
}

一般情况下,上述 JSON 的返回的信息是不够的,比如有些业务自定义的错误码没地方可以自定义。

如果你有这种需求,可以传递 object 给 HttpException 的第一个参数来实现:

throw new HttpException({errcode: 40010, errmsg: '您无权登录'}, HttpStatus.FORBIDDEN);

客户端访问时,如果不允许登录,会收到以下响应:

{
  "errcode": 40010,
  "errmsg": "您无权登录"
}

自定义异常

企业级应用开发过程中,使用 HttpException 进行处理对开发是不太友好的,一个比较常用的做法是自定义一个 UserException 来承载业务异常(系统运行正常,只不过当前请求不满足业务上的要求而中断,比如注册的时候用户名重复的时候打回去,此时数据库查询是正常的,这就是业务异常和系统异常的区别)。

export class UserException extends HttpException {constructor(errcode: number, errmsg: string, statusCode: number) {super({ errcode, errmsg}, statusCode);
  }
}

业务层在使用该异常时直接使用以下代码即可,将原来传递对象的代码扁平化了:

throw new UserException(40010, '您无权登录', HttpStatus.FORBIDDEN);

语义化业务异常

使用自定义异常时 HTTP 协议层是正常的,抛出 403 错误有点不符合语义化的需求。对上例改造一下:

export class UserException extends HttpException {constructor(errcode: number, errmsg: string) {super({ errcode, errmsg}, HttpStatus.OK);
  }
}
throw new UserException(40010, '您无权登录');

此时客户端收到的 HttpStatus 为 200,意味着此次请求在协议层面是成功的,只不过业务层返回了错误。前端在处理响应时可以直接对 errcode 是否为 0 来确定此次请求是否成功。

自定义异常过滤器

虽然内置的异常过滤器可以自动处理很多情况,但是不是“可编程”的,也就是说我们无法完全控制异常处理过程,如果我们需要记录日志的话,使用内置的异常过滤器办不到,这时候可以使用 @Catch 注解来自定义异常处理器,添加日志记录什么的。

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

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter<HttpException> {catch(exception: HttpException, host: ArgumentsHost) {const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();
        // @todo 记录日志
    console.log('%s %s error: %s', request.method, request.url, exception.message);
    // 发送响应
    response
      .status(status)
      .json({
        statusCode: status,
          message: exception.message
        path: request.url,
      });
  }
}

ArgumentHost

ArgumentHost 是原始请求的包装器,由于 NestJs 支持 HTTP/GRPC/WebSocket,这三种请求的原始请求对象是有差异的,为了异常过滤器能够统一处理这三种异常,NestJs 做了包装。最终在使用时处理那种异常由开发者来决定。

ArgumentHost 接口定义如下:

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

如果需要处理的是 WebSocket 异常,就使用host.switchToWs(),其他异常以此类推。

使用自定义异常过滤器

如果定义完自定义异常过滤器之后,直接去访问会抛出异常的接口,此时可以发现并没有走自定义异常过滤器。

因为我们 只是定义,并没有注册

使用 @UseFilters 注册自定义异常过滤器。

异常过滤器有以下三种作用范围:

  • 方法级别
  • 控制器级别
  • 全局级别

方法级别

只会处理该方法上抛出的异常,其他方法抛出的异常不会处理。

@Post('login')
@UseFilters(UserExceptionFilter)
login(@Body('username') username:string, password: string) {throw new UserException(40010, '您无权登录');
}

控制器级别

只会处理该控制器方法上抛出的异常,其他控制器抛出的异常不处理。

@Controller('user')
@UseFilters(UserExceptionFilter)
export class UserController { }

全局级别

在应用入口注册,不会对 Websocket 或者混合应用(同时支持两种应用,如 HTTP/GRPC 或者 HTTP/WebSocket)生效。一般 Web 开发中全局异常过滤器已经够用了。

在 main.ts 中注册全局异常过滤器

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

依赖注入

由于异常过滤器并不是任何模块上下文的一部分,所以 NestJs 无法对其进行依赖注入管理,如果有此种需求,比如在异常过滤器中注入 service,需要定义服务提供者。服务提供者名称为 NestJs 规定的常量 APP_FILTER

import {Module} from '@nestjs/common';
import {APP_FILTER} from '@nestjs/core';

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

捕获多种异常或者所有异常

上例中提到的自定义异常处理器只会捕获 UserException 异常,如果有系统异常,会使用内置的异常处理器。通过传入异常类型给 @Catch 装饰器来捕获多种异常。如果不传任何异常类型的话,NestJs 会捕获所有异常(也就是 Error 及其子类)。

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

@Catch() // 捕获所有异常
export class HttpExceptionFilter implements ExceptionFilter<Error> {catch(exception: Error, host: ArgumentsHost) {const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();
        // @todo 记录日志
    console.log('%s %s error: %s', request.method, request.url, exception.message);
    // 发送响应
    response
      .status(status)
      .json({
        statusCode: status,
          message: exception.message
        path: request.url,
      });
  }
}

结尾

异常过滤器让应用异常有了统一的处理渠道,同时也解决文章开头提出的两个问题。通过自定义异常过滤器,开发者可以进行统一响应格式,统一记录日志等等操作。

如果您觉得有所收获,分享给更多需要的朋友,谢谢!

如果您想交流关于 NestJs 更多的知识,欢迎加群讨论!

退出移动版