Golang 入門

Go カスタムエラー型

組み込みの error インターフェースや errors.New() はシンプルなシナリオには十分ですが、複雑なビジネスシステムでは、エラーに関するより詳細なコンテキスト情報が必要になることが多々あります。「どの商品が在庫切れなのか?」「不足分はいくつなのか?」「外部 API 呼び出しに失敗した際のステータスコードは何だったのか?」「このエラーはリトライ可能なのか?」といった情報です。

カスタムエラー型 (Custom Error Types) を作成することで、エラーにフィールド、メソッド、振る舞いを付加できるようになります。これにより、エラーが読みやすくなるだけでなく、コードによる識別、分類、そして自動化された処理が容易になります。本章では、この高度なテクニックを網羅的に解説します。

1. カスタムエラー型の定義

Go において、error は単なるインターフェースであり、唯一の要件は Error() string メソッドを実装することです。したがって、どんな構造体 (Struct) であっても、このメソッドを実装すれば、即座に正当なエラー型となります。

1.1 基本的なカスタムエラーの作成

Eコマースシステムを開発していると仮定しましょう。商品が在庫切れであることを示すエラーが必要で、これにはエラーメッセージだけでなく、商品 ID とユーザーが要求した購入数量を記録する必要があります。

package main

import (
	"fmt"
)

// 1. 必要なコンテキスト情報を含む構造体を定義
type OutOfStockError struct {
	ProductID string
	Quantity  int
}

// 2. この構造体に Error() string メソッドを実装 (通常、ポインタレシーバを使用)
func (e *OutOfStockError) Error() string {
	return fmt.Sprintf("購入失敗:商品 '%s' の在庫が不足しています。要求数量: %d", e.ProductID, e.Quantity)
}

func main() {
	// カスタムエラーをインスタンス化し、通常の error として出力
	err := &OutOfStockError{ProductID: "IPHONE-15", Quantity: 100}
	fmt.Println(err) 
	// 出力: 購入失敗:商品 'IPHONE-15' の在庫が不足しています。要求数量: 100
}

1.2 カスタムエラーへの専用メソッドの追加

カスタムエラーの最大の強みは、それが構造体であるため、任意の数のメソッドをバインドできる点にあります。これは、マイクロサービスアーキテクチャにおいて、エラーがリトライ可能かどうかを判断する際に極めて有用です。

package main

import "fmt"

// APIError は外部インターフェースの呼び出し時に発生したエラーを表す
type APIError struct {
	Endpoint   string
	StatusCode int
	Message    string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("API リクエスト失敗: エンドポイント=%s, ステータスコード=%d, メッセージ=%s", e.Endpoint, e.StatusCode, e.Message)
}

// IsRetryable 専用メソッド:このエラーがリトライに値するかどうかを判断
func (e *APIError) IsRetryable() bool {
	// 例:5xx サーバーエラー、または明確なレート制限エラーの場合はリトライ可能とする
	return e.StatusCode >= 500 || e.Message == "Rate limit exceeded"
}

func main() {
	err1 := &APIError{Endpoint: "/api/pay", StatusCode: 503, Message: "サービス利用不可"}
	fmt.Println(err1)
	fmt.Println("リトライ可能か:", err1.IsRetryable()) // 出力: true 

	err2 := &APIError{Endpoint: "/api/pay", StatusCode: 400, Message: "パラメータ不正"}
	fmt.Println("リトライ可能か:", err2.IsRetryable()) // 出力: false
}

2. カスタムエラーの詳細抽出:型アサーションとerrors.As

下位レイヤーの関数から error インターフェースが返されたとき、コンパイラはそれが単なるエラーであることしか知りません。それが定義した OutOfStockError であるかどうかは不明です。内部の特有な ProductID フィールドなどを読み取るには、元の型を復元する必要があります。

2.1 errors.As (公式推奨のベストプラクティス)

Go 1.13 で errors.As 関数が導入されました。これはエラーの型をチェックするだけでなく、エラーラッピングチェーンを貫通して一致する型を探し、ターゲット変数に直接値を代入してくれます。これが現在、最も安全でエレガントな手法です。

package main

import (
	"errors"
	"fmt"
)

type InsufficientFundsError struct {
	AccountID string
	Balance   float64
	Attempted float64
}

func (e *InsufficientFundsError) Error() string {
	return fmt.Sprintf("アカウント '%s' の残高不足: 残高=%.2f, 引き出し試行額=%.2f", e.AccountID, e.Balance, e.Attempted)
}

// 払い戻し・引き出しをシミュレートする関数
func Withdraw(accountID string, balance, amount float64) error {
	if balance < amount {
		return &InsufficientFundsError{AccountID: accountID, Balance: balance, Attempted: amount}
	}
	return nil
}

func main() {
	err := Withdraw("USER-123", 50.0, 100.0) 
	if err != nil {
		// 1. 期待するカスタムエラー型のポインタ変数を宣言
		var fundsErr *InsufficientFundsError
				
		// 2. errors.As を使用して err を fundsErr に変換することを試みる
		// 注意:第2引数はポインタのポインタである必要があります (fundsErr 自体がポインタであるため)
		if errors.As(err, &fundsErr) {
			// 変換成功!fundsErr 特有のフィールドを安全に使用できます
			fmt.Printf("⚠️ 引き出し拒否!アカウント %s の不足額は %.2f 円です\n", 
				fundsErr.AccountID, fundsErr.Attempted - fundsErr.Balance)
		} else {
			// 変換失敗。残高不足エラーではなく、別の未知のエラーです
			fmt.Println("システムエラーが発生しました:", err)
		}
	}
}

(注:従来の型アサーション if fundsErr, ok := err.(*InsufficientFundsError); ok も使用可能ですが、fmt.Errorf("%w") でラッピングされたエラーを処理できないため、モダンな Go 開発では errors.As を優先的に使用してください。)

3. エラーラッピングとアンラップ

前章で述べた通り、関数の上位レイヤーに位置し、下位レイヤーから上がってきたエラーにコンテキスト(例えば、現在処理中のファイル名など)を追加したいが、下位エラーの元の型構造を壊したくない場合には、エラーラッピングを使用します。

package main

import (
	"errors"
	"fmt"
)

// 下位レイヤーのカスタムエラー
type FileNotFoundError struct {
	Filename string
}

func (e *FileNotFoundError) Error() string {
	return fmt.Sprintf("ボトムエラー: ファイル [%s] が完全に見つかりません", e.Filename)
}

func ReadConfig(filename string) error {
	// 下位レイヤーのカスタムエラーの送出をシミュレート
	return &FileNotFoundError{Filename: filename}
}

func ProcessApp(filename string) error {
	err := ReadConfig(filename)
	if err != nil {
		// %w を使用してエラーをラッピングし、現在のレイヤーのコンテキストを追加
		return fmt.Errorf("ProcessApp 初期化失敗: %w", err)
	}
	return nil
}

func main() {
	err := ProcessApp("db_config.yaml") 
	if err != nil {
		fmt.Println("完全なエラーチェーンを出力:", err)
		// 出力: ProcessApp 初期化失敗: ボトムエラー: ファイル [db_config.yaml] が完全に見つかりません 

		// 非常に強力:err は fmt.Errorf でラップされているが
		// errors.As はラッピングを貫通し、最深部の FileNotFoundError を正確に抽出できる!
		var notFoundErr *FileNotFoundError
		if errors.As(err, ¬FoundErr) {
			fmt.Println("✅ 根本原因を抽出成功。見つからないファイルは:", notFoundErr.Filename)
		}
	}
}

4. センチネルエラー (Sentinel Errors) vs カスタムエラー型

Go におけるエラー定義には、通常二つの流派があります。

センチネルエラー (Sentinel Errors):

  • 定義方法: var ErrNotFound = errors.New("not found")
  • 特徴: シンプルで、グローバルに一意。呼び出し側は errors.Is(err, ErrNotFound) を使用して等値比較を行う。
  • ユースケース: エラー状態が固定されており、動的なパラメータやコンテキストデータを保持する必要がない場合(例:標準ライブラリの io.EOF)。

カスタムエラー型 (Custom Error Types):

  • 定义方法: 構造体 (struct) を作成し、Error() を実装する。
  • 特徴: 構造化されており、大量の動的データを保持可能。メソッドのバインドが可能。呼び出し側は errors.As を使用してデータを抽出する。
  • ユースケース: エラーが発生した具体的なパラメータ(ユーザー ID、リクエスト URL など)を記録する必要がある場合や、エラー型に基づいて異なるロジックを実行する必要がある複雑なビジネスシナリオ。

核心的なアドバイス: 堅牢なエンタープライズアプリケーションを構築する際は、可能な限りカスタムエラー型を多用するようにしてください。