概要
以前
こちらの記事で、アプリケーション内でのレイヤ間のエラーハンドリングについてまとめました。
ではマイクロサービス間でそのエラーコードを伝播していくのはどうすれば良いのか、というのが今回の主題です。
課題
gRPCはレスポンスコードを持っています。
しかしこれだけでは下記のようなケースをハンドリングできません。
- フォームのvalidationエラーを伝える際に、どのフィールドの不備が原因か
- カード決済時のエラーで、カードの何が問題でエラーが起きているのか
このような詳細なエラーをクライアントに伝えられない場合、クライアントは抽象的なエラー文言しかユーザに出せず、結果としてユーザは問題を解決することができなくなります。
解決案1) エラー文言をparse
gRPCは以下のようにレスポンスコード以外にもメッセージ(文字列)を返すことが可能です。
msg := "code: name_field_error, msg: validation error occurred" return nil, status.Error(codes.InvalidArgument, msg)
それを使ってマイクロサービス間で文字列のフォーマットを決め、ハンドリングしていくという方法です。
問題点
しかしながら
- 文字列比較のtypo可能性
- ルールの徹底が必要
といった点から、運用が辛くなることが分かります。
解決案2) response bodyに独自のエラーコードを入れるフィールドを追加
facebookのGraph APIは、エラーレスポンスのbodyが以下のようになっており、その中にcode
というエラーコードが含まれています。
{ "error": { "message": "Message describing the error", "type": "OAuthException", "code": 190, "error_subcode": 460, "error_user_title": "A title", "error_user_msg": "A message", "fbtrace_id": "EJplcsCHuLu" } }
ref: エラーを処理する - グラフAPI - ドキュメンテーション - Meta for Developers
このようなアプリケーション用のコードを、gRPCのレスポンスにも含めてしまうイメージです。
service Greeter { rpc SayHello(HelloRequest) returns (HelloReply) {} } message HelloRequest { string name = 1; } message HelloReply { string code = 1; string message = 2; }
code
やstatus
といったフィールド名になると思います。
問題点
しかしながら
- 全レスポンスに統一して埋め込むのが大変
- 偶然同じフィールド名が使いたいユースケースで使えなくなる
という問題が起きます。
解決案3) error details protoを使う
ではどうするかというと、公式が用意してくれています。
This model enables servers to return and clients to consume additional error details expressed as one or more protobuf messages. It further specifies a standard set of error message types to cover the most common needs (such as invalid parameters, quota violations, and stack traces). The protobuf binary encoding of this extra error information is provided as trailing metadata in the response.
https://grpc.io/docs/guides/error/
statusにはStatus.WithDetailsというエラー詳細を埋め込む実装が用意されています。
この関数に公式が用意していくれているerror_details.protoや、独自のerror詳細protoを用意して埋め込むことで、詳細なエラーを表現することが可能です。
errdetailsの基本的な使い方
Goではerrdetails packageとして用意されています。
埋め込み方(サーバ側)
このようにStatusにセットできます。
st := status.New(codes.InvalidArgument, "some error occurred") v := &errdetails.BadRequest{ FieldViolations: []*errdetails.BadRequest_FieldViolation{ { Field: "username", Description: "should not empty", }, }, } dt, _ := st.WithDetails(v) return nil, dt.Err()
ポイント
WithDetails()
で追加する- 引数はMessage型(=protoのmessageなら何でもOK)
- Detailsはsliceになっているので複数追加できる
- 最期は
Err()
でエラーとして返す
取り出し方(クライアント側)
以下のように取り出してハンドリングします。
st := status.Convert(err) for _, detail := range st.Details() { switch t := detail.(type) { case *errdetails.BadRequest: fmt.Println("Oops! Your request was rejected by the server.") for _, violation := range t.GetFieldViolations() { fmt.Printf("The %q field was wrong:\n", violation.GetField()) fmt.Printf("\t%s\n", violation.GetDescription()) } } }
ポイント
- 最初に通常のerrorからstatus.Statusに変換
- Detailsはsliceになっているのでforでそれぞれ取り出す
- type assertionでdetail Messageの場合分け
他のMessage
他のMessageに関しては以下の記事でコピペを用意してくれています。
gRPCのstatusとerrdetails Go言語版コピペ用 #Go - Qiita
独自エラーproto
次は独自のエラーメッセージでハンドリングするケースを紹介します。
proto
Error messageと、エラーコード用のEnumを用意しておきます
enum ErrorCode { UNKNOWN = 0; INVALID_RECEIPT = 1; EXPIRED_RECEIPT = 2; INVALID_CARD = 3; EXPIRED_CARD = 4; INVALID_COUNTRY = 5; PREPAID_CARD = 6; } message ErrorDetail { ErrorCode code = 1; string message = 2; }
サーバ
ErrorCode_EXPIRED_RECEIPT
を使いたい場合は以下のように追加しますし、
st := status.New(codes.InvalidArgument, "some error occurred") dt, _ := st.WithDetails(&pb.ErrorDetail{Code: pb.ErrorCode_EXPIRED_RECEIPT}) return nil, dt.Err()
ErrorCode_INVALID_COUNTRY
を使いたいときは以下のように追加します。
st := status.New(codes.InvalidArgument, "some error occurred") dt, _ := st.WithDetails(&pb.ErrorDetail{Code: pb.ErrorCode_INVALID_COUNTRY}) return nil, dt.Err()
クライアント
各エラーケースをtype assertionでハンドリングします。
st, _ := status.FromError(err) for _, detail := range st.Details() { switch t := detail.(type) { case *errdetails.BadRequest: fmt.Println("handle BadRequest case") case *errdetails.QuotaFailure: fmt.Println("handle QuotaFailure case") case *pb.ErrorDetail: // handle original error code fmt.Println("error code:", t.Code) } }
検証
すると以下のようにエラー毎に詳細にハンドリングできるようになります。
error-details $ go run client/main.go error code: EXPIRED_RECEIPT 2019/09/05 08:00:07 rpc error: code = InvalidArgument desc = some error occurred exit status 1 error-details $ go run client/main.go 2019/09/05 08:00:08 Reply: Hello alice error-details $ go run client/main.go handle BadRequest case 2019/09/05 08:00:10 rpc error: code = InvalidArgument desc = some error occurred exit status 1 error-details $ go run client/main.go error code: INVALID_COUNTRY 2019/09/05 08:00:12 rpc error: code = InvalidArgument desc = some error occurred exit status 1 error-details $ go run client/main.go handle QuotaFailure case 2019/09/05 08:00:13 rpc error: code = InvalidArgument desc = some error occurred exit status 1
独自protoの場合、protocのpluginに注意
注意として公式のprotoc-gen-go
でコンパイルした時は問題ありませんでしたが、protoc-gen-gogo
で独自protoをコンパイルしたところtype assertionやキャストができませんでした。
差分を見ると
import ( context "context" fmt "fmt" - proto "github.com/golang/protobuf/proto" + proto "github.com/gogo/protobuf/proto" grpc "google.golang.org/grpc"
でした。
proto "github.com/golang/protobuf/proto"
を使っていないとダメなようです。
なのでWithDetails()
に入れるMessageに関しては公式のpluginを使ってコンパイルしてください。
サンプルコード
今回のサンプルコードはこちら