Zenn
🔍

Go言語のエラーハンドリング:5つの鉄則 - 実践ガイド

2025/03/23に公開
1

Go言語のエラーハンドリング:5つの鉄則

はじめに

エラー処理はプログラミングにおいて避けて通れない重要な要素です。特にGo言語は、他の多くの言語とは異なるアプローチでエラーハンドリングを行います。Go言語では「エラーは単なる値である」という哲学があり、try-catchやexceptionといった構文はありません。

この記事では、Go言語におけるエラーハンドリングの5つの鉄則を紹介し、実践的なコード例を通じて効果的なエラー処理の方法を解説します。これらの原則を理解し適用することで、より堅牢で保守性の高いGoプログラムを書くことができるようになります。

Go言語のエラー処理の基本

Go言語のエラー処理は、errorインターフェースを中心に設計されています:

type error interface {
    Error() string
}

この非常にシンプルなインターフェースを実装していれば、どんな型もエラーとして扱うことができます。Go言語の関数は、複数の戻り値を返すことができ、慣習的に最後の戻り値でエラーを返します:

func readFile(path string) ([]byte, error) {
    // 実装...
}

data, err := readFile("config.json")
if err != nil {
    // エラー処理
}

それでは、Go言語のエラーハンドリングにおける5つの鉄則を見ていきましょう。

鉄則1:エラーは明示的に検査しましょう

Go言語では、すべてのエラーを明示的に検査することが重要です。エラーを無視することは、潜在的な問題を見逃す原因となります。

悪い例

// エラーを無視している
data, _ := os.ReadFile("config.json")
result := processData(data) // データが正しく読み込めなかった場合、予期せぬ動作になる

良い例

data, err := os.ReadFile("config.json")
if err != nil {
    log.Printf("設定ファイルの読み込みに失敗しました: %v", err)
    return nil, fmt.Errorf("設定の読み込みエラー: %w", err)
}
result := processData(data)

ガイドライン

  1. すべてのエラーを検査する: アンダースコア (_) を使ってエラーを無視することは避けましょう。
  2. エラーが発生したらすぐに処理する: エラーが発生したら、通常は即座に処理またはエラーを返します。
  3. エラーコンテキストを提供する: エラーメッセージには、何が起きたのかを明確に示す情報を含めましょう。

鉄則2:エラーをラップして情報を追加しましょう

Go 1.13以降では、fmt.Errorf%w動詞を使用して、元のエラーをラップし、コンテキスト情報を追加することができます。

悪い例

func processConfig() error {
    data, err := os.ReadFile("config.json")
    if err != nil {
        // 元のエラー情報が失われる
        return errors.New("設定の処理に失敗しました")
    }
    // ...
}

良い例

func processConfig() error {
    data, err := os.ReadFile("config.json")
    if err != nil {
        // 元のエラーを保持しながら、コンテキスト情報を追加
        return fmt.Errorf("設定ファイルの処理中にエラーが発生しました: %w", err)
    }
    // ...
}

// 呼び出し側
if err := processConfig(); err != nil {
    // エラーの種類によって処理を分岐
    if errors.Is(err, os.ErrNotExist) {
        // ファイルが存在しない場合の処理
    } else {
        // その他のエラーの処理
    }
}

ガイドライン

  1. エラーをラップする: fmt.Errorf%wを使用して、元のエラーを保持しながら情報を追加します。
  2. エラーチェーンを作る: 低レベルのエラーから高レベルのコンテキスト情報を持つエラーへとチェーンを形成します。
  3. errors.Is / errors.As を使う: Go 1.13以降では、ラップされたエラーを検査するためにerrors.Iserrors.Asを使用します。

鉄則3:カスタムエラー型を適切に設計しましょう

特定のエラー状況に対して、より多くの情報を含めたい場合や、特定のエラーに対して特別な処理が必要な場合、カスタムエラー型が役立ちます。

基本的なカスタムエラー型

type NotFoundError struct {
    ResourceType string
    ID           string
}

func (e *NotFoundError) Error() string {
    return fmt.Sprintf("%s with ID %s not found", e.ResourceType, e.ID)
}

// 使用例
func GetUser(id string) (*User, error) {
    user, exists := userDB[id]
    if !exists {
        return nil, &NotFoundError{
            ResourceType: "User",
            ID:           id,
        }
    }
    return user, nil
}

振る舞いによるエラーのカテゴリ化

特定の振る舞いを持つエラーインターフェースを定義することもできます:

// 一時的なエラーを表すインターフェース
type temporary interface {
    Temporary() bool
}

// 一時的なエラーの実装例
type temporaryError struct {
    err error
}

func (t *temporaryError) Error() string {
    return t.err.Error()
}

func (t *temporaryError) Temporary() bool {
    return true
}

// 使用例
func processWithRetry(task func() error) error {
    var err error
    for attempts := 0; attempts < 3; attempts++ {
        err = task()
        if err == nil {
            return nil
        }
        
        // エラーが一時的なものか確認
        var tempErr temporary
        if errors.As(err, &tempErr) && tempErr.Temporary() {
            log.Printf("一時的なエラー、リトライします: %v", err)
            time.Sleep(time.Second * time.Duration(attempts+1))
            continue
        }
        
        // 一時的でないエラーはすぐに返す
        return err
    }
    return fmt.Errorf("最大リトライ回数に達しました: %w", err)
}

ガイドライン

  1. 目的に合わせたエラー型を設計する: エラーの種類によって適切な情報を含めたカスタムエラー型を設計します。
  2. インターフェースでエラーの振る舞いを定義する: 特定の振る舞いを持つエラーを分類するために、インターフェースを活用します。
  3. エラーの詳細は必要な場所で公開する: エラーの詳細情報は、それを必要とするコンポーネントにのみ公開するようにします。

鉄則4:エラーはslogで構造化してログに記録しましょう

エラーを適切にログに記録することで、問題の診断とデバッグが容易になります。Go 1.21から標準ライブラリに追加されたslogパッケージを使用すると、構造化ログを簡単に実現できます。

悪い例

func processOrder(orderID string) error {
    order, err := getOrder(orderID)
    if err != nil {
        // エラーのログ記録が不十分
        fmt.Println("エラー:", err)
        return err
    }
    // ...
}

良い例(slogを使用)

import "log/slog"

func processOrder(orderID string) error {
    order, err := getOrder(orderID)
    if err != nil {
        // 構造化ログとコンテキスト情報
        slog.Error("注文の取得に失敗しました",
            "orderID", orderID,
            "error", err)
        return fmt.Errorf("注文 %s の処理中にエラーが発生しました: %w", orderID, err)
    }
    // ...
}

slogの初期設定

import (
    "log/slog"
    "os"
)

func setupLogger() {
    // JSONフォーマットのロガーを設定
    opts := &slog.HandlerOptions{
        Level: slog.LevelDebug,
        // AddSource: true を設定すると、呼び出し元のファイル名と行番号も記録される
        AddSource: true,
    }
    
    // 本番環境用JSONハンドラー
    // handler := slog.NewJSONHandler(os.Stdout, opts)
    
    // 開発環境用テキストハンドラー(読みやすい)
    handler := slog.NewTextHandler(os.Stdout, opts)
    
    // デフォルトロガーとして設定
    slog.SetDefault(slog.New(handler))
    
    slog.Info("ロガーの初期化が完了しました", "environment", os.Getenv("APP_ENV"))
}

複数階層でのslogを使用したログ記録戦略

// アプリケーション層
func HandleCreateUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        slog.Error("リクエストのデコードに失敗しました",
            "error", err,
            "remote_addr", r.RemoteAddr,
            "path", r.URL.Path)
        http.Error(w, "不正なリクエスト形式", http.StatusBadRequest)
        return
    }
    
    // リクエストIDを生成(分散トレーシングのため)
    requestID := generateRequestID()
    
    // コンテキストに情報を追加
    ctx := r.Context()
    logger := slog.With("request_id", requestID)
    
    user, err := service.CreateUser(ctx, req.Name, req.Email)
    if err != nil {
        // サービスからのエラーを適切に処理
        logger.Error("ユーザー作成に失敗",
            "name", req.Name,
            "email", req.Email,
            "error", err)
        
        // エラーの種類に応じたHTTPステータスの決定
        var validationErr *ValidationError
        if errors.As(err, &validationErr) {
            http.Error(w, validationErr.Error(), http.StatusBadRequest)
            return
        }
        
        http.Error(w, "内部サーバーエラー", http.StatusInternalServerError)
        return
    }
    
    // 成功レスポンス
    logger.Info("ユーザーが正常に作成されました", "user_id", user.ID)
    json.NewEncoder(w).Encode(user)
}

エラー情報をグループ化する例

func processTransaction(ctx context.Context, txID string, amount float64) error {
    logger := slog.With("transaction_id", txID)
    
    // 取引情報の取得
    transaction, err := getTransaction(txID)
    if err != nil {
        // 関連情報をエラーグループとしてまとめる
        logger.Error("取引情報の取得に失敗",
            slog.Group("transaction",
                "id", txID,
                "amount", amount,
            ),
            slog.Group("error_details",
                "error", err,
                "timestamp", time.Now().Format(time.RFC3339),
            ))
        return fmt.Errorf("取引情報の取得エラー: %w", err)
    }
    
    // 処理続行...
    return nil
}

ガイドライン

  1. slogを使用して構造化ログを記録する: Go 1.21で導入された標準のlog/slogパッケージを使用して、構造化されたログを記録します。
  2. キーと値のペアでコンテキスト情報を提供する: キーと値のペアでログを構造化し、検索と分析を容易にします。
  3. 適切なログレベルを選択する: エラーの重大度に応じて適切なログレベル(Debug、Info、Warn、Error)を使い分けます。
  4. ユーザー情報やセキュリティ情報を適切に扱う: 機密情報はログに記録しないか、マスクをかけるよう注意します。
  5. リクエストIDやトレースIDを含める: 分散システムでのデバッグを容易にするため、リクエストを識別する一意のIDをログに含めます。
  6. エラー発生場所に近いところでログを記録する: 問題発生箇所を特定しやすくするため、エラーが発生した場所の近くでログを記録します。

鉄則5:パニックとリカバーを適切に使いましょう

Goではパニックとリカバーのメカニズムがありますが、通常のエラー処理には使用しません。特定の状況でのみ、注意して使用します。

パニックとリカバーの基本

func riskyOperation() (result string, err error) {
    // パニックが発生した場合に備えてリカバーする
    defer func() {
        if r := recover(); r != nil {
            log.Printf("パニックから回復: %v", r)
            err = fmt.Errorf("内部エラーが発生しました: %v", r)
        }
    }()
    
    // 何らかの処理
    if somethingWentWrong {
        panic("予期せぬエラー状態")
    }
    
    return "処理結果", nil
}

リソースクリーンアップにdeferを使用

func processFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("ファイルを開けませんでした: %w", err)
    }
    // 関数の最後でファイルを確実に閉じる
    defer file.Close()
    
    // ファイル処理
    // ...
    
    return nil
}

複数のリソースクリーンアップ

func copyFile(src, dst string) error {
    srcFile, err := os.Open(src)
    if err != nil {
        return fmt.Errorf("ソースファイルを開けませんでした: %w", err)
    }
    defer srcFile.Close()
    
    dstFile, err := os.Create(dst)
    if err != nil {
        return fmt.Errorf("宛先ファイルを作成できませんでした: %w", err)
    }
    // エラーが発生してもdstFileは確実にクローズする
    defer func() {
        closeErr := dstFile.Close()
        if closeErr != nil {
            // ここでは元のエラーを上書きしない方が良い場合がある
            log.Printf("宛先ファイルのクローズに失敗: %v", closeErr)
        }
    }()
    
    _, err = io.Copy(dstFile, srcFile)
    if err != nil {
        return fmt.Errorf("ファイルのコピーに失敗しました: %w", err)
    }
    
    return nil
}

ガイドライン

  1. パニックを通常のエラー処理に使わない: パニックは回復不可能な状況や予期せぬエラーのためのものです。
  2. パニックからのリカバーは適切な場所で行う: サーバーのリクエストハンドラなど、パニックの影響範囲を限定できる場所でリカバーを行います。
  3. deferを使ってリソースを確実に解放する: ファイルハンドルやデータベース接続などのリソースは、deferを使って確実に解放します。
  4. エラー処理を明示的に行う: パニック/リカバーに頼らず、エラーは値として明示的に処理するのがGoの流儀です。

実践的なエラーハンドリングパターン

エラーハンドリングの一貫性を保つ

特に大規模なプロジェクトでは、エラーハンドリングの一貫性が重要です:

// アプリケーション全体で使用できるエラーヘルパー
func wrapError(op string, err error) error {
    return fmt.Errorf("%s: %w", op, err)
}

// 使用例
func UpdateUser(id string, userData UserData) error {
    user, err := repository.FindUser(id)
    if err != nil {
        return wrapError("UpdateUser.FindUser", err)
    }
    
    user.Name = userData.Name
    err = repository.SaveUser(user)
    if err != nil {
        return wrapError("UpdateUser.SaveUser", err)
    }
    
    return nil
}

センチネルエラーの使用

特定のエラー条件を表す定数エラー値(センチネルエラー)を使用することで、エラー条件を明確に示すことができます:

var (
    ErrNotFound = errors.New("リソースが見つかりません")
    ErrInvalidInput = errors.New("入力が無効です")
    ErrUnauthorized = errors.New("権限がありません")
)

func GetItem(id string) (*Item, error) {
    if id == "" {
        return nil, ErrInvalidInput
    }
    
    item, exists := items[id]
    if !exists {
        return nil, ErrNotFound
    }
    
    return item, nil
}

// 呼び出し側
item, err := GetItem(id)
if err != nil {
    switch {
    case errors.Is(err, ErrNotFound):
        // 見つからない場合の処理
    case errors.Is(err, ErrInvalidInput):
        // 入力エラーの処理
    default:
        // その他のエラー
    }
}

エラーハンドリングのユニットテスト

エラー処理も重要なロジックの一部として、テストすべきです:

func TestGetItem_NotFound(t *testing.T) {
    // 存在しないアイテムを取得
    _, err := GetItem("non-existent-id")
    
    // エラーがnilでないことを確認
    if err == nil {
        t.Errorf("エラーが期待されていましたが、nilが返されました")
    }
    
    // エラータイプを検証
    if !errors.Is(err, ErrNotFound) {
        t.Errorf("ErrNotFoundエラーが期待されていましたが、%vが返されました", err)
    }
}

func TestGetItem_EmptyID(t *testing.T) {
    // 空のIDでテスト
    _, err := GetItem("")
    
    if !errors.Is(err, ErrInvalidInput) {
        t.Errorf("ErrInvalidInputエラーが期待されていましたが、%vが返されました", err)
    }
}

まとめ

Go言語のエラーハンドリングにおける5つの鉄則を理解し適用することで、より堅牢で保守しやすいコードを書くことができます:

  1. エラーは明示的に検査しましょう:すべてのエラーを意識的に処理し、無視しないようにします。
  2. エラーをラップして情報を追加しましょう:元のエラーを保持しながら、コンテキスト情報を追加します。
  3. カスタムエラー型を適切に設計しましょう:特定のエラー状況に対して、より多くの情報や特別な振る舞いを提供します。
  4. エラーは適切にログに記録しましょう:問題診断に役立つ情報とともにエラーをログに残します。
  5. パニックとリカバーを適切に使いましょう:通常のエラー処理はエラー値で行い、パニック/リカバーは特別な状況に限定します。

Go言語の「エラーは単なる値である」という哲学は、明示的でシンプルなエラー処理を促します。これにより、エラー条件が明確になり、より信頼性の高いプログラムを作成することができます。

これらの原則を日々の開発作業に取り入れることで、より明確で堅牢、そして保守しやすいGoプログラムを書くことができるでしょう。

1

Discussion

ログインするとコメントできます