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

この記事は[OpenSaaS Studio Advent Calendar 2019](https://adventar.org/calendars/3928)、19日目の記事です。
今回はBazelではなくgRPCについてのお話。

# はじめに
こんな要件がありました。

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

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

* [grpc-gateway](https://grpc-ecosystem.github.io/grpc-gateway/)
    * プロトコル定義ファイルから生成したリバースプロキシがRESTとgRPCを中継する。
    * リバースプロキシを自分で運用する必要がある（のでちょっと手間かな、という第一印象）。
* envoyの[gRPC-JSON transcoder](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter)
    * envoyのHTTPフィルタの一つ。プロトコル定義ファイルから生成したproto descriptorをもとにRESTとgRPCを中継する。
    * envoyを運用する必要があるが、istioを使っているなら追加の手間は増えない（かな、という第一印象）。

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

* [レスポンスをカスタマイズしていない版](https://github.com/mmmknt/grpc-rest-example/releases/tag/v0.0.1)
* [レスポンスをカスタマイズした版](https://github.com/mmmknt/grpc-rest-example/releases/tag/v0.0.2)

# 素直に使ってみる
まずはプロトコル定義。詳細は[公式のドキュメント](https://grpc-ecosystem.github.io/grpc-gateway/docs/usage.html#usage)に譲りますが、importと`Echo`rpcの内部にgrpc-gateway用の設定が入っています。

```protobuf: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サーバを起動するだけなので説明は省略。

```golang: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
こちらも[公式のドキュメント](https://grpc-ecosystem.github.io/grpc-gateway/docs/usage.html#usage)の通りに実装しただけなので説明は省略。必要なら`gateway/main.go`を見てください。
リバースプロキシを起動するとRESTでAPIが叩けるようになるのでcurlでリクエストを投げてみます。

### 正常系
こんなリクエストを投げてみる。

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

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

```bash
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`というヘッダが設定されていますが、それ以外は想定通りといったところ。

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

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

```bash
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"}
```

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

## gRPC-JSON transcoder
transcoderを追加したenvoyの設定ファイルと生成したproto descriptorを使って作成したDockerイメージでenvoyを起動しました。
envoyの設定は[公式のドキュメント](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/grpc_json_transcoder_filter#sample-envoy-configuration)を参考に`proto_descriptor`や`services`を修正したくらいなので説明は省略。必要なら`envoy/envoy.yaml`を見てください。
envoyを起動するとRESTでAPIが叩けるようになるのでcurlでリクエストを投げてみます。

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

```bash
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の時と同じなので省略。
すると、こんなレスポンスが返ってきました。

```bash
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-type`も`application/grpc`となっています。

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

## grpc-gateway
[こちらの記事](https://mycodesmells.com/post/grpc-gateway-error-handler)を参考に、エラーハンドラを定義。

```golang: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))
	}
}
```

[公式のドキュメント](https://grpc-ecosystem.github.io/grpc-gateway/docs/customizingyourgateway.html#mutate-response-messages-or-set-response-headers)を参考にヘッダフィルタを定義。

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

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

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

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

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

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

{"value":"Hello, JSON"}
```

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

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

```bash
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フィルタ](https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/lua_filter)でカスタマイズしてみます。なんでも、HTTPフィルタの中で[Luaスクリプト](https://www.lua.org/)が記述できるらしいです。

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

```yaml: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でリクエストを投げてみます。

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

```bash
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`を消し忘れてましたが、概ね良さそう。

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

```bash
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として使えるケースもあるかもしれない。その場合でも、メンテナンス性を考えるとできるだけカスタマイズはせずにシンプルに使うようにした方が良さそうです。