CLOVER🍀

That was when it all began.

Goのエラーに関するAPIを学ぶ

これは、なにをしたくて書いたもの?

Goの本を読んだり、サンプルコードを見ていたりすると、こういうのを目にするのですが。

    file, err = os.Create(filename)
    if err == nil {
        return
    }

nilかどうかの判定はさておき…。

エラーの中身や種類に踏み込みたい場合は?とか、自分でエラーを定義する場合は?あたりが気になるので、
ちょっと調べてみることにしました。

インプット、それから

今回のエントリの主な情報源は、以下です。

The Go Programming Language Specification / Errors

Effective Go / Errors

errors - The Go Programming Language

Working with Errors in Go 1.13 - The Go Blog

言語仕様、Effective Goそれぞれのエラーのセクション、errorsパッケージ、それからGo 1.13以降のエラーを扱うことに
ついてのブログです。

このあたりを見たり、実際に動かしてみていろいろ考えたのですが、こんな感じでしょうか?

  • 自分でエラーを作る、作らないに関係なく
    • エラーに関する条件判定にはerros.Isを使う
    • 具体的なエラーの型の変数に束縛する際には、erros.Asを使う
  • 自分でエラーを作る場合
    • erros.Newから始める
    • 他のエラーを原因とする、簡単なエラーを作成する場合はfmt.Errorfと%w書式を使う
    • エラーの内容が固定できる場合は、Errで始まる名前でパッケージからエクスポートしておく(※1)
    • 関数呼び出し時にエラーのインスタンスを作成する場合は、error.Isで比較可能な手段を提供しておく(※2)
      • Variableと直接比較できないから
      • エラーの発生原因等、比較可能な情報を使ってerror.Isさせることになる?
    • 自分でエラーに関する構造体を定義する場合は、Error、少なくともUnwrapメソッドを作成する
      • 型の名前はErrorで終わるようにする
      • 比較をカスタマイズする場合はIs、Asの作成も考える

Goを見ている感じ、※1の場合は通常の型として、※2の場合はエラーの型をポインタとすることになるのかな?という
印象を持ちました。

エラーとは?

Goの言語仕様に関する内容を見てみます。

The Go Programming Language Specification / Errors

Goでいうエラーとは、以下のように定義されたerrorインターフェースです。要するに、stringを返すErrorメソッドを
実装しなさい、と。

type error interface {
    Error() string
}

Goで使う多数の関数のうち、エラーが発生する可能性があるものは、成功した時の値とerrorのインスタンスを返すような
複数の戻り値を返せる定義になっています。

errorを返せる定義になっている関数の戻り値は、先に返されたerrorがnilかどうかを確認するところからスタート、となります。

ここで、Effective Goを見てみます。

Effective Go / Errors

errorがnilでない場合で、さらにエラーの中身に踏み込む場合(起こった事象や原因を知りたい場合など)は、
型アサーションを使って具体的な型の変数に代入してフィールドを見たりします。

for try := 0; try < 2; try++ {
    file, err = os.Create(filename)
    if err == nil {
        return
    }
    if e, ok := err.(*os.PathError); ok && e.Err == syscall.ENOSPC {
        deleteTempFiles()  // Recover some space.
        continue
    }
    return
}

これが、まずは基本(?)みたいですね。

errorsパッケージとfmtパッケージ

次に、errorsパッケージを見てみます。

errors - The Go Programming Language

errosパッケージには、errorを扱う関数が含まれています。

  • 文字列(メッセージ)からerrorを作成するNew
  • errorに別のerrorが含まれる場合、それを取り出すUnwrap
  • errorを(Unwrapすることも含めて)特定のerrorと比較するIs
  • errorを(Unwrapしていき)特定の型の引数に割り当てるAs

https://github.com/golang/go/blob/go1.15.6/src/errors/errors.go

https://github.com/golang/go/blob/go1.15.6/src/errors/wrap.go

また、fmtパッケージのErrorf関数を使うことでもerrorを作成できます。

Package fmt / func Errorf

Errorf関数は指定した文字列とその中に含まれる書式からerrorを作成しますが、この時に%w書式とその引数としてerrorを
含めることにより、errors.Unwrapすることができるerror(原因となるerrorを含んだerrorを作成します。

https://github.com/golang/go/blob/go1.15.6/src/fmt/errors.go

エラーの原因を含めない場合は、fmt.Errorfはerrors.Newと完全に同等です。

では、このあたりのAPIを使ってちょっと確認していってみましょう。

環境

今回の環境は、こちら。

$ go version
go version go1.15.6 linux/amd64

モジュール作成。

$ go mod init error-handling
go: creating new go.mod: module error-handling

確認は、テストコードで行います。
go.mod

module error-handling

go 1.15

require github.com/stretchr/testify v1.7.0

標準パッケージに定義されているエラーで確認してみる

まずは、標準パッケージに定義されているエラーで確認してみましょう。

簡単に起こせるエラーでパッと思いつくものとして、開こうとしたファイルがなかった場合、をお題にしましょう。

Package os / func Open

エラーが起こった場合、*PathErrorが返ってくるようです。

Package os / type PathError

テストコードの雛形を用意。
file_read_error_test.go

package main

import (
    "errors"
    "github.com/stretchr/testify/assert"
    "os"
    "syscall"
    "testing"
)

// ここに、テストを書く!

最初はシンプルに。返ってきたerrorがnilかどうかだけ確認。

func TestOpenFileSuccess(t *testing.T) {
    file, err := os.Open("file_read_error_test.go")

    if err == nil {
        assert.Equal(t, "file_read_error_test.go", file.Name())
    } else {
        assert.Failf(t, "can't open file", "target file = %s", "file_read_error_test.go")
    }
}

よくありそうなコードです。

次に、型アサーションを使って*PathErrorとして扱ってみます。

The Go Programming Language Specification / Type assertions

func TestOpenFileFailureTypeAssertion1(t *testing.T) {
    _, err := os.Open("not_found_file")

    if err == nil {
        assert.FailNow(t, "file exists")
    }

    if pathError, isPathError := err.(*os.PathError); isPathError {
        assert.Equal(t, "open", pathError.Op)
        assert.Equal(t, "not_found_file", pathError.Path)
        assert.Equal(t, "no such file or directory", pathError.Err.Error())

        assert.Equal(t, syscall.ENOENT, pathError.Err)

        errno, isErrno := pathError.Err.(syscall.Errno)
        assert.True(t, isErrno)
        assert.True(t, errno.Is(os.ErrNotExist))
    } else {
        assert.FailNow(t, "not PathError")
    }
}

*PathError型にできてしまえば、いくつかフィールドがあるのでもう少し詳細な内容を確認できます。

       assert.Equal(t, "open", pathError.Op)
        assert.Equal(t, "not_found_file", pathError.Path)
        assert.Equal(t, "no such file or directory", pathError.Err.Error())

また*PathErrorはErrというフィールドにエラーの発生原因を持っていて、こちらでosパッケージを使ってエラーの種類を
確認できます。

       assert.Equal(t, syscall.ENOENT, pathError.Err)

        errno, isErrno := pathError.Err.(syscall.Errno)
        assert.True(t, isErrno)
        assert.True(t, errno.Is(os.ErrNotExist))

osパッケージに定義されているエラーは、Variablesを見るとわかります。

Package os / Variables

今回はファイルが存在しないので、ErrNotExistとなります。

なお、これらのエラーはsyscallパッケージのErrnoという型(実体はuintptr)です。

Package syscall / type Errno

型の判定は、Type switchesを使う方法もあるでしょう。

The Go Programming Language Specification / Type switches

func TestOpenFileFailureTypeAssertion2(t *testing.T) {
    _, err := os.Open("not_found_file")

    if err == nil {
        assert.FailNow(t, "file exists")
    }

    switch err := err.(type) {
    case *os.PathError:
        assert.Equal(t, "open", err.Op)
        assert.Equal(t, "not_found_file", err.Path)
        assert.Equal(t, "no such file or directory", err.Err.Error())

        assert.Equal(t, syscall.ENOENT, err.Err)

        switch errno := err.Err.(type) {
        case syscall.Errno:
            assert.True(t, errno.Is(os.ErrNotExist))
        default:
            assert.FailNow(t, "not Errno")
        }

        switch err.Err {
        case syscall.ENOENT:
            // no-op
        default:
            assert.FailNow(t, "not ENOENT")
        }
    default:
        assert.FailNow(t, "not PathError")
    }
}

この部分に関しては、値でも比較できます。

       switch err.Err {
        case syscall.ENOENT:
            // no-op
        default:
            assert.FailNow(t, "not ENOENT")
        }

次に、これらをerrorsパッケージを使って書き換えていきましょう。

errors - The Go Programming Language

Asから。Asを使用すると、エラー(とその内部的に持っている原因となったエラー)に対して指定した変数の型に合致するものが
あれば、その変数に値を設定しtrueを返します。そうでなければfalseを返します。

Package errors / func As

こんな感じですね。先ほどの*PathErrorに対する型アサーションを書き換えたものです。

func TestOpenFileFailureUsingErrorsAs1(t *testing.T) {
    _, err := os.Open("not_found_file")

    if err == nil {
        assert.FailNow(t, "file exists")
    }

    var pathError *os.PathError
    if errors.As(err, &pathError) {
        assert.Equal(t, "open", pathError.Op)
        assert.Equal(t, "not_found_file", pathError.Path)
        assert.Equal(t, "no such file or directory", pathError.Err.Error())

        assert.Equal(t, syscall.ENOENT, pathError.Err)

        var errno syscall.Errno
        if errors.As(err, &errno) {
            assert.True(t, errno.Is(os.ErrNotExist))
        } else {
            assert.FailNow(t, "not Errno")
        }
    } else {
        assert.FailNow(t, "not PathError")
    }
}

内部的に持っているエラー…チェインされたエラーに対しても見れるという話なので、*PathErrorからいきなりsyscall.Errnoまで
引き抜くことも可能です。

func TestOpenFileFailureUsingErrorsAs2(t *testing.T) {
    _, err := os.Open("not_found_file")

    if err == nil {
        assert.FailNow(t, "file exists")
    }

    var errno syscall.Errno
    if errors.As(err, &errno) {
        assert.True(t, errno.Is(os.ErrNotExist))
    } else {
        assert.FailNow(t, "not Errno")
    }
}

次は、Isです。Isを使うと、エラー(とその内部的に持っている原因となったエラー)に対して、引数で指定したerrorに
合致するものがあれば、trueを返します。そうでなければfalseを返します。

Package errors / func Is

要するに、Asで変数に値を束縛しない版みたいなものです。

シンプルに、条件分岐で使うのでしょう。

func TestOpenFileFailureUsingErrorsIs(t *testing.T) {
    _, err := os.Open("not_found_file")

    if err == nil {
        assert.FailNow(t, "file exists")
    }

    if errors.Is(err, os.ErrNotExist) {
        pathError, _ := err.(*os.PathError)
        assert.Equal(t, "no such file or directory", pathError.Err.Error())
    } else {
        assert.FailNow(t, "not PathError")
    }
}

*PathErrorの場合、値として比較するerrorがないのでos.ErrNotExistで比較しました。これでtrueになります。

   if errors.Is(err, os.ErrNotExist) {

最後はUnwrapです。Unwrapを使うと、指定したerrorがUnwrapメソッドを実装していた場合、Unwrapメソッドの
呼び出し結果を返します。要するに、errorの原因が返ります。Unwrapメソッドを実装していなかった場合は、nilが
返ります。

Package errors / func Unwrap

こんな感じです。

func TestOpenFileFailureUsingErrorsUnwrap(t *testing.T) {
    _, err := os.Open("not_found_file")

    if err == nil {
        assert.FailNow(t, "file exists")
    }

    unwrapped := errors.Unwrap(err)

    if errno, isErrno := unwrapped.(syscall.Errno); isErrno {
        assert.True(t, errno.Is(os.ErrNotExist))
    } else {
        assert.FailNow(t, "not PathError")
    }
}

ファイル未存在の場合の*PathError型の変数をerrors.Unwrapに渡すと、その原因(syscall.Errno)が取得できます。

errorsパッケージの使い方はこんな感じです。

ところで、今回のお題では*PathError、syscall.Errno、os.ErrNotExistと3つ扱うことになっていろいろややこしかったのですが、
改めてと見ると

という定義になっています。いずれも、errorインターフェースの定義を満たしています。

*PathErrorはErrフィールドをUnwrapで返すようになっていたことから、

https://github.com/golang/go/blob/go1.15.6/src/os/error.go#L58

*PathError → 原因 → syscall.Errno → 実体の型 or 原因 → os.ErrNotExistみたいな関係になっているのかなと思ったのですが。

そうではなく、syscall.ErrnoはIsで受け取ったerrorの値を使った比較処理を行っていたので

https://github.com/golang/go/blob/go1.15.6/src/syscall/syscall_unix.go#L126-L136

*PathErrorをUnwrapした結果を==で直接os.ErrNotExistと比較してもtrueにならずに、だいぶ混乱しました。
errors.Isで比較すると、trueになるのに…と。

Isの使い方をちゃんと見た感じですね…。

また、Errnoの実体が持つエラーに割り当てられた数字は、以下で確認でき、

Package syscall / Constants

https://github.com/golang/go/blob/go1.15.6/src/syscall/tables_js.go#L102-L228

os.Err〜に割り当てられる実体は、こちらを見ると確認できます。

https://github.com/golang/go/blob/go1.15.6/src/os/error.go#L29-L34

だいぶ、型に振り回されましたね…。

自分でエラーを定義する

ここまでは、標準パッケージに定義されているエラーを使いましたが、ここからは自分でエラーを定義していきましょう。

コードの雛形を書いていきます。

エラーを定義する方。
myerrors.go

package main

import (
    "errors"
)

// ここに、エラーを定義していく

テストコードを書く方。
myerrors_test.go

package main
import (
    "errors"
    "fmt"
    "github.com/stretchr/testify/assert"
    "os"
    "syscall"
    "testing"
)

// ここに、テストを書く!

自分でエラーを定義する1番シンプルなパターンとしては、errors.New、fmt.Errorfを使う方法かな、と。

Package errors / func New

Package fmt / func Errorf

errors.Newは文字列からerrorを作成し、fmt.Errorfは書式も使えつつ文字列からerrorを作成します。

テストコードで定義すると、こんな感じに。

func TestCreateError(t *testing.T) {
    error1 := errors.New("Oops!!")

    assert.Error(t, error1)
    assert.Equal(t, "Oops!!", error1.Error())
    assert.Nil(t, errors.Unwrap(error1))

    error2 := fmt.Errorf("Oops!!")

    assert.Error(t, error2)
    assert.Equal(t, "Oops!!", error2.Error())
    assert.Nil(t, errors.Unwrap(error2))
}

どちらもErrorメソッドを実装しています。この場合は、errors.Unwrapを呼び出しても双方nilです。

ただ、fmt.Errorfの場合、書式に%wを含め、そこにerrorをあてはめるとUnwrapで指定したerrorが返るように構築されます。

こんな感じですね。

func TestWrapedError1(t *testing.T) {
    _, err := os.Open("not_found_file")

    wrappedError := fmt.Errorf("Open file error, reason [%w]", err)

    assert.Error(t, wrappedError)
    assert.Equal(t, "Open file error, reason [open not_found_file: no such file or directory]", wrappedError.Error())
    assert.Equal(t, "*fmt.wrapError", fmt.Sprintf("%T", wrappedError))

    causeError := errors.Unwrap(wrappedError)
    assert.True(t, err == causeError)

    assert.True(t, errors.Is(wrappedError, os.ErrNotExist))
    assert.ErrorIs(t, wrappedError, os.ErrNotExist)
}

ファイル未存在のエラーを元にして%wに与え

   _, err := os.Open("not_found_file")

    wrappedError := fmt.Errorf("Open file error, reason [%w]", err)

これをUnwrapすると%wに指定したものと同じであることが確認できます。

   causeError := errors.Unwrap(wrappedError)
    assert.True(t, err == causeError)

あまり意味はないですが、%wだけを与えてUnwrapで返させることもできます。

func TestWrapedError2(t *testing.T) {
    _, err := os.Open("not_found_file")

    wrappedError := fmt.Errorf("%w", err)

    assert.Error(t, wrappedError)
    assert.Equal(t, "open not_found_file: no such file or directory", wrappedError.Error())
    assert.Equal(t, "*fmt.wrapError", fmt.Sprintf("%T", wrappedError))

    causeError := errors.Unwrap(wrappedError)
    assert.True(t, err == causeError)

    assert.True(t, errors.Is(wrappedError, os.ErrNotExist))
    assert.ErrorIs(t, wrappedError, os.ErrNotExist)
}

myerrors.go側に、Variableとしてerrorを定義(エクスポート)し、関数呼び出しの結果としてerrorを返すようにしてみましょう。

var (
    ErrMySimpleError = errors.New("My Error")
)

func GetMySimpleErr() error {
    return ErrMySimpleError
}

Variableとしてエクスポートするerrorの名前は、Errで始めるのが通例のようです。

テストコードはこんな感じで。

func TestGetMySimpleErr(t *testing.T) {
    err := GetMySimpleErr()

    assert.Equal(t, ErrMySimpleError, err)

    assert.Equal(t, "My Error", err.Error())
    assert.Equal(t, "*errors.errorString", fmt.Sprintf("%T", err))
}

最後に、エラーを構造体で定義してみます。

2つ用意して、片方はエラーの原因を持てるようにしました。ところで、エラーとなる型の名前はErrorで終わらせるのが通例の
ようです。1とか2とか付けてしまいました…。

type MyError1 struct {
    Message string
}

func (e MyError1) Error() string {
    return e.Message
}

func (e MyError1) Is(target error) bool {
    return errors.Is(e, target)
}

type MyError2 struct {
    Message string
    Cause   error
}

func (e *MyError2) Error() string {
    return e.Message + ": " + e.Cause.Error()
}

func (e *MyError2) Unwrap() error {
    return e.Cause
}

Errorメソッドを実装するのはerrorとしての必須条件ですが、MyError1の方はIsを、MyError2の方はUnwrapを実装して
おきました。

MyError1の方は、Variableとして固定で定義します。

var (
    ErrMyError = MyError1{Message: "Oops!!"}
)

MyError2の方は、実行時にエラーの原因を含んで動的に作成することにします。

これら2つのエラーを、引数の指定でどちらかのエラー(もしくは正常に値を返す)を返すような関数を作成します。

func Execute(word string, cause error) (string, error) {
    switch word {
    case "Error1":
        return "NG", ErrMyError
    case "Error2":
        e := &MyError2{Message: "Oops!!", Cause: cause}
        return "NG", e
    default:
        return "OK", nil
    }
}

MyError2の方は、この関数呼び出し時に構造体のインスタンスを作成していますが、ポインタを返すようにしました。
*PathErrorと同じですね。

https://github.com/golang/go/blob/go1.15.6/src/os/file_unix.go#L210

他のパッケージもいくつか見たのですが、関数の呼び出し時にエラー発生時の情報を含めるなどの理由でインスタンスを作ったものは、
ポインタを返しているように思います。それに習いました。

テストコードで確認してみます。

MyError1の確認。

func TestMyCustomError1(t *testing.T) {
    _, err := os.Open("not_found_file")

    result, myerr := Execute("Error1", err)

    assert.Equal(t, "NG", result)

    assert.True(t, myerr == ErrMyError)

    assert.Equal(t, "Oops!!", myerr.Error())
    assert.True(t, errors.Is(myerr, ErrMyError))
    assert.Nil(t, errors.Unwrap(myerr))

    var e MyError1
    assert.True(t, errors.As(myerr, &e))
}

関数の戻り値となったerrorを、==やerrors.Isで比較できます。

   assert.True(t, myerr == ErrMyError)

    assert.True(t, errors.Is(myerr, ErrMyError))

Unwrapは実装していないのでnilですね。

   assert.Nil(t, errors.Unwrap(myerr))

errors.Asで変数に束縛もできます。

   var e MyError1
    assert.True(t, errors.As(myerr, &e))

続いて、MyError2を使う方。

func TestMyCustomError2(t *testing.T) {
    _, err := os.Open("not_found_file")

    _, myerr := Execute("Error2", err)

    assert.Equal(t, "Oops!!: open not_found_file: no such file or directory", myerr.Error())

    assert.True(t, errors.Is(myerr, os.ErrNotExist))
    assert.True(t, errors.Unwrap(myerr) == err)

    var e *MyError2
    assert.True(t, errors.As(myerr, &e))

    var errno syscall.Errno
    assert.True(t, errors.As(myerr, &errno))
}

Isメソッドは実装していないのですが、普通に使えます。

   assert.True(t, errors.Is(myerr, os.ErrNotExist))

Unwrapメソッドを実装していれば、そちらも見てくれるので。

https://github.com/golang/go/blob/go1.15.6/src/errors/wrap.go#L39-L59

単純比較、Is、Unwrapと推移していくので、比較に特殊な条件がなければUnwrapを実装しておくのがよいのでしょう。

errors.Unwrapでは、エラーの原因が取得できます。これは、Unwrapメソッドを実装したからですね。

   assert.True(t, errors.Unwrap(myerr) == err)

errors.Asでの変数への束縛。

   var e *MyError2
    assert.True(t, errors.As(myerr, &e))

こちらも、Asメソッドを実装していなくてもUnwrapメソッドの呼び出し結果を使ってくれます。

https://github.com/golang/go/blob/go1.15.6/src/errors/wrap.go#L77-L101

*PathErrorの時と同じように、errorsAsで一気にsyscall.Errno`を取得することもできます。

   var errno syscall.Errno
    assert.True(t, errors.As(myerr, &errno))

こんな感じでしょうか。

まとめ

Goのエラーに関するAPIがよくわからなかったので、標準ライブラリの使い方、自分でエラーを定義する場合を含めて、
いろいろ試してみました。

ある程度使い方がわかったかな?という気がします。