paint-brush
この新しいフレームワークでエラーの増加を防ぐ@olvrng
270 測定値

この新しいフレームワークでエラーの増加を防ぐ

Oliver Nguyen30m2024/12/11
Read on Terminal Reader

長すぎる; 読むには

これは、私たちがどのようにして単純なエラー処理アプローチから始めて、問題が拡大するにつれて完全にイライラし、最終的に独自のエラー フレームワークを構築したかというストーリーです。
featured image - この新しいフレームワークでエラーの増加を防ぐ
Oliver Nguyen HackerNoon profile picture
0-item
1-item

Go でのエラー処理はシンプルで柔軟ですが、構造化されていません。


単純なはずですよね? errorを返して、メッセージでラップして、先に進むだけです。しかし、コードベースがパッケージや開発者、そして永久に残る「クイック フィックス」が増えるにつれて、その単純さはすぐに混乱に変わります。時間が経つにつれて、ログは「これを実行できませんでした」や「予期しないあれ」でいっぱいになり、それがユーザーのせいなのか、サーバーのせいなのか、バグのあるコードなのか、それとも単に星の配置がずれているだけなのか、誰にもわかりません。


エラーは一貫性のないメッセージで作成されます。各パッケージには、独自のスタイル、定数、またはカスタム エラー タイプのセットがあります。エラー コードは任意に追加されます。実装を詳しく調べなければ、どの関数からどのエラーが返されるかを簡単に知る方法はありません。


そこで、私は新しいエラー フレームワークを作成するという課題に取り組みました。エラーを意味のあるものにし、追跡可能にし、そして最も重要なことに、安心感を与えるために、名前空間コードを使用した構造化された集中型システムを採用することにしました。


これは、私たちがどのようにして単純なエラー処理アプローチから始め、問題が大きくなるにつれて完全にイライラし、最終的に独自のエラー フレームワークを構築したかというストーリーです。設計上の決定、実装方法、学んだ教訓、そしてそれがエラー管理へのアプローチを変えた理由についてです。皆さんにも何かアイデアが浮かぶことを願っています。


Goエラーは単なる値です

Go には、エラーを処理する簡単な方法があります。エラーは単なる値です。エラーは、単一のメソッドError() stringerrorインターフェイスを実装する単なる値です。例外をスローして現在の実行フローを中断する代わりに、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-redisgorm.io は再利用可能なエラー変数を定義します。
  • カスタム エラー タイプ: lib/pq grpc/status.Errorなどのライブラリは、多くの場合、追加のコンテキスト用の関連コードを含む特殊なエラー タイプを作成します。
  • 実装によるエラー インターフェース: aws-sdk-go は、インターフェース ベースのアプローチを使用して、さまざまな実装でエラー タイプを定義します。
  • または複数のインターフェース:エラーを分類および管理するための複数のインターフェースを定義するDocker の errdefsなど。

私たちは共通のアプローチから始めました

初期の頃は、多くの 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


標準化されていないため、エラー処理が不適切になる

  • 各パッケージはエラーを異なる方法で処理するため、関数がエラーを返したか、ラップしたか、または変換したかを知ることが困難になります。
  • エラーが広がるにつれて、コンテキストが失われることがよくありました。
  • 上位層は、根本的な原因が明確でない漠然とした500 内部サーバー エラーを受け取りました。


分類がないため監視は不可能

  • エラーは重大度や動作によって分類されていませんcontext.Canceledエラーは、ユーザーがブラウザ タブを閉じるときの通常の動作である可能性がありますが、そのクエリがランダムに遅いためにリクエストがキャンセルされた場合は重要です。
  • 重要な問題はノイズの多いログに埋もれてしまい、特定するのが困難でした。
  • 分類しないと、エラーの頻度、重大度、影響を効果的に監視することは不可能でした。

エラー処理を一元化する時期が来た

計画に戻る

増大する課題に対処するために、集中化され構造化されたエラー コードという中核的なアイデアに基づいて、より優れたエラー戦略を構築することにしました。

  • エラーはあらゆる場所で宣言されています →整理と追跡可能性を向上させるために、エラー宣言を 1 か所に集中させます。
  • 一貫性がなく恣意的なログ →明確で一貫性のあるフォーマットで構造化されたエラー コード。
  • 不適切なエラー処理 → 包括的なヘルパー セットを使用して、新しい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 ステータス コード、タグに自動マップします。

  • エンジニアは一元的な場所でマッピングを定義し、フレームワークは各エラー コードを対応する Protobuf ErrorCodeErrorType 、gRPC ステータス、HTTP ステータス、およびログ/メトリックのタグにマッピングします。
  • これにより一貫性が確保され、重複が削減されます。

名前空間エラーフレームワーク

コアパッケージとタイプ

新しいエラー処理フレームワークの基盤を形成するコア パッケージがいくつかあります。

connectly.ai/go/pkgs/

  • errors : Error種類とコードを定義するメイン パッケージ。
  • errors/api : フロントエンドまたは外部 API にエラーを送信します。
  • errors/E : ドットインポートで使用することを目的としたヘルパーパッケージ。
  • testing : 名前空間エラーを処理するためのテスト ユーティリティ。


ErrorCode

Errorインターフェースは標準errorインターフェースの拡張であり、 Code返すメソッドが追加されています。 Codeuint16として実装されています。

 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_ARGUMENTCodeです。
  • 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}

さまざまなタイプ: Error0VlErrorApiError

現在、 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")


baseCode()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 // ... }


この型は現在非推奨です。代わりに、すべてのマッピング ( ErrorTypeErrorCode 、 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コード

各レイヤーには通常、 UNEXPECTEDUNKNOWN 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.aiolivernguyen.ioでも公開されています 👋