newmo 技術ブログ

技術で地域をカラフルに

ergo - Goのエラーライブラリを自作して1年間利用してみた振り返り

はじめに

Goのエラー処理

Goのエラー処理に何かライブラリを利用していますか?

この質問はGo 1.0のリリースから10年以上経つ今でも、日本のGoコミュニティでよくされる質問です。筆者(tenntenn)もよく他社の方からされます。

pkg/errorsgolang.org/x/xerrorsがデファクトスタンダードだった頃と比べ、近年(2025年)はあまりデファクトスタンダードと呼べるライブラリが無いのが現状です。

また、言語仕様やerrorsパッケージについても徐々にアップデートされてきました。Go 1.13でエラーのラップやerrors.Iserrors.Asがリリースされ、pkg/errorsが作ったエラーをラップする文化が標準にも導入されました。同様にGo 1.20でerrors.Joinが入り、hashicorp/go-multierrorgo.uber.org/multierr が広めた「複数のエラーをまとめる」文化も標準に取り入れられました。Go1.26ではAsType関数が導入される予定で更に利便性が増す予定です。

一方、Go公式ブログのOn | No syntactic support for error handlingでは、これまでのGoのエラー処理の歴史を振り返るとともに、今後は基本的には言語仕様の大きな変更によってエラー処理を改善されることは無いという方針が発表されました。

また、Go1.13のリリース時にコードがrevertされたため、今の標準のerrorsパッケージではエラーにスタックトレースを付与することができません。

このような背景から冒頭の質問がされることが多く、多くの企業では社内限定のエラーライブラリを自作することが多くなったのではないでしょうか。

ergoの開発と公開

newmoは創業当初はエラー処理は標準のerrorsパッケージを使って開発を行っていました。しかし、機能も増え、コードベースも膨らんでいったこともあり、コンパクトで扱いやすいエラーライブラリが求められるようになりました。

newmo社内では、ergoというエラーライブラリを開発しており、昨年(2024年)の12月ごろから利用しています。そろそろ1年が経つころなので、ergoをOSSとして公開し、特徴と運用してみて課題に感じている点を本記事にまとめます。

github.com

ergoの特徴

スタックトレース

ergoでは、New関数Wrap関数を用いてエラーを作成する際に、スタックトレースを付与します。付与されるスタックトレースは、すでにOSSとして公開しているnewmo-oss/go-callerを用いて取得します。

ergoにおいてもエラーをラップしていきますが、スタックトレースの付与は1度だけに限定しています。ルートになるエラーにスタックトレースが付与されていれば十分だからです。外部ライブラリによって作成されたエラーには、Wrap関数でラップした際にスタックトレースを付与します。

付与したスタックトレースは、StackTraceOf関数で取得できます。newmo-oss/go-callerパッケージのStackTrace型を返すため、ファイル名や行番号を簡単に取得できます。

func f() error {
    err := ergo.New("error")
    return err
}

func g() {
    err := f()
    st := ergo.StackTraceOf(err)
    for _, frame := range st {
        fmt.Printf("%s/%s:%d\n", frame.PkgPath(), frame.File(), frame.Line())
    }
}

ergoを使った開発では、エラーコード(後述)を中心にエラーハンドリングを行うため、パッケージ変数として宣言したエラー変数を用いる機会は多くありません。しかし、必要なケースでNew関数を使用していると、スタックトレースが付与されます。スタックトレースが一度付与されると、その後の関数でいくらラップしてもスタックトレースは付与されません。また、スタックトレースはパッケージ変数として宣言された際のものが付与されるため、スタックトレースとして役に立ちません。

そこで、ergoでは、そのようなエラーを作成する場合は、New関数の代わりにNewSentinel関数を用います。センチネルエラーという名称は、Dave Cheney氏の記事を参考にしています。NewSentinel関数は、[ 実際にはerrors.New関数のラッパー関数にすぎませんが、後述する静的解析ツールによって効果を発揮します。

// NG: スタックトレースが付与される
var ErrSomething1 = ergo.New("error something")

// OK: スタックトレースが付与されない
var ErrSomething2 = ergo.NewSentinel("error something")

属性の追加

ergoという名称は、error + go の語感に加え、ラテン語で「それゆえに(ergo)」を意味する語に由来します。ergoを利用したエラー処理では、「それゆえにエラーになった」という形で、原因やその時の状態を後から特定できることを意図しています。

標準ライブラリでは、fmt.Errorf関数で、文字列としてエラーに情報を付与します。構造化された情報ではないため、その後の処理で個別に取り出すことが難しくなります。

ergoでは、標準のlog/slogパッケージが提供しているAttr型を、エラーの作成時およびラップ時に付与できます。付与された属性は、AttrsAll関数でslog.Attrのイテレータを取得できます。イテレータはGo1.23で導入された機能で、任意の型の値をシーケンシャルに並べたデータ型でfor range文で繰り返し処理が行えます。そのため、イテレータを得ることでエラーに付与されたslog.Attr型の値をfor range文を使って取得できます。イテレータであるため繰り返し処理を行いながら値のフィルター(選別)を行うのも容易で、newmoではイテレータのフィルター関数を社内ライブラリとして用意しています。

// 属性の付与
err := ergo.New("error", slog.String("ride_id", rideID.String()))
err = ergo.Wrap(err, "wrapped", slog.String("vehicle_id", vehicleID.String()))

ergoの属性は単に情報を付与するだけでなく、エラーがラップされる過程で伝播していきます。そのため、newmoでは上位レイヤーにあがっていく度に属性が付与されるように使用しています。

func getVehicle(ctx context.Context, vehicleID uuid.UUID) (*Vehicle, error) {
    vehicle, err := db.FindVehicle(ctx, vehicleID)
    if err != nil {
        return nil, ergo.Wrap(err, "failed to get vehicle",
            slog.String("vehicle_id", vehicleID.String()))
    }
    return vehicle, nil
}

func dispatch(ctx context.Context, rideID, vehicleID uuid.UUID) error {
    vehicle, err := getVehicle(ctx, vehicleID)
    if err != nil {
        // さらにwrapしてride_idを追加
        return ergo.Wrap(err, "failed to dispatch",
            slog.String("ride_id", rideID.String()))
    }
    // ...(略)...
}

そして、ログ出力時にergo.AttrsAll関数でエラーチェーン全体から属性を収集し、newmo.attrsというグループにまとめて出力しています。もしログに出したくない属性があれば、このときにフィルターをかけて除外しても良いでしょう。

func PreprocessAttrs(ctx context.Context, err error, attrs []slog.Attr) []slog.Attr {
    // ergo.AttrsAll でエラーチェーン全体から属性を収集
    newmoAttrs := slices.Collect(ergo.AttrsAll(err))

    // ...(略)...

    // newmo.attrs としてグループ化する
    if len(newmoAttrs) != 0 {
        attrs = append(attrs, slog.Group("newmo.attrs", newmoAttrs...))
    }
    return attrs
}

gRPCのinterceptorでエラーを受け取ると、ロガーにエラーが渡されます。このエラーは伝搬されていく中で属性が付与されおり、これらが前述のようにまとめられてログに付与されます。

func NewLogger(logger log.Logger) grpc.UnaryServerInterceptor {
    return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
        // ...(略)...

        resp, err := handler(ctx, req)
        if err != nil {
            // ...(略)...

            // エラーに付与された属性が自動的にnewmo.attrsに集約される
            logger.Error(ctx, "grpc request failed", err)
        }
        return resp, err
    }
}

出力されるログは次のようになります。

{
  "level": "ERROR",
  "msg": "grpc request failed",
  "error": {
    "message": "failed to dispatch: failed to get vehicle: record not found",
    "stack": "...(省略)..."
  },
  "newmo.attrs": {
    "ride_id": "...",
    "vehicle_id": "..."
  }
}

getVehicleで付与したvehicle_idと、dispatchで付与したride_idnewmo.attrsに集約されています。Datadogで@newmo.attrs.vehicle_id:<value>のようなクエリが可能になります。

fmt.Errorf"vehicle_id=%s"のように文字列として埋め込んでしまうと、このような構造化された検索ができません。

エラーコード

ergoにはエラーコードを発行し、エラーに付与する機能もあります。エラーコードは、ergo.Code型で表現され、ergo.NewCode関数でキーとメッセージを指定して作成できます。エラーコードはパッケージ変数として宣言されることを期待しており、宣言時のスタックトレースを取得することでパッケージ名やファイル名を内部に保持しています。そのため、複数のパッケージにまたがってエラーコードを宣言する場合においてもキーに余計な接頭語をつける必要がありません。Goのコード上では値で区別でき、文字列としてログなどに出力した場合はパッケージ名は自動で付与されます。

既存のエラーにエラーコードの付与する場合はergo.WithCode関数を使用します。エラーからエラーコードを取得するにはergo.CodeOf関数を用います。ergo.Code型はそのまま比較が可能なため、エラーから取得したエラーコードはパッケージ変数として宣言したものと==や!=の比較演算子を用いて比較ができます。エラーコードが設定されていない場合はゼロ値で表され、(ergo.Code).IsZeroメソッドtrueを返します。

newmoでは、erogoを導入する以前は、次のようにドメイン層でセンチネルエラーを定義し、ハンドラ層でerrors.Is()を使って分岐していました。

// ドメイン層でセンチネルエラーを定義
var (
    ErrVehicleNotFound  = errors.New("vehicle not found")
    ErrDriverNotFound   = errors.New("driver not found")
    ErrInvalidVehicleID = errors.New("invalid vehicle id")
    ErrInvalidDriverID  = errors.New("invalid driver id")
)

// ハンドラ層で errors.Is() を使って分岐
func (s *server) GetVehicle(ctx context.Context, req *pb.GetVehicleRequest) (*pb.GetVehicleResponse, error) {
    vehicle, err := s.service.GetVehicle(ctx, req.VehicleId)
    if err != nil {
        if errors.Is(err, ErrVehicleNotFound) {
            return nil, libgrpc.NewNotFound(err)
        }
        if errors.Is(err, ErrDriverNotFound) {
            return nil, libgrpc.NewNotFound(err)
        }
        if errors.Is(err, ErrInvalidVehicleID) {
            return nil, libgrpc.NewInvalidArgument(err)
        }
        if errors.Is(err, ErrInvalidDriverID) {
            return nil, libgrpc.NewInvalidArgument(err)
        }
        return nil, libgrpc.NewInternal(err)
    }
    return &pb.GetVehicleResponse{Vehicle: vehicle}, nil
}

これをergoのエラーコードを使って次のように書き直すとswitch文で簡潔に分岐できるようになりました。

// ドメイン層でエラーコードを定義
var (
    ErrCodeVehicleNotFound  = ergo.NewCode("VehicleNotFound", "vehicle not found")
    ErrCodeDriverNotFound   = ergo.NewCode("DriverNotFound", "driver not found")
    ErrCodeInvalidVehicleID = ergo.NewCode("InvalidVehicleID", "invalid vehicle id")
    ErrCodeInvalidDriverID  = ergo.NewCode("InvalidDriverID", "invalid driver id")
)

// ハンドラ層で switch ergo.CodeOf() を使って分岐
func (s *server) GetVehicle(ctx context.Context, req *pb.GetVehicleRequest) (*pb.GetVehicleResponse, error) {
    vehicle, err := s.service.GetVehicle(ctx, req.VehicleId)
    if err != nil {
        switch ergo.CodeOf(err) {
        case ErrCodeVehicleNotFound, ErrCodeDriverNotFound:
            return nil, libgrpc.NewNotFound(err)
        case ErrCodeInvalidVehicleID, ErrCodeInvalidDriverID:
            return nil, libgrpc.NewInvalidArgument(err)
        }
        return nil, libgrpc.NewInternal(err)
    }
    return &pb.GetVehicleResponse{Vehicle: vehicle}, nil
}

エラーの種類が増えてもswitch文で見通しよく書け、複数のコードを同じcaseでまとめることもできます。また、エラーコードを宣言したパッケージ名とコード名がログのerror.kindに出力されるため、Datadogでエラーの種類ごとに検索できます。

運用を1年間してみて

既存コードの移行

ergoを利用する前は、標準ライブラリを利用していたため既存のコードの多くを修正する必要がありました。newmoではモジュラモノリスを採用しており、モジュール(newmoではコンポーネントと呼んでいます)ごとにergoに移行していきました。

まずは試しに1つのコンポーネントから移行し、何名かによって徐々に移行していきました。移行の完了したコンポーネントのコードには後述する静的解析ツールをかけることにより、新しいコードで標準ライブラリが利用されることを防ぎました。

少しずつ移行していき、最終的にはすべてのパッケージでergoを使用するように静的解析ツールによって強制するようになりました。

ergoの移行は主に手動で行いましたが、本記事の執筆時であればClaude Codeなどをもっと活用するでしょう。単純な書き換えではあるため、静的解析ツール(ergocheck)に修正機能を追加して移行プロセスを整えることもできそうです。

こうしてプロダクト開発を大きくブロックすることなく、ergoへの移行が完了しました。

ergocheck - 静的解析ツールの活用

ergoを導入し始めた当初は、errorsパッケージを使ってしまったり、使い方を間違えたりという問題が度々起きていました。そのため、ergocheckという静的解析ツール(Linter)を自作して防ぐことにしました。

ergocheckは、次の4つについて検出を行います。

  • errors.New関数とfmt.Errorf関数の使用
  • エラーメッセージ内の%dや%sなどの文字列
  • nilエラーの検出
  • パッケージ変数初期化でのergo.New関数の使用

errors.Newとfmt.Errorfの使用

ergoを使用している箇所と使用していない箇所が混在するとスタックトレースが付与されていないエラーを返してしまうおそれがあります。また、これまで標準ライブラリを利用していた経験をやめられず、ついつい標準ライブラリで書いてしまうこと予想されます。最近ではCloude Codeなどの生成AIが書いたコードの中に標準ライブラリを利用したコードが混ざってしまうこともあります。このような場合にergoの使用を強制することにより、コードベースのエラーハンドリングを統一化しています。

エラーメッセージ内の%d%sなどの文字列

ergoではslog.Attr型を用いてエラーに文脈を与えます。標準ライブラリを使い慣れていると%d%sをエラーメッセージにうっかり入れてしまうことがあります。これらを検出することでエラーメッセージに不要な情報を含めず、属性としてエラーに情報を付与できます。

nilの検出

ergoでは、ergo.Wrap関数やergo.WithCode関数など第1引数にerror型の値を指定する場合に、その値をnilを指定すると戻り値もnilになります。ergo.New関数で作成したエラーの代わりにnilを渡してしまい、戻り値のエラーもnilになっていたケースがありました。第1引数にnilを指定しているコードを検出することでこの問題を解決しました。

しかし、第1引数のnilをすべて検出しようとすると、途中に分岐が入ってしまう場合にうまく検出ができません。

// NG: 第1引数がnil
func f() error {
    return ergo.Wrap(nil, "something failed")
}

// NG: 分岐によって第1引数がnilになるパスが存在する
func g() error {
    var err error
    if someCondition {
        err = doSomething()
    }
    return ergo.Wrap(err, "something failed")
}

// OK: 第1引数がnilになるパスが存在しない
func h() error {
    err := doSomething()
    if err != nil {
        return ergo.Wrap(err, "something failed")
    }
    return nil
}

そこで、昔zaganeというGoogle Cloud SpannerのLinterを作った際に用いた有効グラフのアルゴリズムを用いて、第1引数がnilになるパスが存在するかを調べるようにしました。

engineering.mercari.com

このアルゴリズムでは、対象のコードを静的単一代入(SSA)形式に変換し、その制御フローグラフを有向グラフとして扱うアルゴリズムです。アルゴリズムを簡略化したコードで表現すると次のようになります。詳しい解説は同じアルゴリズムを使ったzaganeのブログを読んでいただくか、ソースコードをご覧ください。

// checkNilErr は ergo.Wrap の第1引数がnilになりうるパスを検出する
func checkNilErr(call *ssa.Call) bool {
    errarg := call.Call.Args[0]
    return isNil(call.Block(), errarg)
}

// isNil は値がnilになりうるかを判定する
// Phi関数(分岐の合流点)も再帰的にチェックする
func isNil(b *ssa.BasicBlock, v ssa.Value) bool {
    switch v := v.(type) {
    case *ssa.Const:
        return v.IsNil()
    case *ssa.Phi:
        // nilガードがあればnilにはならない
        if hasNilGuard(b, v) {
            return false
        }
        // 分岐のいずれかがnilならtrue
        for _, edge := range v.Edges {
            if isNil(b, edge) {
                return true
            }
        }
    }
    return false
}

// hasNilGuard は if err != nil のようなガードがあるかを判定する
// 制御フローグラフを逆方向にたどり、nilガードを経由せずに
// 開始ブロックに到達するパスが存在しないことを確認する
func hasNilGuard(b *ssa.BasicBlock, v ssa.Value) bool {
    guards := findNilGuards(v)
    if len(guards) == 0 {
        return false
    }
    // ガードを除外してバックトレース
    // 開始ブロックに到達できなければガードあり
    return !backtrace(b, guards)
}

パッケージ変数初期化でのergo.New関数の使用

ergo.New関数をパッケージ変数の宣言文に用いてしまうと無用なスタックトレースが付与されてしまいます。ergoでは代わりにergo.NewSentinel関数を用いるようにドキュメントで案内していますが、それだけだとうっかりミスをしてしまうおそれがあります。静的解析ツールに組み込むことでミスをなくしています。

ergocheckは、静的単一代入(SSA: Single Static Assign)形式という形式に変換して解析を行っています。nilの検出やパッケージ変数の宣言文の解析など、静的単一代入形式を用いた解析パターンとして良い実例となっているため、静的解析ツールを開発している方はぜひソースコードも読んでみてください。

複数のコードの付加

ergoはシンプルな作りをしているため、大きな不満のようなものは社内メンバーから指摘されることがありませんでした。しかし、それでも 1つのエラーに複数のコードを付与したいという要望が挙がったことがありました。

現在のergoでは、1つのエラーに複数のエラーコードを付与することはできません。もちろん、ergo.WithCode関数を何重にも実行することはできますが、そのように付与されたエラーコードは、最後に付与した1つしか取得できません。また、errors.Join関数で複数のコードを持つエラーが結合された場合も同様です。

これはergo.CodeOf関数がerrors.As関数(Go1.26以降ではerrors.AsType関数)を用いているためです。ergo内部では、コードを持つエラーをcodedErrorという構造体型として宣言し、そのポインタ型がerrorsインタフェースを実装するようにしています。errors.As関数はラップされたエラーのチェインを深さ優先探索で探索し、最初に見つけた該当する型のエラーを返します。そのため、該当するエラー型がその中に複数個含まれていても最初の1つだけしか返しません。

issue#66455で提案されていたerrors.All関数やerrors.AllAs関数が導入されていれば簡単に実現できましたが、ユースケースが乏しいということで不採択になってしまっています。

執筆時点で強い要望が社内から出ているわけではないため、OSSとして利用していただいて要望が強そうであれば、良い実装方法を模索していこうと考えています。

社内メンバーから提案されている改善点

本記事を執筆するにあたって、社内メンバーに改善点を募集しました。筆者本人からのものを含めいくつか集まったので、ここに掲載します。

  • 属性を追加忘れが多い
  • ergo.With関数を追加し、関数の先頭で共通の属性を付与したい
  • ergocheckをSuggested Fixに対応させ、Go1.26で導入されるgo fixコマンドに対応させてほしい

どれも日頃の開発で使用していく中で挙がってきたものなので納得感のある要望です。実装自体は難しいものではないため、利便性の向上とライブラリのシンプルさを天秤にかけながらアップデートする予定です。

おわりに

本記事では、newmo社内で開発したergoというエラーライブラリの紹介と1年間の運用の振り返りをまとめました。早い段階で静的解析ツールを導入したことにより、移行は大きな問題はなく行われました。

Goのエラーライブラリにお困りの場合はぜひergoを選択肢の1つに考えてみてはいかがでしょうか。