grpc-nodeでエラー時にerrdetailsを返却する
gprcのバックエンドとしてnodeを選択するとフレームワークとして公式のgrpc-nodeが選択に上がると思います
https://github.com/grpc/grpc-node
正常系はそつなく実装できるのですが、エラー時にGoなどで言うところのerrdetailsを利用したくなったときに苦労したのでその覚書
(正確にはerrdetailsと呼ぶのかは分かりませんがGoのパッケージ名がerrdetailsなのでそう呼んでます)
errdetailsって何?
gRPCのフレームワークが用意しているエラーの詳細を記述できるメッセージオブジェクトです
例えば、REST-APIを設計するときなどにjsonのエラーオブジェクトも設計する必要があると思うのですが、
統一的なフォーマットがなく(一応RFC7807という規格はあるが従っていることが少ない)、設計時に揉めるであろうことが予測されます
そこで、gRPCが用意しているメッセージオブジェクトと仕組みを利用することで、設計時に疲弊しなくて済みます
下の実装例だとステータスはInvalidArgument
でname
フィールドが必須である情報を付与したエラーを返却できます
RESTで言うところの「HTTPステータスコード=400
でエラー詳細のjsonも一緒に返しました」みたいな感じです
(サンプル的なコードなので間違っている可能性あり)
sts:=status.New(codes.InvalidArgument,"validation error")badRequest:=&errdetails.BadRequest{FieldViolations:[]*errdetails.BadRequest_FieldViolation{{Field:"name",Description:"Must not be null",},},}details,_:=sts.WithDetails(badRequest)err:=details.Err()// 最終的にこのエラーを返せばフレームワークがあとはうまくやる
..._,err:=client.SendMessage(context.Background(),req)sts:=status.Convert(err)for_,detail:=rangest.Details(){switchds:=detail.(type){case*errdetails.BadRequest:fmt.Println(len(ds))// <= 長さ 「1」fmt.Println(ds[0].Field)// <= フィールド 「name」fmt.Println(ds[1].Description)// <= メッセージ詳細 「Must not be null」casedefault:fmt.Println("Other error detail")}}
このメッセージオブジェクトの実態はgRPC自体が持っている仕様ではなく、googleが提供しているプリセットのコンパイル済みprotoファイルになります
今回はBadRequestのメッセージを試しましたが、他にもいろいろなものがあります
https://godoc.org/google.golang.org/genproto/googleapis/rpc/errdetails
また、コンパイル済みprotoファイルは他にもEmpty型などがあります
GoのgRPCフレームワークにはprotocされた状態で同梱されていますので、Go言語の場合は特に何もしなくても利用可能です
このエラーメッセージの仕組み自体はシンプルでエラー時にこのメッセージをgRPCのTrailerに乗せているだけになります
実際にnodeでやってみた
先程、Go言語の場合はフレームワークに同梱済みとありましたが、nodeの場合はどうなのでしょう?
結論から言うと同梱されていないため持参する必要があります
ちょっと不親切ですが、利用するerrdetailsのprotoファイルをgoogleapisのリポジトリから拝借してprotocします
https://github.com/googleapis/googleapis/blob/master/google/rpc/error_details.proto
さて、メッセージオブジェクトも手に入ったので、実際にエラーをTrailerに載せて返してみます
Trailerにメッセージを乗せるにはキーが必要ですので、そのキーがなにかを調べてみます
Go側のgRPCの実装を調べてみるとgrpc-status-details-bin
というキー名でオブジェクトが格納されているようです
次にエラーをTrailerに詰める際にはgRPCのAny型を利用する必要があります
https://developers.google.com/protocol-buffers/docs/proto3#any
Any型はちょっと飛び道具的な型で、gRPCのオブジェクトを何でも詰めれるという型です
ただ、Any型にgRPCオブジェクトを詰めるのは良いのですがシリアライズ、デシリアライズの際に何型かを指定する必要があります
例えばBadRequest
はgoogle.rpc.BadRequest
という型名になるみたいです
パッケージ名とメッセージを.
でつないでフルネームで型名を指定すれば良さそう?
上記を参考にTypeScriptで実装をしてみるとつぎのようになります
(サンプル的なコードなので間違っている可能性あり)
import{Metadata,ServiceError,status}from'grpc'import{BadRequest}from'../proto/google/error_details_pb'import{StatusasRpcStatus}from'../proto/google/status_pb'constbadRequest=newBadRequest()constfieldViolation=newBadRequest.FieldViolation()fieldViolation.setField('name')fieldViolation.setDescription('Must not be null')badRequest.addFieldViolations(fieldViolation)const[metadata,rpcStatus,detail]=[newMetadata(),newRpcStatus(),newAny()]detail.pack(badRequest.serializeBinary(),'google.rpc.BadRequest')// Any型をpackrpcStatus.setCode(status.INVALID_ARGUMENT)rpcStatus.setMessage('validation error')rpcStatus.addDetails(detail)metadata.set('grpc-status-details-bin',Buffer.from(status.serializeBinary()))constserviceError:ServiceError={code,message,name,metadata}// 最終的にこのエラーを返せばフレームワークがあとはうまくやる
import{BadRequest}from"../proto/google/error_details_pb"import{StatusasRpcStatus}from'../proto/google/status_pb'...constbuffer=error.metadata.get("grpc-status-details-bin")[0]// <= このerrorはgrpc-clientから帰ってくるerrorstatus=Status.deserializeBinary(buffer)detail=status.getDetailsList()[0]// 今回は`BadRequest`型を決め打ちしたが本当はerrdetailsで入る型を`detail.getTypeName()`でハンドルする必要があるconstbadRequest=detail.unpack(BadRequest.deserializeBinary,detail.getTypeName())// Any型をunpack
このようなやり方をすればerrdtailsをGo言語とnodeの間でやりとりすることができます
その他
上の例ではクライアント側でもマニュアルでデシリアライズしていましたが、errdetailsを受け取ったときは次のようなライブラリがあるので、それを利用するのも悪くないです
https://github.com/stackpath/node-grpc-error-details
このライブラリを参考にして、上の実装を考えたりもしましたdetail.getTypeName()
で型名のハンドリングするところも書いてあるので参考になります
ちなみに、grpc-nodeでもerrdetailsが公式でほしいなぁ的なissueは上がっているようです(ここでこのライブラリを発見しました)
https://github.com/grpc/grpc-node/issues/184
errdetailsは設計の手間を省けるので便利なのですが、同梱されているされていないが言語によってまちまちなのは辛いなぁと思いました
なので、他の言語で同梱されていない場合や公式がサポートしないgRPCライブラリを利用するときにerrdetailsを扱うときは今回の内容を他の言語にローカライズしてあげればできるはずです
上記のようにgrpc-nodeはでerrdetailsを使うのは結構めんどくさいですが、
最近はgRPC+GraphQLの組み合わせも増えてきておりGraphQL側はnodeでApolloServerを使いたいなんて場合はgrpc-nodeは有用かなと思います
gRPC界隈だとGo言語って優遇されているなぁ...と改めて思いました