🐁

[Golang] エラーハンドリング周りの知識

2022/09/29に公開約6,600字

そもそものGoのerrorとは?

Goのエラーは単なる値である。と実用Go言語では記載されている。
これはどういったことか。詳しく見てみると、

  • Goのエラーはerror型のinterfaceを満たした単なる値である
    と解説がある。

Goのランタイムに組み込まれており、interfaceとして定義されている。

type error interface {
  Error() string
}

このinterfaceを満たしているものは error intarfaceを満たしていることになるため、 error として扱うことが可能になる。

Goでエラーを書く方法

主に3つ存在する。

  1. 標準ライブラリ:errors.New()
  2. 標準ライブラリ : fmt.Errorf()
  3. 独自でerror interfaceを満たす型を作成する

標準ライブラリ:errors.New() での実装

使い方は以下。
単純に呼び出し元にエラーであることを伝えるときに使用することが多い。

var ErrNotUser = errors.New("user not found")

標準ライブラリ : fmt.Errorf() での実装

フォーマットされた文字列を元にエラーを生成できる。また、エラーをラップすることで抽象度をあげたエラー内容にすることもできる。
エラーのラップについては後述。

func validate(length int) error {
  if length <= 0 {
    return fmt.Errorf("length must be greater than 0, length = %d", length)
  }
}

独自でerror interfaceを満たす型を実装

エラーが発生した時の情報を構造体のフィールドとして保持しておきたい場合などに有効。

type HTTPError struct {
        StatusCode int
        URL        string
}

func (he *HTTPError) Error() string {
	return fmt.Sprintf("http status code = %d, url = %s", he.StatusCode, he.URL)
}

func ReadContents(url string) ([]byte, error) {
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if err.StatusCode != http.StatusOK {
		return nil, &HTTPError{
			StatusCode: resp.StatusCode,
			URL:        url
		}
	}
}

エラーのラップ、アンラップ

初めラップとアンラップを聞いたときは全く理解ができなかった。ただ、ラップは既存のエラーの上から何かを被せるようなイメージ、アンラップはその反対であることは想像がついた。

以下の記事がラップについて噛み砕いて説明してくれており、わかりやすかった。
https://qiita.com/egawata/items/fcf3f5918f9a5284dc2d

エラーをラップする

エラーをラップするには、さきほどエラーを書く方法で説明した fmt.Errorf を使ってできる。
errors package のOverviewにも

ラップされたエラーを作る簡単な方法は、fmt.Errorfを呼び出し、エラーの引数に%w動詞を適用することである。

と記載があった。

通常は fmt.Errorferrors.errorString 型のエラーを返すが、以下の条件を満たす場合、 fmt.wrapError 型のエラーを返すようになる。

  • フォーマット演算子に%w を使うこと
  • %wを置き換えるのが error型であること

fmt.Errorfの実装を確認

func Errorf(format string, a ...interface{}) error {
	p := newPrinter()
	p.wrapErrs = true
	p.doPrintf(format, a)
	s := string(p.buf)
	var err error
        // 以下の処理でエラーの型を決定している。
	if p.wrappedErr == nil {
		err = errors.New(s)
	} else {
		err = &wrapError{s, p.wrappedErr}
	}
	p.free()
	return err
}

p.wrappedErrがnilか確認を行い、その結果によってエラーの型を変更していることがわかる。

まずは errors.errorString型がどのような型かを確認する。

type errorString struct {
	s string
}

errors.errorString はエラー内容を単純な文字列として保持している。

そして、fmt.wrapError型も確認する

type wrapError struct {
	msg string // ラップ後のエラー文字列を格納する
	err error // ラップ前のエラー文字列を格納する
}

fmt.wrapErrorerrに元のエラーを格納することでエラーのラップを可能にしている。
これを踏まえて、fmt.Errorfを使ってエラーのラップをしてみる。

user, err := getUser(email)
if err != nil {
	return fmt.Errorf("fail to get user with email(%s): %w", email, err)
}

上記のように使える。

なぜラップするのか、何が嬉しいのか

エラーに情報が追加できるから (他にもあると思うけど、今までの経験からこれしかわからない)
例えば、 I/Oエラーが起きた(原因)というエラーメッセージよりも、コピーに失敗した(結果)というようなエラーメッセージの方が質が良いと思う。どのレイヤーで失敗しているかなどの情報を付与してあげることでエラーの原因を探りやすくなる。

例えば以下のような2つのエラー内容があったとして、
googleapi: Error 409: Already Exists: Table test, duplicate
duplicate

この2つのエラー内容を見て、より詳細が分かりやすいのは上記であることは明白。
このようにエラー内容をラップ(情報を付与)することでどこでどんなエラーが発生しているのかを確認しやすくできるのがメリット。

エラーをアンラップする

上記のようにラップされたエラーを取り出すためには、errors.Unwrapを使う。
fmt.Errorfでは、%wを使ってラップすることによってfmt.wrapError型になることが分かったが、fmt.wrapError型には Unwrapメソッドが定義されている。

使い方は以下

err := errors.New("deplicate")
newErr := fmt.Errorf("googleapi: Error 409: Already Exists: Table test, %w", err) // errをラップ
fmt.Println(newErr) // googleapi: Error 409: Already Exists: Table test, duplicate
fmt.Println(errors.Unwrap(newErr)) // deplicate

errors.Unwrapは以下のように実装されている。

func Unwrap(err error) error {
	u, ok := err.(interface { // 渡されたerrがUnwrapを実装しているか
		Unwrap() error
	})
	if !ok { // Unwrapを実装していなければnilを返す
		return nil
	}
	return u.Unwrap() // Unwrapを実装していればそのUnwrapを実行した結果を返す
}

fmt.wrapError型には Unwrapメソッドが定義されていると説明したが、そのUnwrapメソッドは以下のように定義されている。

type wrapError struct {
	msg string
	err error
}

func (e *wrapError) Error() string {
	return e.msg
}

// これ
func (e *wrapError) Unwrap() error {
	return e.err
}

Unwrapメソッドは単純にe.errを返却するだけであり、これは先述したようにラップ前のエラー文字列を格納しているため、

fmt.Println(errors.Unwrap(newErr)) // deplicate

という結果になる。
これを理解しておくと、この後エラーを比較するerrors.Iserrors.Asの理解が早くなると思った。

Goのエラーの比較方法

エラーの比較方法は2つ。

  • エラーを値として比較する errors.Is
  • エラーを型として比較する errors.As

errors.Is

errors.Isは、以下のように実装されている。

func Is(err, target error) bool {
	if target == nil {
		return err == target
	}

        // targetが比較可能であるか
	isComparable := reflectlite.TypeOf(target).Comparable()
	for {
                 // targetが比較可能であれば、errとtargetを比較
		if isComparable && err == target {
			return true
		}
                 // 比較できないエラーや == で合致しなかった場合は
                 // errにIs(error) bool が実装されているか確認し
                 // 実装されていればIsを使って評価する
		if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) {
			return true
		}
                 // Isを実装していない場合や、Isの結果が合致しなかった場合は
                 // errをUnwrapし、次のerrを比較する
                 // もしUnwrapできない場合はnilが返却されるので、その時はfalseが返る
		if err = Unwrap(err); err == nil {
			return false
		}
	}
}

errors.Isを使ってみる。

var ErrNotRead = errors.New("not readable")

func main() {
	readableErrByIs(ErrNotRead) // not readable
	readableErrByIs(fmt.Errorf("wrapping: %w", ErrNotRead)) // wrapping: not readable
}

func readableErrByIs(err error) {
	if errors.Is(err, ErrNotRead) {
		fmt.Println(err.Error())
	}
}

初めは実用Go言語に「値の比較」とあったため、not readableという文字列を比較していると思っていたが、実装を見ると「ラッピングされたエラーであっても、エラーが任意エラーと一致するかどうか」を検証していることが分かった。

errors.As

errors.Asは以下のように実装されている。

func As(err error, target any) bool {
	if target == nil {
		panic("errors: target cannot be nil")
	}
	val := reflectlite.ValueOf(target) // ①
	typ := val.Type()
	if typ.Kind() != reflectlite.Ptr || val.IsNil() { // ①'
		panic("errors: target must be a non-nil pointer")
	}
	targetType := typ.Elem()
	if targetType.Kind() != reflectlite.Interface && !targetType.Implements(errorType) { // ②
		panic("errors: *target must be interface or implement error")
	}
	for err != nil {
		if reflectlite.TypeOf(err).AssignableTo(targetType) { // ③
			val.Elem().Set(reflectlite.ValueOf(err)) // ④
			return true
		}
		if x, ok := err.(interface{ As(any) bool }); ok && x.As(target) {
			return true
		}
		err = Unwrap(err) // ⑤
	}
	return false
}

①, ①':引数のtargetに有効なポインターが入っているか確認
②:引数targetがインターフェースではない場合、もしくはerrorインターフェースであるか
③:引数targetに引数errが代入可能であるか確認
④:代入できればtrueを返す
⑤:Unwrapして次のエラーを比較する

errors.Asは「型として比較」すると記載しているが、これは上記③・④がその特徴を表しているんだなと思った。

まとめ

いままでなんとなく使っていたが、内部の実装を確認しにいったり、アウトプット前提で調査すると頭に入りやすいことを再認識できた。

Discussion

ログインするとコメントできます