ラップエラーの諸刃の剣:明らかにする以上のものを隠してしまうとき
Ethan Miller
Product Engineer · Leapcell

現代のソフトウェア開発において、堅牢なエラー処理は最重要です。シンプルさと明瞭さという独自の哲学を持つGoは、バージョン1.13でエラーラッピングを導入しました。この機能により、エラーの発生源に関するより多くのコンテキスト情報を提供する能力が大幅に向上しました。あるエラーが別エラーをラップすることを許可することで、開発者は障害の追跡可能なチェーンを構築でき、デバッグが大幅に容易になります。しかし、あらゆる強力なツールと同様に、エラーラッピングは、誤用または誤解された場合、皮肉にも混乱と複雑さの源となり得ます。これは「諸刃の剣」とも、より直接的には「不適切なラッピングとアンラッピング」とも呼べる現象です。
ラッピングの約束:「fmt.Errorf」と「errors.Is」/「errors.As」
落とし穴に飛び込む前に、コアメカニズムを簡単に復習しましょう。Goのエラーラッピングは、%w verb を持つ fmt.Errorf 関数と、検査のための errors.Is および errors.As 関数を利用します。
簡単なシナリオを考えてみましょう。readConfig 関数は設定ファイルを読み取る必要があります。ファイルが存在しない場合、標準の os.ErrNotExist エラーをラップする可能性があります。
package main import ( "errors" "fmt" "os" ) // ErrConfigRead は設定読み取り中の一般的なエラーを示します。 var ErrConfigRead = errors.New("failed to read configuration") func readConfig(filename string) ([]byte, error) { data, err := os.ReadFile(filename) if err != nil { // ここで、より多くのコンテキストで基盤となるエラーをラップします。 // os.ErrNotExist は、特定の認識可能なエラーであるためラップされます。 if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("%w: config file '%s' not found", err, filename) } // 他のエラーについては、ラップするがメッセージを一般化するかもしれません。 return nil, fmt.Errorf("%w: Failed to read config file '%s'", err, filename) } return data, nil } func main() { _, err := readConfig("non_existent_config.json") if err != nil { fmt.Println("Error:", err) if errors.Is(err, os.ErrNotExist) { fmt.Println("\t--> It's a 'file not found' error!") } var pathErr *os.PathError if errors.As(err, &pathErr) { fmt.Printf("\t--> It's an os.PathError! Op: %s, Path: %s\n", pathErr.Op, pathErr.Path) } // 内蔵エラーのアンラップ例(アプリケーションロジックで直接必要になることはまれ) unwrappedErr := errors.Unwrap(err) fmt.Println("\t--> Unwrapped error:", unwrappedErr) } fmt.Println("\n--- Simulating another error ---") // os.ErrNotExist ではないエラーをシミュレート、例:パーミッションエラー // (注:os.ReadFile はすべてのケースでパーミッション拒否に対して特定のエラータイプを返さない可能性があります。 // デモンストレーションのためにモックエラーを強制することで概念を実証できます) const mockPermissionDenied = "permission denied" // シミュレーションするには、モックファイルシステムが必要ですが、デモンストレーションのために、単に複合エラーを作成しましょう mockError := fmt.Errorf("%w: failed to open file", errors.New(mockPermissionDenied)) // os.ReadFile から来たかのようにモックエラーを再ラップします _, err = readConfigWithSimulatedError("protected_config.json", mockError) if err != nil { fmt.Println("Error:", err) if errors.Is(err, os.ErrNotExist) { fmt.Println("\t--> This should not happen for a permission error.") } if errors.Is(err, errors.New(mockPermissionDenied)) { // これは直接機能しません fmt.Println("\t--> This check won't pass without careful implementation or using a named error constant.") } // エラーメッセージ内の特定の文字列に対するより堅牢なチェックですが、理想的ではありません if err.Error() == fmt.Errorf("%w: failed to open file", errors.New(mockPermissionDenied)).Error() || errors.Is(errors.Unwrap(err), errors.New(mockPermissionDenied)) { fmt.Println("\t--> This is a wrapped permission error (demonstrative check).") } } } // 特定のエラーで readConfig の動作をシミュレートするためのヘルパー func readConfigWithSimulatedError(filename string, simErr error) ([]byte, error) { return nil, fmt.Errorf("%w: Failed to read config file '%s'", simErr, filename) }
この例は、errors.Is がチェーンのどこにある特定の Сrror をチェックできるか、また errors.As が特定のエラータイプを抽出できるかを示しており、タイプ固有の処理を可能にします。これは「正しい」ラッピングであり、元のエラーのアイデンティティを失わずにコンテキストを追加します。
落とし穴:ラッピングがうまくいかないとき
fmt.Errorf("%w", err) の力には責任が伴います。無差別に、またはその影響を十分に理解せずに使用すると、問題が発生する可能性があります。
1. 過剰なラッピング:「マトリョーシカ人形」効果
一般的なアンチパターンは、内蔵エラーが呼び出し元にユニークでアクション可能な情報を提供しない場合や、外側のレイヤーが内側のレイヤーから新しいコンテキストを導き出さない場合でも、コールスタックのすべてのレイヤーでエラーをラップすることです。
package main import ( "errors" "fmt" ) // Service errors var ( ErrDatabaseOpFailed = errors.New("database operation failed") ErrInvalidInput = errors.New("invalid input received") ) // --- Low-level database access --- func queryDatabase(sql string) error { // Simulate a database error if sql == "bad query" { return fmt.Errorf("%w: syntax error in SQL", errors.New("sql.ErrSyntax")) // Simulating a DB-specific error } return nil } // --- Repository layer --- func getUser(id string) error { err := queryDatabase("bad query") // Calls a low-level function if err != nil { // Problem: Wrapping a low-level error that provides minimal value to the caller // The caller likely only cares if the DB op failed, not the specific SQL syntax error. return fmt.Errorf("%w: failed to fetch user from DB", err) // Over-wrapping! } return nil } // --- Service layer --- func processUserRequest(userID string) error { if userID == "" { return ErrInvalidInput } err := getUser(userID) // Calls repository if err != nil { // Problem: Another layer of wrapping where maybe just ErrDatabaseOpFailed is sufficient. // The original `sql.ErrSyntax` is now deeply nested. return fmt.Errorf("%w: processing request for user %s failed", err, userID) // More over-wrapping! } return nil } func main() { err := processUserRequest("123") if err != nil { fmt.Println("Final Error:", err) // Debugging becomes harder as the error message gets verbose, // and the actual root cause might be several `errors.Unwrap` calls away. // Let's Unwrap a few times currentErr := err for i := 0; currentErr != nil; i++ { fmt.Printf("Layer %d: %v\n", i, currentErr) currentErr = errors.Unwrap(currentErr) } // What if we only cared if it was a database error? if errors.Is(err, ErrDatabaseOpFailed) { fmt.Println("\t--> Confirmed: Database operation failed!") } // What if an external system expects a very specific error type (e.g., sql.ErrSyntax)? // It's still there but buried. var syntaxErr string // Placeholder, as we used a string error isSyntaxErr := errors.As(err, &syntaxErr) // This won't work for `errors.New("sql.ErrSyntax")` if errors.Is(err, errors.New("sql.ErrSyntax")) { // This is how you'd check for a *named* error, not an arbitrary string fmt.Println("\t--> Found SQL syntax error!") } else { fmt.Println("\t--> SQL Syntax error not directly detected yet, need to check its representation.") } } }
過剰なラッピングの問題は、エラーメッセージが複雑な文字列になり、errors.Is/errors.As チェックが非効率になるか、開発者が過剰なコンテキストのために意図した特定のチェックを見逃す可能性があることです。また、エラー境界が不明確であることを示します。上位レイヤーが実際にDB操作が失敗したかどうかのみを気にする場合、下位レイヤーはより一般的な ErrDatabaseOpFailed を直接返すか、それに即座のコンテキストを 一度だけ ラップするべきであり、内部エラーを無差別に通過させるべきではありません。
解決策: 呼び出しレイヤーが 意味のある コンテキストを追加しない限り、または上位レイヤーが正確にラップされたエラーを内密に調べる必要がある場合(例: os.ErrNotExist)のみ、エラーをラップしてください。それ以外の場合は、そのレイヤーに新しいコンテキストエラーを作成してください。
// --- Revised Repository layer --- func getUserRevised(id string) error { err := queryDatabase("bad query") if err != nil { // Here, we transform the low-level error into a domain-specific one. // We might still wrap the original if debugging details are needed in logs, // but the *returned* error is `ErrDatabaseOpFailed`. // return fmt.Errorf("%w: failed to fetch user (internal error: %s)", ErrDatabaseOpFailed, err.Error()) // Or if we specifically want `errors.Is(..., ErrDatabaseOpFailed)`: // return fmt.Errorf("failed to fetch user: %w", ErrDatabaseOpFailed) // Incorrect, this wraps ErrDatabaseOpFailed // Correct way to "return" ErrDatabaseOpFailed while preserving the original for logging/debugging: // logger.Error("Failed to query database for user", "error", err) // Log the original // return ErrDatabaseOpFailed // Return a simpler, domain-specific error } return nil }
これは慎重な議論を必要とします。errors.Is(err, ErrDatabaseOpFailed) は、ErrDatabaseOpFailed が ラップされている 場合でも真であるべきでしょうか? Goの標準ライブラリはしばしばラップしますが、アプリケーション固有のエラーについては、新しいエラーを導入いつ、またはラッピングを継続するかを決定するのは難しい場合があります。
2. 特定のチェックのための汎用エラーのラッピング:「なぜ errors.Is は機能しないのですか?」
一般的な誤解は、errors.Is が汎用エラー文字列の背後にある意図を魔法のように理解することです。 errors.New("permission denied") をラップしてから、後で errors.Is(err, errors.New("permission denied")) をチェックしようとすると、errors.New は毎回 新しいエラーインスタンス を作成するため、失敗します。
package main import ( "errors" "fmt" ) // --- Helper simulating an internal operation --- func readFileContent() error { // Simulating a specific internal error, but not as a named // package-level constant. return errors.New("file system: permissions denied") } // --- Higher-level function wrapping it --- func processFile() error { err := readFileContent() if err != nil { return fmt.Errorf("could not process file: %w", err) } return nil } func main() { err := processFile() if err != nil { // This check will FAIL because errors.New("file system: permissions denied") // creates a *new* error instance, which is not the same as the one // that was wrapped. if errors.Is(err, errors.New("file system: permissions denied")) { fmt.Println("ERROR: Detected generic permission denied error!") } else { fmt.Println("INFO: Generic permission denied error NOT detected directly via errors.Is.") fmt.Printf("Full error: %v\n", err) fmt.Printf("Unwrapped error: %v\n", errors.Unwrap(err)) } // Correct way: Check against a named error constant or a specific type. // For example, if readFileContent returned os.ErrPermission. if errors.Is(err, errors.ErrUnsupported) { // Just for demo, assuming readFileContent could return this fmt.Println("This is an unsupported operation error.") } } }
出力は次のようになります:INFO: Generic permission denied error NOT detected directly via errors.Is.
解決策: errors.Is および errors.As チェックのために安定したIDを提供する、errors.New またはカスタムエラータイプを使用して、常に特定の Сrror をパッケージレベルのエクスポートされた変数として定義してください。
package main import ( "errors" "fmt" ) // Define a named error constant for comparison var ErrPermissionDenied = errors.New("permission denied") // --- Helper simulating an internal operation --- func readFileContentGood() error { return ErrPermissionDenied // Return the named error } // --- Higher-level function wrapping it --- func processFileGood() error { err := readFileContentGood() if err != nil { return fmt.Errorf("could not process file: %w", err) } return nil } func main() { err := processFileGood() if err != nil { // Now, this check will SUCCEED! if errors.Is(err, ErrPermissionDenied) { fmt.Println("CORRECT: Detected named permission denied error!") } else { fmt.Println("ERROR: Should have detected permission denied error.") } } }
3. 誤解を招くエラーメッセージ:根本原因の曖昧化
ラッピングはコンテキストを追加しますが、適切に構築されていないラッピングメッセージは誤解を招いたり、元の問題を曖昧にしたりします。ラッピングメッセージが単にラップされたエラーを言い換えたり、さらに悪いことに不正確なコンテキストを提供したりすると、その目的は無効になります。
package main import ( "errors" "fmt" "strconv" ) func parseInt(s string) (int, error) { val, err := strconv.Atoi(s) if err != nil { // Misleading wrapping: This implies a network issue when it's a parsing error. return 0, fmt.Errorf("network error failed to parse string: %w", err) } return val, nil } func main() { _, err := parseInt("abc") if err != nil { fmt.Println("Error:", err) // Debugger seeing "network error" would initially look at network code // instead of the actual parsing logic. var numErr *strconv.NumError if errors.As(err, &numErr) { fmt.Printf("\t--> Actually a NumError: %v (Func: %s, Num: %q, Err: %v)\n", numErr, numErr.Func, numErr.Num, numErr.Err) } } }
解決策: ラッピングメッセージが現在のレイヤーの操作に関連する正確な追加コンテキストを追加し、基盤となるエラーと矛盾したり曖昧にしたりしないことを確認してください。
4. 不要なアンラッピング:パフォーマンスと可読性の低下
errors.Is および errors.As はエラーチェーンを賢くトラバースしますが、直接の errors.Unwrap 呼び出しはアプリケーションロジックではまれであるべきで、主にロギングまたは高度に専門化されたエラー処理のために予約されています。条件付きチェックのため、アプリケーションロジックで繰り返し errors.Unwrap を呼び出すことは、errors.Is または errors.As がより適切であるか、エラータイプが適切に定義されていないことを示している可能性があります。
// Example of problematic explicit unwrapping in application logic fundID, err := getFundID(req) if err != nil { // If the error is not *exactly* our FundNotFoundError, try unwrapping. // This is less idiomatic than errors.Is if !errors.Is(err, domain.ErrFundNotFound) { if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { if !errors.Is(unwrappedErr, domain.ErrFundNotFound) { // ... maybe unwrap again? This quickly becomes tedious and error-prone. // It also signifies that the error structure might be overly complex // or not designed for easy checking. } } } return nil, err }
解決策: チェーン内の特定のエラーと一致するかどうかを確認するには errors.Is を、カスタムエラータイプの特定のエラーを抽出するには errors.As を優先してください。
エラーラッピングとアンラッピングのためのベストプラクティス
- 名前付きエラーの定義: errors.Isを使用してチェックすることを意図したエラーには、var ErrSomething = errors.New("something went wrong")を使用します。構造化データが必要なエラーについては、errorインターフェースを実装するカスタムエラータイプを定義してください。
- 意味のあるラッピング: 現在のレイヤーが、後続のレイヤーが恩恵を受ける可能性のある貴重なコンテキストを追加できる場合にのみ、エラーをラップしてください。ラッパーは、この特定のレイヤーで何が失敗したか を説明し、%wは なぜ(根本原因)を提供します。
- errors.IsをIDチェックに使用:- err(またはそれがラップする任意のエラー)が特定のエラーインスタンス(例:- os.ErrNotExist、- ErrAuthFailed)であるかどうかを気にする場合は、- errors.Isを使用します。
- errors.Asをタイプ固有の処理に使用: チェーン内のカスタムエラータイプの特定フィールドまたはメソッド(例:- *MyCustomError、- *os.PathError)にアクセスする必要がある場合は、- errors.Asを使用します。
- 過剰なラッピングをしない: 単に軽微な新しいコンテキストで同じエラーを再ラップするだけの、過度に深いエラーチェーンを作成しないでください。しばしば、新しい、より上位レベルのエラーを返す(デバッグのために元をログに記録しながら)方が明確です。
- アンラップはデバッグ/ロギングのため、制御フローのためではない(ほとんどの場合): errors.Unwrapは主にロギングまたはトレース目的で内部エラーを検査するのに役立ちます。制御フローのためのif err == errors.Unwrap(anotherErr)スタイルのコードに直接依存することは、一般的にerrors.Isまたはerrors.Asがより適切であることを示す兆候です。
- エラー境界を考慮する: エラーの「所有権」がどこで変更されるかを考えてください。低レベルの io.EOFは、リポジトリレイヤーではrepository.ErrNoRecordsFoundになり、サービスレイヤーではservice.ErrUserNotFoundになる可能性があります。特定のio.EOFは、リポジトリレイヤーを超えて関連性がない場合があります。多くの場合、無限にラップするのではなく、論理レイヤー間でエラーを変換します。
結論
Goのエラーラッピングメカニズムは、明らかにエラーデバッグとイントロスペクションを改善する強力な追加機能です。しかし、その効果は、思慮深く規律ある適用にかかっています。「誤ったラッピング」—過剰なラッピング、エラーの誤称、または誤解を招くコンテキスト—は、この強力な機能を負担に変え、複雑なエラーメッセージ、壊れやすいチェック、およびイライラするデバッグ体験につながる可能性があります。 適切な fmt.Errorf、errors.Is、および errors.As のニュアンスを理解し、ベストプラクティスに従うことで、開発者はこのツールを、より堅牢で保守可能で観測可能なGoアプリケーションを構築するために活用できます。目標は常に、エラーの旅を曖昧にするのではなく、明確にすることです。