🍎

その try-catch、意味がありますか?NestJSにおける例外処理の戦略 | TrustHub テックブログ

2024/07/11に公開

弊社(トラストハブ)では、バックエンドのプログラミング言語として TypeScript、ウェブフレームワークとしてNestJSを採用しています。本記事では、NestJSの特長を生かした、実際に弊社で採用されている例外処理の戦略について述べます。

その try-catch、意味がありますか?

TypeScriptのコードにおいて、try-catch が使われている場面をたびたび見かけます。

try {
  fn();
} catch (e) {
  // ...
}

try-catch で囲う意図としては以下のようなものがあると思います。

  • 例外をハンドリングすることで処理を継続したい。
  • 例外の型によって処理を分岐したい。
  • 例外の内容をログに出力したい。

例外をハンドリングする必要がありますか

例外は、例外的な状態なので例外なのです。

例外が発生した多くの場合、書かれたコードでは処理を継続できない状況が発生します。処理を継続できない状況において、例外を catch してもそれ以上どうしようもありません[1]

例えば、呼び出した関数がデータベースからレコードを取得する処理を実行するものだったとしましょう。この関数による例外のパターンは多々あります。

  • データベースとの接続に失敗した。
  • レコードの取得処理が終わらずタイムアウトした。
  • レコードの取得件数が多すぎてアプリケーションサーバーのメモリが枯渇した。
  • 意図しない構造のデータがレコードに入っていた。

ここで挙げたパターン以外の例外も発生する可能性があります。

このような例外が発生した場合、それらを catch してもそれ以降の処理を継続できない場合が多いのではないでしょうか。再度データベースに問い合せても同様の例外が発生するかもしれません。結局のところ、catch してもコード上でできることは限られています。

例外の型によって処理を分岐する必要がありますか

場合によっては、例外の型によって catch 後の処理を分岐したいということがあるかもしれません。

try {
  fn();
} catch (e) {
  if (e instanceof TypeError) {
    console.log(e.message);
  } else if (typeof e === "string") {
    console.log(e.toUpperCase())
  } else {
    throw e;
  }
}

ただ、型によって処理を分岐したい状況がどれだけありますか?

例外の型に応じた異なるレスポンスをフロントエンドに送り、フロントエンドでは受け取ったレスポンスに応じて描画を分けたいでしょうか。それも本当に必要でしょうか。 Instagram や X を使っていて、多種多様で親切なエラーポップアップを見たことがありますでしょうか。

必ずしも、例外の型で処理を分岐するのを禁止すべきだとは思いません。場合によってはそのような書き方も必要だと私も思います。ただし、意図がない状態でそのようなコードが増えるとコード全体の見通しが悪くなります。

そもそも TypeScript は例外の型を明確に扱う文化ではない

そもそも、TypeScript は Java などと異なり、例外の型を明確に扱う文化ではないと思います。

例外の型について、多くの有名ライブラリはドキュメントでそれについて詳細には述べていません。そのことについては、以下の TypeScript の Issue において、TypeScript のコントリュビューターも触れています。

https://github.com/microsoft/TypeScript/issues/13219#issuecomment-1515037604

lodashのドキュメントは200ページあり、その中でどのような例外が投げられるかについての記述はゼロだ。jQueryにおいて、ユーザーが見てとれる例外についてドキュメントでは言及されていない。Reactは、投げることができる例外のいくつかには言及しているが、そのすべてには言及していない。また、「エラーを投げる」というような言葉しか使っておらず、どのような例外の型なのかについての具体的な情報は含んでいない。Material-UIに関する850ページの本では、例外については一切触れておらず、ユーザーコードからのスローについてのみ触れている。xstateには、文書化された例外はない。Svelteのドキュメントには、100ページにわたって、単に「エラーをスローする」と書かれているだけだ。
(翻訳)

原文

the lodash documentation is 200 pages, of which there is zero description of what kinds of exceptions are thrown, even though the source code reveals that a handful of functions are capable of throwing exceptions. The one apparent user-surfable throw in jQuery is not mentioned in the documentation. React mentions some of the exceptions it can throw, but not all of them, and only uses language like "throws an error", opting not to include specific information about what type of exception. An 850-page book on Material-UI never mentions exceptions, and only talks about throws from user code. There are no documented exceptions in xstate. The Svelte documentation, over the course of 100 pages, simply says "throws an error" in one occurrence.

TypeScript において、例外の型を明確にする文化が形成されていない理由は多々あります。

上記の Issue コメントで述べられている理由のひとつとして、Java などの例外の型文化が強い言語では、明示的かつ命令的なリソース管理が浸透しており、プログラムの長期的な正しい動作を保証するために、すべての関数が重要なクリーンアップコードを必要としているためだと書いてあります。一方、JavaScript ではそのような制約を強く意識する必要はありません。

結局のところ、例外の型によって分岐を分けたくても、多くのライブラリは例外の型について明確ではないため、その方針をコード全体で遵守するのは難しいと思います。

例外の内容をログに出力するために catch する必要ありますか

例外が発生したとき、それをログに出力することは大賛成です。適切にログを出すことは、アプリケーションの状態を適切に観察するための重要な手段です。

ただ、例外が発生しうるすべての箇所でログ出力のコードを書くのは現実的でしょうか。例外が発生する原因はさまざまです。そう考えると、関数の呼び出し箇所すべてに try-catch を書かなくてはなりません。

NestJS の機能を使えば、少ないコードで必要なログ出力を実現できます。以降の章で説明します。

提案

ここまでの内容を踏まえると、例外処理の戦略としては以下の2つを採用するのが良いと思います。

  1. 例外が発生しうる箇所で try-catch を逐一書かない。
  2. 例外が発生しうる箇所でログ出力のコードを逐一書かない。

1について、セキュリティ上の懸念が出てくると思います。例外を catch しないようにすると、例外の内容がそのまま API のレスポンスとして返る可能性があります。例外の内容に個人情報などが含まれていると、個人情報の漏洩につながりかねません。

セキュリティ上の懸念を解消しつつ1と2を両方とも実現するのが、NestJS の exception filter を使った方法です。

すべてを exception filter に任せましょう

例外の露出の心配はいらない

REST APIの場合

NestJS には global exception filter というフィルターがデフォルトで実装されており、アプリケーションに対して自動で適用されます。REST API において、すべての例外がこのフィルターを通過します[2]

https://docs.nestjs.com/exception-filters

そして、例外が HttpException でもその拡張クラスでもない場合、このフィルターによって REST API では以下のレスポンスがフロントエンドに返ります。

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

この機能によって、例外の詳細な情報は NestJS のアプリケーションから露出しない仕組みになっています。よって、例外が発生しうる箇所で try-catch を逐一書く必要はありません。

GraphQL の場合

GraphQL の場合はひとつ工夫が必要です。global exception filter ( BaseExceptionFilter クラス)は実は REST API 向けに作られています。 そのクラス名から推測すると、まるで GraphQL API にも適用されていそうですが、実際はそうではないようです[3]

https://github.com/nestjs/nest/issues/5958#issuecomment-747483125

GraphQL 用の対応として、以下の2つが考えられます。

  • GqlExceptionFilter を拡張する。
  • GraphQL server adapter 側の設定を変更する。

前者の場合、以下のような実装が考えられます。

@Catch()
export class CustomGqlExceptionFilter implements GqlExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    if (exception instanceof Error && !(exception instanceof HttpException)) {
      // 詳細な情報が漏れないように、簡単なメッセージを持った GraphQLError オブジェクトを返す。
      return new GraphQLError('unexpected error'); 
    }
    return exception;
  }
}

GqlExceptionFilter を拡張したら、app.module.ts ファイルに以下の設定を足すことでアプリケーション全体に拡張したフィルターを適用できます。


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

参照: https://docs.nestjs.com/exception-filters

後者の場合、弊社では GraphQL server adapter に GraphQL Yoga を使っているのですが、app.module.ts ファイルに maskedErrors: true という設定を足しています。この設定により、エラーの詳細が露出しないようになります。

@Module({
  imports: [
    GraphQLModule.forRoot<YogaDriverConfig>({
      driver: YogaDriver,
      maskedErrors: true, // この設定を足すことでエラーの詳細が露出しないようになる。
      
      以下、略。
    })
  ],
}),

弊社では後者の方法を採用しています。追加する行数が少なく、GraphQL API に対する設定であることがわかりやすいためです。

Exception filter にログ出力を書く

例外が発生したときにログを出したい場合、try-catch を逐一書くのではなく、exception filter にログ出力を書くことをおすすめします。

今回は global exception filter を拡張してログ出力のコードを足します。global exception filter は BaseExceptionFilter というクラス名で実装されており、それを拡張します。以下のように新しいクラスを作成します。

@Catch()
export class CustomBaseExceptionFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) { 
 
	  // exception に例外の内容が載っているのでそれをログに出力する。
	  console.log(exception); 
	  
  }
}

例外が発生すると、exception にその詳細がセットされます。それをログとして出力すれば例外の内容をログとして追うことができます[4]

そして、この拡張したフィルターをアプリケーション全体に適用するために、ルートのモジュール( app.module.ts )に以下のコードを記載します。

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

ここまでの対応によって、アプリケーションで例外が発生したときにログが自動で出力されるようになります[5]

GraphQLの場合の注意点

global exception filter を上記のように拡張しアプリケーション全体に適用した際、アプリケーションで GraphQL API も使っている場合は注意が必要です。

@Catch()
export class CustomBaseExceptionFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) { 
 
    // exception に例外の内容が載っているのでそれをログに出力する。
    console.log(exception); 

    // GraphQL や RPC 用に、以下の分岐が必要になる。
    if (host.getType() == 'http') {
      super.catch(exception, host);
    } else {
      throw exception; // GraphQL や RPC を使っていれば例外を再スローする必要がある。
    } 
  }
}

GraphQL API で例外が発生した場合、その例外は本来 ExternalExceptionFilter というフィルターを通過します[6]。そのため、拡張した global exception filter に例外が通ったあと、ExternalExceptionFilter に到達できるように再スローする必要があります。

例外をスローする場合の戦略

少し話は変わりますが、ここまでは例外を「受ける」ほうの話をしてきました。しかし、実際にコードを書き進めると、自身が書いたコードで例外をスローする必要が出てくることが多々あります。

// 例えば、値が本来存在するはずの input という変数に null か undefined が入っていた場合、
// このあとのコードは input の値が存在するものとして書かれているので、これ以上処理を継続できない。
if (!input) {
  throw new BadRequestException() // 例外をスローし処理全体を終了する。
}

例外をスローするときに、弊社のコードでは以下の方針を採用しています。

  • 処理が継続できないと判明した箇所で HttpException をすぐにスローする。
  • スローするオブジェクトの型には Error ではなく、 HttpException とその拡張クラスを使う。
  • 例外オブジェクトのメッセージを空にする。

処理が継続できないと判明した箇所で HttpException をすぐにスローする

この方針に従うと、処理を継続できないと判明したとき、下図のようなフローになります。
例えば、Repository クラス内で処理を継続できない状況が発生した場合、Repository クラス内で HttpException をスローし、そのままフロントエンドへ response を返します。

これまで述べたように、各呼び出し元で例外を catch する必要はありません。

例外のスローイメージ

スローするオブジェクトの型には Error ではなく、 HttpException とその拡張クラスを使う

例外が発生したときに重要なのは、 Error 型ではなく HttpException (あるいはその拡張クラス)をスローすることです。HttpException をスローする方針は NestJS の公式ドキュメントでもベストプラクティスとして紹介されています。

For typical HTTP REST/GraphQL API based applications, it's best practice to send standard HTTP response objects when certain error conditions occur.

Documentation | NestJS - A progressive Node.js framework

HttpException をスローすることでログ出力を柔軟に設計できるというメリットがあります。

例外が exception filter に到達したとき、例外の型が HttpException 型であれば、警告ログやエラーログを出す必要はないと思います。それらの型は、アプリケーションコードが意図的にスローしたものであり、開発者からすると「意図した状態」であるからです。

一方、HttpException 型でない例外は、外部ライブラリなどからスローされた意図しない例外です。それらの例外に対しては警告ログやエラーログを出したほうが良いと思います。

前の章で「例外の型によって処理を分岐する必要はない」という旨を説明しましたが、それはすべての例外箇所でそのような分岐を書く必要がないという意味です。今回のように必要最低限であれば分岐を書いてもいいと思います。

この方針を exception filter に追記すると以下のようになります。以下の例では、ログ出力の部分の実際のコードを書いていませんが、コメントアウトに書いてあるようなログの出し分けが可能です。

@Catch()
export class CustomBaseExceptionFilter extends BaseExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) { 
    if (exception instanceof Error) {
      if (exception instanceof Prisma.PrismaClientKnownRequestError) {
        /*
         * Prisma の PrismaClientKnownRequestError 型はアプリケーション上、緊急的な状態ではないので、
         * 警告ログとしてログを出力する。 
         */
      } else if (!(exception instanceof HttpException)) {
        /*
         * HttpException でない Error 型の例外がきたときは、意図しない例外なので、
         * エラーログとしてログを出力する。
         */
      }
    } else {
      /*
       * Error 型でない例外がきたら何か想定していないことが起こっているので、
       * エラーログとしてログを出力する。
       */
    }
  }
}

例外オブジェクトのメッセージを空にする

アプリケーションから例外をスローするとき、例外オブジェクトのメッセージには何も入れないほうがいいと思います。

例外オブジェクトのメッセージに例外の詳細を詰めてしまうと、フロントエンドにその詳細が露出してしまうためです。

if (!input) {
  throw new BadRequestException() // ⭕️
  throw new BadRequestException(`The input is invalid. Details: ${input}`) // ❌
}

もし、例外の原因をあとから追いたいのであれば、例外をスローする前にログ出力のコードを書くべきです。

if (!input) {
  // 例外の原因を追いたいのであれば、ログとしてその内容を出力する。
  console.log(`The input is invalid. Details: ${input}`throw new BadRequestException()
}

最後に

TypeScript や NestJS において、例外処理を大局的に見た記事が世の中に少ないように感じたので今回この記事を書きました。紹介した方法が常にベストではなく、導入するチームや技術スタックによってやり方はさまざまだと思います。

最後に、株式会社トラストハブではカード事業だけでなくtoC向けの様々なプロダクトを提供していますが、やりたいことに対してエンジニアが足りておりません。toC向けプロダクトを開発したいという方はぜひこちらからお話しさせてください!

脚注
  1. バックエンドのアプリケーションを想定していることを忘れないでください。バックエンドのアプリケーションで catch してもエラーを示すポップアップをブラウザに出すことはできません。 ↩︎

  2. 正確には「すべてのエラー」が通過します。HttpException 型でなくても Error 型とその拡張クラスであればすべて通過します。 ↩︎

  3. NestJS の作成者もクラス名を変えたほうがいいと思っているようですが、破壊的変更になるので変更していないようです。https://github.com/nestjs/nest/pull/5972#discussion_r556419859 ↩︎

  4. 上記のサンプルコードでは話を簡単にするために console.log を使っています。実際の弊社サービスでは、Google Cloud を使っているため、Cloud Logging に即した「構造化ロギング」としてログを出力しています https://cloud.google.com/logging/docs/structured-logging?hl=ja↩︎

  5. ちなみに細かい話をすると、global exception filter はデフォルトでログを出力してくれます。https://github.com/nestjs/nest/blob/4b8b971d58094a6132fb2dbe11ad55a0092296c3/packages/core/exceptions/base-exception-filter.ts#L72
    ただし、弊社のようにログの構造を Google Cloud に即した形に変えたい場合や、以降の章で紹介するような、例外の型によってログレベルを変えたい場合、global exception filter を拡張するのが良いと思います。 ↩︎

  6. ちなみに、このクラスは NestJS の利用側に露出していないため拡張できません。 ↩︎

TrustHub テックブログ

Discussion