Go言語のエラーハンドリング:5つの鉄則 - 実践ガイド
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)
ガイドライン
-
すべてのエラーを検査する: アンダースコア (
_
) を使ってエラーを無視することは避けましょう。 - エラーが発生したらすぐに処理する: エラーが発生したら、通常は即座に処理またはエラーを返します。
- エラーコンテキストを提供する: エラーメッセージには、何が起きたのかを明確に示す情報を含めましょう。
鉄則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 {
// その他のエラーの処理
}
}
ガイドライン
-
エラーをラップする:
fmt.Errorf
と%w
を使用して、元のエラーを保持しながら情報を追加します。 - エラーチェーンを作る: 低レベルのエラーから高レベルのコンテキスト情報を持つエラーへとチェーンを形成します。
-
errors.Is / errors.As を使う: Go 1.13以降では、ラップされたエラーを検査するために
errors.Is
とerrors.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)
}
ガイドライン
- 目的に合わせたエラー型を設計する: エラーの種類によって適切な情報を含めたカスタムエラー型を設計します。
- インターフェースでエラーの振る舞いを定義する: 特定の振る舞いを持つエラーを分類するために、インターフェースを活用します。
- エラーの詳細は必要な場所で公開する: エラーの詳細情報は、それを必要とするコンポーネントにのみ公開するようにします。
鉄則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
}
ガイドライン
-
slogを使用して構造化ログを記録する: Go 1.21で導入された標準の
log/slog
パッケージを使用して、構造化されたログを記録します。 - キーと値のペアでコンテキスト情報を提供する: キーと値のペアでログを構造化し、検索と分析を容易にします。
- 適切なログレベルを選択する: エラーの重大度に応じて適切なログレベル(Debug、Info、Warn、Error)を使い分けます。
- ユーザー情報やセキュリティ情報を適切に扱う: 機密情報はログに記録しないか、マスクをかけるよう注意します。
- リクエストIDやトレースIDを含める: 分散システムでのデバッグを容易にするため、リクエストを識別する一意のIDをログに含めます。
- エラー発生場所に近いところでログを記録する: 問題発生箇所を特定しやすくするため、エラーが発生した場所の近くでログを記録します。
鉄則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
}
ガイドライン
- パニックを通常のエラー処理に使わない: パニックは回復不可能な状況や予期せぬエラーのためのものです。
- パニックからのリカバーは適切な場所で行う: サーバーのリクエストハンドラなど、パニックの影響範囲を限定できる場所でリカバーを行います。
- deferを使ってリソースを確実に解放する: ファイルハンドルやデータベース接続などのリソースは、deferを使って確実に解放します。
- エラー処理を明示的に行う: パニック/リカバーに頼らず、エラーは値として明示的に処理するのが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つの鉄則を理解し適用することで、より堅牢で保守しやすいコードを書くことができます:
- エラーは明示的に検査しましょう:すべてのエラーを意識的に処理し、無視しないようにします。
- エラーをラップして情報を追加しましょう:元のエラーを保持しながら、コンテキスト情報を追加します。
- カスタムエラー型を適切に設計しましょう:特定のエラー状況に対して、より多くの情報や特別な振る舞いを提供します。
- エラーは適切にログに記録しましょう:問題診断に役立つ情報とともにエラーをログに残します。
- パニックとリカバーを適切に使いましょう:通常のエラー処理はエラー値で行い、パニック/リカバーは特別な状況に限定します。
Go言語の「エラーは単なる値である」という哲学は、明示的でシンプルなエラー処理を促します。これにより、エラー条件が明確になり、より信頼性の高いプログラムを作成することができます。
これらの原則を日々の開発作業に取り入れることで、より明確で堅牢、そして保守しやすいGoプログラムを書くことができるでしょう。
Discussion