Carpe Diem

備忘録

Goの実プロジェクトでのエラーハンドリングの悩みどころと解決案

概要

Go言語に限らずではありますが

  • レイヤ間のエラー伝搬
  • 外部APIを叩いた時のエラーコードハンドリング
  • HTTPやgRPCとしてレスポンスを返す時のエラーハンドリング

で悩むことは多いと思います。
今回はそれの1つの方針を紹介します。

課題

レイヤ間のエラー伝搬

Goのエラーは基本的に例外を扱わず、常にハンドリングする前提です。なので

user, err := findUser(id)
if err != nil {
  return User{}, err
}

みたいなコードが大量に生まれます。

一方でその関数を呼び出した側ではそれがどんな種類のエラーなのか分かりません
NotFoundだったりDB自体のエラーだったりしますが、それを外層のレイヤ(HTTPレスポンスを返すハンドラなど)でハンドリングするのは辛いです。
レイヤ毎のエラー内容を知る必要が出てくるため余計な依存が生まれてしまいますし、どこにどんなエラーが有るか把握するのが難しくなって考慮漏れも出てきます。

外部APIを叩いた時のエラーコードハンドリング

外部APIはエラーの詳細が分かるように独自のエラーコードを定義しています。

Facebook Graph API Handling Errors

HTTP Status Codeでも4xx系エラーか5xx系エラーかは判定できますが、これらのエラーコードもちゃんとハンドリングしないと問題が起きた時の調査が難航します。

HTTPやgRPCとしてレスポンスを返す時のエラーハンドリング

エラーは最終的にHTTPやgRPCのレスポンスとして返す時にHTTP Status Code等に変換する必要があります。
この変換をする時に前述の問題が解決されていないと変換が難しくなります。

またエラーの種類がどれくらいあるのかも把握できず、ハンドリングが漏れて500エラーとして返ってしまう問題が起きたりします。

解決案

上記問題の解決策として、以下の2つの方針を取ります。

  • レイヤ間で共通の抽象化したエラーコードを用意する
  • レイヤ内で生成されたエラーはそのレイヤ内でハンドリングする

レイヤ間で共通のエラーコードを用意する

エラーコード

grpc-goのcodesパッケージのように共通の抽象化したエラーコードを用意しておきます。

例えば以下のようなパッケージを用意します。

.
└── codes/
    └── codes.go
package codes

type Code string

const (
        OK Code = "OK"

        InvalidSyntax  Code = "invalid_syntax"
        BadParams      Code = "bad_params"
        EmptyBody      Code = "empty_body"
        InvalidRequest Code = "invalid_request"

        Unauthorized Code = "unauthorized"

        NotFound Code = "not_found"

        Database    Code = "database_error"
        Redis       Code = "redis_error"
        Internal    Code = "internal_error"
        ExternalAPI Code = "external_api_error"
        Google      Code = "google_error"
        Apple       Code = "apple_error"
        AWS         Code = "aws_error"

        Forbidden Code = "forbidden"

        Unknown Code = "unknown"
)

このようにエラーコードを定義することで、エラーの種類がここですべて把握できるようになります。

共通関数

また先程のエラーコードを使いやすくするための汎用関数を用意しておきます。
システム内で使用するエラーを定義しておき、その内部にエラーコードや実際のエラーを保持しておきます。

package errors

import (
        "fmt"

        "github.com/jun06t/sample-server/codes"
        "github.com/pkg/errors"
)

type privateError struct {
        code codes.Code
        err  error
}

func (e privateError) Error() string {
        return fmt.Sprintf("Code: %s, Msg: %s", e.code, e.err)
}

// Errorf returns an error containing an error code and a description;
// Errorf returns nil if c is OK.
func Errorf(c codes.Code, format string, a ...interface{}) error {
        if c == codes.OK {
                return nil
        }
        return privateError{
                code: c,
                err:  errors.Errorf(format, a...),  // github.com/pkg/errorsでラップする
        }
}

// Code returns the error code for err if it was produced by this system.
// Otherwise, it returns codes.Unknown.
func Code(err error) codes.Code {
        if err == nil {
                return codes.OK
        }
        var e privateError
        if errors.As(err, &e) {
                return e.code
        }
        return codes.Unknown
}

// StackTrace shows stacktrace. If error is not private error, this returns empty string.
func StackTrace(err error) string {
        var e privateError
        if errors.As(err, &e) {
                return fmt.Sprintf("%+v\n", e.err)
        }
        return ""
}

使い方

先程のエラーを以下のようにエラーコードで抽象化して扱います。

user, err := findUser(id)
if err != nil {
  return User{}, errors.Errorf(codes.Database, "failed to find user: %s", err)
}

これによって別のレイヤ、例えばgRPCのレスポンスを返すハンドラでは

package handler

import (
        "fmt"

        "github.com/jun06t/sample-server/codes"
        "github.com/jun06t/sample-server/errors"
        gcodes "google.golang.org/grpc/codes"
        "google.golang.org/grpc/status"
)

// toGrpcError wraps the error code in grpc and message in the status structure and returns int.
func toGrpcError(err error) error {
        var gcode gcodes.Code
        c := errors.Code(err)

        switch c {
        case codes.InvalidSyntax, codes.BadParams, codes.EmptyBody, codes.InvalidRequest:
                gcode = gcodes.InvalidArgument
        case codes.Unauthorized:
                gcode = gcodes.Unauthenticated
        case codes.NotFound:
                gcode = gcodes.NotFound
        case codes.Forbidden:
                gcode = gcodes.PermissionDenied
        case codes.RegisteredCard:
                gcode = gcodes.AlreadyExists
        case codes.Database, codes.Internal, codes.ExternalAPI,
                codes.Redis, codes.Google, codes.Apple, codes.AWS:
                gcode = gcodes.Internal
        case codes.Unknown:
                gcode = gcodes.Unknown
        default:
                gcode = gcodes.Unknown
        }

        switch gcode {
        case gcodes.Internal, gcodes.Unknown:
                fmt.Printf("stacktrace: %s\n", errors.StackTrace(err))
        }

        fmt.Printf("gRPC: %s, %s\n", gcode, err)

        return status.Error(gcode, string(c))
}

このように1つ変換する関数を用意しておけば良いです。
codesパッケージにエラーコードが集約されているので、仮に新しいエラーコードを増やしたとしても修正箇所がここだけで済みます。

そのレイヤ内で生成されたエラーはレイヤ内でハンドリングする

通常のエラー

例えばインフラ層でエラーが起きた時は先程のように内部エラーでラッピングしますが、

package mongodb

func FindUser(id string) (model.User, error) {
  user := entity.User{}
  err := collection.FindId(id).One(&user)
  if err != nil {
    return model.User{}, errors.Errorf(codes.Database, "failed to find user: %s", err)
  }
  return toModelUser(user), nil
}

それを使っているサービス層ではそのまま返します。

package service

func GetUsername(id string) (string, error) {
  user, err := mongodb.FindUser(id)
  if err != nil {
    return "", err
  }
  return user.Name, nil
}

理由はこのエラーがサービス層で生成されたエラーではなく、それなのにサービス層でハンドリングしようとする(=エラーの詳細について知る)ことはレイヤ間の依存が増すためです。
そのパッケージでのエラーの詳細はそのパッケージが一番詳しいので、そこに知識を集約させるべきです。

エラー自体はgithub.com/pkg/errorsでラップしてコンテキストを保持しているので、スタックトレースもちゃんと取得できます。

外部APIのハンドリング

これもそのレイヤでハンドリングする関数を用意して、codesのエラーコードにマッピングしておきます。
例えば以下はAWSのS3 APIを使った時のエラーハンドリングです。
把握できているエラーはcodesエラーコードにマッピングし、それ以外の想定外のエラーはAWSというエラーコードにまとめて扱います。

func handleError(e error) (error) {
  if e == nil {
    return nil
  }
  if aerr, ok := e.(awserr.Error); ok {
    switch aerr.Code() {
    case "InvalidRequest":
      return errors.Errorf(codes.InvalidRequest, "%s", err)
    case "AccessDenied":
      return errors.Errorf(codes.Forbidden, "%s", err)
    case "NoSuchKey", "NoSuchBucket":
      return errors.Errorf(codes.NotFound, "%s", err)
    }
  }
  return errors.Errorf(codes.AWS, "%s", err)
}

繰り返しになりますがそのパッケージでのエラーの詳細はそのパッケージが一番詳しいので、そこに知識を集約させるべきです。

Q&A

エラーコードでなく独自エラーを1つずつ定義していくのは?

このケースも多いと思いますし、それで実装済みであればそのままで良いと思います。

Go 1.13からはerrors.Is()errors.As()が導入され、エラーチェックもある程度容易になりました。

ただし「HTTPやgRPCとしてレスポンスを返す」といったユースケースで複数のエラーの種類のどれであるか、を調べる時に非常に煩雑になる問題があります。

なので個人的にはエラーコードの方が定義や実装が楽で、レイヤ間・サービス間の変換も容易かと思います。

pkg/errorsのCauseで元のエラー分かるよ?

pkg/errorsでWrap()したエラーは[Cause()](https://godoc.org/github.com/pkg/errors#Cause)によって元となったエラーをそのまま取り出すことができます。
これによってエラー伝搬でのエラーが分かりにくい問題を解決できます。

Go 1.13からはUnwrap()メソッドがあるので元のエラーを遡るのも容易になりました。

しかしながらこれはエラーの種類がどれだけあるのか把握できない問題を解決できません。結果HTTPやgRPCのエラーレスポンスへの変換で漏れが発生する可能性があります。

したがってエラーコードを自分で定義して扱った方が良い、というのが今回の案です。

まとめ

実際にプロジェクトで悩みどころになりそうなエラーハンドリングについてまとめました。
同じような悩みを持っていた方にとって参考になれば幸いです。