🙄

NestJSのExceptionFiltersでいい感じに例外処理とログ出力を実現する方法

2023/03/29に公開

記事の目的

この記事は、NestJSを使用してアプリケーションを開発する際に、ExceptionFiltersとLoggerを活用して効率的な例外処理とログ出力を実現する方法を解説します。

ExceptionFiltersについて

NestJSのExceptionFiltersは、アプリケーション全体または特定のスコープで発生する例外をキャッチし、一元的に処理するための機能です。
これにより、エラーハンドリングの処理を効率化できます。
ExceptionFiltersを実装するには、ExceptionFilterインターフェースを実装したクラスを作成し、その中にcatch()メソッドを定義します。このcatch()メソッドは、例外が発生したときに実行されます。

global-exception.filter.ts
import { Catch, ExceptionFilter, ArgumentsHost } from '@nestjs/common';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    // エラーハンドリングやログ出力の処理をここに記述
  }
}

Loggerについて

NestJSのLoggerは、アプリケーションで発生するイベントやエラー情報を記録するための組み込みモジュールです。
Loggerを使用するには、Loggerクラスをインポートし、インスタンスを作成します。その後、対応するログレベルのメソッドを呼び出して、ログメッセージを出力できます。

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

const logger = new Logger('MyClass');

logger.error('エラーメッセージ');
logger.warn('警告メッセージ');
logger.log('一般情報メッセージ');
logger.debug('デバッグ情報メッセージ');

実装

方針

例外の発生を検知したら、

  • ログファイルにエラーを記録する
  • エラーのレスポンスを返す
    ようにします。

正直、main.tsにグローバルフィルターとして登録したかったのですが、イマイチうまくいかないので、moduleごとにDIする方針でいきます。

構成

例外とロギングを構成するファイル構成は以下の通りです。
src/module/exception/all-exception.filter.ts
src/module/exception/exception.module.ts
src/module/logging/logging.service.ts
src/module/logging/logging.module.ts

moduleの実装

src/module/logging/logging.module.ts
@Module({
  providers: [LoggingService],
  exports: [LoggingService],
})
export class LoggingModule {}
src/module/exception/exception.module.ts
@Module({
  imports: [LoggingModule],
  providers: [{ provide: APP_FILTER, useClass: AllExceptionFilter }],
})
export class ExceptionModule {}

LoggingServiceの実装

winstonというロギングライブラリを使用しています。
NestJSのLoggerServiceを拡張しています。
開発環境の場合、コンソールにログを表示しています。

長いので折りたたみます。

src/module/logging/logging.service.ts
src/module/logging/logging.service.ts

type LogLevel = "debug" | "info" | "warn" | "error" | "verbose";

interface ILog {
  url: string;
  method: string; // http method
  uid?: string;
  message: string;
}

@Injectable()
export class LoggingService implements LoggerService {
  logger: winston.Logger;
  private readonly dateFormat = "YYYY-MM-DD";
  private readonly dirName = ".log";

  private createLogTransport(level: LogLevel, filename: string): winstonDailyRotateFile {
    return new winstonDailyRotateFile({
      level: level,
      datePattern: this.dateFormat,
      filename: `${filename}-%DATE%.log`,
      dirname: this.dirName,
      maxSize: "20m",
      maxFiles: "32d",
    });
  }

  private readonly loggerFormat = winston.format.combine(
    winston.format.timestamp({ format: `${this.dateFormat} HH:mm:ss` }),
    winston.format.errors({ stack: true }),
    winston.format.printf(
      (info: TransformableInfo) =>
        `"${info.timestamp}", "${info.level}", "${info.url}"," ${info.method}", "${info.uid}", "${info.message}"`
    )
  );

  private readonly createDevelopTransport = new winston.transports.Console({
    level: "debug",
    format: winston.format.combine(winston.format.colorize(), winston.format.simple()),
  });

  private readonly createLog = (logLevel: LogLevel, props: ILog) => {
    return this.logger.log({
      level: logLevel,
      url: props.url,
      method: props.method,
      uid: props.uid ? props.uid : "anonymous",
      message: props.message,
    });
  };

  constructor() {
    const logger = winston.createLogger({
      format: this.loggerFormat,
      transports: [
        this.createLogTransport("debug", "application"),
        this.createLogTransport("error", "error"),
      ],
    });

    if (process.env.NODE_ENV !== "production") logger.add(this.createDevelopTransport);

    this.logger = logger;
  }

  public log(props: ILog) {
    this.createLog("info", props);
  }
  public error(props: ILog) {
    this.createLog("error", props);
  }
  public warn(props: ILog) {
    this.createLog("warn", props);
  }
  public debug(props: ILog) {
    this.createLog("debug", props);
  }
  public verbose(props: ILog) {
    this.createLog("verbose", props);
  }
}

ExceptionFilterの実装

Fastifyを利用しているため、以下2点の注意が必要です。

  • レスポンスの型にFastifyReplyが必要
    • ×: const response = context.getResponse();
    • ○: const response = context.getResponse<FastifyReply>();
  • .jsonの代わりに.sendを使用する
    • ×: response.status(status).json({ statusCode: status, message });
    • ○: response.status(status).send({ statusCode: status, message });

例外が発生した際に、例外とリクエストから必要な情報を取得します。
その情報を元にログに出力します。
その後、ステータスコードとメッセージをレスポンスします。

長いので折りたたみます。

src/module/exception/exception.filter.ts
src/module/exception/exception.filter.ts
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from "@nestjs/common";
import { LoggingService } from "src/module/logging/logging.service";
import { FastifyReply } from "fastify";

@Catch()
export class AllExceptionFilter implements ExceptionFilter {
  constructor(private readonly log: LoggingService) {}

  private readonly selectStatus = (exception: unknown): number => {
    return exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;
  };

  private readonly selectErrorText = (exception: unknown): { message: string; stack: string } => {
    if (exception instanceof Error) {
      return { message: exception.message, stack: exception.stack };
    } else if (exception instanceof HttpException) {
      return { message: exception.message, stack: exception.stack };
    } else {
      return { message: "unknown message", stack: "unknown stack" };
    }
  };

  public catch(exception: unknown, host: ArgumentsHost) {
    const context = host.switchToHttp();
    const response = context.getResponse<FastifyReply>();
    const request = context.getRequest();

    // ロギングとレスポンスのための変数を定義
    const status = this.selectStatus(exception);
    const message = this.selectErrorText(exception).message;
    const stack = this.selectErrorText(exception).stack;
    const uid = request.uid ? request.uid : "anonymous";
    const url = request.url;
    const method = request.method;

    // ロギング
    this.log.error({ url, method, uid, message: `${message} : ${stack}` });

    // レスポンス
    response.status(status).send({ statusCode: status, message });
  }
}

利用

addressモジュールで利用する場合、以下のように記述します。
imports: [LoggingModule, ExceptionModule],

address.module.ts
@Module({
  imports: [AuthModule, PrismaModule, LoggingModule, ExceptionModule],
  controllers: [AddressController],
  providers: [
    { provide: "AddressRepositoryProvide", useClass: AddressRepository },
    SaveAddressUseCase,
    FindOneAddressUseCase,
    ResultOneAddressUseCase,
  ],
  exports: [],
})
export class AddressModule {}

下記のコードをテストします。
request.uidのデータをデータベースから引っ張ってレスポンスするコードです。
データベースは空っぽにしてあるので、テストに失敗します。

address.controller.ts
    @useGuard(Auth)
  @Get()
  public async findOne(@Req() request: CustomRequest): Promise<AddressResponseDto> {
    try {
      return await this.findOneAddressUseCase.execute(request.uid);
    } catch (e) {
      throw new Error(e.message);
    }
  }

そのため、例外が発生し、ログには以下のように記録されます。

"2023-03-27 09:17:31", "error", "/address"," GET", "userId", "住所が見つかりません : Error: 住所が見つかりません
    at AddressController.findOne (**************/backend/src/address/controller/address.controller.ts:26:13)
    at (**************//backend/node_modules/@nestjs/core/router/router-execution-context.js:46:28
    at Object.<anonymous> ((**************//backend/node_modules/@nestjs/core/router/router-proxy.js:9:17)"

レスポンスは以下のとおりです。
メッセージはユースケースでthrow new Error("住所が見つかりません");を発生させてます。

  Object {
	"message": "住所が見つかりません",
	"statusCode": 500,
  }

終わりに

main.tsでグローバルに利用できないので誰か解決策あったら教えて下さい!

main.ts
async function bootstrap() {
  const fastify = new FastifyAdapter();
  const app = await NestFactory.create<NestFastifyApplication>(AppModule, fastify);

  // ↓でうまくいくはずだが・・・
  /*
  // ロギング
  const loggingService: LoggingService = app.get(LoggingService);
  app.useLogger(loggingService);

  // 例外
  const aAllExceptionsFilter = new AllExceptionFilter(loggingService);
  app.useGlobalFilters(aAllExceptionsFilter);
 */

  // アプリケーション
  await app.listen(3011, "0.0.0.0");
}

bootstrap();
 */

参考

Discussion