🦎

アプリケーションのサーバーエラーを型化しました。

2023/12/12に公開

はじめに

サーバーログはシステムの状態を把握するために不可欠です。特にエラーログはシステムの問題を特定し、解決するための重要な情報源です。
しかし、エラー管理を適切に行わないと、無駄なアラートを発生させたり、クライアントに不要な情報を送信してしまう原因になってしまいます。

本記事ではNodeとTypeScriptを用いて、サーバーエラーを型化し上記のような課題を解決する方法を解説します。

型化前の問題

エラー処理方法として、型化を導入する前はデフォルトのErrorクラスに実装者が任意のmessageをハードコーティングしていました。

if (!hoge) {
  throw new Error('hoge is not found')
}

しかし、この方法では以下のような問題が発生するようになりました。

アラート慣れ

サーバーで起こったエラーはSlackに通知されるようになっているのですが、エラーインスタンスの情報だけでは通知すべきエラーかそうでないエラーかの判断ができません。そのため、ユーザー起因のエラーなどの通知する必要がないエラーも通知が飛ぶようになってしまっていました。

こうなると、「システム運用アンチパターン」に書かれているように、エンジニアが無駄なアラート(偽のアラート)に多くさらされることで、アラート自体に慣れが発生し、アラートへの反応が鈍くなってしまいます。

エラー詳細の漏れ

エラー発生時にサーバーログに残すメッセージとクライアントに返すメッセージは分けるべきですが、Errorインスタンスに渡したmessageがそのままクライアントに送信されてしまっている箇所が多くありました。

こうなるとシステムの内部情報などの外部に漏れてはいけない情報が外部に送信される危険性があります。
また、クライアントには起こったエラーに対してどう対応すれば良いか分かるようなメッセージを返す方が親切です。

(ちなみにサーバーにはGraphQLを採用しているのですが、GraphQLではサーバー内でエラーがthrowされた場合、よしなにGraphQLErrorとして型化してクライアントに返却してくれるため、サーバー側で例外処理を実装するときにクライアントへ返すデータを意識する機会が少なかったのも、要因の1つだと考えています。)

エラーメッセージの一貫性と再利用性の欠如

同様のエラーでも開発者や実装箇所によってエラーメッセージが一貫していないため、エラーログの分析が難しくなっていました。また、ハードコーディングのため同様のエラーメッセージの再利用ができていない状態でした。

型化の概念と利点

型化は、データの構造を定義し、その構造に沿ったデータのみを受け入れるプログラミングの方法です。サーバーエラーに型化を導入することで、先述の問題点を解決することができます。NodeではTypeScriptの型を活用することで型化を容易にすることができます。

実装

ServerErrorクラス

以下のようにErrorクラスを継承したServerErrorクラスを定義します。

export class ServerError extends Error {
  readonly logLevel: LogLevel = LOG_LEVEL.ERROR
  readonly clientMessage: string = ERROR_MESSAGE.INTERNAL_SERVER_ERROR.message
  readonly errorMessageCode: string = ERROR_MESSAGE.INTERNAL_SERVER_ERROR.code
  readonly statusCode: ErrorStatusCode = ERROR_STATUS_CODE.INTERNAL_SERVER_ERROR

  /**
   * @defaultValue
   * - logLevel: ERROR
   * - clientMessage サーバーエラーが発生しました
   * - errorMessageCode 500000
   */
  constructor({ serverLog, logLevel, clientMessage }: ServerErrorArgs) {
    super(serverLog)
    this.name = this.constructor.name
    this.logLevel = logLevel ?? this.logLevel
    this.clientMessage = clientMessage?.message ?? this.clientMessage
    this.errorMessageCode = clientMessage?.code ?? this.errorMessageCode
    Error.captureStackTrace(this, this.constructor)
  }
}

ServerErrorクラスはErrorクラスを継承しており、これから後述するエラークラスたちの継承元になるクラスです。例外処理でこのクラスをthrowすることで構造化されたログを出力することができます。
また、想定外のエラーや実装不備で通常のErrorクラスがthrowされてしまった場合も、サーバーログとして出力される前にServerErrorクラスとして型化されるようになっています。(具体的な実装方法はサーバーの実装によって変わるので割愛します。)

5つのプロパティ

ServerClassの5つのプロパティについて解説します。

serverLog
サーバーログとして残すメッセージ。プロパティ名をserverLogとすることで、実装者がサーバーログに残るメッセージだということをすぐ認識できるようにする意図があります。

clientMessage
クライアントに送信されるメッセージです。何も指定しない場合、「サーバーエラーが発生しました」というメッセージになるようにしています。こうすることで、想定外のエラーが発生した場合やクライアントへのメッセージを記述し忘れた場合も、エラー詳細の漏れを防止できます。

logLevel
ログの重要度を表します。ログレベルは6段階で、WARN以上の場合はSlackに通知がいきます。

export const LOG_LEVEL = {
  EMERGENCY: 5,
  ERROR: 4,
  WARN: 3,
  INFO: 2,
  DEBUG: 1,
  TRACE: 0,
} as const

errorMessageCode
エラーにコードを付与します。エラーメッセージだけだと検索性が乏しいので。エラーを追いやすくする狙いがあります。こちらはclientMessageと1対1対応にしたいため、以下のように定数で定義します。

export const ERROR_MESSAGE = {
  // 500系
  INTERNAL_SERVER_ERROR: {
    message: 'サーバーエラーが発生しました', // 汎用メッセージ
    code: '500000',
  },
} as const

statusCode
ここの値をHTTPステータスコードしてクライアントに送信します。

具体的なエラークラス

よりエラークラスに具体性を持たせるために、ServerClassを継承したいくつかのエラークラスを定義します。
下記はその1つの例で無効な値によるエラーを表現するInvalidValueErrorです。

export class InvalidValueError extends ServerError {
  readonly statusCode: ErrorStatusCode = ERROR_STATUS_CODE.BAD_REQUEST
  /**
   * @defaultValue
   * - logLevel: INFO
   * - clientMessage: 無効な値が含まれています
   * - errorMessageCode: 400000
   */
  constructor({ serverLog, logLevel, clientMessage }: ServerErrorArgs) {
    super({
      serverLog,
      logLevel: logLevel ?? LOG_LEVEL.INFO,
      clientMessage: clientMessage ?? ERROR_MESSAGE.INVALID_VALUE,
    })
  }
}

InvalidValueErrorは基本的にユーザー起因のエラーになるため、デフォルトのログレベルはINFOに設定されています。また、clientMessageのデフォルト値もServerClassのデフォルト値よりも具体性が増したメッセージが設定されています。

このようにServerClassを元に具体的なエラークラスを定義することで、開発者にとってもクライアントにとってもよりわかりやすく扱いやすい情報にすることができます。

まとめ

型化によるエラーログ管理の改善は、システム運用の効率化だけでなく、将来的なシステムのスケーラビリティや保守性の向上にも寄与します。この記事を通じて、読者が型化の重要性とその実装方法について理解し、実際のプロジェクトに適用するきっかけとなれば幸いです。

Discussion