Goのエラーハンドリングを極める:errors.Is、errors.As、およびカスタムラッパーのガイド

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Goにおけるエラーハンドリングの進化

Gopherとしてのキャリアの初期、私はGoのエラー処理の方法に不満を感じることがよくありました。try-catchブロックを持つ言語から来た私にとって、繰り返される if err != nil は不格好に感じられたのです。しかし、より大規模なシステムを構築するにつれ、Goの明示的なエラーハンドリングこそが最大の強みであると気づきました。それは、あらゆるステップで失敗について考えることを強制してくれるからです。

Go 1.13以前は、単純なセンチネルエラー(io.EOF など)やエラー文字列のチェックに限定されていました。そのため、元のエラーの同一性を失わずにエラーにコンテキストを追加することが困難でした。エラーを新しい文字列でラップしてしまうと、煩雑な文字列パースを行わない限り、それが元々「Not Found」エラーであったかどうかを確認できなくなっていたのです。

アプローチの比較:従来の手法 vs モダンなラッピング

なぜ errors.Iserrors.As を使うのかを理解するために、従来の手法と現在の標準的な手法を比較してみましょう。

伝統的な手法(Go 1.13以前)

var ErrNotFound = errors.New("見つかりません")

func findUser(id string) error {
    return fmt.Errorf("ユーザー %s: %v", id, ErrNotFound)
}

// エラーのチェック
err := findUser("123")
if err == ErrNotFound { // これは失敗します!エラーは別の文字列になっています。
    // 見つからない場合の処理
}

モダンな手法(Go 1.13以降)

func findUser(id string) error {
    return fmt.Errorf("ユーザー %s: %w", id, ErrNotFound) // %w に注目
}

// エラーのチェック
err := findUser("123")
if errors.Is(err, ErrNotFound) { // これは成功します!エラーチェーンをアンラップします。
    // 見つからない場合の処理
}

手動 vs ラップされたエラーのメリットとデメリット

適切な戦略の選択は、アプリケーションの複雑さに依存します。以下にトレードオフのまとめを示します。

単純な文字列エラー

  • メリット: 素早く記述でき、メモリオーバーヘッドが少ない。
  • デメリット: 本番環境でのデバッグが困難。文字列マッチングなしではプログラム的に分類することが不可能。

ラップされたエラー (%w)

  • メリット: 元のエラー(「原因」)を保持できる。IDや操作名などのコンテキストを追加でき、errors.Is と完璧に連携する。
  • デメリット: やや冗長になる。注意しないと内部の実装詳細が露出する可能性がある。

カスタムエラー構造体

  • メリット: 構造化されたデータ(HTTPコード、マシン読み取り可能なエラーコードなど)を保持でき、非常に柔軟。
  • デメリット: ボイラープレートコードが増える。データを取り出すために errors.As が必要。

プロダクションアプリ向けの推奨構成

実務経験から言えば、スクリプト作成から堅牢なマイクロサービスの構築へとステップアップするために、これはマスターすべき必須スキルの1つです。エラーハンドリングには以下の3層アプローチを推奨します。

  1. センチネルエラーを使用する:一般的で想定内の状態異常(例:ErrDataNotFoundErrUnauthorized)に使用します。
  2. 常にエラーをラップする:アプリケーションの各レイヤーで fmt.Errorf("...: %w", err) を使用します。これにより、スタックトレースのような効果が得られます。
  3. カスタムラッパーを使用する:アプリケーションの境界(APIハンドラーやCLIのエントリポイントなど)で、内部エラーをユーザーフレンドリーなレスポンスに変換します。

実装ガイド:ツールを使いこなす

1. errors.Is によるエラーの特定

エラーが特定のセンチネル値と一致するかどうかを確認したい場合は、errors.Is を使用します。これはエラーチェーンを再帰的にアンラップし、履歴内のいずれかのエラーがターゲットと一致するかを調べます。

var ErrDatabaseDown = errors.New("DB接続が失われました")

func queryRow() error {
    return fmt.Errorf("リポジトリ層: %w", ErrDatabaseDown)
}

func serviceLayer() error {
    return fmt.Errorf("サービス層: %w", queryRow())
}

func main() {
    err := serviceLayer()
    if errors.Is(err, ErrDatabaseDown) {
        fmt.Println("接続を再試行する必要があります!")
    }
}

2. errors.As によるデータの抽出

単なる「はい/いいえ」のチェック以上の情報が必要な場合があります。カスタムエラー構造体の中の特定のフィールドにアクセスする必要がある場合、errors.As が威力を発揮します。

type APIError struct {
    StatusCode int
    Message    string
}

func (e *APIError) Error() string {
    return fmt.Sprintf("ステータス %d: %s", e.StatusCode, e.Message)
}

func callExternalAPI() error {
    return &APIError{StatusCode: 404, Message: "リモートシステムにユーザーが見つかりません"}
}

func main() {
    err := callExternalAPI()
    
    var apiErr *APIError
    if errors.As(err, &apiErr) {
        fmt.Printf("特定のステータスをログに記録: %d\n", apiErr.StatusCode)
    }
}

3. プロフェッショナルなカスタムエラーラッパーの構築

プロダクション級のアプリケーションでは、すべてのエラーに「リクエストID」や「ドメインコード」などのメタデータを付加したいことがよくあります。以下は、Goの標準ライブラリとの互換性を維持しつつ、堅牢なラッパーを作成するためのパターンです。

package main

import (
    "errors"
    "fmt"
)

// AppError はカスタムラッパーです
type AppError struct {
    Code    string
    Message string
    Err     error // 元のエラー
}

func (e *AppError) Error() string {
    if e.Err != nil {
        return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Err)
    }
    return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}

// Unwrap により errors.Is と errors.As が動作するようになります
func (e *AppError) Unwrap() error {
    return e.Err
}

func NewAppError(code, message string, err error) error {
    return &AppError{
        Code:    code,
        Message: message,
        Err:     err,
    }
}

func main() {
    // データベースエラーをシミュレート
    dbErr := errors.New("sql: 接続タイムアウト")
    
    // ドメインのコンテキストでラップする
    wrapped := NewAppError("DB_TIMEOUT", "ユーザープロフィールの取得に失敗しました", dbErr)
    
    // 後ほどトランスポート層(HTTP/gRPCなど)で
    fmt.Println(wrapped.Error())
    
    // 元の原因をチェックできます
    if errors.Is(wrapped, dbErr) {
        fmt.Println("これは間違いなくデータベースのタイムアウトです。")
    }
}

エラー戦略に関する最終的な考察

優れたエラーハンドリングとは、単にコードをコンパイルさせることではなく、システムを観測可能(observable)にすることです。%w でエラーをラップすると、本番環境での問題解決を大幅に容易にする「パンくずリスト」のような手がかりが作成されます。ログに漠然とした「unexpected EOF」と表示される代わりに、「[USER_SERVICE] failed to fetch avatar: [S3_CLIENT] connection lost: unexpected EOF」といった詳細な情報が表示されるようになります。

まずは文字列比較を errors.Is に置き換えることから始め、アプリケーションの成長に合わせて徐々にカスタムエラー構造体を導入していきましょう。最初の本番障害が発生したとき、未来の自分(そしてチームメイト)はあなたに感謝することでしょう。

Share: