Go でのエラー処理はシンプルで柔軟ですが、構造化されていません。
単純なはずですよね? error
を返して、メッセージでラップして、先に進むだけです。しかし、コードベースがパッケージや開発者、そして永久に残る「クイック フィックス」が増えるにつれて、その単純さはすぐに混乱に変わります。時間が経つにつれて、ログは「これを実行できませんでした」や「予期しないあれ」でいっぱいになり、それがユーザーのせいなのか、サーバーのせいなのか、バグのあるコードなのか、それとも単に星の配置がずれているだけなのか、誰にもわかりません。
エラーは一貫性のないメッセージで作成されます。各パッケージには、独自のスタイル、定数、またはカスタム エラー タイプのセットがあります。エラー コードは任意に追加されます。実装を詳しく調べなければ、どの関数からどのエラーが返されるかを簡単に知る方法はありません。
そこで、私は新しいエラー フレームワークを作成するという課題に取り組みました。エラーを意味のあるものにし、追跡可能にし、そして最も重要なことに、安心感を与えるために、名前空間コードを使用した構造化された集中型システムを採用することにしました。
これは、私たちがどのようにして単純なエラー処理アプローチから始め、問題が大きくなるにつれて完全にイライラし、最終的に独自のエラー フレームワークを構築したかというストーリーです。設計上の決定、実装方法、学んだ教訓、そしてそれがエラー管理へのアプローチを変えた理由についてです。皆さんにも何かアイデアが浮かぶことを願っています。
Go には、エラーを処理する簡単な方法があります。エラーは単なる値です。エラーは、単一のメソッドError() string
でerror
インターフェイスを実装する単なる値です。例外をスローして現在の実行フローを中断する代わりに、Go 関数は他の結果とともにerror
値を返します。呼び出し元は、その処理方法 (値をチェックして決定を下す、新しいメッセージとコンテキストでラップする、または単にエラーを返して親の呼び出し元に処理ロジックを残す) を決定できます。
Error() string
メソッドを追加することで、任意の型をerror
にすることができます。この柔軟性により、各パッケージは独自のエラー処理戦略を定義し、最適なものを選択できます。これは Go の構成可能性の哲学ともうまく統合されており、必要に応じてエラーを簡単にラップ、拡張、またはカスタマイズできます。
一般的な方法は、 error
インターフェイスを実装するエラー値を返し、呼び出し元が次に何を行うかを決定できるようにすることです。次に典型的な例を示します。
func loadCredentials() (Credentials, error) { data, err := os.ReadFile("cred.json") if errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("file not found: %w", err) } if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } cred, err := verifyCredentials(cred); if err != nil { return nil, fmt.Errorf("invalid credentials: %w", err) } return cred, nil }
Go には、エラーを処理するためのユーティリティがいくつか用意されています。
errors.New()
とfmt.Errorf()
。fmt.Errorf()
と%w
動詞を使用して、エラーを追加のコンテキストでラップします。errors.Join()
複数のエラーを 1 つに結合します。errors.Is()
特定の値を持つエラーを照合し、 errors.As()
特定の型を持つエラーを照合し、 errors.Unwrap()
基になるエラーを取得します。
実際には、次のようなパターンがよく見られます。
errors.New()
またはfmt.Errorf()
を使用して単純なエラーを返します。初期の頃は、多くの Go 開発者と同様に、私たちも Go の一般的なプラクティスに従い、エラー処理を最小限に抑えながらも機能的にしていました。数年間は問題なく動作していました。
当時人気のパッケージであったpkg/errors を使用してスタックトレースを組み込みます。
パッケージ固有のエラーの定数または変数をエクスポートします。
特定のエラーを確認するには、 errors.Is()
を使用します。
エラーを新しいメッセージとコンテキストでラップします。
API エラーの場合、Protobuf 列挙型を使用してエラー タイプとコードを定義します。
pkg/errors
にスタックトレースを含める
当時人気のあったエラー処理パッケージであるpkg/errorsを使用して、エラーにスタックトレースを含めました。これは、アプリケーションのさまざまな部分にわたるエラーの原因をトレースできるため、デバッグに特に役立ちました。
スタックトレースを使用してエラーを作成、ラップ、および伝播するために、 Newf()
、 NewValuef()
、 Wrapf()
などの関数を実装しました。以下は、初期の実装の例です。
type xError struct { msg message, stack: callers(), } func Newf(msg string, args ...any) error { return &xError{ msg: fmt.Sprintf(msg, args...), stack: callers(), // 👈 stacktrace } } func NewValuef(msg string, args ...any) error { return fmt.Errorf(msg, args...) // 👈 no stacktrace } func Wrapf(err error, msg string, args ...any) error { if err == nil { return nil } stack := getStack(err) if stack == nil { stack = callers() } return &xError{ msg: fmt.Sprintf(msg, args...), stack: stack, } }
エラー変数のエクスポート
コードベース内の各パッケージは独自のエラー変数を定義しており、多くの場合、一貫性のないスタイルになっています。
package database var ErrNotFound = errors.NewValue("record not found") var ErrMultipleFound = errors.NewValue("multiple records found") var ErrTimeout = errors.NewValue("request timeout")
package profile var ErrUserNotFound = errors.NewValue("user not found") var ErrBusinessNotFound = errors.NewValue("business not found") var ErrContextCancel = errors.NewValue("context canceled")
errors.Is()
でエラーをチェックし、追加のコンテキストでラップする
res, err := repo.QueryUser(ctx, req) switch { case err == nil: // continue case errors.Is(database.NotFound): return nil, errors.Wrapf(ErrUserNotFound, "user not found (id=%v)", req.UserID) default: return nil, errors.Wrapf(ctx, "failed to query user (id=%v)", req.UserID) }
これにより、エラーがより詳細に伝わるようになりましたが、ログの冗長性、重複、不明瞭さが生じることがよくありました。
internal server error: failed to query user: user not found (id=52a0a433-3922-48bd-a7ac-35dd8972dfe5): record not found: not found
Protobuf で外部エラーを定義する
外部向け API については、Meta の Graph APIにヒントを得た Protobuf ベースのエラー モデルを採用しました。
message Error { string message = 1; ErrorType type = 2; ErrorCode code = 3; string user_title = 4; string user_message = 5; string trace_id = 6; map<string, string> details = 7; } enum ErrorType { ERROR_TYPE_UNSPECIFIED = 1; ERROR_TYPE_AUTHENTICATION = 2; ERROR_TYPE_INVALID_REQUEST = 3; ERROR_TYPE_RATE_LIMIT = 4; ERROR_TYPE_BUSINESS_LIMIT = 5; ERROR_TYPE_WEBHOOK_DELIVERY = 6; } enum ErrorCode { ERROR_CODE_UNSPECIFIED = 1 [(error_type = UNSPECIFIED)]; ERROR_CODE_UNAUTHENTICATED = 2 [(error_type = AUTHENTICATION)]; ERROR_CODE_CAMPAIGN_NOT_FOUND = 3 [(error_type = NOT_FOUND)]; ERROR_CODE_META_CHOSE_NOT_TO_DELIVER = 4 /* ... */; ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS = 5; }
このアプローチはエラーの構造化に役立ちましたが、時間が経つにつれて、明確な計画なしにエラーの種類とコードが追加され、不整合や重複が生じました。
いたるところでエラーが宣言された
gorm.ErrRecordNotFound
なのか、 user.ErrNotFound
なのか、それとも両方なのか?
ランダムなエラーラッピングにより、一貫性のない任意のログが生成される
unexpected gorm error: failed to find business channel: error received when invoking API: unexpected: context canceled
標準化されていないため、エラー処理が不適切になる
分類がないため監視は不可能
context.Canceled
エラーは、ユーザーがブラウザ タブを閉じるときの通常の動作である可能性がありますが、そのクエリがランダムに遅いためにリクエストがキャンセルされた場合は重要です。増大する課題に対処するために、集中化され構造化されたエラー コードという中核的なアイデアに基づいて、より優れたエラー戦略を構築することにしました。
Error
タイプでのエラーの作成とチェックを標準化します。すべてのエラー コードは、名前空間構造を持つ集中的な場所で定義されます。
名前空間を使用して、明確で意味のある拡張可能なエラー コードを作成します。例:
PRFL.USR.NOT_FOUND
。FLD.NOT_FOUND
。DEPS.PG.NOT_FOUND
「PostgreSQL でレコードが見つかりません」を意味する) を共有できます。
サービスまたはライブラリの各レイヤーは、独自の名前空間コードのみを返す必要があります。
gorm.ErrRecordNotFound
受け取った場合、「database」パッケージはそれをDEPS.PG.NOT_FOUND
としてラップする必要があります。その後、「profile/user」サービスはそれをPRFL.USR.NOT_FOUND
として再度ラップする必要があります。
すべてのエラーはError
インターフェースを実装する必要があります。
error
) と内部Error
の間に明確な境界が作成されます。
エラーは 1 つまたは複数のエラーをラップできます。それらが一緒になってツリーを形成します。
[FLD.INVALID_ARGUMENT] invalid argument → [TPL.INVALID_PARAMS] invalid input params 1. [TPL.PARAM.EMPTY] name can not be empty 2. [TPL.PARAM.MALFORM] invalid format for param[2]
常にcontext.Context
が必要です。エラーにコンテキストを添付できます。
trace_id
がなく、どこから来たのかわからないスタンドアロン エラーのログを何度も目にしました。
エラーがサービス境界を越えて送信される場合、最上位レベルのエラー コードのみが公開されます。
外部エラーの場合は、現在の Protobuf ErrorCode と ErrorType を引き続き使用します。
名前空間エラー コードを Protobuf コード、HTTP ステータス コード、タグに自動マップします。
ErrorCode
、 ErrorType
、gRPC ステータス、HTTP ステータス、およびログ/メトリックのタグにマッピングします。新しいエラー処理フレームワークの基盤を形成するコア パッケージがいくつかあります。
connectly.ai/go/pkgs/
errors
: Error
種類とコードを定義するメイン パッケージ。errors/api
: フロントエンドまたは外部 API にエラーを送信します。errors/E
: ドットインポートで使用することを目的としたヘルパーパッケージ。testing
: 名前空間エラーを処理するためのテスト ユーティリティ。
Error
とCode
Error
インターフェースは標準error
インターフェースの拡張であり、 Code
返すメソッドが追加されています。 Code
はuint16
として実装されています。
package errors // import "connectly.ai/go/pkgs/errors" type Error interface { error Code() Code } type Code struct { code uint16 } type CodeI interface { CodeDesc() CodeDesc } type GroupI interface { /* ... */ } type CodeDesc struct { /* ... */ }
パッケージerrors/E
すべてのエラーコードと一般的なタイプをエクスポートします
package E // import "connectly.ai/go/pkgs/errors/E" import "connectly.ai/go/pkgs/errors" type Error = errors.Error var ( DEPS = errors.DEPS PRFL = errors.PRFL ) func MapError(ctx context.Context, err error) errors.Mapper { /* ... */ } func IsErrorCode(err error, codes ...errors.CodeI) { /* ... */ } func IsErrorGroup(err error, groups ...errors.GroupI) { /* ... */ }
エラーコードの例:
// dependencies → postgres DEPS.PG.NOT_FOUND DEPS.PG.UNEXPECTED // sdk → hash SDK.HASH.UNEXPECTED // profile → user PRFL.USR.NOT_FOUND PFRL.USR.UNKNOWN // profile → user → repository PRFL.USR.REPO.NOT_FOUND PRFL.USR.REPO.UNKNOWN // profile → auth PRFL.AUTH.UNAUTHENTICATED PRFL.AUTH.UNKNOWN PRFL.AUTH.UNEXPECTED
パッケージdatabase
:
package database // import "connectly.ai/go/pkgs/database" import "gorm.io/gorm" import . "connectly.ai/go/pkgs/errors/E" type DB struct { gorm: gorm.DB } func (d *DB) Exec(ctx context.Context, sql string, params ...any) *DB { tx := d.gorm.WithContext(ctx).Exec(sql, params...) return wrapTx(tx) } func (x *DB) Error(msgArgs ...any) Error { return wrapError(tx.Error()) // 👈 convert gorm error to 'Error' } func (x *DB) SingleRowError(msgArgs ...any) Error { if err := x.Error(); err != nil { return err } switch { case x.RowsAffected == 1: return nil case x.RowsAffected == 0: return DEPS.PG.NOT_FOUND.CallerSkip(1). New(x.Context(), formatMsgArgs(msgArgs)) default: return DEPS.PG.UNEXPECTED.CallerSkip(1). New(x.Context(), formatMsgArgs(msgArgs)) } }
パッケージpb/services/profile
:
package profile // import "connectly.ai/pb/services/profile" // these types are generated from services/profile.proto type QueryUserRequest struct { BusinessId string UserId string } type LoginRequest struct { Username string Password string }
パッケージservice/profile
:
package profile import uuid "github.com/google/uuid" import . "connectly.ai/go/pkgs/errors/E" import l "connectly.ai/go/pkgs/logging/l" import profilepb "connectly.ai/pb/services/profile" // repository requests type QueryUserByUsernameRequest struct { Username string } // repository layer → query user func (r *UserRepository) QueryUserByUsernameAuth( ctx context.Context, req *QueryUserByUsernameRequest, ) (*User, Error) { if req.Username == "" { return PRFL.USR.REPO.INVALID_ARGUMENT.New(ctx, "empty request") } var user User sqlQuery := `SELECT * FROM "user" WHERE username = ? LIMIT 1` tx := r.db.Exec(ctx, sqlQuery, req.Username).Scan(&user) err := tx.SingleRowError() switch { case err == nil: return &user, nil case IsErrorCode(DEPS.PG.NOT_FOUND): return PRFL.USR.REPO.USER_NOT_FOUND. With(l.String("username", req.Username)) Wrap(ctx, "user not found") default: return PRFL.USR.REPO.UNKNOWN. Wrap(ctx, "failed to query user") } } // user service layer → query user func (u *UserService) QueryUser( ctx context.Context, req *profilepb.QueryUserRequest, ) (*profilepb.QueryUserResponse, Error) { // ... rr := QueryUserByUsernameRequest{ Username: req.Username } err := u.repo.QueryUserByUsername(ctx, rr) if err != nil { return nil, MapError(ctx, err). Map(PRFL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "the user %q cannot be found", req.UserName, api.UserTitle("User Not Found"), api.UserMsg("The requested user id %q can not be found", req.UserId)). KeepGroup(PRFL.USR). Default(PRFL.USR.UNKNOWN, "failed to query user") } // ... return resp, nil } // auth service layer → login user func (a *AuthService) Login( ctx context.Context, req *profilepb.LoginRequest, ) (*profilepb.LoginResponse, *profilepb.LoginResponse, Error) { vl := PRFL.AUTH.INVALID_ARGUMENT.WithMsg("invalid request") vl.Vl(req.Username != "", "no username", api.Detail("username is required")) vl.Vl(req.Password != "", "no password", api.Detail("password is required")) if err := vl.ToError(ctx); err != nil { return err } hashpwd, err := hash.Hash(req.Password) if err != nil { return PRFL.AUTH.UNEXPECTED.Wrap(ctx, err, "failed to calc hash") } usrReq := profilepb.QueryUserByUsernameRequest{/*...*/} usrRes, err := a.userServiceClient.QueryUserByUsername(ctx, usrReq) if err != nil { return nil, MapError(ctx, err). Map(PRFL.USR.NOT_FOUND, PRFL.AUTH.UNAUTHENTICATED, "unauthenticated"). Default(PRFL.AUTH.UNKNOWN, "failed to query by username") } // ... }
さて、上記のコードには多くの新しい機能と概念があります。それらを順を追って見ていきましょう。
まず、ドットインポートを使用してパッケージerrors/E
をインポートします。
これにより、 errors.Error
の代わりにError
などの一般的なタイプを直接使用し、 errors.PRFL.USR.NOT_FOUND
の代わりにPRFL.USR.NOT_FOUND
でコードにアクセスできるようになります。
import . "connectly.ai/go/pkgs/errors/E"
CODE.New()
使用して新しいエラーを作成する
無効なリクエストを受け取った場合は、次の方法で新しいエラーを作成できます。
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
PRFL.USR.INVALID_ARGUMENT
はCode
です。Code
新しいエラーを作成するためのNew()
やWrap()
などのメソッドを公開します。New()
関数は、最初の引数としてcontext.Context
を受け取り、その後にメッセージとオプションの引数を受け取ります。
fmt.Print(err)
で印刷します:
[PRFL.USR.INVALID_ARGUMENT] invalid request
または、 fmt.Printf("%+v")
を使用して詳細を表示します。
[PRFL.USR.INVALID_ARGUMENT] invalid request connectly.ai/go/services/profile.(*UserService).QueryUser /usr/i/src/go/services/profile/user.go:1234 connectly.ai/go/services/profile.(*UserRepository).QueryUser /usr/i/src/go/services/profile/repo/user.go:2341
CODE.Wrap()
を使用してエラーを新しいエラー内にラップします。
dbErr := DEPS.PG.NOT_FOUND.Wrap(ctx, gorm.ErrRecordNotFound, "not found") usrErr := PRFL.USR.NOT_FOUND.Wrap(ctx, dbErr, "user not found")
fmt.Print(usrErr)
を使用すると、次の出力が生成されます。
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found
またはfmt.Printf("%+v", usrErr)
を使用します
[PRFL.USR.NOT_FOUND] user not found → [DEPS.PG.NOT_FOUND] not found → record not found connectly.ai/go/services/profile.(*UserService).QueryUser /usr/i/src/go/services/profile/user.go:1234
スタックトレースは最も内側のError
から取得されます。ヘルパー関数を記述している場合は、 CallerSkip(skip)
を使用してフレームをスキップできます。
func mapUserError(ctx context.Context, err error) Error { switch { case IsErrorCode(err, DEPS.PG.NOT_FOUND): return PRFL.USR.NOT_FOUND.CallerSkip(1).Wrap(ctx, err, "...") default: return PRFL.USR.UNKNOWN.CallerSkip(1).Wrap(ctx, err, "...") } }
With()
使用してエラーにコンテキストを追加する
.With(l.String(...))
を使用して、エラーに追加のキー/値のペアを追加できます。logging/l
、ログ記録用のシュガー関数をエクスポートするためのヘルパー パッケージです。l.String("flag", flag)
Tag{String: flag}
を返し、 l.UUID("user_id, userID)
Tag{Stringer: userID}
を返します。 import l "connectly.ai/go/pkgs/logging/l" usrErr := PRFL.USR.NOT_FOUND. With(l.UUID("user_id", req.UserID), l.String("flag", flag)). Wrap(ctx, dbErr, "user not found")
タグはfmt.Printf("%+v", usrErr)
で出力できます:
[PRFL.USR.NOT_FOUND] user not found {"user_id": "81febc07-5c06-4e01-8f9d-995bdc2e0a9a", "flag": "ABRW"} → [DEPS.PG.NOT_FOUND] not found {"a number": 42} → record not found
New()
、 Wrap()
、またはMapError()
内でエラーにコンテキストを直接追加します。
l.String()
関数とそのファミリーを活用することで、 New()
や同様の関数は書式設定引数の中からタグをスマートに検出できます。別の関数を導入する必要はありません。
err := INF.HEALTH.NOT_READY.New(ctx, "service %q is not ready (retried %v times)", req.ServiceName, l.String("flag", flag) countRetries, l.Number("count", countRetries), )
出力は次のようになります:
[INF.HEALTH.NOT_READY] service "magic" is not ready (retried 2 times) {"flag": "ABRW", "count": 2}
Error0
、 VlError
、 ApiError
現在、 Error
インターフェースを実装するタイプは 3 つあります。必要に応じて、タイプをさらに追加できます。それぞれに異なる構造を持たせることができ、特定のニーズに合わせてカスタム メソッドを追加できます。
Error
Goの標準error
インターフェースの拡張です
type Error interface { error Code() Message() Fields() []tags.Field StackTrace() stacktrace.StackTrace _base() *base // a private method }
これには、 errors
パッケージの外部で新しいError
タイプを誤って実装しないようにするためのプライベート メソッドが含まれています。今後、より多くの使用パターンを経験したときに、この制限を解除する可能性があります (または解除しない可能性があります)。
標準error
インターフェイスを使用して型アサーションを使用するのはなぜでしょうか?
サードパーティのエラーと内部エラーを区別したいからです。内部コード内のすべてのレイヤーとパッケージは、常にError
返す必要があります。こうすることで、サードパーティのエラーを変換する必要があるときと、内部エラーコードのみを処理する必要がある場合を安全に把握できます。
また、移行済みのパッケージとまだ移行されていないパッケージの間に境界が作成されます。現実に戻ると、新しいタイプを宣言し、魔法の杖を振って呪文プロンプトをささやくだけで、何百万行ものコードがすべて魔法のように変換され、バグなしでシームレスに動作するということはできません。いいえ、そのような未来はまだ来ていません。いつか来るかもしれませんが、今のところは、パッケージを 1 つずつ移行する必要があります。
Error0
デフォルトのError
タイプです
ほとんどのエラー コードはError0
値を生成します。これにはbase
とオプションのサブエラーが含まれます。NewX NewX()
使用して、 Error
インターフェイスの代わりに具体的な*Error0
構造体を返すことができますが、注意が必要です。
type Error0 struct { base err error } var errA: Error = DEPS.PG.NOT_FOUND.New (ctx, "not found") var errB: *Error0 = DEPS.PG.NOT_FOUND.NewX(ctx, "not found")
base
、 Code()
、 Message()
、 StackTrace()
、 Fields()
などの共通機能を提供するために、すべてのError
実装で共有される共通構造体です。
type base struct { code Code msg string kv []tags.Field stack stacktrace.StackTrace }
VlError
は検証エラー用です
複数のサブエラーを含めることができ、検証ヘルパーを操作するための便利なメソッドを提供します。
type VlError struct { base errs []error }
他のError
と同様のVlError
作成できます。
err := PRFL.USR.INVALID_ARGUMENT.New(ctx, "invalid request")
または、 VlBuilder
を作成し、それにエラーを追加して、それをVlError
に変換します。
userID, err0 := parseUUID(req.UserId) err1 := validatePassword(req.Password) vl := PRFL.USR.INVALID_ARGUMENT.WithMsg("invalid request") vl.Add(err0, err1) vlErr := vl.ToError(ctx)
そして、通常どおりキー/値のペアを含めます。
vl := PRFL.USR.INVALID_ARGUMENT. With(l.Bool("testingenv", true)). WithMsg("invalid request") userID, err0 := parseUUID(req.UserId) err1 := validatePassword(req.Password) vl.Add(err0, err1) vlErr := vl.ToError(ctx, l.String("user_id", req.UserId))
fmt.Printf("%+v", vlErr)
を使用すると、次のように出力されます。
[PRFL.USR.INVALID_ARGUMENT] invalid request {"testingenv": true, "user_id": "A1234567890"}
ApiError
APIエラーを移行するためのアダプタです
以前は、フロントエンドと外部クライアントに API エラーを返すために別のapi.Error
構造体を使用していました。これには、前述のようにErrorCode
としてErrorType
が含まれます。
package api import errorpb "connectly.ai/pb/models/error" // Deprecated type Error struct { pbType errorpb.ErrorType pbCode errorpb.ErrorCode cause error msg string usrMsg string usrTitle string // ... }
この型は現在非推奨です。代わりに、すべてのマッピング ( ErrorType
、 ErrorCode
、 gRPC コード、 HTTP コード) を一元的に宣言し、対応する境界で変換します。コード宣言については次のセクションで説明します。
新しい名前空間エラー フレームワークへの移行を行うために、一時的な名前空間ZZZ.API_TODO
を追加しました。すべてのErrorCode
ZZZ.API_TODO
コードになります。
ZZZ.API_TODO.UNEXPECTED ZZZ.API_TODO.INVALID_REQUEST ZZZ.API_TODO.USERNAME_ ZZZ.API_TODO.META_CHOSE_NOT_TO_DELIVER ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS
そして、 ApiError
はアダプターとして作成されます。以前は*api.Error
を返していたすべての関数は、代わりにError
( *ApiError
によって実装) を返すように変更されました。
package api import . "connectly.ai/go/pkgs/errors/E" // previous func FailPreconditionf(err error, msg string, args ...any) *Error { return &Error{ pbType: ERROR_TYPE_FAILED_PRECONDITION, pbCode: ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS, cause: err, msg: fmt.Sprintf(msg, args...) } } // current: this is deprecated, and serves and an adapter func FailPreconditionf(err error, msg string, args ...any) *Error { ctx := context.TODO() return ZZZ.API_TODO.MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS. CallerSkip(1). // correct the stacktrace by 1 frame Wrap(ctx, err, msg, args...) }
すべての移行が完了すると、以前の使用法は次のようになります。
wabaErr := verifyWabaTemplateStatus(tpl) apiErr := api.FailPreconditionf(wabaErr, "template cannot be edited"). WithErrorCode(ERROR_CODE_MESSAGE_WABA_TEMPLATE_CAN_ONLY_EDIT_ONCE_IN_24_HOURS). WithUserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead."). ErrorOrNil()
次のように変更します。
CPG.TPL.EDIT_ONCE_IN_24_HOURS.Wrap( wabaErr, "template cannot be edited", api.UserMsg("According to WhatsApp, the message template can be only edited once in 24 hours. Consider creating a new message template instead."))
ErrorCode
内部名前空間コードから暗黙的に派生していることに注意してください。毎回明示的に割り当てる必要はありません。しかし、コード間の関係をどのように宣言するのでしょうか? 次のセクションで説明します。
この時点で、既存のコードから新しいエラーを作成する方法がすでにわかっています。コードについて、また新しいコードを追加する方法について説明します。
コードCode
uint16
値として実装され、対応する文字列表現を持ちます。
type Code struct { code: uint16 } fmt.Printf("%q", DEPS.PG.NOT_FOUND) // "DEPS.PG.NOT_FOUND"
これらの文字列を格納するために、利用可能なすべてのCodeDesc
の配列があります。
const MaxCode = 321 // 👈 this value is generated var allCodes [MaxCode]CodeDesc type CodeDesc { c int // 42 code string // DEPS.PG.NOT_FOUND api APICodeDesc } type APICodeDesc { ErrorType errorpb.ErrorType ErrorCode errorpb.ErrorCode HttpCode int DefMessage string UserMessage string UserTitle string }
コードの宣言方法は次のとおりです。
var DEPS deps // dependencies var PRFL prfl // profile var FLD fld // flow document type deps struct { PG pg // postgres RD rd // redis } // tag:postgres type pg struct { NOT_FOUND Code0 // record not found CONFLICT Code0 // record already exist MALFORM_SQL Code0 } // tag:profile type PRFL struct { REPO prfl_repo USR usr AUTH auth } // tag:profile type prfl_repo struct { NOT_FOUND Code0 // internal error code INVALID_ARGUMENT VlCode // internal error code } // tag:usr type usr struct { NOT_FOUND Code0 `api-code:"USER_NOT_FOUND"` INVALID_ARGUMENT VlCode `api-code:"INVALID_ARGUMENT"` DISABlED_ACCOUNT Code0 `api-code:"DISABLED_ACCOUNT"` } // tag:auth type auth struct { UNAUTHENTICATED Code0 `api-code:"UNAUTHENTICATED"` PERMISSION_DENIED Code0 `api-code:"PERMISSION_DENIED"` }
新しいコードを宣言した後、生成スクリプトを実行する必要があります。
run gen-errors
生成されたコードは次のようになります。
// Code generated by error-codes. DO NOT EDIT. func init() { // ... PRFL.AUTH.UNAUTHENTICATED = Code0{Code{code: 143}} PRFL.AUTH.PERMISSION_DENIED = Code0{Code{code: 144}} // ... allCodes[143] = CodeDesc{ c: 143, code: "PRFL.AUTH.UNAUTHENTICATED", tags: []string{"auth", "profile"}, api: APICodeDesc{ ErrorType: ERROR_TYPE_UNAUTHENTICATED, ErrorCode: ERROR_CODE_UNAUTHENTICATED, HTTPCode: 401, DefMessage: "Unauthenticated error", UserMessage: "You are not authenticated.", UserTitle: "Unauthenticated error", })) }
各Error
タイプには対応するCode
タイプがあります
PRFL.USR.NOT_FOUND.New()
*Error0
を作成し、 PRFL.USR.INVALID_ARGUMENTS.New()
*VlError
作成するのはなぜか、疑問に思ったことはありませんか? それは、異なるコード タイプを使用しているためです。
また、各Code
タイプは異なるError
タイプを返し、それぞれ独自の追加メソッドを持つことができます。
type Code0 struct { Code } type VlCode struct { Code } func (c Code0) New(/*...*/) Error { return &Error0{/*...*/} } func (c VlCode) New(/*...*/) Error { return &VlError{/*...*/} } // extra methods on VlCode to create VlBuilder func (c VlCode) WithMsg(msg string, args ...any) *VlBuilder {/*...*/} type VlBuilder struct { code VlCode msg string args []any } func (b *VlBuilder) ToError(/*...*/) Error { return &VlError{Code: code, /*...*/ } }
外部APIで利用可能なコードをマークするにはapi-code
使用します
名前空間エラー コードは内部で使用する必要があります。
外部 HTTP API で返せるコードを作成するには、 api-code
でマークする必要があります。値は対応するerrorpb.ErrorCode
です。
エラー コードがapi-code
でマークされていない場合は、内部コードであり、一般的なInternal Server Error
として表示されます。
PRFL.USR.NOT_FOUND
は外部コードですが、 PRFL.USR.REPO.NOT_FOUND
内部コードであることに注意してください。
enum オプションを使用して、protobuf 内のErrorCode
、 ErrorType
、および gRPC/HTTP コード間のマッピングを宣言します。
// error/type.proto ERROR_TYPE_PERMISSION_DENIED = 707 [(error_type_detail_option) = { type: "PermissionDeniedError", grpc_code: PERMISSION_DENIED, http_code: 403, // Forbidden message: "permission denied", user_title: "Permission denied", user_message: "The caller does not have permission to execute the specified operation.", }]; // error/code.proto ERROR_CODE_DISABlED_ACCOUNT = 70020 [(error_code_detail_option) = { error_type: ERROR_TYPE_DISABlED_ACCOUNT, grpc_code: PERMISSION_DENIED, http_code: 403, // Forbidden message: "account is disabled", user_title: "Account is disabled", user_message: "Your account is disabled. Please contact support for more information.", }];
UNEXPECTED
コードとUNKNOWN
コード
各レイヤーには通常、 UNEXPECTED
とUNKNOWN
2 つの汎用コードがあります。これらは、目的が若干異なります。
UNEXPECTED
コードは、決して発生しないはずのエラーに使用されます。UNKNOWN
コードが使用されます。関数から返されたエラーを受け取った場合は、それを処理する必要があります。つまり、サードパーティのエラーを内部名前空間エラーに変換し、エラー コードを内部レイヤーから外部レイヤーにマップする必要があります。
サードパーティのエラーを内部名前空間エラーに変換する
エラーの処理方法は、サードパーティ パッケージが返す内容とアプリケーションに必要なものによって異なります。たとえば、データベース エラーまたは外部 API エラーを処理する場合:
switch { case errors.Is(err, sql.ErrNoRows): // map a database "no rows" error to an internal "not found" error return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found") case errors.Is(err, context.DeadlineExceeded): // map a context deadline exceeded error to a timeout error return nil, PRFL.USR.TIMEOUT.Wrap(ctx, err, "query timeout") default: // wrap any other error as unknown return nil, PRFL.USR.UNKNOWN.Wrap(ctx, err, "unexpected error") }
内部名前空間エラーに対するヘルパーの使用
IsErrorCode(err, CODES...)
: エラーに指定されたコードが含まれているかどうかを確認します。IsErrorGroup(err, GROUP)
: エラーが入力グループに属している場合は true を返します。
一般的な使用パターン:
user, err := queryUser(ctx, userReq) switch { case err == nil: // continue case IsErrorCode(PRL.USR.REPO.NOT_FOUND): // check for specific error code and convert to external code // and return as HTTP 400 Not Found return nil, PRFL.USR.NOT_FOUND.Wrap(ctx, err, "user not found") case IsGroup(PRL.USR): // errors belong to the PRFL.USR group are returned as is return nil, err default: return nil, PRL.USR.UNKNOWN.Wrap(ctx, err, "failed to query user") }
マッピング コードを簡単に記述するためのMapError()
:
エラー コードのマッピングは一般的なパターンであるため、コードの記述を高速化するためのMapError()
ヘルパーがあります。上記のコードは次のように書き直すことができます。
user, err := queryUser(ctx, userReq) if err != nil { return nil, MapError(ctx, err). Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user not found"). KeepGroup(PRF.USR). Default(PRL.USR.UNKNOWN, "failed to query user") }
通常どおり引数をフォーマットし、キー/値のペアを追加できます。
return nil, MapError(ctx, err). Map(PRL.USR.REPO.NOT_FOUND, PRFL.USR.NOT_FOUND, "user %v not found", username, l.String("flag", flag)). KeepGroup(PRF.USR). Default(PRL.USR.UNKNOWN, "failed to query user", l.Any("retries", retryCount))
Error
を使用したテストテストは、あらゆる本格的なコード ベースにとって重要です。フレームワークは、テストでのエラー条件の記述とアサートをより簡単に、より表現豊かにするために、 ΩxError()
などの特殊なヘルパーを提供します。
// 👉 return true if the error contains the message ΩxError(err).Contains("not found") // 👉 return true if the error does not contain the message ΩxError(err).NOT().Contains("not found")
他にも多くのメソッドがあり、それらを連鎖させることもできます。
ΩxError(err). MatchCode(DEPS.PG.NOT_FOUND). // match any code in top or wrapped errors TopErrorMatchCode(PRFL.TPL.NOT_FOUND) // only match code from the top error MatchAPICode(API_CODE.WABA_TEMPLATE_NOTE_FOUND). // match errorpb.ErrorCode MatchExact("exact message to match")
Ω(err).To(testing.MatchCode())
の代わりにメソッドを使用するのはなぜですか?
メソッドの方が見つけやすいからです。testing.MatchValues testing.MatchValues()
のような関数が多数ある場合、どれがError
で機能し、どれが機能しないかを知るのは困難です。メソッドを使用すると、ドット.
を入力するだけで、IDE はError
をアサートするために特別に設計された利用可能なすべてのメソッドを一覧表示します。
フレームワークは物語の半分に過ぎません。コードを書くのは簡単な部分です。本当の課題は、数十人のエンジニアが毎日変更をプッシュし、顧客はすべてが完璧に動作することを期待し、システムの実行を停止できない、大規模で生きたコードベースにそれを組み込む必要があるときに始まります。
移行には責任が伴います。コードの細かい部分を注意深く分割し、一度に小さな変更を加え、その過程で大量のテストを中断します。その後、手動で 1 つずつ検査して修正し、メイン ブランチにマージし、本番環境にデプロイし、ログとアラートを監視します。これを何度も繰り返します...
移行の過程で学んだいくつかのヒントを以下に示します。
検索と置換から始めます。まず、古いパターンを新しいフレームワークに置き換えます。このプロセスで発生したコンパイルの問題を修正します。
たとえば、このパッケージ内のすべてのerror
Error
に置き換えます。
type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, error) }
新しいコードは次のようになります。
import . "connectly.ai/go/pkgs/errors" type ProfileController interface { LoginUser(req *LoginRequest) (*LoginResponse, Error) QueryUser(req *QueryUserRequest) (*QueryUserResponse, Error) }
一度に 1 つのパッケージを移行します。最下位レベルのパッケージから始めて、徐々に上位レベルのパッケージに移行します。この方法では、上位レベルのパッケージに進む前に、下位レベルのパッケージが完全に移行されていることを確認できます。
不足している単体テストを追加する:コードベースの一部にテストが不足している場合は、追加します。変更に自信がない場合は、テストを追加します。テストは、変更によって既存の機能が損なわれないようにするのに役立ちます。
パッケージが上位レベルのパッケージの呼び出しに依存している場合は、関連する関数を DEPRECATED に変更してから、新しいError
タイプで新しい関数を追加することを検討してください。
Transaction()
メソッドを持つデータベース パッケージを移行していると仮定します。
package database func (db *DB) Transaction(ctx context.Context, fn func(tx *gorm.DB) error) error { return db.gorm.Transaction(func(tx *gorm.DB) error { return fn(tx) }) }
これはユーザー サービス パッケージで使用されます。
err = s.DB(ctx).Transaction(func(tx *database.DB) error { user, usrErr := s.repo.CreateUser(ctx, tx, user) if usrErr != nil { return usrErr } }
最初にdatabase
パッケージを移行するため、 user
と他の多数のパッケージはそのままになります。s.repo.CreateUser s.repo.CreateUser()
呼び出しは依然として古いerror
タイプを返しますが、 Transaction()
メソッドは新しいError
タイプを返す必要があります。Transaction Transaction()
メソッドをDEPRECATED
に変更し、新しいTransactionV2()
メソッドを追加できます。
package database // DEPRECATED: use TransactionV2 instead func (db *DB) Transaction_DEPRECATED(ctx context.Context, fn func(tx *gorm.DB) error) error { return db.gorm.Transaction(func(tx *gorm.DB) error { return fn(tx) }) } func (db *DB) TransactionV2(ctx context.Context, fn func(tx *gorm.DB) error) Error { err := db.gorm.Transaction(func(tx *gorm.DB) error { return fn(tx) }) return adaptToErrorV2(err) }
必要に応じて新しいエラー コードを追加します。既存のエラー コードに当てはまらないエラーが発生した場合は、新しいコードを追加します。これにより、時間の経過とともに包括的なエラー コード セットを構築できるようになります。他のパッケージのコードは、常に参照として利用できます。
Go でのエラー処理は、最初は単純に感じられます。 error
を返して先に進むだけです。しかし、コードベースが大きくなるにつれて、そのシンプルさは、あいまいなログ、一貫性のない処理、そして終わりのないデバッグ セッションの絡み合った混乱に変わりました。
一歩下がってエラーの処理方法を再考することで、私たちは自分たちにとって不利ではなく有利に働くシステムを構築しました。集中化され構造化された名前空間コードによって明確さがもたらされ、エラーのマッピング、ラッピング、テストを行うツールによって作業が楽になりました。ログの海を泳ぎ回る代わりに、何が問題でどこを調べるべきかを教えてくれる意味のある追跡可能なエラーが手に入るようになりました。
このフレームワークは、コードをよりきれいにするだけではありません。時間を節約し、フラストレーションを軽減し、未知の状況に備えるのに役立ちます。これはまだ旅の始まりに過ぎません。私たちはまだ多くのパターンを発見しているところですが、その結果、エラー処理に何らかの安心感をもたらすシステムが生まれました。皆さんのプロジェクトにも、このフレームワークがヒントを与えてくれることを願っています! 😊
私は Oliver Nguyen です。主に Go と JavaScript で作業するソフトウェア メーカーです。毎日、学習して自分自身が成長していくのを楽しんでいます。時々、新しいオープン ソース プロジェクトを立ち上げます。旅の途中で知識や考えを共有します。
この投稿はblog.connectly.aiとolivernguyen.ioでも公開されています 👋