👁️

エラーメッセージに仕様を書く!?

2024/12/14に公開

よろしくどーぞ、 knwoop です。
カウシェ Advent Calendar 2024 14日目を担当させていただきます。

はじめに

カウシェでは、エラーメッセージをちゃんと書くという文化があります。そしてエラーメッセージが詳細に記載されているが故、スタックトレースなど必要だと思ったことないです。他のチームメンバーからもそのような意見も出たことがない(ような気がする)です。。どういうことなのか説明しきていきたいと思います!

Go におけるエラーメッセージ

エラーメッセージとは、アプリケーションやシステムが問題を検出した際に、ユーザーに対して表示する意味のある情報を含んだメッセージを指します。これは、システムの状態や問題の内容を理解する上で重要な情報源となります。

Goでは、以下のようにエラーメッセージを作成するのが一般的です:

err := doSomething()
if err := nil {
  return fmt.Errorf("failed to do something: %w", err)
}

このように、エラーの内容を明確に示し、必要に応じて元のエラーをラップすることで、問題の追跡や理解を容易にします。

重要性

エラーメッセージで重要なことは、エラー発生時点のエラーメッセージをみれば何が起こっているか一目でわかることが重要だと考えています。特に、呼び出し元や呼び出し先に依存しないエラーメッセージであることが重要です。
エラーメッセージは、システムが「異常な状態」に陥ったことを示す最初の手がかりです。一目でわかるエラーメッセージがあることで、開発者やシステム管理者は問題が発生した状況を素早く把握し、適切な対応を取ることができます。

よくない例

まずよくない例から見てきましょう。

受け取ったエラーをそのまま返す

同じ関数内で複数のエラーが発生する可能性がある場合、以下のようなコードはエラーの文脈を失ってしまいます。

// 自身の情報を取得
if err := findCustomer(ctx, customerID); err != nil {
  return err
}

// 自身のプロフィールを取得
if err := findProfile(ctx, customerID); err != nil {
  return err
}

問題点

  • どこでエラーが発生したのかが不明確になる
  • エラーの発生箇所や原因を特定するのが困難になる
  • デバッグに必要な文脈情報が失われる
  • 同じエラーメッセージが異なる場所で発生した場合、区別がつかない

このようなエラーメッセージでは、例えば、データベース側で何か問題が起こった時どこでエラーになったのか特定するのが難しくなるというのは、容易に想像がつくと思います。

単調なエラーメッセージの作成

// 自身の情報を取得
if err := findCustomer(ctx, customerID); err != nil {
  return fmt.Errorf("failed findCustomer: %w", err)
}

// 自身のプロフィールを取得
if err := findProfile(ctx, customerID); err != nil {
  return fmt.Errorf("failed findProfile: %w", err)
}

このような単調なエラーメッセージを作成するパターンはよくやる思います。表面的にはエラーの場所は特定できるものの、実際のトラブルシューティングの際には有用な情報が不足しています。

それに、以下のように後続で同じ関数が呼ばれた時に、最初の例同様に特定が難しくなっていまいます。

// 自身の情報を取得
if err := findCustomer(ctx, customerID); err != nil {
  return fmt.Errorf("failed findCustomer: %w", err)
}

// 自身のプロフィールを取得
if err := findProfile(ctx, customerID); err != nil {
  return fmt.Errorf("failed findProfile: %w", err)
}

// 友達の情報を取得
if err := findCustomer(ctx, friendCustomerID); err != nil {
  return fmt.Errorf("failed findCustomer: %w", err) // ここでエラーが起こった時、ログだけでは最初の findCustomer でエラーがでたのか判断できない
}

さらに関数名が変わったりするとそれに応じて変更する必要があります。

実際に行っているエラーハンドリング

それでは実際に行っている実装です。

// 自身の情報を取得
if err := findCustomer(ctx, customerID); err != nil {
    return fmt.Errorf("unexpected error returned when finding a customer: %w", err)
}

// 自身のプロフィールを取得
if err := findProfile(ctx, customerID); err != nil {
    return fmt.Errorf("unexpected error returned when finding a profile: %w", err)
}

// 友達の情報を取得
if err := findCustomer(ctx, friendCustomerID); err != nil {
    if errors.Is(err, ErrNotFound) {
        return fmt.Errorf("the customer is not a friend by customer_id:`%s`: %w", err)
    }
    return fmt.Errorf("unexpected error returned when finding a friend customer: %w", err")
}

上記の例ではエラーごとにエラーメッセージを作成しています

エラーメッセージに仕様を記載するということ

以下の部分をみてください。友達の情報を取得した時それが NotFound だったとき友達の情報が存在しないという旨を表すエラーメッセージではなくて、友達でない という旨のメッセージが記載されています。

// 友達の情報を取得
if err := findCustomer(ctx, friendCustomerID); err != nil {
    if errors.Is(err, ErrNotFound) {
        return fmt.Errorf("the customer is not a friend by friend_customer_id:`%s`: %w", err)
    }
    return fmt.Errorf("unexpected error returned when finding a friend customer: %w", err")
}

この状況では、友達が見つからないこと = 友達ではないという仕様が理解しやすくなっています。

より具体的な例

実際に使っているコードからちょっとシンプルにした例をお見せします。お客様が所有するキャラクターの詳細を取得するユースケースを考えてみます。お客様は複数のキャラクターを保持することができ、お客様がキャラクターを保持するごとにキャラクターデータが作成されるという設計になっています。

以下の例では、リクエストを行ったお客様の情報を取得して、キャラクターの情報を取得して返すというシンプルなロジックになっています。(customerID は token などから渡されて引数に渡されています)

func (u *Usecase) GetCharacter(ctx, customerID, characterID string) (*Character, error) {
customer, err := findCustomer(ctx, customerID)
if err != nil {
    if errors.Is(err, ErrNotFound) {
        return fmt.Errorf("the customer is not found by customer_id:`%s`: %w", customerID, err)
    }
    return fmt.Errorf("unexpected error returned when finding a customer: %w", err")
}

character, err := findCharacter(ctx, characterID)
if err != nil {
     if errors.Is(err, ErrNotFound) {
        return fmt.Errorf("the character is not found by character_id:`%s`: %w", characterID, err)
    }
    return fmt.Errorf("unexpected error returned when finding a character: %w", err") 
}

if customer.ID != character.CustomerID {
    return nil, fmt.Errorf("the specified character `%s` doesn't belong to the customer: %w", characterID, ErrCharacterNotFound)
}

return character, nil

実際には、トランザクション処理やバリデーションがあったりなどでもっと複雑な処理となっています

上記の例では、リクエストを行ったお客様IDと取得したキャラクターが保持しているお客様ID が異なっていると、「指定されたキャラクターは、お客様のものではない」という旨を表すエラーになっています。

このようにしておくと、エラーを見た時、本来意図しないはずのリクエストがクライアントが送ってきたのだなと感覚的にわかると思います。それにあとからコードみたときに、なぜ、ID が異なるとエラーになるのか理解しやすいです。

デメリット

  • とはいえ、冗長になりやすいのでエラーメッセージを書くのにコストがかかる
    • これに関しては、最近では、 GitHub Copilot などがいい感じに補完してくれているのでほとんど苦にならないです
  • 新しく作成するエラーメッセージは補完が効かないのでタイポなどがおこりやすい
    • コードレビューでもタイポや文法ミスの指摘が多めなのが課題です...

最後に

これまで紹介したようにエラーメッセージを詳細に書くことは、多少なりとも労力が伴うのかなと思います。しかし、実際に自分やチームメンバーが半年前に書いたうろ覚えなコードの異常に対してもすぐにあたりをつけられるような感覚があります。
自分個人としては、エラーに対する実装に関しては、あまり認知負荷を持ちたくないと考えています。スタックトレースや付加情報などを伝搬するためにツールの使い方を覚えたり、アプリケーション固有のカスタムエラーの実装の把握などを行いたくないです。(エラーに限らず基本的には標準パッケージのみで完結したい)なので、うちのチームでは、エラーメッセージをしっかり書くという方針で行なっています。特に問題なく運用できていると感じています。
とはいえ、これが最善策だと思ってもないので、運用していく上で課題を探りながらもっといい方法を模索していければと思います。

カウシェ Tech Blog

Discussion