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

Go のエラーハンドリングはシンプルだが、適切に行わないとコードが読みにくくなったり、デバッグが困難になったりする。ここでは実践的なベストプラクティスを紹介する。

エラーは必ずチェックする

Go では未使用の変数があるとコンパイルエラーになるが、エラーを _ で無視すると警告が出ない。

// 悪い例:エラーを無視している
data, _ := ioutil.ReadFile("config.json")

// 良い例:エラーをチェックしている
data, err := ioutil.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("failed to read config: %w", err)
}

エラーを無視すると、問題が発生したとき原因の特定が非常に困難になる。無視してよいケースは極めて稀だ。

エラーメッセージにコンテキストを追加する

エラーをそのまま返すのではなく、どこで何をしていたかを追加すると、デバッグが容易になる。

コンテキストなし

“file not found” だけでは、どのファイルで何をしていたかわからない。

コンテキストあり

“failed to load user config /etc/app/config.json: file not found” なら状況が明確。

func loadConfig(path string) (*Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read config file %s: %w", path, err)
    }
    
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
    }
    
    return &cfg, nil
}

エラーチェックは早期リターンで

条件分岐を深くネストさせず、エラーがあれば即座にリターンする。

// 悪い例:ネストが深い
func process(id int) error {
    user, err := getUser(id)
    if err == nil {
        orders, err := getOrders(user.ID)
        if err == nil {
            // 処理...
        } else {
            return err
        }
    } else {
        return err
    }
    return nil
}

// 良い例:早期リターン
func process(id int) error {
    user, err := getUser(id)
    if err != nil {
        return fmt.Errorf("get user: %w", err)
    }
    
    orders, err := getOrders(user.ID)
    if err != nil {
        return fmt.Errorf("get orders: %w", err)
    }
    
    // 処理...
    return nil
}

早期リターンにより、正常系のコードがネストなしで読める。

センチネルエラーの定義

パッケージ外から判定したいエラーは、パッケージレベルで定義する。

package user

var (
    ErrNotFound      = errors.New("user not found")
    ErrAlreadyExists = errors.New("user already exists")
    ErrInvalidInput  = errors.New("invalid input")
)

呼び出し側は errors.Is で判定できる。

user, err := user.Find(id)
if errors.Is(err, user.ErrNotFound) {
    // 404 を返す
}

エラーをログに記録するタイミング

エラーを処理(ログ記録やユーザーへの通知)するのは、エラーチェーンの最上位で行う。

下位層(リポジトリ、サービス)

エラーをラップして返す。ログには記録しない。

上位層(ハンドラ、main)

エラーをログに記録し、適切なレスポンスを返す。

下位層でもログを出すと、同じエラーが複数回ログに出力されてしまう。

// ハンドラ層でログとレスポンス
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
    user, err := h.service.FindUser(id)
    if err != nil {
        log.Printf("failed to get user %d: %v", id, err)
        
        if errors.Is(err, ErrNotFound) {
            http.Error(w, "User not found", 404)
        } else {
            http.Error(w, "Internal error", 500)
        }
        return
    }
    // ...
}

defer でのエラー処理

defer 内でエラーが発生した場合、名前付き戻り値を使って返すことができる。

func writeFile(path string, data []byte) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()
    
    _, err = f.Write(data)
    return err
}

Close のエラーを握りつぶさず、かつ Write のエラーを上書きしないようにしている。エラーハンドリングの基本は「エラーを見逃さない」ことだ。適切にコンテキストを追加し、一貫したパターンで処理することで、保守しやすいコードになる。