この記事は「Gaudiy Advent Calendar 2022」の19日目の記事です。
Web3 スタートアップの Gaudiy でソフトウェアエンジニアをしている Namiki ( @ruwatana ) です 🙂
ワールドカップを楽しみすぎていたら(日本代表もそして数時間前の決勝戦も本当に最高でした 🙌)、あっという間に自分の番が来てしまったわけですが、先月個人にフォーカスした 入社エントリ を書かせていただいたのもあり、今回は直近取り組んでいた gRPC周りのエラーハンドリングで得た技術的なナレッジ を共有できればと思います。
みなさんエモさ全開の記事を書いているところ、ただ実直にTechでコアな話をしていきます。
ちなみに弊社 Tech Blog への寄稿は初となりますので、お手柔らかにお願いします。誤ったことを書いていましたらこそっとご指摘いただけますと幸いです 🙇
1. gRPC について
現在、Gaudiy のプロダクトにおけるバックエンドサーバー間の API 通信のプロトコルとして gRPC を採用し始めています。
そもそも gRPC とは?の詳細については今回は割愛させていただきますが、簡単にいうとインターフェースを定義した言語(IDL)である Protocol Buffers をベースとしたエコシステムを用いることで、クライアント・サーバー間のメッセージのやり取りを効率的に実現することができる仕組みです。
詳しくは、公式 docs をご参考いただければと思います。 grpc.io
Gaudiy のプロダクトにおけるシステム構成は執筆現在では以下の図のようになっており、Go 製のマイクロサービス間での一部やりとりや、Node.js 製の BFF と Go サービス間の一部やりとりなどに gRPC を徐々に採用していっています。
この辺りの初期導入は、Dev Enabling チームのメンバーを中心に行っています。取り組み内容の詳細については、また別途記事になった時にご覧いただければと思います(誰か書きましょう)。
2. 独自のパターンを用いたエラーハンドリングの実現
自分の所属している Stream Aligned な開発チームでは、直近で決済基盤のリニューアル開発を行ってきましたが 、その実装をしていく上で、gRPC サーバーにて複数の独自エラーパターンの返却を行い、フロントエンドで表示を制御したいというユースケースに直面しました。
これはさまざまなシステムにおいても、ごく一般的なユースケースかと思うのですが、gRPC でどのように実現したのかを詳しく説明していきたいと思います!
2-1. gRPC で推奨されているエラーモデル
まず、gRPC 公式の docs を見ると、Error handling についての Guide が提供されていることがわかります。2つの Error model についての説明がなされていますが、どちらかに沿って実現していくことが提唱されています。 grpc.io
2-1-1. Standard error model
抽象的かつシンプルなエラーハンドリングに関しては、docs にある通り Standard error model での表現が可能です。
code と message という文字列が参照可能となっており、サーバー側で任意の情報を詰め、クライアント側でそれをもとにハンドリングするといったやり方で実現が可能です。
詳しい実装方法については、公式 docs に記載されている下記リポジトリにて言語ごとにサンプルが公開されているので割愛します(とても参考になります)。 github.com
2-1-2. Richer error model
非常にシンプルなエラーハンドリングであれば Standard error model で事足りますが、オブジェクトの中にデータも詰めてエラー返却をしたいなどのケースを考慮すると、Richer error model での表現がベターになってきます。
今回、Gaudiy で実際に直面したユースケースでは、カスタムのエラーコードとそれに付随する任意の情報を含めたいといったものでした。その点、Standard error model でも上手くやりくりすれば実現自体はできたかもしれないのですが、より拡張性や共通フォーマットの利用を意識して、こちらのモデルにて実装を行いました。
2-2. Richer error model の実装について
それでは、実際にどのように実装していったか、サンプルコードとともに順に見ていきます。
2-2-1. Protocol Buffers の定義
まずは、エラーの汎用的な独自の message を Protocol Buffers に定義し、protoc で各々の言語のコードを自動生成します。
今回は以下のような感じで、独自の message を定義し、サーバー側ではこの型を用いて任意の情報を詰め、クライアント側でデシリアライズ (パース) して受け取るという流れで実装しました。
独自 message の各フィールドには docs でも推奨されている、google が提供している error_details.proto の各messageを採用し、汎用的かつ条件によってさまざまな情報を詰め込むことができるようにしています。
syntax = "proto3"; package gaudiy.apierror; import "google/api/field_behavior.proto"; import "google/protobuf/any.proto"; import "google/rpc/error_details.proto"; import "google/rpc/status.proto"; ~~~ message Details { google.rpc.ErrorInfo error_info = 1 [(google.api.field_behavior) = REQUIRED]; google.rpc.BadRequest bad_request = 2; google.rpc.DebugInfo debug_info = 3; google.rpc.LocalizedMessage localized_message = 4; google.rpc.PreconditionFailure precondition_failure = 5; ...
また、任意のカスタムエラーコードなどの情報も Protocol Buffers で enum などを用いて定義しておくと、クライアント・サーバー双方から参照できるので良いと思います。
先述した通り、Gaudiy ではこの辺りの整備や方針は Dev Enabling チームが導入から利用までをサポートしてくれたので、現在は非常にシームレスな利用が可能になっています。フィーチャー開発がメインの我々にとっては本当に頼もしい限りです🙌 (大感謝)
2-2-2.サーバー側の実装
さて、サーバー側の実装ですが、Gaudiy ではシステム構成的にも基本的に Go での実装となります。
エラーの詰め方としては、 grpc/status パッケージの error インターフェースの実装型である status.Status ( google/rpc/status.proto の message に相当 ) の details フィールドに先ほど作成した独自の message をセットすることで実現しています。
下の例では、Required なフィールドとなる ErrorInfo の Reason に Protocol Buffers で定義しておいたカスタムエラーコード (enum) の文字列を詰めるような実装にしています。
もちろん、Metadata フィールドに customErrorCode のような key で格納しても良いと思いますし、カスタムエラーコードに依存した message を作ってしまうというのも良いと思います。この辺りは、開発メンバーの中で合意形成が必要でしょう。
以下が実装サンプルのコードになります。
import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "gaudiy/apierror" hogepb "gaudiy/hoge" ) func (s *Server) Hoge() (*emptypb.Empty, error) { res, err := s.service.Hoge() if err != nil { // status.Statusを任意のcodeとerrから作成 st := status.New(codes.InvalidArgument, err.String()) // Status.detailsに定義した型を詰める st, _ = st.WithDetails(&apierror.Details{ ErrorInfo: &errdetails.ErrorInfo{ // pbに定義したカスタムのエラーコードをReasonに詰める Reason: hogepb.CustomErrorCode.Hoge.String(), Domain: "your.domain", Metadata: map[string]string{ // その他任意の情報があれば詰める "key": "value", }, }, // その他の error_details フィールドにも任意の情報を詰められる }) // statusからerrorを生成して返却 // (wrapしてしまうとstatusが正しくセットされないのでその場合はUnwrapが必要) return nil, st.Err() } return &emptypb.Empty{}, nil }
2-2-3.クライアント側の実装
Gaudiy ではクライアント側となりうるのは Go 製のマイクロサービスまたは Node.js 製の BFF です。それぞれの実装を見ていきます。
まず、Go で受け取る場合は先ほどの逆を同じパッケージを用いつつ行うだけなので非常にシンプルです 🙆
import ( "log" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/emptypb" "gaudiy/apierror" ) func (c *Client) Hoge() (*emptypb.Empty, error) { res, err := c.service.Hoge() if err != nil { // errからstatus型への変換を試みる st, ok := status.FromError(err) if !ok { return nil, err } // status.detailsを取り出して独自の型へのキャストを試みる details := st.Details() if len(details) > 0 { switch d := st.Details()[0].(type) { case *apierror.Details: // expected case log.Printf("reason: %v", d.ErrorInfo.Reason) default: // unexpected case } } ~~~
さて、問題は Node.js での実装です。
gRPC は、各言語のライブラリがそれぞれ独立された思想で実装・提供されているため、status という構造1つとっても、Go と同じように Node.js でも簡単に扱えるというわけではありません。
Richer error model の説明にも下記のようにあり、Node.js ライブラリは非対応だったりします。
This richer error model is already supported in the C++, Go, Java, Python, and Ruby libraries, and at least the grpc-web and Node.js libraries have open issues requesting it.
Go の gRPC 公式ライブラリである grpc-go では、status.details の情報を gRPC HTTP Client / Server どちらの実装にも serialize / deserialize してくれるロジックが埋め込まれており、利用する側はあまり HTTP 通信の規格までを意識せずに非常にシームレスに扱うことができます。
client
https://github.com/grpc/grpc-go/blob/v1.51.0/internal/transport/http2_client.go#L1403-L1408server
https://github.com/grpc/grpc-go/blob/v1.51.0/internal/transport/http2_server.go#L1040
しかし、Node.js の公式パッケージである grpc-js にはそうした実装が現在なく、Go 製のサーバーから status の情報が HTTP 通信に含まれてきたとしても、自分でクライアントのデシリアライズロジックを書く必要があります。
上記の grpc-go の実装の通り、status.details の情報は grpc-status-details-bin という HTTP header key に格納されていることがわかるので、そちらを取得しつつ手動でデシリアライズする必要があります。
この辺りを行ってくれる Node.js の OSS が下記のようにいくつか公開されていますが、そこまでヘビーなものではないので Gaudiy では自前で実装することにしました。
stackpath/node-grpc-error-details
https://github.com/stackpath/node-grpc-error-detailsquangtm210395/grpc-node-status-proto
https://github.com/quangtm210395/grpc-node-status-proto
それでは、サンプルコードとともに紹介していきます。 やってることは Go と同じでリクエストのエラーオブジェクトから status オブジェクトとその中の details に格納された独自の message の型情報を取り出しています。
import { credentials } from '@grpc/grpc-js'; // protobufから自動生成された各種コードをimport import * as GaudiyApiError from './gaudiy/apierror/apierror_pb'; import * as grpc from './gaudiy/hoge/hoge_grpc_pb'; import { HogeRequest, HogeResponse } from './gaudiy/hoge/hoge_pb'; import { Status } from './google/rpc/status_pb'; const client = new grpc.HogeClient('localhost:5001', credentials.createInsecure()); client.hoge( new HogeRequest(), function (error: ServiceError | null, response: HogeResponse) { if (!error) { // 正常系処理 return; } // grpc-goのサーバー側で埋め込まれたkeyからstatus.detailsのバイナリ情報を取り出す const detailsBinary = error.metadata.get('grpc-status-details-bin'); if (!detailsBinary) { throw error; } const buffer = detailsBinary[0]; if (!Buffer.isBuffer(buffer)) { throw error; } // binaryをdeserializeして該当の型だった場合のみunpackする const details = Status.deserializeBinary(new Uint8Array(buffer)) .getDetailsList() .map((element) => { // protobufで定義したtype名で格納されているので判定 if (element.getTypeName() === 'gaudiy.apierror.Details') { return element.unpack(GaudiyApiError.Details.deserializeBinary, element.getTypeName())?.toObject(); } return null; }) .find((element) => element); if (!details) { throw error; } // detailsの中身が取れる console.log(details?.errorInfo?.reason); } );
かなり取り回しにくい感じではありますが、これでようやく Go のように Node.js でも Richer error model の情報を取得することができました(Go に比べるとコード量が多いですね😅)。
いかがでしたでしょうか。
gRPC と Go の記事は多いものの、Node.js を駆使したエラーの実装方法やナレッジはなかなか探しても見つからなかったため、改めて全体感をまとめました。
サンプルで紹介したような実装に行き着く際にも、文献や実装を漁ったり、デバッグやトライアンドエラーを繰り返しして実現したので、苦労したポイントでした。
この周辺で悩む方がいましたらその助けになれば幸いです 🙏
3. おわりに
Gaudiy では今回紹介した gRPC をはじめ、新しい技術に対して積極的に投資・チャレンジしていくというスタイルで開発を行っています!
一緒に働くエンジニアも積極的に募集しておりますので、こうした技術や開発スタイルに興味をお持ちの方がいらっしゃいましたら、選考前にカジュアル面談も可能ですので、ぜひ採用ページからお気軽にご応募いただければと思います!
Gaudiy Advent Calendar 2022の20日目は、@yusukesatoo06 さんが担当です!
今年も残り少なくなってきましたね、身体にはお気をつけて良い年末をお過ごしください🎄
それでは、また 👋