1 of 35

エラー設計について / Designing Errors

Go Conference 2019 Spring

2019/05/18

2 of 35

自己紹介

3 of 35

本日の内容

アプリケーションのエラーハンドリングについて

  • エラーとは?
  • エラーに求められること
  • Goのアプリケーションでエラーをどのように扱うか?

Failure is your Domainという記事を参考にしています。�https://middlemost.com/failure-is-your-domain/

4 of 35

エラーとは?

5 of 35

エラーとは?

  • エラーは処理が失敗したときに発生する
  • エラーには既知のエラーと未知のエラーの2種類がある
    • 既知のエラー
      • 発生することが想定できているエラー
      • 例: 明示的にハンドリングされているエラー
    • 未知のエラー
      • 発生することが想定できていないエラー
      • 例: panic, 500 Internal Server Errorになるようなエラー
  • 同じエラーでも状況によって既知か未知かは異なる

6 of 35

エラーとは?

どうやったら未知のエラーは既知になるのか?

  • エラーをハンドリングし、自分達のアプリケーションの一部として組み込む
  • 未知のエラーを既知とするための方法がエラーハンドリングである
  • Goはエラーを明示的に返却するので本当に未知なのはpanicくらい
  • 既知にもレベルがある
    • エラーが発生することを想定していない (panic)
    • エラーが発生することは知っているが詳細はわからない (error)
    • 具体的にどういう種類のエラーが発生するのかを知っている (sql.ErrNoRowsなど)

7 of 35

エラーに求められること

8 of 35

エラーに求められること

  • エラーは、関係者に処理が失敗した原因を伝える必要がある
  • 次のような関係者がそれぞれ異なる情報を求めている
    • アプリケーション
    • エンドユーザー
    • 運用者

9 of 35

エラーに求めること

アプリケーション

  • エラーを識別できること(ハンドリングできること)
  • Goにおいては次のいずれかのパターンで表現される(と思う)

var ErrNotFound = errors.New("not found")

type NotFound struct{}

type Error struct {

Code string

}

type NotFoundError interface {

NotFound()

}

10 of 35

エラーに求めること

(クライアント)アプリケーション

  • エラーを識別できること(ハンドリングできること)
  • 多くの場合、通信プロトコルで定義されているエラー表現が使われる
    • HTTP Status Code
    • gRPC Status Code

11 of 35

エラーに求めること

エンドユーザー

  • 問題を解決するヒントになるようなメッセージが含まれていること
  • メッセージはユーザーが理解できる形式であることが望ましい
    • 例: お金を引き出そうとしたときに残高が足りなかった場合
      • Bad: 「CODE 19283757 invalid amount」
      • Good: 「入力された金額が残高を上回っています。残高をご確認ください。」

12 of 35

エラーに求めること

運用者

  • 問題の根本的な原因を調査、解決する手助けになる情報が含まれていること
  • エラーの識別情報やメッセージに加えてコールスタックやコンテキスト(引数など)も表示し、エラーが発生した場所や状況が理解できるようになっていることが望ましい

13 of 35

ここまでのまとめ

14 of 35

ここまでのまとめ

  • エラーには既知のエラーと未知のエラーがある
  • 未知のエラーを既知とする方法がエラーハンドリングである
  • 関係者によってエラーに求める情報が異なる
    • エラーの識別情報 (for アプリケーション)
    • 人間が理解可能なメッセージ (for エンドユーザー)
    • コールスタックやコンテキスト (for 運用者)

15 of 35

Goのアプリケーションで�エラーをどのように扱うか?

16 of 35

Goのアプリケーションでエラーをどのように扱うか?

  • アプリケーション固有のエラーコードを定義し、エラーを識別する
    • HTTPとかgRPCもエラーコードによる識別をしているので従う
  • 既知の外部のエラーは上で定義したエラーコードに変換する
  • 定義したエラーコードのみを想定してエラーハンドリングをする
    • 外部のエラーをそのまま使い回すと、想定すべきエラーが多くなり、アプリケーションのエラーハンドリングが複雑になる
  • エンドユーザーがエラーを解決できる場合にメッセージを追加する
  • 必要に応じてコールスタックや引数の情報を追加する
  • 未知のエラーが見つかったらハンドリングして既知にする

17 of 35

github.com/morikuni/failure

これらをアプリケーションで簡単に扱うために...

18 of 35

github.com/morikuni/failure

  • エラーコードを中心にしたエラーハンドリング
  • エラーメッセージ、コールスタック、コンテキスト情報の付与�
  • Wrapper interfaceによるカスタマイズ性の高さ
  • err.Error() の自動生成

19 of 35

failureを使ったエラーハンドリング

20 of 35

failureを使ったエラーハンドリング

github.com/morikuni/failure-example/simple-crud

  • /create, /read, /update, /deleteの操作を持つHTTPのKey-Value Store
  • Controller, Service, Database, Modelというシンプルな構成
  • データベースにはMySQLを使用

21 of 35

failureを使ったエラーハンドリング

  • アプリケーション固有のエラーコードを定義する

package errors

import (

"github.com/morikuni/failure"

)

const (

InvalidArgument failure.StringCode = "InvalidArgument"

NotFound failure.StringCode = "NotFound"

AlreadyExist failure.StringCode = "AlreadyExist"

)

22 of 35

failureを使ったエラーハンドリング

  • 既知の外部のエラーは定義したエラーコードに変換する
    • failure.Translateでエラーにエラーコードを付与できる

const query = `

SELECT v FROM kv WHERE k = ?

`

r := db.conn.QueryRowContext(ctx, query, key)

var i int64

if err := r.Scan(&i); err != nil {

if err == sql.ErrNoRows {

return 0, failure.Translate(err, errors.NotFound)

}

return 0, failure.Wrap(err)

}

23 of 35

failureを使ったエラーハンドリング

  • 定義したエラーコードのみを想定してエラーハンドリングをする
    • failure.Isでエラーコードで分岐できる

if !failure.Is(err, errors.NotFound) {

return failure.Wrap(err,

context,

)

}

24 of 35

failureを使ったエラーハンドリング

  • 定義したエラーコードのみを想定してエラーハンドリングをする
    • failure.CodeOfでエラーコードを取り出せる

func httpStatus(err error) int {

switch c, _ := failure.CodeOf(err); c {

case errors.InvalidArgument:

return http.StatusBadRequest

case errors.NotFound:

return http.StatusNotFound

case errors.AlreadyExist:

return http.StatusConflict

default:

return http.StatusInternalServerError

}

}

25 of 35

failureを使ったエラーハンドリング

  • エンドユーザーがエラーを解決できる場合にメッセージを追加する
    • failure.Messageをfailure.Newやfailure.Translateのオプションとして渡せる

_, err := s.db.Get(ctx, key)

if err == nil {

return failure.New(errors.AlreadyExist,

failure.Message("Specified key already exists. Use update for existing key."),

)

}

26 of 35

failureを使ったエラーハンドリング

  • エンドユーザーがエラーを解決できる場合にメッセージを追加する
    • failure.MessageOfで付与したメッセージのみを取り出せる

msg, ok := failure.MessageOf(err)

if ok {

io.WriteString(w, msg)

} else {

io.WriteString(w, http.StatusText(status))

}

27 of 35

failureを使ったエラーハンドリング

  • 必要に応じてコールスタックや引数の情報を追加する
    • failure.Newやfailure.Wrapを使うと自動でコールスタックが追加される

func (s *service) Create(ctx context.Context, key model.Key, value model.Value) error {

context := failure.Context{"key": string(key)}

...

if err != nil {

return failure.Wrap(err,

context,

)

}

return nil

}

28 of 35

failureを使ったエラーハンドリング

  • 必要に応じてコールスタックや引数の情報を追加する
    • err.Error()などが自動で生成される

c.logger.Printf("%v\n", err)

// controller.(*Controller).create: service.(*service).Create: Specified key already exists. Use update for existing key.: key=a: code(AlreadyExist)

c.logger.Printf("%+v\n", err)

// [controller.(*Controller).create] /Users/morikuni/go/src/github.com/morikuni/failure-example/simple-crud/controller/controller.go:76

// [service.(*service).Create] /Users/morikuni/go/src/github.com/morikuni/failure-example/simple-crud/service/service.go:32

// message("Specified key already exists. Use update for existing key.")

// key = a

// code(AlreadyExist)

// [CallStack]

// [service.(*service).Create] /Users/morikuni/go/src/github.com/morikuni/failure-example/simple-crud/service/service.go:32

// [controller.(*Controller).create] /Users/morikuni/go/src/github.com/morikuni/failure-example/simple-crud/controller/controller.go:74

// …(略)

29 of 35

failureを使ったエラーハンドリング

  • 未知のエラーが見つかったらハンドリングして既知にする
    • 運用をしているとエラーコードやメッセージが付いていないエラーが見つかる
      • failure.CodeOfやfailure.MessageOfの第2返り値がfalseになっているエラー
    • これらのエラーが未知のエラーである
    • 未知のエラーが見つかったら、エラーハンドリングをしてエラーコードやメッセージを付与し、未知のエラーを既知のものとしていく

30 of 35

failureとxerrorsの考え方の違い

31 of 35

failureとxerrorsの考え方の違い

  • failureはerror型にOptionalフィールドを足してエラーを拡張するイメージ
    • 1つのWrapperが1つのフィールドや機能を追加する
  • xerrorsはエラーを変換していくイメージ

type Error interface {

Error() string

ErrorCode() (Code, bool)

CallStack() (CallStack, bool)

Message() (string, bool)

// other optional fields...

}

AppError{

URLError{

SyscallError{

Errno()

}

}

}

32 of 35

failureとxerrorsの考え方の違い

failure

xerrors

エラーの比較

failure.Isによって最新のエラーコードを見る

xerrors.Isによってエラーが含まれているか見る

エラー情報の取り出し

failure.MessageOfのように1フィールド毎に関数を用意する

xerrors.Asによってオブジェクトに情報をマッピングする

エラーのラップ方法

failure.Wrapper interfaceでラッピング方法が統一されている

ラッパー毎にxerrors.Errorfなどの専用の関数を用意する

カスタマイズ性

Wrapperは1つずつ独立して使用可能

挙動を変えるためには、新しい型を実装する

33 of 35

failureとxerrorsの考え方の違い

failureとxerrorsのどちらを使えばいいのか?

  • アプリケーション(エンドユーザーに機能を提供する)ものであれば、failure
  • ライブラリ(アプリケーションに組み込まれる)ものであれば、xerrors

ライブラリでfailureを使うと、ライブラリのエラー情報とfailureを使うアプリケーションのエラー情報が混ざってしまって区別するのが困難になる

(例: ライブラリのMessageがアプリケーションのエンドユーザーに出てしまう)

34 of 35

まとめ

35 of 35

まとめ

  • エラーには既知のエラーと未知のエラーがある
  • 未知のエラーを既知とする方法がエラーハンドリングである
  • 関係者によってエラーに求める情報が異なる
    • エラーの識別情報 (for アプリケーション)
    • 人間が理解可能なメッセージ (for エンドユーザー)
    • コールスタックやコンテキスト (for 運用者)
  • アプリケーション内のエラーを定義し、自分達でエラーを管理しよう
  • github.com/morikuni/failure オススメです