会社のブログに書こうと思ったんだけど、ちょっとマイナスイメージを持つ人もいそうな気がしたので、個人ブログに書くことにした。
この3ヶ月くらい、システムのリニューアル(アプリ間で分散したロジックを集約するバックエンドサーバと、用途に応じたフロントエンドサーバを立てるみたいなマイクロサービス構成)をやっていて、そこでサーバ間のやりとりにgRPCを使っていた。すごーく雑な絵を書くとこんな感じです。
しかし、最近になってプロジェクトのスコープについて見直しが入りました。マイクロサービス化ではなく単純にレガシーJavaで独自FWなアプリをリプレースするだけになり、必要なのはSPAとSpringBootのAPIサーバだけに(要するにRails側のロジックをなんとかするのがスコープ外になった)。
で、SPAに提供するAPIのためにgRPC(+ grpc-gateway)を使うのはちょっとオーバースペックだよねーという話になり、gRPCをやめて普通にRESTのAPIを作ることになりました。*1
プロジェクトが終わったら色々知見を公開したいなとおもったんだけど、その機会がなくなってしまい、ちょっと勿体無いのでブログにしておきます。 あくまでプロジェクト都合でgRPCを使うのをやめただけなので、何か問題があってとかではないです。その辺を期待してこの記事を開いた人はスミマセン。
このポストで話すこと
Spring BootでgRPCサーバーを作る
Javaコードを生成する
gradleのプラグインを使いましょう
なぜかドキュメントに書いてない気がするんだけど、 ./gradlew generateProto
で .proto
からコードが生成できます。
コンパイル時に必ずこれが走るようにしておくとよいでしょう。最初は雑に生成したコードもGitの管理に含めてしまってましたが、割と差分が馬鹿にならない量になっていくので、.gitignore
しておくことをオススメします。
クライアントコードを生成する
ruby / gateway用のGoコード、さらにcom.google.protobuf
で公開されているような Timestamp
などを使おうと思うと、これらをビルドする依存関係をインストールするだけでも
かなり大変になってきます。
protoeasy
というツールを使うと、このあたりの生成が以下のように大分楽になります(公式のDockerImageも提供してくれています)。
protoeasy --go --grpc --go-import-path github.com/user/your-go-project --cpp --ruby . # exclude protocol buffers files in foo/*
ただ2018年3月13日現在では公式のDockerイメージではSwagger定義を生成することができないので、独自のDocker Imageを作って対応しましょう。 --grpc-gatewa-swagger
オプションはよ。
このツールを使って、コアとなるAPIサーバがmasterにマージされたら各クライアントライブラリと後述するgrpc-gatewayサーバのリポジトリを更新しにいくということをしていました。
私は大本となるgRPCのサーバで.proto
ファイルも管理してしまっていましたが、このように色んなものをCIでビルドしようなどと思うと結構辛くなってきそうなので、クライアントが増えてきたら.proto
だけ配布して、そっちでビルドしてくれ!って世界観もありかなと思います。
.proto
ファイルの依存関係を解決してくれるようなツールは標準では私が知る限りないのですが、CyberAgentの方が protodep
というツールを作成されているようです。
Spring BootでgRPCを動かす
Spring BootでgRPCを動かすまでは本当に簡単です。 springboot-starter
というライブラリが公開されており、ほぼそれで完結します。
こちらは過去Qiitaにも書いたので、よかったら見てみてください。
.protoファイルの整理について
シンプルなサーバであれば、 .proto
ファイル一つで事足りるかもしれませんが、アプリケーションが大きくなってくると、
一つの .proto
ファイルでは見通しが悪くなってきます。
じゃあどう整理したらいいのかというところですが、私はgoogleが公開しているGCP用の .proto
ファイルの構成を参考にしました。(ちなみに結構ファイルによって書き方がマチマチ…w)
例えば bigtableの .proto
ファイルは以下の様に3つに分割されています。
googleapis/google/bigtable/v1 at master · googleapis/googleapis · GitHub
bigtable_data.proto -- Request / Responseで使うmessage定義 bigtable_service.proto -- Service定義 bigtable_service_messages.proto -- ServiceのRequest / Responseの定義
自分は最終的に以下のように整理しました。
src/main/proto/todo L todo_service.proto L request L get_todo_request.proto L post_todo_request.proto L response L post_todo_response.proto L get_todo_response.proto
認証をどのように行うか
このブログで紹介されているように、Interceptorを使ってSpring SecurityのSecurity Contextに認証情報を詰め込むという方法を取っていました。
で、最初は割とうまく行ってたんだけど、普通に動かしてる分にはよくても、Spring Securityまわりでテストが落ちることが頻発したりとちょっと挙動が不安定に。
Spring Security allows us to specify an alternative SecurityContext store by implementing a custom SecurityContextHolderStrategy. Additionally, the gRPC Java runtime provides the Context class, which can be used to carry state across API boundaries and between threads.
と、あるように grpc-java の Context
を使って無理やり実装してみたらテストの不安定さは解消されたものの、Securiy Contect
の機構に頼らず、素直にgrpc-java のContext
を使ったほうが良さそうだなぁという感触です。
エラーハンドリングについて
エラーハンドリングについてはこちらに書きました。
例えばバリデーションのような詳細な情報を返したい場合にはMetadataに詰めて返すようにしていました。
grpc-javaのサンプルにもあるように、以下のようにすると良いでしょう。
Metadata trailers = new Metadata();
trailers.put(DEBUG_INFO_TRAILER_KEY, DEBUG_INFO);
responseObserver.onError(Status.INTERNAL.withDescription(DEBUG_DESC)
.asRuntimeException(trailers));
gRPCをアプリケーションのどのレイヤーにおくか
クライアント、サーバで型を共有できるのが、gRPCの良いところではありますが、gRPCで生成されたクラスを所謂ドメインレイヤーまで引きずるような設計にはしないほうが無難です。
この場合gRPCのモデルからドメイン層、アプリケーション層にもってくるときのマッピングがかなり面倒になります。が、gRPCに強く依存する形でアプリケーションを作ってしまうと、今回のようにgRPCを差し替えないといけないことになったときに大変な事になります。
今回意識してgRPCにアプリケーションロジックが依存しないように作っていたので、gRPCからRESTへの書き換えも1週間程度で終わりました。
参考までに、プロジェクトの構成はざっくり以下のようにしていました。
domain/ application/ presentation/ └ grpc/ ├── interceptor/ └── service/ infrastructure/
grpc-gateway について
grpc-gatewayを使用して、SPA向けのRESTのエンドポイントを作成していました。grpc-gatewayのためのコード生成時にSwagger定義を同時に生成することができるので、Swagger Codegenを使ってフロントエンド用のTypeScriptクライアントも同時に生成して配布していました。
grpc-web
という選択肢もあったのですが、社内にAPIアグリゲータがいるとか、パスを見てnginxでリクエストを振り分ける必要があるとか色々あり、ブラウザから叩かれるエンドポイントとしてはgrpc-gatewayを選択しました。
grpc-gatewayはエンドポイントとしてのコードを自動で生成してくれるだけなので、実際のGoのサーバーはある程度自分で書く必要があります。逆にいうと足りないものがあれば拡張してくことが可能です。
エラー情報をクライアントにJSONでいい感じに返す
grpc-gateway
ではデフォルトでエラーの内容が以下のようになります。
{ "error": "invalid token", "code": 16 }
上記で書いたように、 Metadata
に詰めた内容も grpc-gateway
でクライアントにJSONとして返したい場合には、エラーの場合の挙動をカスタマイズする必要があります。
実はこの方法は、grpc-gatewayのwikiにリンクが貼られている以下のブログに書いてあります。
runtime.HTTPError
にエラーハンドラーが定義されているので、これを差し替えます。
色々端折っていますが、自分は以下のようなエラーハンドラを定義しました。
// CustomHTTPError おもにgRPCのMetadataをerrorBodyのような構造に変換し、クライアントに返すためのカスタムハンドラー func customHTTPError(ctx context.Context, _ *runtime.ServeMux, marshaler runtime.Marshaler, w http.ResponseWriter, _ *http.Request, err error) { const fallback = `{"error": "failed to marshal error message"}` w.Header().Set("Content-type", marshaler.ContentType()) w.WriteHeader(runtime.HTTPStatusFromCode(grpc.Code(err))) eb := errorBody{ Err: grpc.ErrorDesc(err), } md, _ := runtime.ServerMetadataFromContext(ctx) for k, v := range md.TrailerMD { eb.ErrorDetails = append(eb.ErrorDetails, errorDetail{ Field: strings.TrimSuffix(k, "-bin"), // バイナリで帰ってくる文字列は-binのprefixがつくので、クライアントが扱いやすいよう消す Message: string(v[0]), // サーバー側では文字列としてしか入れていないが、何故かArrayで入ってくるので最初のものだけ取得する }) } jErr := json.NewEncoder(w).Encode(eb) if jErr != nil { w.Write([]byte(fallback)) } }
ファイルアップロード / ダウンロードする
以下のIssueにもあるように multipart form request
は grpc-gateway
では使えません。
一番簡単な方法は、Base64エンコーディングして受け渡しをしてしまうことです。
ただしProtocol Buffersの説明にもあるように、Protocol BuffersではMB以上のサイズを受け渡しするのは向いていません。
https://developers.google.com/protocol-buffers/docs/techniques
手元でやったところ大体3MB以上のファイルになると、この方法ではうまく行きませんでした。この場合には、後述するようにgrpc-gatewayに独自のエンドポイントを設けて 一度gatewayでファイルを受けて、Client Streamingで送るとかを検討する必要があるかもしれません。※自分はそれほど大きなファイルを送らずに済んだので、試してませんが…
JSONのMarshallをカスタマイズする
grpc-gateway
はデフォルトでは、Protocol Buffersでデフォルト値になっているものをレスポンスから省いてしまいます。
これはproto3では、デフォルト値なのか値がセットされていないのかを区別できないのでこのような挙動になっているようです。
この挙動は以下のIssueのコメントにもあるように WithMarshalerOption
で変える事ができます。
gwmux := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true}))
- OrigName
- デフォルトではJSONはキャメルケースになりますが、これをtrueにすると、
.proto
に定義されたフィールド名で出力されるようになります
- デフォルトではJSONはキャメルケースになりますが、これをtrueにすると、
- EmitDefaults
- proto3でデフォルト値になっているものもJSONで出力することができます。
まとめ
いまいちまとまりのないポストになってしまいましたが、プロジェクトで触った、gRPC / grpc-gateway について書きました。
個人的にはgRPCはかなり開発体験が良かったので、次に機会があればまた検討したいなと思います。