5
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

RESTでgRPCサーバとやりとりする

この記事はOpenSaaS Studio Advent Calendar 2019、19日目の記事です。
今回はBazelではなくgRPCについてのお話。

はじめに

こんな要件がありました。

  • 利用者の好みに合わせて選べるように、アプリのAPIをRESTとgRPCの両方で使えるようにしたい。
  • 楽にメンテできるように、RESTとgRPCで個別に実装する部分をできるだけ減らしたい。
  • アプリのほとんどがgRPCを採用しているので、APIの裏側のロジック(マイクロサービス)はgRPCで作りたい。

RESTでgRPCサーバといい感じにやりとりできる方法を調べたところ、こんな方法が見つかりました。

  • grpc-gateway
    • プロトコル定義ファイルから生成したリバースプロキシがRESTとgRPCを中継する。
    • リバースプロキシを自分で運用する必要がある(のでちょっと手間かな、という第一印象)。
  • envoyのgRPC-JSON transcoder
    • envoyのHTTPフィルタの一つ。プロトコル定義ファイルから生成したproto descriptorをもとにRESTとgRPCを中継する。
    • envoyを運用する必要があるが、istioを使っているなら追加の手間は増えない(かな、という第一印象)。

というわけで、この二つの方法を試した結果をまとめてみました。実際に使う場合はレスポンスをカスタマイズしたくなると思うので、その結果もまとめています。
検証で使用したソースコードはそれぞれタグを切って置いてあるので試してみたい場合は参考にしてください。
stubやproto descriptorもコミットしているので、プロトコル定義を変更しなければgRPC周りのツールもいらないはず。

素直に使ってみる

まずはプロトコル定義。詳細は公式のドキュメントに譲りますが、importとEchorpcの内部にgrpc-gateway用の設定が入っています。

service/service.proto
syntax = "proto3";
package service;

import "google/api/annotations.proto";

message StringMessage {
    string value = 1;
}

service EchoService {
    rpc Echo(StringMessage) returns (StringMessage) {
        option (google.api.http) = {
            post: "/v1/example/echo"
            body: "*"
        };
    }
}

gRPCサーバ

定義したAPIに渡されたパラメータを使って、Hello, XXXと返します。また、エラー時の挙動も確認したいので特定のinputの場合に固定でエラーを返すようにしています。
それ以外は普通にgRPCサーバを起動するだけなので説明は省略。

server/main.go
func (s *server) Echo(ctx context.Context, in *pb.StringMessage) (*pb.StringMessage, error) {
    input := in.GetValue()
    log.Printf("Received: %v", input)

    if input == "error" {
        return nil, status.Error(codes.NotFound, "not found")
    }

    return &pb.StringMessage{
        Value:                fmt.Sprintf("Hello, %v", input),
    }, nil

}

grpc-gateway

こちらも公式のドキュメントの通りに実装しただけなので説明は省略。必要ならgateway/main.goを見てください。
リバースプロキシを起動するとRESTでAPIが叩けるようになるのでcurlでリクエストを投げてみます。

正常系

こんなリクエストを投げてみる。

curl -D - -s -H 'Content-Type:application/json' -d '{"value":"JSON"}' -X POST http://localhost:8080/v1/example/echo

すると、こんなレスポンスが返ってきました。

HTTP/1.1 200 OK
Content-Type: application/json
Grpc-Metadata-Content-Type: application/grpc
Date: Sat, 21 Dec 2019 16:35:43 GMT
Content-Length: 23

{"value":"Hello, JSON"}

Grpc-Metadata-Content-Typeというヘッダが設定されていますが、それ以外は想定通りといったところ。

異常系

curl -D - -s -H 'Content-Type:application/json' -d '{"value":"error"}' -X POST http://localhost:8080/v1/example/echo

すると、こんなレスポンスが返ってきました。

HTTP/1.1 404 Not Found
Content-Type: application/json
Trailer: Grpc-Trailer-Content-Type
Date: Sat, 21 Dec 2019 15:13:55 GMT
Transfer-Encoding: chunked

Grpc-Trailer-Content-Type: application/grpc

{"error":"not found","code":5,"message":"not found"}

ステータスコードは想定通りですが、ボディにerrorcodeといったgRPC起因だと思われる項目が設定されていて、ヘッダにもTrailerGrpc-Trailer-Content-Typeといった同様の項目が見受けられます。

gRPC-JSON transcoder

transcoderを追加したenvoyの設定ファイルと生成したproto descriptorを使って作成したDockerイメージでenvoyを起動しました。
envoyの設定は公式のドキュメントを参考にproto_descriptorservicesを修正したくらいなので説明は省略。必要ならenvoy/envoy.yamlを見てください。
envoyを起動するとRESTでAPIが叩けるようになるのでcurlでリクエストを投げてみます。

正常系

リクエストはgrpc-gatewayの時と同じなので省略。
すると、こんなレスポンスが返ってきました。

HTTP/1.1 200 OK
content-type: application/json
x-envoy-upstream-service-time: 0
grpc-status: 0
grpc-message: 
content-length: 28
date: Sat, 21 Dec 2019 16:41:06 GMT
server: envoy

{
 "value": "Hello, JSON"
}

内部実装が分かりそうなヘッダがいくつか設定されていますが、それ以外は想定通りといったところ。

異常系

リクエストはgrpc-gatewayの時と同じなので省略。
すると、こんなレスポンスが返ってきました。

HTTP/1.1 404 Not Found
content-type: application/grpc
grpc-status: 5
grpc-message: not found
x-envoy-upstream-service-time: 1
content-length: 0
date: Sat, 21 Dec 2019 15:11:17 GMT
server: envoy

ステータスコードは想定通りですがボディが設定されておらず、grpc-messageというヘッダにエラーメッセージが設定されています。
また、content-typeapplication/grpcとなっています。

レスポンスをカスタマイズしてみる

そのまま使うと外部に公開するのはなかなか難しそうなので、レスポンスをカスタマイズしてみましょう。

grpc-gateway

こちらの記事を参考に、エラーハンドラを定義。

gateway/main.go
// エラーレスポンスボディ
type errorBody struct {
    Err string `json:"error,omitempty"`
}
// カスタムエラーハンドラ
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)))
    // レスポンスボディに必要な値だけ設定
    jErr := json.NewEncoder(w).Encode(errorBody{
        Err: status.Convert(err).Message(),
    })

    if jErr != nil {
        w.Write([]byte(fallback))
    }
}

公式のドキュメントを参考にヘッダフィルタを定義。

gateway/main.go
// カスタムヘッダフィルタ
func responseHeaderFilter(ctx context.Context, w http.ResponseWriter, resp proto.Message) error {
    // 不要なヘッダを削除
    w.Header().Del("Grpc-Metadata-Content-Type")
    return nil
}

エラーハンドラとヘッダフィルタを設定。

gateway/main.go
func run() error {
    // 省略
    runtime.HTTPError = customHTTPError
    mux := runtime.NewServeMux(runtime.WithForwardResponseOption(responseHeaderFilter))
    // 省略
}

リバースプロキシを起動してcurlでリクエストを投げてみます。

正常系

リクエストは同じなので省略。
レスポンスはこちら。

HTTP/1.1 200 OK
Content-Type: application/json
Date: Sat, 21 Dec 2019 16:34:32 GMT
Content-Length: 23

{"value":"Hello, JSON"}

不要なヘッダは設定されておらず、良さそうな感じです。

異常系

リクエストは同じなので省略。
レスポンスはこちら。

HTTP/1.1 404 Not Found
Content-Type: application/json
Date: Sat, 21 Dec 2019 16:25:24 GMT
Content-Length: 22

{"error":"not found"}

正常系の時と同じく、良さそうな感じです。

gRPC-JSON transcoder

transcoderで変換する方法が分からなかった(おそらくそこまで自由にできるものではなさそう)ので、envoyのHTTPフィルタの一種であるLuaフィルタでカスタマイズしてみます。なんでも、HTTPフィルタの中でLuaスクリプトが記述できるらしいです。

ということで追加した設定がこちら。bodyに値を設定できそうなAPIがなかったので、ヘッダを色々操作してるくらい。
レスポンスの変換には直接関係ないですが、追加したスクリプトのテストはどうするのがいいんでしょうか。E2Eのテストでヘッダが想定通りになっているかを確認すればいいのかな?

envoy/envoy.yaml
          http_filters:
          - name: envoy.lua
            typed_config:
              "@type": type.googleapis.com/envoy.config.filter.http.lua.v2.Lua
              inline_code: |
                function envoy_on_response(response_handle)
                  response_handle:headers():remove('grpc-status')
                  response_handle:headers():remove('x-envoy-upstream-service-time')
                  message = response_handle:headers():get('grpc-message')
                  if message ~= '' then
                    response_handle:headers():add('error-message', message)
                  end
                  response_handle:headers():remove('grpc-message')
                end

envoyを起動してcurlでリクエストを投げてみます。

正常系

リクエストは同じなので省略。
レスポンスはこちら。

HTTP/1.1 200 OK
content-type: application/json
content-length: 28
date: Sat, 21 Dec 2019 16:36:54 GMT
server: envoy

{
 "value": "Hello, JSON"
}

serverを消し忘れてましたが、概ね良さそう。

異常系

リクエストは同じなので省略。
レスポンスはこちら。

HTTP/1.1 404 Not Found
content-type: application/grpc
content-length: 0
error-message: not found
date: Sat, 21 Dec 2019 15:33:06 GMT
server: envoy

レスポンスボディに触れていないのでエラーメッセージがヘッダに設定されたままですが、ヘッダは色々カスタマイズできそう。

まとめ

grpc-gatewayはリバースプロキシが必要で運用の手間が増えるが、レスポンスヘッダ/ボディともに高い自由度でカスタマイズできるので公開するAPIに向いてそうですね。
一方、envoyのフィルタはレスポンスのカスタマイズの自由度が低いので、公開するAPIに採用するのは避けた方が良さそう。ただし、既にenvoyが稼働していれば追加のサーバは必要ないため、内部向けのAPIとして使えるケースもあるかもしれない。その場合でも、メンテナンス性を考えるとできるだけカスタマイズはせずにシンプルに使うようにした方が良さそうです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
5
Help us understand the problem. What are the problem?