173
113

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Golangのエラーハンドリングの基本

Last updated at Posted at 2019-01-27

想定している読者

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で上記問題を解決できるのかというと、以下のWrapCauseを用います。

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インターフェースを実装した構造体を作ることで、WrapCauseを使えつつ、エラータイプを取得できるようになります。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が起こってもエラーとして扱うことができます。

参考

173
113
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
173
113

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?