Goのエラーハンドリングのベストな方法を考えて実装してみる
はじめに
ここ数年Goでの開発が多く、今ではだいぶ慣れてきたな~
と感じているのですがGoを書き始めたころを思い返すと
エラーハンドリングについてはかなり悩んだ記憶があります。
当時はとりあえずエラーをラップして...みたいな感じでしたが
ここらでいったんしっかり整理しておきたいなと思いまして
記事として書き残すことにしました。
まずはGoのエラー機構について簡単に振り返り、またよくある手法もまとめたうえで
後半の実装部分に入っていきたいと思います。
タイトルにベストなとか入れてしまいましたが、別にそんな自信はないです。
それでは少し長いですがお付き合いください!
Goのerrorはシンプル(すぎるかもしれない)
Goのerrorはめちゃくちゃシンプルです。
err := errors.New("文字列しか持ってないよ!")
fmt.Printf("%v", err)
// Output
// 文字列しか持ってないよ!...
errors.New()が返すのはerror型ですが
errorというのはError() stringを持つインターフェースで
実際は以下のerrorStringが返されます。
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}
見ての通り文字列だけをもったerrorですね...
あとGoはtry-catchの機構は持っていません。
他言語でtry-catchに慣れ親しんだ方からすれば、マジか...と思われるかもしれません。
残念ながらマジです。
このようにGoのエラーはあまりにシンプルすぎるため
開発者は各々自作のパッケージを作る、もしくは用途に合ったライブラリを探して開発を行うことになると思います。
次に一般的によく行われる手法をいくつか紹介したいと思います。
よく行われる手法
標準のerrorだけだと
- どこでエラーが起きた?
- どんな種類のエラーが起きた?
ということさえもわかりません。
そこで以下のような実装を行うことが多くあります。
エラーをラップしていく
エラーがどこで起きたかたどれるようにGoの標準パッケージのfmt.Errorf()を使ってerrに対してメッセージを追加することがよくあります。
fmt.Errorf()は厳密にいえばwrapErrorを返却します。
wrapErrorは内部に追加メッセージと元のerrorが格納されています。
func Wrap(err error, msg string) error {
// fmt.Errorf()はGoの標準ライブラリ
return fmt.Errorf("%s : %w", msg, err)
}
fmt.Errorf()は直接呼ぶのではなくpkg/errorsやcockroachdb/errorsなど多くのライブラリでWrap()という名前で関数化されています。またGoでエラーをラップする方法を調べるとかなりの割合でWrap()という名前で関数化されているのをよく見かけます。
fmt.Errorf()を直接呼ぶのはダメなの?
個人的には上記のようなただのラッパー関数になったとしても
Wrap()として一枚かますように実装したほうが良いと思っています。
エラー系の処理ってソースコードのあらゆる場所から呼ばれますし
標準パッケージや他の依存ライブラリがいろんな場所で散り散りになっていたら
のちにリファクタリングするのはめちゃくちゃ大変です。
ただのラッパー関数となってしまったとしても、特に将来的な変更が想像できなかったとしても、処理を集約化してあとから振る舞いを変えやすい状態にしておくほうが良いと私は考えています。
ラップされたエラーはどのようになるかというと...
func something1() error {
err := something2()
if err != nil {
return Wrap(err, "something1()")
}
return nil
}
func something2() error {
err := something3()
if err != nil {
return Wrap(err, "something2()")
}
return nil
}
func something3() error {
return errors.New("エラーしか返さない謎の関数")
}
以下のようにエラーメッセージを出力すると
エラーメッセージが数珠つなぎ的に追加されています。
これによりどこでエラーが起きたかを辿ることができます。
err := something1()
// something1() : something2() : エラーしか返さない謎の関数
fmt.Print(err.Error())
事前にエラー変数を定義 + errors.Is() で同一性チェック
エラーの種類に応じて処理を分岐するという場合は
Goの標準パッケージのerrors.Is()とerrors.As()を使います。
まずはerrors.Is()から使い方を見ていきましょう。
以下はエラーをあらかじめ変数で定義しておいて該当のエラーが起きた際にその変数を返すという手法です。goの標準パッケージで時々行われているのと一昔前のgoのライブラリなどでよく行われているイメージです。
そしてerrors.Is()はエラーが同一かどうかチェックします。
var errNotFound = errors.New("not found")
var errConnectionFailed = errors.New("connection failed")
func GetByID(id string) (User, error) {
// 接続エラーが起きたり、ユーザーが見つからなかった場合に
// 事前定義されたエラーを返す
//return User{}, errNotFound
//return User{}, errConnectionFailed
return User{}, nil
}
err := GetByID("1234")
if err != nil {
if errors.Is(err, errNotFound) {
// データが見つからなかった場合
} else if errors.Is(err, errConnectionFailed) {
// 接続エラーの場合
}
}
もしerrが何重にもラップされていたとしても大丈夫です。
errors.Is()と後述のerrors.As()も、内側にくるまれたエラーたちに対して
同様にチェックを行ってくれます。
errors.Is()の注意点としては同じメッセージを持っているだけの違う変数のerrorと比較してもfalseになることです。
var errNotFound = errors.New("not found")
var errNotFound2 = errors.New("not found")
// 以下はfalse
errors.Is(errNotFound, errNotFound2)
カスタムエラー型 + errors.As() で型チェック
errors.Is()は厳密な同一性をチェックするので
別のところでerrors.New()したエラーなどはメッセージが同じであっても
別物としてfalseとなります。
一方でerrors.As()は型チェック + アサーションする関数です。
アサーションについてはこちらをご覧ください。
まずは以下のようにerrorインターフェースを満たす構造体を実装します。
type DataNotFoundError struct {
id string
}
func (e *DataNotFoundError) Error() string {
return fmt.Sprintf("message:data not found id:%s", e.id)
}
func NewDataNotFoundError(id string) error {
return &DataNotFoundError{id: id}
}
type ConnectionFailedError struct {
code int
}
func (e *ConnectionFailedError) Error() string {
return fmt.Sprintf("message:connection failed code:%d", e.code)
}
func NewConnectionFailedError(code int) error {
return &ConnectionFailedError{code: code}
}
処理で状況に応じてNewDataNotFoundError()やNewConnectionFailedError()を呼び出しエラーを返します。
func GetByID(id string) (User, error) {
// 接続エラーが起きたり、ユーザーが見つからなかった場合にエラーを返す
//return User{}, NewDataNotFoundError(id)
//return User{}, NewConnectionFailedError(100)
return User{}, nil
}
そして、errors.As()でエラーが該当の型かどうか比較し
合致すればアサーションされ値が取得できます。
err := GetByID("1234")
if err != nil {
var errNotFound *DataNotFoundError
var errConnectionFailed *ConnectionFailedError
if ok := errors.As(err, errNotFound); ok {
// データが見つからなかった場合
// errNotFound.id = "1234" となっている
} else if ok := errors.As(err, errConnectionFailed); ok {
// 接続エラーの場合
// errConnectionFailed.code = 100 となっている
}
}
よく使われるライブラリ
主観にはなりますが、2025/11の時点では
最近はcockroachdb/errors
一昔前はpkg/errors(アーカイブ済み)
その前はxerrors(go1.13リリースにより役目を終えた)
みたいな印象があります。
erisというライブラリは今回調査する中ではじめて見つけました。
スター数的にはかなり使われてそうです。
oopsは名前とは裏腹にコードの見た目がきれいです。
あとpanicをrecoverするための機構も備えているのが特徴的です。
上記のような先人たちの知恵を借りるのが賢い選択だと思いますが
Goのエラーハンドリングについて理解を深めるために
ここからはエラーハンドリングに求めることを整理して
さらに自前で実装してみようと思います。
ではまずエラーハンドリングってそもそも何をする?
というところから整理していきます!
エラーハンドリングで求めることは?
調査・監視
- いつエラーが起きた?
- どの場所でエラーが起きた?
- どういう経緯でその場所まで来た?
- どのリクエストで起きたエラー?
- どんな種類のエラーが起きた?
- エラーが起きた時のxxxの変数の値は?
- 見やすい、もしくは検索しやすいログの形で出力できる?
開発
- エラーの種別に応じて処理を分岐できる?
- 開発者がエラーを迷わず処理できる?(とりあえずラップするなどルールが明確)
- エラー処理の最中に別のエラーが起きたけどどう扱う?
細かい点では違いがあるとは思いますが、上に挙げたものについては
多くの人が共感してくれるのではないかと思っています。
では実際にリストアップしたことを実現するための
自前のエラーパッケージを実装していきましょう!
自前のエラーパッケージを実装する
ソースコードはGitHubにアップしています。
冒頭にも書きましたがGoのerrorはインターフェースです。
Error() string を実装していればerrorとして扱うことができます。
早速、エラーに求めることを実現するために独自のエラー構造体を実装します。
以下のErrorTypeとStackTraceFrameも独自の型、構造体です。
type ErrorType string
const ErrorTypeNone ErrorType = ""
type MyError struct {
// required
errorType ErrorType
err error
stacktrace []StackTraceFrame
// optional
when *time.Time
requestId string
tags map[string]interface{}
subErrors []error
}
func (e *MyError) Error() string {
return fmt.Sprintf("type:%s message:%s", e.errorType, e.err.Error())
}
type StackTracFrame struct {
File string
Line int
Function string
}
エラーに求めることに挙げた部分とは以下のように対応しています。
type MyError struct {
// required
errorType ErrorType // どんな種類のエラーが起きた?
err error
stacktrace []StackTraceFrame // どの場所でエラーが起きた?どういう経緯でその場所まで来た?
// optional
when *time.Time // いつエラーが起きた?
requestId string // どのリクエストで起きたエラー?
tags map[string]interface{} // エラーが起きた時のxxxの変数の値は?
subErrors []error // エラー処理の最中に別のエラーが起きたけどどう扱う?
}
なぜfunc (e MyError) Error() stringではなくfunc (e *MyError) Error() stringなのかについてはこちらを参考にしてください。
どの場所でエラーが起きた?
どういう経緯でその場所まで来た?
エラーがどこで起きたのか?どういう経緯で起きたのか?
についてはスタックトレースがあればわかります。
一旦はスタックトレースは必須という前提で
先ほど作ったMyErrorを生成する関数を実装します。
関数内でスタックトレースを自動的に付与するようにします。
もし関数名をNew()とするのなら返す型はerrorとしておいたほうが
後々リファクタリングをする際に互換性が保ちやすいかもしれません。
// 関数名はNewとかでもよいと思いますがお好みで
func NewMyError(message string) *MyError {
err := &MyError{
err: errors.New(message),
stacktrace: NewStackTrace(2, defaultMaxDepth), // skip=2だとNewMyError()を読んだところ場所からのスタックトレースが取れる
}
return err
}
var defaultMaxDepth = 32 // スタックトレースの深さ
// NewMyError()の返り値は*MyErrorですが
// *MyErrorはerrorインターフェースを満たしているのでerrorとして扱えます
var err error
err = NewMyError("エラー")
New()の引数はfunctional Optionパターンで拡張性を持たせておくと
後に仕様変更があった場合に便利かと思います。
コードが長くなりそうなのでここではシンプルな実装にとどめさせていただきます。
スタックトレースを取得する実装は以下です。
func NewStackTrace(skip int, maxDepth int) []StackTraceFrame {
if skip < 0 {
skip = 0
}
skip += 2 // skip runtime.Callers and NewStackTrace
if maxDepth <= 0 {
return make([]StackTraceFrame, 0)
}
var trace []StackTraceFrame
pc := make([]uintptr, maxDepth)
cnt := runtime.Callers(skip, pc)
frames := runtime.CallersFrames(pc[:cnt])
for {
frame, more := frames.Next()
item := NewStackTraceFrame(frame)
trace = append(trace, item)
if !more {
break
}
}
return trace
}
// 最後にjson化するためにjsonタグをつけています
type StackTraceFrame struct {
File string `json:"file"`
Line int `json:"line"`
Function string `json:"function"`
}
func NewStackTraceFrame(f runtime.Frame) StackTraceFrame {
return StackTraceFrame{
File: f.File,
Line: f.Line,
Function: f.Function,
}
}
いつエラーが起きた?
どのリクエストで起きたエラー?
どんな種類のエラーが起きた?
エラーが起きた時のxxxの変数の値は?
エラーが起きた時に、どのリクエストで起きたのかがわからなかったり
どのユーザーで起きたのかわからなかったりすると、これ以上調査不能という事態に陥ってしまうかもしれません。
どういう状況で起きたのか後で把握するためにエラーに情報を付与したいと思います。
先ほど定義したMyErrorは以下のように付加情報を保持できる構造体となっています。
type MyError struct {
// required
errorType ErrorType // どんな種類のエラーが起きた?
err error
stacktrace []StackTraceFrame // どの場所でエラーが起きた?どういう経緯でその場所まで来た?
// optional
when *time.Time // いつエラーが起きた?
requestId string // どのリクエストで起きたエラー?
tags map[string]interface // エラーが起きた時のxxxの変数の値は?
subErrors []error // エラー処理の最中に別のエラーが起きたけどどう扱う?
}
この構造体に情報を付加するためのsetterメソッドなどを実装していきます。
最終的には以下のような書き方で情報を付加できるようになります。
err = With(err, RequestID("12345"), When(time.Now()))
ではまずはMyErrorにsetterを実装します。
func (e *MyError) SetWhen(t time.Time) *MyError {
e.when = &t
return e
}
func (e *MyError) SetRequestID(requestID string) *MyError {
e.requestId = requestID
return e
}
func (e *MyError) AddTag(key string, value any) *MyError {
if e.tags == nil {
e.tags = make(map[string]any)
}
e.tags[key] = value
return e
}
開発中にerr変数を毎回アサーションするのは煩雑なので
エラーに情報を付加するための汎用的な処理を作ろうと思います。
以下のWith()は第1引数にerrorをとり、以降の引数optionsは可変となっています。
WithFuncはfunc(err error) errorでつまり
errorを引数にとり最終的にerrorを返す関数です。
Goで引数をオプションにするパターンとしてfunctional optionsという手法があり
With(err error, options ...WithFunc) errorではそのfunctional optionsを用いています。
func With(err error, options ...WithFunc) error {
if err == nil {
return nil
}
for _, opt := range options {
err = opt(err)
}
return err
}
type WithFunc func(err error) error
func RequestID(id string) WithFunc {
return func(err error) error {
me := ToMyError(err)
me.requestId = id
return me
}
}
func When(t time.Time) WithFunc {
return func(err error) error {
me := ToMyError(err)
me.when = &t
return me
}
}
func Tag(key string, value any) WithFunc {
return func(err error) error {
me := ToMyError(err)
me.AddTag(key, value)
return me
}
}
// もしerrが*MyErrorでなければ*MyErrorに変換する
func ToMyError(err error) *MyError {
if err == nil {
return nil
}
me, ok := err.(*MyError)
if ok {
return me
}
return NewMyErrorByErr(err)
}
func NewMyErrorByErr(e error) *MyError {
err := &MyError{
err: e,
stacktrace: NewStackTrace(2, defaultMaxDepth),
}
return err
}
これで以下のような書き方ができるようになります。
err = With(
err,
Type("MyErrorType"),
RequestID("12345"),
When(time.Now()),
Tag("key1", "value1"),
Tag("key2", 42),
)
// 引数のoptionsは ...WithFunc と可変長引数なので
// セットしたいものだけを渡すことができます
err = With(
err,
Tag("key1", "value1"),
)
エラーの種類に応じて処理を分岐できる?
エラーの種類に応じてAPIレスポンスのステータスコードを変えるなど
開発ではエラーの種類で処理を分岐する場面がたくさんあります。
まずはErrorTypeの定義と種類ごとのNew()を実装します。
const ErrorTypeDataNotFound ErrorType = "DataNotFound"
const ErrorTypeConnectionFailed ErrorType = "ConnectionFailed"
func NewDataNotFoundError(id string) *MyError {
err := NewMyError("data not found")
err.AddTag("id", id)
return err
}
func NewConnectionFailed(code int) *MyError {
err := NewMyError("connection failed")
err.SetType(ErrorTypeConnectionFailed)
err.AddTag("code", code)
return err
}
次にMyErrorのerrorTypeで比較を行うIsType()を実装します。
func IsType(err error, t ErrorType) bool {
if err == nil {
return false
}
// MyErrorでアサーションするのもOKですが
// また違うカスタム構造体を作ったときのことを考えて
// Type() ErrorType を実装していればなんでもOKとしています
te, ok := err.(interface{ Type() ErrorType })
if ok && te.Type() == t {
return true
}
// errがラップされている場合Unwrapして内部のエラーもチェックします。
switch x := err.(type) {
case interface{ Unwrap() error }:
return IsType(x.Unwrap(), t)
case interface{ Unwrap() []error }:
for _, subErr := range x.Unwrap() {
if IsType(subErr, t) {
return true
}
}
}
return false
}
一応DataNotFoundErrorかどうかなども関数にしておきます。
func IsDataNotFoundError(err error) bool {
return IsType(err, ErrorTypeDataNotFound)
}
func IsConnectionFailed(err error) bool {
return IsType(err, ErrorTypeConnectionFailed)
}
これで準備は整いました!
func GetByID(id string) (User, error) {
// 接続エラーが起きたり、ユーザーが見つからなかった場合
//return User{}, NewDataNotFoundError(id)
//return User{}, NewConnectionFailedError(100)
return User{}, nil
}
err := GetByID("1234")
if err != nil {
if IsDataNotFoundError(err) {
// データが見つからなかった場合
} else if IsConnectionFailed(err) {
// 接続エラーの場合
}
}
開発者がエラーを迷わず処理できる?(とりあえずラップするなどルールが明確)
ここまでエラーが起きた時の処理を実装してきましたが
Goはtry catchがないので、エラー共通処理の層までバケツリレー的に上位へ返していく必要があります。
今回はWrap()関数を実装しますが、MyErrorをfmt.Errorf()でラップすると
wrapErrorになってしまうので単純にラップするのではなく
-
MyError.errをfmt.Errorf()でラップして、MyError内のerrに再セットする - もし
Wrap()に渡されたerrがMyErrorでなければスタックトレース付きのMyErrorに変換する -
Wrap()の返す型はerrorだが、中身は必ず*MyErrorを返す
としてアプリケーション内では基本的にMyErrorがバケツリレーされているという状態を作ることが目的です。
func Wrap(err error, msg string) error {
if err == nil {
return nil
}
me := ToMyError(err)
if len(me.StackTrace()) == 0 {
me.WithStackTrace()
}
me.SetErr(fmt.Errorf("%s: %w", msg, me.Unwrap()))
return me
}
エラー処理の最中に別のエラーが起きたけどどう扱う?
- 本処理のエラーが起きたあとさらにdefer関数でもエラーが起きた!
- エラーに応じた処理を行っている最中にさらにエラーが起きた!
こういうことって時々ありますよね。
でもあくまで本筋のエラーをメインとして上記のようなエラーは同列に扱いたくない
みたいなことってあると思います。
今回はMyErrorにサブのエラーという形で使いできるようにします。
ちなみに標準パッケージでerrors.Join()というものがあり
複数のerrorをjoinErrorという構造体でひとまとめしてくれます。
しかしjoinErrorでひとまとめにするとはいっても
本筋のエラーとは同列に扱いたくないみたいな場合もあると思います。
func (e *MyError) AddSubError(errs ...error) *MyError {
if len(errs) == 0 {
return e
}
filtered := make([]*MyError, 0)
for _, err := range errs {
if err == nil {
continue
}
filtered = append(filtered, ToMyError(err))
}
if len(filtered) == 0 {
return e
}
if e.subErrors == nil {
e.subErrors = make([]*MyError, 0)
}
e.subErrors = append(e.subErrors, filtered...)
return e
}
見やすい、もしくは検索しやすいログの形で出力できる?
エラーにいろいろ情報を付加したのなら、のちの調査のためにログに残しておきたいですね。
あとでログファイルを見たときの可読性やログ検索のことを考えると
本記事ではログはJSON形式で残そうと思います。
MyErrorをJson文字列に変換する処理を実装します。
func ToJsonString(err error) (string, error) {
ej := ToErrorJson(err)
b, err := json.Marshal(ej)
if err != nil {
return "{}", err
}
return string(b), nil
}
type ErrorJson struct {
Type string `json:"type"`
Message string `json:"message"`
When *time.Time `json:"when,omitempty"`
Request string `json:"request_id,omitempty"`
Tags interface{} `json:"tags,omitempty"`
StackTrace []StackTraceFrame `json:"stack_trace,omitempty"`
SubErrs []ErrorJson `json:"sub_errors,omitempty"`
}
func ToErrorJson(err error) ErrorJson {
if err == nil {
return ErrorJson{}
}
me := ToMyError(err)
ej := ErrorJson{
Type: string(me.errorType),
Message: me.err.Error(),
When: me.when,
Request: me.requestId,
StackTrace: me.stacktrace,
}
if len(me.tags) > 0 {
ej.Tags = me.tags
}
if len(me.subErrors) > 0 {
subErrs := make([]ErrorJson, len(me.subErrors))
for i, subErr := range me.subErrors {
subErrs[i] = ToErrorJson(subErr)
}
ej.SubErrs = subErrs
}
return ej
}
上記でワンライナーのjson文字列が作られますが
展開すると以下のようになります。
{
"type": "validation_error",
"message": "invalid input data",
"when": "2024-06-01T12:34:56Z",
"request_id": "abcd-1234-efgh-5678",
"tags": {
"field": "email",
"reason": "missing"
},
"stack_trace": [
{
"file": "/path/to/file.go",
"line": 42,
"function": "main.main"
},
{
"file": "/path/to/other_file.go",
"line": 27,
"function": "main.validateInput"
}
],
"sub_errors": [
{
"type": "format_error",
"message": "email format is invalid"
},
]
}
まとめ
MyErrorを軸として
| 状況 | 何を呼ぶ? |
|---|---|
| エラーを生成する | NewMyError()やNewDataNotFoundError() |
| エラーの種類に応じて分岐する | IsType() |
| エラーを上位にバケツリレーする | Wrap() |
| エラーをログに出す | ToJson() |
とエラーハンドリングする実装を書いてみました。
ベストプラクティスは何か?というとなかなか答えるのが難しいのですが
個人的には
- 調査のために必要な情報をしっかりerrorに付加する
- エラーを検索しやすいフォーマットでログ出力する
- エラーハンドリングのルールが明確でコードを書くときに迷わない、開発者に依存しない
という機構を作ることができれば良いと考えています。
今回は雑に扱っても一定のルールが守れる状態を目指す方針で実装しましたが
もっと堅実に実装したい場合はエラーの種類別に構造体を定義したほうが良いと思います。
ただその場合でも記事で実装したMyErrorのように情報を一定持った構造体をあらかじめ定義しておくと便利です。
では最後になりますが記事を読んでいただきありがとうございました!
参考
Discussion