Golang 入門

Go エラーハンドリングのベストプラクティス

Go言語がマルチリターン(多重戻り値)を用いた明示的なエラーチェックを要求するスタイルは、初心者にはやや冗長に映るかもしれません。しかし、この設計こそが開発者にエラーと直面することを強制し、例外(Exceptions)に依存する言語のようにエラーを隠蔽したり、未知のコールスタックへ丸投げしたりすることを防いでいるのです。

本章では、これまでに学んできたすべてのエラーハンドリングメカニズムを総括し、さらなる高みへと昇華させます。実際のエンジニアリングにおいて、これらの技術(if err != nil、カスタムエラー、エラーラッピング、センチネルエラー、そして panic/recover)をどのように運用すれば、コードの明瞭性、メンテナンス性、およびシステム全体のレジリエンス(弾力性)を向上させることができるのかを考察します。

1. コアとなる土台:error 型と if err != nil

改めて強調しますが、Go言語における errorError() string メソッドのみを持つ組み込みインターフェースです。失敗する可能性のあるすべての関数は、error を最後の戻り値として返すべきです。

1.1 エラーを無視してはならない

if err != nil パターンは Go 言語のエラーハンドリングの魂です。エラーが発生した源泉で即座にインターセプト(遮断)し、処理することを要求します。

package main

import (
	"fmt"
	"os"
)

func createFile(filename string) (*os.File, error) {
	file, err := os.Create(filename)
	if err != nil {
		// ベストプラクティス:%w を使用して下位エラーをラッピングし、コンテキストを付加する
		return nil, fmt.Errorf("ファイル作成に失敗しました [%s]: %w", filename, err)
	}
	return file, nil
}

func main() {
	file, err := createFile("/root/secret.txt") // 権限のないディレクトリでの作成を試行
	if err != nil {
		// エラー処理は必須!発生しなかったかのように振る舞ってはいけない
		fmt.Println("致命的なエラーが発生しました:", err)
		return
	}
		
	// ベストプラクティス:リソース取得成功後、即座に defer で解放を確実にする
	defer file.Close() 
	fmt.Println("ファイルの作成に成功しました。")
}

2. コンテキストの拡充:カスタムエラー型

errors.New が提供する純粋なテキスト情報だけでは、複雑なビジネスロジック(リトライメカニズムや詳細なエラーレポートなど)を支えきれない場合、カスタムエラー型を定義する必要があります。

package main

import (
	"fmt"
	"time"
)

// 豊富なビジネスコンテキストを保持するカスタムエラーを定義
type InsufficientFundsError struct {
	AccountID           string
	AttemptedWithdrawal float64
	Balance             float64
	Timestamp           time.Time
}

// error インターフェースの実装
func (e *InsufficientFundsError) Error() string {
	return fmt.Sprintf("アカウント %s の残高が不足しています。引き出し試行額: %.2f, 現在の残高: %.2f, 発生時刻: %s",
		e.AccountID, e.AttemptedWithdrawal, e.Balance, e.Timestamp.Format(time.RFC3339))
}

// ビジネス関数
func withdraw(accountID string, amount, balance float64) (float64, error) {
	if amount > balance {
		return balance, &InsufficientFundsError{
			AccountID:           accountID,
			AttemptedWithdrawal: amount,
			Balance:             balance,
			Timestamp:           time.Now(),
		}
	}
	return balance - amount, nil
}

func main() {
	_, err := withdraw("USER-888", 1000, 500)
	if err != nil {
		// 🌟 ベストプラクティス:型アサーション(または errors.As)でカスタムエラーの詳細を抽出する
		if fundsErr, ok := err.(*InsufficientFundsError); ok {
			fmt.Println("--- 取引拒否の詳細 ---")
			fmt.Printf("アカウント: %s\n不足額: %.2f\n", fundsErr.AccountID, fundsErr.AttemptedWithdrawal-fundsErr.Balance)
		} else {
			fmt.Println("未知のエラー:", err)
		}
	}
}

3. エラーラッピングと追跡:fmt.Errorf と errors パッケージ

モダンな Go 開発(バージョン 1.13 以降)において、エラーラッピング (Error Wrapping) は極めて重要なプラクティスです。エラーが関数呼び出しスタックをさかのぼる際、各レイヤーが独自のコンテキストを付加しつつ、最下層のオリジナルなエラー型を破壊せずに保持できます。

  • エラーのラッピング: fmt.Errorf("... %w", err) を使用します。
  • エラーチェーンの判定: errors.Is(err, targetErr) を使い、エラーチェーン全体に特定のエラーが含まれているか確認します。
  • エラーチェーンの抽出: errors.As(err, &targetPtr) を使い、エラーチェーンを貫通して特定のカスタムエラー型を抽出します。

4. グローバルな指標:センチネルエラー

センチネルエラーとは、パッケージレベルであらかじめ定義された、グローバルに一意なエラー変数のことです。これらは通常、特定の、あるいは予測可能な終端状態を表します。最も有名な例は標準ライブラリの io.EOF(ファイルの終端)です。

package main

import (
	"errors"
	"fmt"
)

// ベストプラクティス:センチネルエラーは通常 Err で始める
var ErrNotFound = errors.New("リソースが見つかりません")

func fetchResource(id string) (string, error) {
	if id != "123" {
		return "", ErrNotFound // センチネルエラーを返す
	}
	return "貴重なデータ", nil
}

func main() {
	data, err := fetchResource("456")
	if err != nil {
		// ベストプラクティス:センチネルエラーの判定には常に errors.Is を使用し、直接比較 (==) は避ける
		if errors.Is(err, ErrNotFound) { 
			fmt.Println("処理ロジック:ユーザーに 404 ページを表示します。")
		} else {
			fmt.Println("処理ロジック:サーバー内部エラーログを記録します。")
		}
		return
	}
	fmt.Println("データ:", data)
}

5. 最後の手段:panic と recover の慎重な使用

panic は、プログラムが「不可能かつ回復不能な」災厄状態に陥ったことを示すために使用されます。

ベストプラクティス・ガイドライン:

  • ビジネスロジックで Panic を使わない: ネットワークタイムアウト、ユーザーの入力ミス、データベースのレコード不在などはすべて予測されるエラーであり、必ず error を返すべきです。
  • 初期化失敗時は Panic を許容する: プログラム起動時に必須の設定ファイルが見つからない、あるいは基幹データベースに接続できない場合、即座に panic (Fail-Fast) させるのが最善の選択です。
  • パッケージ境界を越えて Panic させない: 他者が利用するパッケージ(ライブラリ)を開発する場合、外部へ panic を投げ出すべきではありません。内部で panic が発生した場合は、パッケージの境界で recover し、通常の error に変換して呼び出し元へ返すべきです。
  • Web フレームワークの最終防衛線: 常駐型サービス(HTTP サーバーなど)では、個別のリクエストによるバグでサーバープロセス全体がクラッシュするのを防ぐため、最上位のミドルウェアで defer recover() を使用する必要があります。

6. Go エラーハンドリング 6 大黄金律 (まとめ)

尊敬される Go コードを書くために、以下の指針を心に刻んでください。

  1. すべてのエラーをチェックする: 偶然に期待せず、_ を使って戻り値の error を無視してはいけません。
  2. 早期リターン (Early Return): エラーに遭遇したら直ちに return err し、コードをフラットに保ち、ネスト地獄を回避します。
  3. コンテキストを付加する: 上位レイヤーへエラーを渡す際は、fmt.Errorf("%w") でラッピングし、現在の関数が「何をしようとして失敗したのか」を伝えます。
  4. 一度処理したエラーは返さない: 現在の関数でエラーをログに記録したり、代替処理(フォールバック)を行ったのであれば、そのエラーを再び上位へ return してはいけません。一つのエラーの処理は一度きりです。
  5. エラーのカテゴリーを区別する: 単純な状態判定にはセンチネルエラーを、動的なデータが必要な複雑なエラーにはカスタム構造体を使用します。
  6. Panic の使用を抑制する: プログラムが物理的に動作を継続できない極限状態においてのみ、この「核のボタン」を押してください。