优雅处理状态码:Nest.js 实现的封装方案

本文将介绍如何优雅地处理 Nest.js 项目中的 API 状态码。在 Nest.js 项目中,我们经常需要抛出异常或返回错误信息,这就需要处理合适的状态码,以便客户端够正确地处理返回结果。

项目中的状态码定义的太松散或太严格,都不太好。过于松散,随着项目的逐渐开发,会导致混乱,而过于严格(比如每一个业务都有一个确定的状态码),快速迭代开发的时候又有点束缚。所以这里我先预定义一套简单的规则,0 ​代表请求成功,1 ​代表请求异常。然后再根据业务去定义具体的状态码,两者可以互相结合使用。

首先,定义一个继承自 Nest.js 框架 HttpException​ 类的 ApiException​ 类。后续我们就通过使用这个自定义实现,来抛出 API 消息。

// api.exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';

interface ApiExceptionOptions {
  /** API抛出的状态码 */
  code: number;
  /** API抛出的错误消息 */
  message: string;
}

type ApiError = string | ApiExceptionOptions;

/** API异常 */
export class ApiException extends HttpException {
  /**
   *
   * @param errorOrObj 错误消息,或者是表示错误详情的对象
   * @param status HTTP状态码,默认 200
   */
  constructor(errorOrObj: ApiError, status: HttpStatus = HttpStatus.OK) {
    // 因为还需要在异常过滤器(exception.filter.ts)中统一格式化
    // 所以抛出的异常需要是 {code, message}, httpStatus 这种格式
    if (typeof errorOrObj === 'string') {
      super({ code: 1, message: errorOrObj }, status);
    } else {
      super({ code: errorOrObj.code, message: errorOrObj.message }, status);
    }
  }
}

上述代码中,我们定义了一个 ApiExceptionOptions​ 接口,表示 API 的错误详情,它包含一个状态码和一个错误消息。

ApiError​ 类型是首个参数的类型,可以是一个字符串错误消息,也可以是一个 ApiExceptionOptions​ 对象。通过判断 errorOrObj​ 的类型,视情况调用 super​ 方法来配置状态码和错误消息。第二个参数是 HTTP 的状态码,默认是 200 。

这样,当我们创建了一个 ApiException​ 实例时,就可以通过 message​ 属性来访问错误消息,而状态码则存储在 response​ 属性中。

在实际的应用中,就可以使用这个自定义的 ApiException​ 类来快速的创建错误响应并返回给客户端。


上面我们已经创建了一个自定义的 ApiException​ 类,来抛出 API 异常。下面我们还需要在 Nest.js 项目中统一处理这些异常,并返回正确的响应信息。

⚠️ 注意:主要是从性能方面考虑,我一般都是默认将 Nest.js 依赖的 Web 框架从 Express 更改为 Fastify。 所以下面的请求对象和响应对象都是 Fastify 的,如果你使用的是 Express,将下面 Fastify 的部分改为使用 Express 实现即可。

// exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  HttpException,
  ArgumentsHost,
  HttpStatus,
} from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import { ApiException } from '../exceptions/api.exception';

/** 默认的错误代码 */
const DEFAULT_ERROR_CODE = 1;

/** 从异常里获取HTTP状态码 */
function getHttpStatus(exception: HttpException) {
  if (exception instanceof HttpException) {
    return exception.getStatus();
  }
  return HttpStatus.INTERNAL_SERVER_ERROR;
}

/** 获取API业务抛出的错误代码 */
function getApiCode(exception: HttpException): number {
  // 如果是ApiException的实例,就可以获取自定义状态码
  if (exception instanceof ApiException) {
    return (exception as any)?.response?.code;
  }
  // 否则返回默认的错误状态码
  return DEFAULT_ERROR_CODE;
}

/** 获取异常的错误消息 */
function getErrorMessage(exception: HttpException) {
  return (
    (exception as any)?.response?.message ||
    (exception as any)?.message ||
    '未知错误'
  );
}

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<FastifyRequest>();
    const response = ctx.getResponse<FastifyReply>();
    const httpStatus = getHttpStatus(exception);

    const code = getApiCode(exception);
    const msg = getErrorMessage(exception);
    const data = { code, msg };

    response.code(httpStatus).type('application/json').send(data);
  }
}

上述代码中,我们定义了一个 HttpExceptionFilter​ 类,它实现了 ExceptionFilter​ 接口,用于拦截并处理应用程序中抛出的异常。在 catch​ 方法中,我们获取请求和响应对象,然后根据异常对象的类型,来生成相应的错误信息。

getHttpStatus​ 函数用于获取异常对象中的 HTTP 状态码。如果异常对象不是 HttpException​ 类型,就默认返回 500 状态码。

getApiCode​ 函数用于获取异常对象中自定义的错误码,如果异常对象是我们自定义的 ApiException​ 类型,则获取自定义状态码,否则返回一个默认的错误码,以方便进一步的处理。

getErrorMessage​ 函数用于获取异常对象中自定义的错误消息,如果异常对象是我们自定义的 ApiException​ 类型,则获取自定义消息,否则获取异常对象中的默认错误消息,如果不存在,则返回一个 “未知错误” 的消息。

获取到必要的消息之后,生成了一个包含错误状态码、错误消息的响应对象 data​,最后,我们将响应对象发送回客户端,并在响应头中指定返回的数据类型为 JSON 格式。


由于我们在 API 层面进行了统一封装,因此完全可以在 main.ts​ 中将该异常过滤器全局注册,这样就可以捕获应用程序运行时的任何异常。同时,由于在上面我们进行了格式化,使抛出的异常信息都具有了统一的格式,客户端可以更方便的处理这些异常信息。

// main.ts
import { HttpExceptionFilter } from './common/filters/exception.filter';

// ...
app.useGlobalFilters(new HttpExceptionFilter());

我们现在可以在应用中方便的统一处理状态码和异常信息了: 如:

// 抛出一个默认的API异常
// 错误码1;错误消息:鉴权未通过;HTTP状态码 200
throw new ApiException('鉴权未通过');

// 抛出一个自定义状态码的异常
// 错误码10010;错误消息:鉴权未通过;HTTP状态码 200
throw new ApiException({ code: 10010, message: '鉴权未通过' });

// 抛出一个自定义状态码和自定义HTTP状态码的异常
// 错误码10010;错误消息:鉴权未通过;HTTP状态码 401
throw new ApiException({ code: 10010, message: '鉴权未通过' }, HttpStatus.UNAUTHORIZED);

这是一种灵活而松散的使用方式。

除此之外,我们还可以针对各个模块的业务进行进一步的封装,使状态码更严谨。

例如,我们可以将 10000 ​系列作为系统层面的状态码,20000 ​系列作为具体业务模块的状态码。

举个例子:

// 假设这里我们用户模块的状态码为 21000 系列
// 那么我们可以这么封装

// user.exception.ts
import { ApiException } from './api.exception';

const enum ErrorCode {
  /** 用户已存在 */
  IS_EXIST = 21000,
  /** 用户不存在 */
  NOT_EXIST = 21001,
  /** 用户已被封禁 */
  IS_BAN = 21002,
}

/** 异常:用户已存在 */
export class UserIsExistException extends ApiException {
  constructor() {
    super({ code: ErrorCode.IS_EXIST, message: '用户已存在' });
  }
}

/** 异常:用户不存在 */
export class UserNotExistException extends ApiException {
  constructor() {
    super({ code: ErrorCode.NOT_EXIST, message: '用户不存在' });
  }
}

// 这样,我们在业务层面,可以直接拿来使用了
// user.controller.ts
if (!user) {
  throw new UserNotExistException();
}

当然,具体还是要根据自己的业务来进行定义。这里只是一家之言,起个抛砖引玉的作用。

最后,通过以上这些处理,我们终于实现了一个优雅的处理异常的机制,可以根据应用程序抛出的具体异常类型,生成规范的错误信息并返回给客户端。