⚠️

pkgsite/internal/derrors を使ったGoのエラーラッピング

2023/10/01に公開

mercari.go #23にて、使用されているgolang.org/x/pkgsite/internal/derrors という、pkg.go.devの内部で使用されているエラーラッピング用のパッケージが紹介されていました。
興味を持って調べてみたところ、とてもスタイリッシュで面白かったので紹介します。

derrors.Wrap

このパッケージの基本となる関数です。関数本体はたった3行です。

func Wrap(errp *error, format string, args ...any) {
    if *errp != nil {
    	*errp = fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), *errp)
    }
}

このWrapがエラーラッピングを驚くほど簡潔にしてくれます。

使用例

以下にWrap関数を使用したコード例を掲載します(Go Playground)。

// "[整数]+[整数]"という形の文字列をパースし、その式の計算結果を返す
func ParseAdd(str string) (res int, err error) {
    defer Wrap(&err, "ParseAdd(%q)", str)
    for i := 0; i < len(str); i++ {
        if str[i] == '+' {
            lhs, err := strconv.Atoi(str[0:i])
            // 左辺が数値に変換できなかったらエラー
            if err != nil {
                return 0, err
            }
            rhs, err := strconv.Atoi(str[i+1:len(str)])
            // 右辺が数値に変換できなかったらエラー
            if err != nil {
                return 0, err    
            }
            return lhs + rhs, nil
        }
    }
    // "+"が文字列中になかったらエラー
    return 0, fmt.Errorf("no \"+\" found.")    
}

func main() {
    res, err := ParseAdd("123+45")
    fmt.Println(res) 
    // -> 168
    
    res, err = ParseAdd("x+y")
    fmt.Println(err) 
    // -> ParseAdd("x+y"): strconv.Atoi: parsing "x": invalid syntax
    
    res, err = ParseAdd("12345")
    fmt.Println(err)
    // -> ParseAdd("12345"): no "+" found.
} 

Wrapを使用する際は、まずnamed return valuesでerrを宣言し、次に関数の冒頭でWrapdeferします。その後は、処理のなかで生じたエラーをerrに格納し、そのままreturnするだけで、deferしたWrapが自動的にエラーをラップしてくれます。上記の例では、ParseAdd("x+y")など、エラーを引き起こした関数呼び出しの情報を付加しています。

derrors.WrapStack

スタックトレースを出力したい場合に使用します。

func WrapStack(errp *error, format string, args ...any) {
    if *errp != nil {
        if se := (*StackError)(nil); !errors.As(*errp, &se) {
            *errp = NewStackError(*errp)
        }
        Wrap(errp, format, args...)
    }
}

type StackError struct {
    Stack []byte
    err   error
}

func NewStackError(err error) *StackError {
    var buf [16 * 1024]byte
    n := runtime.Stack(buf[:], false)
    return &StackError{
        err:   err,
        Stack: buf[:n],
    }
}

func (e *StackError) Error() string {
    return e.err.Error()
}

func (e *StackError) Unwrap() error {
    return e.err
}

使用例

使用方法はWrapとほとんど同じです。先ほどと同じParseAddを使います(Go Playground)。

// "[lhs]+[rhs]"という形の文字列をパースし、その式の計算結果を返す
func ParseAdd(str string) (res int, err error) {
    
    defer WrapStack(&err, "ParseAdd(%q)", str)
    for i := 0; i < len(str); i++ {
        if str[i] == '+' {
            lhs, err := strconv.Atoi(str[0:i])
            // 左辺が数値に変換できなかったらエラー
            if err != nil {
                return 0, err
            }
            rhs, err := strconv.Atoi(str[i+1 : len(str)])
            // 右辺が数値に変換できなかったらエラー
            if err != nil {
                return 0, err
            }
            return lhs + rhs, nil
        }
    }
    // "+"が文字列中になかったらエラー
    return 0, fmt.Errorf("No \"+\" found.")
}

func main() {
    _, err := ParseAdd("x+y")
    var se *StackError
    errors.As(err, &se)
    fmt.Println(err)
    // -> ParseAdd("x+y"): strconv.Atoi: parsing "x": invalid syntax
    fmt.Println(string(se.Stack))

上のコードを実行すると、以下のようなスタックトレースが出力されます。

goroutine 1 [running]:
main.NewStackError({0x4bc068?, 0xc0000980c0})
	/tmp/sandbox64614835/prog.go:61 +0x45
main.WrapStack(0xc0000a6e78, {0x49cc39, 0xc}, {0xc0000a6e10, 0x1, 0x1})
	/tmp/sandbox64614835/prog.go:48 +0x8a
main.ParseAdd({0x49be7e, 0x3})
	/tmp/sandbox64614835/prog.go:20 +0x19c
main.main()
	/tmp/sandbox64614835/prog.go:35 +0x25
}

pkg.go.devでの使用例

エラー内容によるwrap処理の分岐

golang.org/x/pkgsite/internal/frontend/fetchserver/fetch.goInfo関数では、
以下のようにerrの内容に応じてラップに使用する関数を変更しています(derrors.WrapAndReportはwrapと同時にエラーレポートを行うための関数です。詳細は実装を参照してください)。

defer func() {
    // Don't report NotFetched, because it is the normal result of fetching
    // an uncached module when fetch is disabled.
    // Don't report timeouts, because they are relatively frequent and not actionable.
    wrap := derrors.Wrap
    if !errors.Is(err, derrors.NotFetched) && !errors.Is(err, derrors.ProxyTimedOut) && !errors.Is(err, derrors.NotFound) {
        wrap = derrors.WrapAndReport
    }
    wrap(&err, "proxy.Client.Info(%q, %q)", modulePath, requestedVersion)
}()

ログ出力を合わせて行う例

同じく golang.org/x/pkgsite/internal/frontend/fetchserver/fetch.go
FetchAndUpdateState関数では、以下のようにdefer文中でWrapと合わせてログ出力を行っています。

defer func() {
    if err != nil {
        log.Infof(ctx, "FetchAndUpdateState(%q, %q) completed with err: %v. ", modulePath, requestedVersion, err)
    } else {
        log.Infof(ctx, "FetchAndUpdateState(%q, %q) succeeded", modulePath, requestedVersion)
    }
    derrors.Wrap(&err, "FetchAndUpdateState(%q, %q)", modulePath, requestedVersion)
}()

wrapしない例

derrorsには、エラーをwrapする(後にunwrapすることを許す)のではなく、単にエラーメッセージに埋め込むだけのderrors.Add関数があります。実装はderrors.Wrap%w%vに置き換えただけです。pkg.go.devでは、
GCPなど外部APIと連携する箇所でAddを使用しているようです(使用例、参考: Working with Errors in Go 1.13)。

感想

deferとnamed return valueをうまく使うことで、特定の関数内で発生するエラーのwrapを一元化できていて美しいと感じました。
また、一元化によって、linterによるwrap漏れチェックがしやすくなるというメリットもあるのではないかと思います。

deferをエラーハンドリングに使用するという発想は全くなく、目から鱗でした。他にもdeferを使って簡潔にできる処理がないか探してみたいです。

Discussion