Go 言語用エラーハンドリング・パッケージ

no extension

本パッケージは Go 言語によるプログラミングに於いて標準の errors パッケージを補完し,構造化されたエラーハンドリングを行うことができる。

check vulns lint status GitHub license GitHub release

errs パッケージは以下の設計目標の下に実装している。

  • 任意の error インスタンスをラッピングしてエラー発生時点の文脈(context)を収集する
    • 任意のコンテキスト情報を埋め込み可能
    • 既定でエラーが発生した関数名をコンテキスト情報として保持する
  • 構造化されたエラー情報を JSON 形式で出力可能
    • MarshalJSON() メソッド完備
    • 書式 %+v を使って JSON 形式で出力
    • 任意の error インスタンスで(Unwrap メソッドの挙動に従い)可能な限り構造を辿って出力
  • Concurrency-safe なマルチエラーハンドリング(v1.3 以降)

なお errs パッケージは Go 1.13 以上を要求する。 またマルチエラーのハンドリングを行う場合は Go 1.20, errs 1.3 以上が必要である。 ご注意を。

インポート

import "github.com/goark/errs"

簡単な使い方

error インスタンスの生成

まず errs.New() 関数は標準の errors.New() を置き換え可能である。

err := errs.New("file open error")

その上で errs.WithContext() 関数を使ってコンテキスト情報を付加することができる。

err := errs.New(
    "file open error",
    errs.WithContext("path", path),
)

errs.WithContext() 関数は errs.New() 関数の引数として複数セット可能で1,付加されたコンテキスト情報は map[string]interface{} 形式の連想配列に格納される。

更に errs.WithCause() 関数を使って原因エラーを付加することもできる。

err := errs.New(
    "file open error",
    errs.WithContext("path", path),
    errs.WithCause(cause),
)

errs.WithCause() 関数も errs.New() 関数の引数として複数セットできるが,最後にセットしたインスタンスのみが有効となる。 なお,複数の原因エラーがある場合は errs.Join() または標準の errors.Join() 関数を使うといいだろう(Go 1.20 以降, errs v1.3 以降)。

err := errs.New(
    "file open error",
    errs.WithContext("path", path),
    errs.WithCause(errors.Join(cause1, cause2)),
)

以上を踏まえて,ファイルをオープンするだけの関数を考えてみよう。 こんな感じ。

func checkFileOpen(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return errs.New(
            "file open error",
            errs.WithContext("path", path),
            errs.WithCause(err),
        )
    }
    defer file.Close()

    return nil
}

この checkFileOpen() 関数の返り値を評価する。

最初は簡単に

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        fmt.Printf("%v\n", err)
    }
}

とする。 これを実行すると

$ go run sample1a.go
file open error: open not-exist.txt: no such file or directory

と,生成したエラーのメッセージと原因エラーのメッセージが連結されて表示される。

ここで書式 %#v を使ってエラー内容を表示してみる。

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        fmt.Printf("%#v\n", err)
    }
}

これを実行すると

$ go run sample1b.go
*errs.Error{Err:&errors.errorString{s:"file open error"}, Cause:&fs.PathError{Op:"open", Path:"not-exist.txt", Err:0x2}, Context:map[string]interface {}{"function":"main.checkFileOpen", "path":"not-exist.txt"}}

と,エラーの内部構造を出力してくれる。

更に 書式 %+v を使って

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        fmt.Printf("%+v\n", err)
    }
}

とすると,実行結果は

$ go run sample1c.go | jq .
{
  "Type": "*errs.Error",
  "Err": {
    "Type": "*errors.errorString",
    "Msg": "file open error"
  },
  "Context": {
    "function": "main.checkFileOpen",
    "path": "not-exist.txt"
  },
  "Cause": {
    "Type": "*fs.PathError",
    "Msg": "open not-exist.txt: no such file or directory",
    "Cause": {
      "Type": "syscall.Errno",
      "Msg": "no such file or directory"
    }
  }
}

と JSON 形式で出力される。

この出力を見ると分かるように errs パッケージでは error インスタンス生成時にエラーが発生した関数をコンテキスト情報として自動的に付加している。 書式を使い分けて上手く利用して欲しい。

error インスタンスのラッピング

errs.Wrap() 関数を使って,他の関数・メソッドが出力した error インスタンスにコンテキスト情報を付加することもできる。 先程の checkFileOpen() 関数であれば

func checkFileOpen(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return errs.Wrap(
            err,
            errs.WithContext("path", path),
        )
    }
    defer file.Close()

    return nil
}

のように書ける。 この checkFileOpen() 関数の返り値を書式 %v で出力すると

$ go run sample2a.go
open not-exist.txt: no such file or directory

と,元の error インスタンスと同じ結果になるが,書式 %+v で出力すると

$ go run sample2c.go | jq .
{
  "Type": "*errs.Error",
  "Err": {
    "Type": "*fs.PathError",
    "Msg": "open not-exist.txt: no such file or directory",
    "Cause": {
      "Type": "syscall.Errno",
      "Msg": "no such file or directory"
    }
  },
  "Context": {
    "function": "main.checkFileOpen",
    "path": "not-exist.txt"
  }
}

と,コンテキスト情報が付加されているのが分かる。

errs.Wrap() 関数にも errs.WithCause() 関数を使って原因エラーを付加することができる。

たとえば

var ErrCheckFileOpen = errors.New("file open error")

などと,あらかじめ error インスタンス ErrCheckFileOpen を定義しておいて

func checkFileOpen(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return errs.Wrap(
            ErrCheckFileOpen,
            errs.WithCause(err),
            errs.WithContext("path", path),
        )
    }
    defer file.Close()

    return nil
}

ErrCheckFileOpen をラップし原因エラーやコンテキスト情報を付加することができる。 これを使えば

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        if errors.Is(err, ErrCheckFileOpen) {
            fmt.Printf("%+v\n", err)
        } else {
            fmt.Printf("Other: %v\n", err)
        }
        return
    }
}

errors.Is() 関数等を使って比較的簡単にエラーハンドリングを行うことができる。

その他のハンドリング関数(2023-02-07 更新)

注意: v1.2.1errs.Cause() 関数を Deprecated とした。 マルチエラーに対応できないため。

標準の errors.As(), errors.Is(), errors.Unwrap() 各関数の互換となる errs.As(), errs.Is(), errs.Unwrap() 関数も用意した。 まぁ,内部で errors の各関数を呼び出しているだけだけど。 でも,これで標準の errors パッケージを errs パッケージに置き換えて使うことができると思う。

さらに,複数の原因エラーを []error 型で返す errs.Unwraps() 関数を用意した。 こんな感じに使える。

func main() {
    err := errors.Join(os.ErrInvalid, io.EOF)
    for _, e := range errs.Unwraps(err) {
        fmt.Println(e)
    }
    //Output:
    //invalid argument
    //EOF
}

なお errs.Unwraps() 関数は原因エラーがひとつの場合でも,要素数1の []error 型で返す。

func main() {
    err := errs.Wrap(os.ErrInvalid)
    for _, e := range errs.Unwraps(err) {
        fmt.Println(e)
    }
    //Output:
    //invalid argument
 }

さらにさらに, errs.EncodeJSON() 関数を使うと,通常の error インスタンスでも可能な限り構造を辿って JSON 形式で出力する。 たとえば

func main() {
    if err := checkFileOpen("not-exist.txt"); err != nil {
        var pathError *fs.PathError
        if errs.As(err, &pathError) {
            fmt.Printf("%v\n", errs.EncodeJSON(pathError))
        } else {
            fmt.Println(err)
        }
        return
    }
}

のように書けば

$ go run sample/sample2d.go | jq .
{
  "Type": "*fs.PathError",
  "Msg": "open not-exist.txt: no such file or directory",
  "Cause": {
    "Type": "syscall.Errno",
    "Msg": "no such file or directory"
  }
}

などと出力される。

Zap にエラーをオブジェクトとして出力する(2023-05-20 更新)

Zap は gRPC 関連サービスや分散システムなどで人気の高い logger で,柔軟なカスタマイズができ,かつ高速で JSON 形式の構造化ログを出力できる。 この logger に拙作のパッケージを食わせてみる。 ソースコードはこんな感じ。

package main

import (
    "os"

    "github.com/goark/errs"
    "go.uber.org/zap"
)

func checkFileOpen(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return errs.New(
            "file open error",
            errs.WithCause(err),
            errs.WithContext("path", path),
        )
    }
    defer file.Close()

    return nil
}

func main() {
    logger := zap.NewExample()
    defer logger.Sync()

    path := "not-exist.txt"
    if err := checkFileOpen("not-exist.txt"); err != nil {
        logger.Error("error in checkFileOpen function", zap.Error(err), zap.String("file", path))
    }
}

これを実行すると

$ go run sample1.go | jq .
{
  "level": "error",
  "msg": "error in checkFileOpen function",
  "error": "file open error: open not-exist.txt: no such file or directory",
  "errorVerbose": "{\"Type\":\"*errs.Error\",\"Err\":{\"Type\":\"*errors.errorString\",\"Msg\":\"file open error\"},\"Context\":{\"function\":\"main.checkFileOpen\",\"path\":\"not-exist.txt\"},\"Cause\":{\"Type\":\"*fs.PathError\",\"Msg\":\"open not-exist.txt: no such file or directory\",\"Cause\":{\"Type\":\"syscall.Errno\",\"Msg\":\"no such file or directory\"}}}",
  "file": "not-exist.txt"
}

となる。 "error" 項目も "errorVerbose" 項目も文字列として出力されてしまうため構造化されているとは言えない。

Zap には zap.Object() 関数があって,これを使えば内部構造を出力することができるのだが,そのためには対象のオブジェクトが zapcore.ObjectMarshaler 型の interface を満たす必要がある。

type ObjectMarshaler interface {
    MarshalLogObject(ObjectEncoder) error
}

この要件を満たすために goark/errs/zapobject モジュールを作った。 こんな感じに error をラッピングして使う。

package main

import (
    "os"

    "github.com/goark/errs"
    "github.com/goark/errs/zapobject"
    "go.uber.org/zap"
)

func checkFileOpen(path string) error {
    ...
}

func main() {
    logger := zap.NewExample()
    defer logger.Sync()

    path := "not-exist.txt"
    if err := checkFileOpen("not-exist.txt"); err != nil {
        logger.Error("error in checkFileOpen function", zap.Object("error", zapobject.New(err)), zap.String("file", path))
    }
}

これを実行すると

$ go run sample2.go | jq .
{
  "level": "error",
  "msg": "error in checkFileOpen function",
  "error": {
    "type": "*errs.Error",
    "msg": "file open error: open not-exist.txt: no such file or directory",
    "error": {
      "type": "*errors.errorString",
      "msg": "file open error"
    },
    "cause": {
      "type": "*fs.PathError",
      "msg": "open not-exist.txt: no such file or directory",
      "cause": {
        "type": "syscall.Errno",
        "msg": "no such file or directory"
      }
    },
    "context": {
      "function": "main.checkFileOpen",
      "path": "not-exist.txt"
    }
  },
  "file": "not-exist.txt"
}

と,いい感じに構造化されて出力される。

なお errs.Error でラップせず通常のエラーのままでも

package main

import (
    "os"

    "github.com/goark/errs/zapobject"
    "go.uber.org/zap"
)

func checkFileOpen(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close()

    return nil
}

func main() {
    logger := zap.NewExample()
    defer logger.Sync()

    path := "not-exist.txt"
    if err := checkFileOpen("not-exist.txt"); err != nil {
        logger.Error("error in checkFileOpen function", zap.Object("error", zapobject.New(err)), zap.String("file", path))
    }
}
$ go run sample2b.go | jq .
{
  "level": "error",
  "msg": "error in checkFileOpen function",
  "error": {
    "type": "*fs.PathError",
    "msg": "open not-exist.txt: no such file or directory",
    "cause": {
      "type": "syscall.Errno",
      "msg": "no such file or directory"
    }
  },
  "file": "not-exist.txt"
}

という感じに可能な限り構造を辿って出力する。

マルチエラーのハンドリング(2023-06-26 更新)

v1.3.0 よりマルチエラーに対応した。

まずは簡単にこんな感じ。

package main

import (
    "errors"
    "fmt"
    "io"
    "os"

    "github.com/goark/errs"
)

func generateMultiError() error {
    return errs.Join(os.ErrInvalid, io.EOF)
}

func main() {
    err := generateMultiError()
    fmt.Printf("%+v\n", err)            // {"Type":"*errs.Errors","Errs":[{"Type":"*errors.errorString","Msg":"invalid argument"},{"Type":"*errors.errorString","Msg":"EOF"}]}
    fmt.Println(errors.Is(err, io.EOF)) // true
}

errs.Join() 関数は標準の errors.Join() 関数と置き換えて使うことができる。 内部では errs.Errors 型のインスタンスを生成している。

標準の errs.joinError 型との違いは,複数の goroutine からのアクセスに対応するため, sync.RWMutex を備えている点である。

type Errors struct {
    mu   sync.RWMutex
    errs []error
}

なので,こんな感じに書くこともできる。

package main

import (
    "fmt"
    "sync"

    "github.com/goark/errs"
)

func generateMultiError() error {
    errlist := &errs.Errors{}
    var wg sync.WaitGroup
    for i := 1; i <= 2; i++ {
        i := i
        wg.Add(1)
        go func() {
            defer wg.Done()
            errlist.Add(fmt.Errorf("error %d", i))
        }()
    }
    wg.Wait()
    return errlist.ErrorOrNil()
}

func main() {
    err := generateMultiError()
    fmt.Printf("%+v\n", err) // {"Type":"*errs.Errors","Errs":[{"Type":"*errors.errorString","Msg":"error 2"},{"Type":"*errors.errorString","Msg":"error 1"}]}
}

ブックマーク

参考図書

photo
プログラミング言語Go
アラン・ドノバン (著), ブライアン・カーニハン (著), 柴田芳樹 (著)
丸善出版 2016-06-20 (Release 2021-07-13)
Kindle版
B099928SJD (ASIN)
評価     

Kindle 版出た! 一部内容が古びてしまったが,この本は Go 言語の教科書と言ってもいいだろう。感想はこちら

reviewed by Spiegel on 2021-05-22 (powered by PA-APIv5)


  1. 可変引数に関数をセットするプログラミング・パターンは “Functional Option Pattern” と呼ばれている。 ↩︎