本番運用まで行かなかったgRPCの知見をまとめておく

会社のブログに書こうと思ったんだけど、ちょっとマイナスイメージを持つ人もいそうな気がしたので、個人ブログに書くことにした。

この3ヶ月くらい、システムのリニューアル(アプリ間で分散したロジックを集約するバックエンドサーバと、用途に応じたフロントエンドサーバを立てるみたいなマイクロサービス構成)をやっていて、そこでサーバ間のやりとりにgRPCを使っていた。すごーく雑な絵を書くとこんな感じです。

f:id:suzan2go:20180316012243p:plain

しかし、最近になってプロジェクトのスコープについて見直しが入りました。マイクロサービス化ではなく単純にレガシーJavaで独自FWなアプリをリプレースするだけになり、必要なのはSPAとSpringBootのAPIサーバだけに(要するにRails側のロジックをなんとかするのがスコープ外になった)。

で、SPAに提供するAPIのためにgRPC(+ grpc-gateway)を使うのはちょっとオーバースペックだよねーという話になり、gRPCをやめて普通にRESTのAPIを作ることになりました。*1

プロジェクトが終わったら色々知見を公開したいなとおもったんだけど、その機会がなくなってしまい、ちょっと勿体無いのでブログにしておきます。 あくまでプロジェクト都合でgRPCを使うのをやめただけなので、何か問題があってとかではないです。その辺を期待してこの記事を開いた人はスミマセン。

このポストで話すこと

Spring BootでgRPCサーバーを作る

Javaコードを生成する

gradleのプラグインを使いましょう

github.com

なぜかドキュメントに書いてない気がするんだけど、 ./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 オプションはよ。

github.com

このツールを使って、コアとなるAPIサーバがmasterにマージされたら各クライアントライブラリと後述するgrpc-gatewayサーバのリポジトリを更新しにいくということをしていました。 私は大本となるgRPCのサーバで.protoファイルも管理してしまっていましたが、このように色んなものをCIでビルドしようなどと思うと結構辛くなってきそうなので、クライアントが増えてきたら.protoだけ配布して、そっちでビルドしてくれ!って世界観もありかなと思います。

.proto ファイルの依存関係を解決してくれるようなツールは標準では私が知る限りないのですが、CyberAgentの方が protodep というツールを作成されているようです。

github.com

Spring BootでgRPCを動かす

Spring BootでgRPCを動かすまでは本当に簡単です。 springboot-starter というライブラリが公開されており、ほぼそれで完結します。 こちらは過去Qiitaにも書いたので、よかったら見てみてください。

qiita.com

.protoファイルの整理について

シンプルなサーバであれば、 .proto ファイル一つで事足りるかもしれませんが、アプリケーションが大きくなってくると、 一つの .protoファイルでは見通しが悪くなってきます。

じゃあどう整理したらいいのかというところですが、私はgoogleが公開しているGCP用の .proto ファイルの構成を参考にしました。(ちなみに結構ファイルによって書き方がマチマチ…w)

github.com

例えば 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に認証情報を詰め込むという方法を取っていました。

eng.revinate.com

で、最初は割とうまく行ってたんだけど、普通に動かしてる分にはよくても、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-javaContext を使って無理やり実装してみたらテストの不安定さは解消されたものの、Securiy Contect の機構に頼らず、素直にgrpc-javaContext を使ったほうが良さそうだなぁという感触です。

エラーハンドリングについて

エラーハンドリングについてはこちらに書きました。

Spring BootでgRPCする

例えばバリデーションのような詳細な情報を返したい場合にはMetadataに詰めて返すようにしていました。

grpc-javaのサンプルにもあるように、以下のようにすると良いでしょう。

https://github.com/grpc/grpc-java/blob/master/examples/src/main/java/io/grpc/examples/errorhandling/DetailErrorSample.java#L75

        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を選択しました。

github.com

grpc-gatewayはエンドポイントとしてのコードを自動で生成してくれるだけなので、実際のGoのサーバーはある程度自分で書く必要があります。逆にいうと足りないものがあれば拡張してくことが可能です。

エラー情報をクライアントにJSONでいい感じに返す

grpc-gateway ではデフォルトでエラーの内容が以下のようになります。

{
    "error": "invalid token",
    "code": 16
}

上記で書いたように、 Metadata に詰めた内容も grpc-gateway でクライアントにJSONとして返したい場合には、エラーの場合の挙動をカスタマイズする必要があります。

実はこの方法は、grpc-gatewaywikiにリンクが貼られている以下のブログに書いてあります。 runtime.HTTPError にエラーハンドラーが定義されているので、これを差し替えます。

mycodesmells.com

色々端折っていますが、自分は以下のようなエラーハンドラを定義しました。

// 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 requestgrpc-gateway では使えません。

github.com

一番簡単な方法は、Base64エンコーディングして受け渡しをしてしまうことです。

ただしProtocol Buffersの説明にもあるように、Protocol BuffersではMB以上のサイズを受け渡しするのは向いていません。

https://developers.google.com/protocol-buffers/docs/techniques

手元でやったところ大体3MB以上のファイルになると、この方法ではうまく行きませんでした。この場合には、grpc-gatewayに独自のエンドポイントを設けて 一度gatewayでファイルを受けて、Client Streamingで送るとかを検討する必要があるかもしれません。※自分はそれほど大きなファイルを送らずに済んだので、試してませんが…

christina04.hatenablog.com

JSONのMarshallをカスタマイズする

grpc-gateway はデフォルトでは、Protocol Buffersでデフォルト値になっているものをレスポンスから省いてしまいます。

これはproto3では、デフォルト値なのか値がセットされていないのかを区別できないのでこのような挙動になっているようです。

github.com

この挙動は以下のIssueのコメントにもあるように WithMarshalerOption で変える事ができます。

github.com

gwmux := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true}))
  • OrigName
    • デフォルトではJSONはキャメルケースになりますが、これをtrueにすると、.proto に定義されたフィールド名で出力されるようになります
  • EmitDefaults
    • proto3でデフォルト値になっているものもJSONで出力することができます。

まとめ

いまいちまとまりのないポストになってしまいましたが、プロジェクトで触った、gRPC / grpc-gateway について書きました。

個人的にはgRPCはかなり開発体験が良かったので、次に機会があればまた検討したいなと思います。

*1:GoならgRPCをwrapしてWEBでも使えるようにするgrpc-webをAPIサーバに組み込めるけど、Go以外だと何かしらのサーバ(grpc-gatewayやgrpcwebproxy)を別で立てる必要があるので、SPAのAPIのためだけにそれ立てる??みたいなところとか、インフラ構成が多少複雑になるのがネックだった