Goのエラー処理のベストプラクティス
Emily Parker
Product Engineer · Leapcell

Goにおけるエラーは単なる値であり、エラー処理は基本的に値を比較した後の意思決定です。
ビジネスロジックは必要な場合にのみエラーを無視すべきです。そうでない場合、エラーは無視すべきではありません。
理論的には、この設計によりプログラマーはすべてのエラーを意識的に処理し、より堅牢なプログラムが生まれます。
この記事では、エラーを適切に処理するためのベストプラクティスについて説明します。
TL;DR
- ビジネスロジックで必要な場合のみエラーを無視し、それ以外の場合はすべてのエラーを処理します。
errors
パッケージを使用して、スタック情報のためにエラーをラップし、エラーの詳細をより正確に出力し、分散システムではtrace_id
を使用して同じリクエストからのエラーをリンクします。- エラーは、ロギングやフォールバックメカニズムの実装を含め、一度だけ処理する必要があります。
- エラー抽象化レベルの一貫性を保ち、現在のモジュールレベルよりも高いエラーをスローすることによる混乱を避けます。
- トップレベルの設計を通じて
if err != nil
の頻度を減らします。
正確なエラーロギング
エラーログは、問題のトラブルシューティングを支援する重要な手段であるため、紛らわしくないログを出力することが非常に重要です。 err
を使用してスタックログを取得し、問題のトラブルシューティングに役立てるにはどうすればよいでしょうか?
ログに記録されたエラーが本当にトラブルシューティングに役立つかを自問してください。
ログを見てもエラーを特定できない場合、エラーをまったくログに記録しないのと同じです。
パッケージgithub.com/pkg/errors
は、スタックを保持するラッパーを提供してくれます。
func callers() *stack { const depth = 32 var pcs [depth]uintptr n := runtime.Callers(3, pcs[:]) var st stack = pcs[0:n] return &st } func New(message string) error { return &fundamental{ msg: message, stack: callers(), } }
スタックの出力は、fundamental
がFormat
インターフェースを実装しているために実現されます。
次に、fmt.Printf("%+v", err)
は、対応するスタック情報を出力できます。
func (f *fundamental) Format(s fmt.State, verb rune) { switch verb { case 'v': if s.Flag('+') { io.WriteString(s, f.msg) f.stack.Format(s, verb) return } fallthrough case 's': io.WriteString(s, f.msg) case 'q': fmt.Fprintf(s, "%q", f.msg) } }
具体的な例を見てみましょう。
func foo() error { return errors.New("何かがうまくいきませんでした") } func bar() error { return foo() // エラーにスタック情報を添付します }
ここでは、foo
がerrors.New
を呼び出してエラーを作成し、次にbar
で別の呼び出しレイヤーを追加します。
次に、テストを記述してエラーを出力してみましょう。
func TestBar(t *testing.T) { err := bar() fmt.Printf("err: %+v\n", err) }
最終的な出力には、foo
とbar
の両方のスタックが含まれます。
err: 何かがうまくいきませんでした golib/examples/writer_good_code/exception.foo E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:8 golib/examples/writer_good_code/exception.bar E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:12 ...
最初の行はエラーメッセージを正確に出力し、最初のスタックトレースはエラーが生成された場所を指していることがわかります。
これらの2つのエラー情報があれば、エラーメッセージとエラーの発生源の両方を確認できます。
分散システムでのエラートレース
単一のマシンでエラーを正確に出力できるようになりましたが、実際のプログラムでは、多くの同時実行状況によく遭遇します。 エラースタックが同じリクエストに属していることを確認するにはどうすればよいでしょうか? これにはtrace_id
が必要です。
要件と推奨される形式に基づいてtrace_id
を生成し、コンテキストに設定できます。
func CtxWithTraceId(ctx context.Context, traceId string) context.Context { ctx = context.WithValue(ctx, TraceIDKey, traceId) return ctx }
ロギング時に、CtxTraceID
を使用してtraceId
を取得できます。
func CtxTraceID(c context.Context) string { if gc, ok := c.(*gin.Context); ok { // ginのリクエストからトレースIDを取得します... } // go contextから取得 traceID := c.Value(TraceIDKey) if traceID != nil { return traceID.(string) } // トレースIDがない場合は生成します return TraceIDPrefix + xid.New().String() }
ログにtraceID
を追加することで、リクエストのエラーログの完全なチェーンをプルできるため、トラブルシューティング中のログ検索の難易度が大幅に軽減されます。
エラーは一度だけ処理する必要があります
エラーは一度だけ処理する必要があり、ロギングもエラーの処理方法と見なされます。
ある場所でのロギングで問題を解決できない場合、適切なアプローチは、エラーにコンテキスト情報を追加して、プログラム内のどこで問題が発生したかを明確にすることです。エラーを複数回処理するのではなく。
当初、エラーログを使用する際に誤解がありました。次のようなビジネスエラーのログを出力する必要があると考えました。
- ユーザーアカウント/パスワードが間違っています
- ユーザーのSMS認証コードエラー
これらはユーザーの入力エラーが原因であるため、処理する必要はありません。
本当に気にする必要があるのは、プログラムのバグによって引き起こされるエラーです。
通常のビジネスロジックエラーの場合、実際にはエラーレベルのログを出力する必要はありません。エラーノイズが多すぎると、実際の問題が不明瞭になるだけです。
エラーのフォールバック
上記の方法を使用すると、実際のプロジェクトでエラーを正確に出力してトラブルシューティングに役立てることができます。 ただし、多くの場合、プログラムが「自己修復」し、エラーを適応的に解決することを望んでいます。
一般的な例:キャッシュからの取得に失敗した場合、ソースデータベースにフォールバックする必要があります。
func GerUser() (*User, error) { user, err := getUserFromCache() if err == nil { return user, nil } user, err = getUserFromDB() if err != nil { return nil, err } return user, nil }
または、トランザクションが失敗した後、補償メカニズムを開始したいと考えています。 たとえば、注文が完了した後、サイト内メッセージをユーザーに送信したいと考えています。
メッセージを同期的に送信できない場合がありますが、このシナリオでは、リアルタイム要件は特に高くないため、メッセージの非同期再試行が可能です。
func CompleteOrder(orderID string) error { // 注文を完了するためのその他のロジック... message := Message{} err := sendUserMessage(message) if err != nil { asyncRetrySendUserMessage(message) } return nil }
意図的にエラーを無視する
APIがエラーを返すのを回避できる場合、呼び出し元はエラーの処理に労力を費やす必要はありません。 したがって、エラーが生成されたが、呼び出し元が特別なアクションを実行する必要がない場合は、エラーを返す必要はありません。コードを通常どおり実行させてください。
これが「Null Object Pattern」です。 元々はエラーまたはnilオブジェクトを返す必要がありますが、呼び出し元をエラー処理から解放するために、空の構造体を返すことで、呼び出し元のエラー処理ロジックをスキップできます。
イベントハンドラーを使用する場合、存在しないイベントがある場合は、「Null Object Pattern」を使用できます。
たとえば、通知システムでは、実際の通知を送信したくない場合があります。 この場合、nullオブジェクトを使用して、通知ロジックでのnilチェックを回避できます。
// Notifierインターフェースを定義します type Notifier interface { Notify(message string) } // EmailNotifierの具体的な実装 type EmailNotifier struct{} func (n *EmailNotifier) Notify(message string) { fmt.Printf("メール通知を送信しています:%s\n", message) } // Null通知の実装 type NullNotifier struct{} func (n *NullNotifier) Notify(message string) { // Nullの実装、何もしません }
メソッドがエラーを返しても、呼び出し元として処理したくない場合、エラーを受け取るために_
を使用するのが最善です。 そうすることで、他の開発者はエラーが忘れられたのか、意図的に無視されたのかについて混乱することはありません。
func f() { // ... _ = notify() } func notify() error { // ... }
カスタムエラーのラッピング
透過的なエラーにより、エラー処理とエラー値の構築の間の結合を減らすことができますが、エラーをロジック処理で効果的に使用することはできません。
エラーに基づいてロジックを処理すると、エラーがAPIの一部になります。
エラーに応じて、上位レイヤーに異なるエラーメッセージを表示する必要がある場合があります。
Go 1.13以降、errors.Is
を使用してエラーを確認することをお勧めします。
エラータイプはerrors.As
で確認できますが、これはパブリックAPIが依然として慎重なエラー保守を必要とすることを意味します。
では、このエラー処理スタイルによって導入される結合を減らす方法はありますか?
エラー特性を統一されたインターフェースに抽出し、呼び出し元はこのインターフェースにエラーをキャストして判断できます。 netパッケージはこの方法でエラーを処理します。
type Error interface { error Timeout() bool // エラーはタイムアウトですか? }
次に、net.OpError
は対応するTimeout
メソッドを実装して、エラーがタイムアウトであるかどうかを判断し、特定のビジネスロジックを処理します。
エラー抽象化レベル
現在のモジュールよりも抽象化レベルが高いエラーをスローすることは避けてください。 たとえば、DAOレイヤーでデータをフェッチするときに、データベースがレコードを見つけられない場合、RecordNotFound
エラーを返すのが適切です。 ただし、上位レイヤーがエラーを変換する手間を省くために、DAOレイヤーから直接APIError
をスローするのは適切ではありません。
同様に、下位レベルの抽象エラーは、現在のレイヤーの抽象化に合わせてラップする必要があります。 上位レイヤーでラップした後、下位レイヤーがエラーの処理方法を変更する必要がある場合でも、上位レイヤーには影響しません。
たとえば、ユーザーログインでは、最初はMySQLをストレージとして使用する場合があります。 一致するものがない場合、エラーは「レコードが見つかりません」になります。 後で、Redisを使用してユーザーを照合する場合、一致の失敗はキャッシュミスになります。 この場合、上位レイヤーに基になるストレージの違いを感じさせたくないため、「ユーザーが見つかりません」というエラーを一貫して返す必要があります。
err != nil
を減らす
if err != nil
の頻度は、トップレベルの設計を通じて減らすことができます。 一部のエラー処理は下位レベルでカプセル化できるため、上位レイヤーに公開する必要はありません。
関数の循環的複雑さを減らすことで、if err != nil
の繰り返しのチェックの数を減らすことができます。 たとえば、関数ロジックをカプセル化することで、外側のレイヤーはエラーを一度だけ処理する必要があります。
func CreateUser(user *User) error { // 検証のために、広げるのではなく、1つのエラーをスローするだけです if err := ValidateUser(user); err != nil { return err } }
また、エラー状態を構造体に埋め込み、構造体内にエラーをカプセル化し、何か問題が発生した場合にのみ最後にエラーを返すことで、外側のレイヤーがエラーを均一に処理できるようになります。 これにより、ビジネスロジックに複数のif err != nil
チェックを挿入することを回避できます。
データコピータスクを例にとってみましょう。 ソースと宛先の構成を渡して、コピーを実行します。
type CopyDataJob struct { source *DataSourceConfig destination *DataSourceConfig err error } func (job *CopyDataJob) newSrc() { if job.err != nil { return } if job.source == nil { job.err = errors.New("source is nil") return } // ソースをインスタンス化します } func (job *CopyDataJob) newDst() { if job.err != nil { return } if job.destination == nil { job.err = errors.New("destination is nil") return } // 宛先をインスタンス化します } func (job *CopyDataJob) copy() { if job.err != nil { return } // データをコピーします... } func (job *CopyDataJob) Run() error { job.newSrc() job.newDst() job.copy() return job.err }
エラーが発生すると、Run
の各ステップは引き続き実行されますが、各関数はerr
をすぐにチェックし、設定されている場合は戻ります。 最後にのみ、job.err
が呼び出し元に返されます。
このアプローチは、メインロジックのerr != nil
の数を減らすことができますが、実際にはチェックが分散されるだけで、実際に減らすことはありません。 したがって、実際の開発では、このトリックはめったに使用されません。
Goプロジェクトのホスティングには、Leapcellをお選びください。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです。
多言語サポート
- Node.js、Python、Go、またはRustで開発します。
無制限のプロジェクトを無料でデプロイ
- 使用量に対してのみ支払い—リクエストも料金もありません。
比類のないコスト効率
- アイドル料金なしで従量課金制。
- 例:25ドルで、平均応答時間60ミリ秒で694万リクエストをサポートします。
合理化された開発者エクスペリエンス
- 簡単なセットアップのための直感的なUI。
- 完全に自動化されたCI/CDパイプラインとGitOpsの統合。
- 実用的な洞察を得るためのリアルタイムのメトリックとロギング。
簡単なスケーラビリティと高性能
- 高い同時実行性を簡単に処理するための自動スケーリング。
- 運用上のオーバーヘッドはゼロ—構築に集中するだけです。
ドキュメントで詳細をご覧ください。
Xでフォローしてください:@LeapcellHQ