想定している読者

Goのエラーハンドリングについて体系だった記事が見つからなかったので、色々調べたことを整理して備忘録も兼ねて記事にしました。
以下のような方を読者対象に記事を書いています。

    • Goのエラーハンドリングの基本的なやり方を知りたい

 

    • スタックトレースを標準エラー出力したい

 

    エラーの種類に応じてステータスコードを変えたい

まずデファクトになっているエラーパッケージpkg/errorsの使い方を確認して、実際のWebアプリケーションで追加で実装しないといけないことを説明していきます。

そもそもerrorとは?

goのエラーは以下のようなエラーメッセージを返す関数を実装していれば満たせるインターフェースです。

// cf. https://golang.org/pkg/builtin/#error
type error interface {
  Error() string
}

実際には返されたエラーがnilかどうかで条件分岐して、nilでない場合はerror.Error()でエラー内容を出力するような使われ方をします。
例として、引数で与えられたファイルを開いて、それをJSONとして扱って、map[string]interface{}型の値(jsonMap)に変換してみます。

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
)

func main() {
  // 中身が空のファイルをJSONとして扱うとjson.Unmarshalでエラーになる
    if _, err := unmarshalToMap("src.json"); err != nil {
    // err.Error()の結果が出力される
    fmt.Println(err)
    // unexpected end of JSON input
  }
}

func unmarshalToMap(src string) (map[string]interface{}, error) {
    jsonMap := map[string]interface{}{}
  // ファイルパスからファイルを読み込む
    data, err := ioutil.ReadFile(src)
    if err != nil {
        return jsonMap, err
    }
  // ファイルの内容をJSONとみなして、key, valueの対応をmap[string]interface{}型の値にする
    if err := json.Unmarshal(data, &jsonMap); err != nil {
        return nil, err
    }
    return jsonMap, nil
}

しかし、引数で渡されたファイルsrc.jsonは何も書かれていないので(JSONの書式に沿っていないので)エラーが返ってきます。

$ go run main.go
unexpected end of JSON input

このように、Error() stringさえ実装してエラーメッセージを返せればerror型を満たすことができます。

一見、上記のような方法でも問題がなさそうですが、実際のアプリケーションは実装が多くなり、複雑になりがちにも関わらず、unexpected end of JSON input というエラー内容だけだと、どのファイルの内容をjson.Umarshalした時に起こったのか分からないとデバッグが困難になります。そこで重要なのが、返ってきたエラーに「何を」、「どこで」、「どんな処理で」起こったのかコンテキスト情報を付与することです。

コンテキスト情報をエラー内容に含める

返ってきたエラーにコンテキスト情報を付与する一番簡単な方法はfmt.Errorを利用することです。
これは、指定したフォーマットにしたがって、第二引数以降をフォーマットし、新しいエラーメッセージを持つエラーを作って返すことができます。

先ほどの実装でfmt.Errorfを使ってみます。

func unmarshalToMap(src string) (map[string]interface{}, error) {
    jsonMap := map[string]interface{}{}
    // ファイルパスからファイルを読み込む
    data, err := ioutil.ReadFile(src)
    if err != nil {
        return jsonMap, err
    }
    if err := json.Unmarshal(data, &jsonMap); err != nil {
        return nil, fmt.Errorf("read %s, %s", src, err) // ここをfmt.Errorfに置き換えた
    }
    return jsonMap, nil
}

これによって以下のようにどのファイルを読み込んでエラーがでたのかメッセージに含めることができます。

$ go run main.go
read src.json, unexpected end of JSON input

このようにfmt.Errorfを使うことで「どこで」、「どんな処理で」エラーが起こったのか知ることができます。

fmt.Errorfの問題点

しかし、これで問題なしかというとそうではありません。理由はfmt.Errorfは元のerrorインターフェースを実装するある型と値を消失させるからです。
fmt.Errorfの実装を見ると、内部でerrors.Newを呼び出していることがわかります。

// Errorf formats according to a format specifier and returns the string
// as a value that satisfies error.
func Errorf(format string, a ...interface{}) error {
    return errors.New(Sprintf(format, a...))
}

errors.Newとは、errorインターフェースを実装したエラーメッセージだけを持つ構造体を返します。

// cf. https://golang.org/pkg/errors/#New

// New returns an error that formats as the given text.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

しかし、ライブラリによって、独自のerrorインターフェースを実装した構造体を定義して、エラーメッセージ以外の情報を付与していることがよくあります。これによって、受け取った(errorインターフェース型に抽象化された)エラーを型アサーションして元の型に戻して、付加された値を取り出すことができます。fmt.Errorfは上記のerrorString構造体に作り直してしまうのでこれをできなくしてしまいます。

例えば、JSONのKey, Valueの対応はSliceで表現することはできないのでUnmarshalするとエラーになりますが、そのエラーはJSONのバイト列におけるエラーが起こった位置やUnmarshalしようとした型の名前をエラーメッセージ以外を持っています。

具体的には、以下のUnmarshalTypeErrorがerrorインターフェースを実装しており、それが返ってきます。

// cf. https://golang.org/pkg/encoding/json/#UnmarshalTypeError

// An UnmarshalTypeError describes a JSON value that was
// not appropriate for a value of a specific Go type.
type UnmarshalTypeError struct {
    Value  string       // description of JSON value - "bool", "array", "number -5"
    Type   reflect.Type // type of Go value it could not be assigned to
    Offset int64        // error occurred after reading Offset bytes
    Struct string       // name of the struct type containing the field
    Field  string       // name of the field holding the Go value
}

func (e *UnmarshalTypeError) Error() string {
    if e.Struct != "" || e.Field != "" {
        return "json: cannot unmarshal " + e.Value + " into Go struct field " + e.Struct + "." + e.Field + " of type " + e.Type.String()
    }
    return "json: cannot unmarshal " + e.Value + " into Go value of type " + e.Type.String()
}

実際に、対応していない型にUnmarshalするとUnmarshalTypeError型に型アサーションして、エラーメッセージ以外を取り出してみます。

import (
    "encoding/json"
    "fmt"
)

var jsonData = []byte(`
{
    "name": "user",
    "password": "pass"
}
`)

func main() {
  // []string型はJSONのKey, Valueの内容を持つことができないのでUnmarshalするとエラーになる
    valueOfInvalidType := make([]string, 0)
    err := json.Unmarshal(jsonData, &valueOfInvalidType)
    switch err := err.(type) {
    case *json.UnmarshalTypeError:
        fmt.Printf("type: %s\n", err.Type)
        fmt.Printf("offet: %d\n", err.Offset)
        fmt.Printf("Error(): %s\n", err)
    default:
        fmt.Println(err)
    }
}
$ go run main.go
type: []string
offet: 2
Error(): json: cannot unmarshal object into Go value of type []string

このようにしたくとも、fmt.Errorfが既存のError()の結果以外の情報を切り捨ててしまうので、以下のような問題に直面します。

    • errorインターフェースの元の型に応じて条件分岐ができなくなる

 

    errorインターフェースの元の型の持つコンテキスト情報が消失する

これらの問題を解決する手段でかつ、現在(2019/01/22)でデファクトとなっているエラーパッケージとして、pkg/errorsがあります。

pkg/errorsを使う

では、具体的にpkg/errorsで上記問題を解決できるのかというと、以下のWrapとCauseを用います。

func Wrap(err error, message string) error
func Cause(err error) error

まず、Wrapを使うことで元のerrorインターフェースを実装した型と値を保持して、エラーメッセージだけコンテキスト情報を追加した新しいものにできます。

if err := json.Unmarshal(data, &jsonMap); err != nil {
    // failed to unmarshal src.json: unexpected end of JSON input
        return nil, errors.Wrap(err, "failed to unmarshal src.json")
}

この結果だけ見れば、fmt.Errorf(“failed to unmarshal scr.json: %s”, err)した結果と同じですが、%+vでフォーマットするとStackTraceを出力することもできます。

// cf. https://godoc.org/github.com/pkg/errors#hdr-Formatted_printing_of_errors
if err := json.Unmarshal(data, &jsonMap); err != nil {
    fmt.Printf("%+v", err)
}
main.unmarshalToMap
        /go/src/github.com/shoichiimamura/error-handling-example/main.go:30
main.main
        /go/src/github.com/shoichiimamura/error-handling-example/main.go:18
runtime.main
        /usr/local/go/src/runtime/proc.go:198
runtime.goexit

さらにCauseを使うことでWrapされたエラーから元のerrorインターフェースを実装した型と値を取り出すことができます。
より厳密に言うと、causerインターフェースを実装していない一番最後のerrorインターフェース型を取り出します。

// cf. https://github.com/pkg/errors/blob/master/errors.go#L269
func Cause(err error) error {
    type causer interface {
        Cause() error
    }

    for err != nil {
        cause, ok := err.(causer)
        if !ok {
            break
        }
        err = cause.Cause()
    }
    return err
}

これを使うと、以下のように元のエラーの型に応じた条件分岐が可能になります。

switch err := errors.Cause(err).(type) {
case *json.UnmarshalTypeError:
  fmt.Println(err.Offset)
case *json.InvalidUnmarshalError:
  fmt.Println(err.Type)
default:
  fmt.Println(err)
}

このCauseでエラーの型に応じた条件分岐ができることがわかりました。

しかし、実際のAPIを持つアプリケーションサーバーの開発では、エラーの種類に応じてHTTPステータスコードを変えたいことがよくあります。これを実現するためには、pkg/errorsを拡張したエラーパッケージを作る必要があります。次はpkg/errorsを利用して、エラーの種類の応じたHTTPステータスコードを返す実装をしてみます。

エラーに応じてステータスコードを選ぶ

pkg/errorsを使った以下のようなerrorインターフェースを実装した構造体を作ることで、WrapやCauseを使えつつ、エラータイプを取得できるようになります。errors.Wrapfなどは説明を簡単にするためにラップしていませんが、同じような要領で実装することもできます。

import (
    "github.com/pkg/errors"
)

// ErrorType エラーの種類
type ErrorType uint

const (
    Unknown ErrorType = iota
    InvalidArgument
    Unauthorized
    ConnectionFailed
)

// ErrorTypeを返すインターフェース
type typeGetter interface {
    Type() ErrorType
}

// ErrorTypeを持つ構造体
type customError struct {
    errorType     ErrorType
    originalError error
}

// New 指定したErrorTypeを持つcustomErrorを返す
func (et ErrorType) New(message string) error {
    return customError{errorType: et, originalError: errors.New(message)}
}

// Wrap 指定したErrorTypeと与えられたメッセージを持つcustomErrorにWrapする
func (et ErrorType) Wrap(err error, message string) error {
    return customError{errorType: et, originalError: errors.Wrap(err, message)}
}

// Error errorインターフェースを実装する
func (e customError) Error() string {
    return e.originalError.Error()
}

// Type typeGetterインターフェースを実装する
func (e customError) Type() ErrorType {
    return e.errorType
}

// Wrap 受け取ったerrorがErrorTypeを持つ場合はそれを引き継いで与えられたエラーメッセージを持つcustomErrorにWrapする
func Wrap(err error, message string) error {
    we := errors.Wrap(err, message)
    if ce, ok := err.(typeGetter); ok {
        return customError{errorType: ce.Type(), originalError: we}
    }
    return customError{errorType: Unknown, originalError: we}
}

// Cause errors.CauseのWrapper
func Cause(err error) error {
    return errors.Cause(err)
}

// GetType ErrorTypeを持つ場合はそれを返し、無ければUnknownを返す
func GetType(err error) ErrorType {
    for {
        if e, ok := err.(typeGetter); ok {
            return e.Type()
        }
        break
    }
    return Unknown
}

これによって、任意のErrorTypeを持つエラーを作って、Controller層でそれを取り出し、対応するステータスコードを選ぶことができます。

func main() {
  err := Unauthorized.New("ある認証の処理内で返されたエラー")
  fmt.Println(statusCode(err)) // 401
}

func statusCode(err error) int {
  switch GetType(err) {
  case ConnectionFailed:
    return http.StatusInternalServerError // 500
  case Unauthorized:
    return http.StatusUnauthorized // 401
  default:
    return http.StatusBadRequest // 400
  }
}

Panicでアプリケーションをクラッシュさせない

panicとは関数呼び出し元の処理を連続的に中断するgoの組み込み関数のことです。
明示的に呼び出すこともできますし、他にはnilポインタにメソッド呼び出しをした時などでも起きます。

type User struct {
  Name string
}
func (u *User) Name() string {
  return u.Name
}

var user *models.User
user.Name() // panic

panicが起こるとdefer内でrecover(後述)しないと、プログラムはCrashしてしまうので、そうさせないようにPanicが起きた旨をエラーにして返してあげるようにします。そのためには、まず、deferとrecoverについて概要を押さえる必要があります。

deferは、関数を登録することができ、その定義元の関数がreturnされた後に呼び出され、panicが起こった場合も呼びされます。
そのため以下の実装は、returnされた値(i)をdeferでインクリメントして出力しているので、結果は2になります。

func a() (i int) {
    defer func() {
        i++
        fmt.Printf("%d\n", i)
    }()
    return 1
}

また、deferに登録した関数は後入れ先出し(Last in First out)で呼び出されるので、以下の実装は3210を出力します。

func b() {
    for i := 0; i < 4; i++ {
        defer fmt.Print(i)
    }
}

そして、recoverはpanicが起こったgoroutineを再び制御する組み込み関数で、panicが起こった後にdeferが呼ばれ、その中でpanicの伝播を止める役割を担います。defer外でrecoverしてもpanic時には呼び出されず、nilを返すだけなので、この使われ方以外はなさそうです。

以下は、panicが起こった時にdefer内でrecoverを呼び出し、panicの伝播を止めてエラーを返しています。

import (
    "fmt"
    "github.com/pkg/errors"
)

func panicAndRecover() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = errors.New(fmt.Sprintf("recovered: %v\n", r))
        }
    }()
    panic("panic at panicAndRecover")
    return
}

func main() {
  err := panicAndRecover()
  fmt.Println(err)
  // recovered: panic at panicAndRecover
}

こうすることで、panicが起こってもエラーとして扱うことができます。

参考

    • Golang — handling errors gracefully

 

    • pkg/errors

 

    Defer, Panic, and Recover
广告
将在 10 秒后关闭
bannerAds