pkgsite/internal/derrors を使ったGoのエラーラッピング
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
を宣言し、次に関数の冒頭でWrap
をdefer
します。その後は、処理のなかで生じたエラーを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.go のInfo
関数では、
以下のように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