gRPC-Gatewayでクライアントストリーミング

Jul 14, 2021 23:46 · 311 words · 2 minute read

画像ファイルのような大きめのファイルを gRPC でやりとりする場合がある。 デフォルトでは 1 リクエストのサイズは 4 MB に絞られていて、ここは設定によって増減させることが可能らしいが、チャンクする方が一般的らしい。

gRPC-Gateway について 🔗

gRPC は、protobuf を使ってスキーマドリブンに開発できるし、HTTP/2 なので通信も一定の効率化が期待できるしと、結構便利に使える。
とはいえ、何らかの事情で RESTful API でも提供しないといけないケースは割とある。
そんなときに protobuf から RESTful API の proxy サーバーを生成してくれるのが gRPC-Gateway。

gRPC <=> HTTP のスキーママッピングについては、google.api.http アノテーションを付与することで明示的に指定する。

service Messaging {
	rpc GetMessage(GetMessageRequest) returns (Message) {
		option (google.api.http) = {
			get: "/v1/{name=messages/*}"
		};
	}
}
message GetMessageRequest {
	string name = 1; // Mapped to URL path.
}
message Message {
	string text = 1; // The resource content.
}

マッピング定義の中では、リクエスト引数になっている message のフィールド名をパスパラメータとして指定することができる。 たとえば上記の例だと、GET /v1/messages/foo とすれば、GetMessageRequest.name=foo というリクエストが送られてきたと解釈される。

Create/Update 系の API では、body を指定したくなることがほとんどだが、この場合は JSON として渡ってきたリクエストを、gRPC の message にどうマッピングするかを指定することが可能。

service Messaging {
	rpc UpdateMessage(Request) returns (Message) {
		option (google.api.http) = {
			post: "/v1/message/{message_id}"
			body: "message"
		}
	}
}
message Request {
	string message_id = 1;
	Message message = 2;
}
message Message {
	string text = 1;
}

このように body の変換を規定した場合、POST /v1/message/123 {text="hello"} のようなリクエストは、(便宜上 JSON で書いたが) 以下のような protobuf メッセージのリクエストと解釈される。

{
	"message_id": "123",
	"message": {
		"text": "hello"
	}
}

rpc の引数になっている message のフィールドは、body の指定がない場合はクエリパラメータとして自動的に解釈される(逆に * を使っている場合はクエリパラメータは使えない、とも言える)。

これは gRPC Gateway の気持ちになると難しくなくて、

  1. REST として呼び出された API の情報から、gRPC の request に指定されている message をなんとかして構築する必要がある。
  2. REST の場合、パスパラメータ、クエリパラメータ、ボディのいずれかで情報が渡ってくるのでどういう順番で参照すればいいのか?
  3. 「パスパラメータ => (見つからなかったフィールドは) ボディ => (それでも見つからなかったフィールドは) クエリパラメータ」という探し方にしよう。
  4. ボディに*が指定されていると言うことは、パスパラメータで指定されていない全てのフィールドはボディにあることを期待していい(見つからなければゼロ値を意図している) ということになる。
  5. なので、ボディに * があるとクエリパラメータが見られることはない。 という理解です。

client stream 🔗

最初に戻ると、RESTful API(というか HTTP/1.1)では stream という概念がないので、gRPC-Gateway では client stream を NDJSON として扱うことにしている らしい。
画像ファイルをチャンクして client stream で送るなら、例えばこんな感じでチャンク化した画像データを base64 にして JSON に詰めて送ってあげればOK。

const payload = (raw: string) => {
  // 3 MB ずつ送る。
  const blockSize = 3 * 1024 * 1024;
  var chunkSize = parseInt((raw.length / blockSize).toFixed(0)) + 1;
  var data = [...Array(chunkSize)].map((_, i) => {
    return {
      chunk: {
        seq: i,
        content: btoa(raw.substr(i*blockSize, blockSize))
      },
      total_chunks: chunkSize,
    };
  });
  return data.map(v => JSON.stringify(v)).join("\n");
};

axios.post(url, payload(data));

サーバー側の仕様はこんなイメージ。

syntax = "proto3";
package sample;
import "google/api/annotations.proto";

service Image {
  rpc upload(stream Payload) returns (Response) {
    option (google.api.http) = {
      post: "/images"
      body: "*"
    };
  }
}

message Payload {
	message Chunk {
		int32 seq = 1;
		bytes content = 2;
	}
	repeated Chunk chunks = 1;
	int32 total_chunks = 2;
}

ちなみに、gRPC-Gateway の proxy サーバーには独自のルーティングを実装することができるので こんな感じ で直接バイナリを受け付けるのも1つの手ではある(API仕様が proto で完結しなくなってしまうのが…という感じはしますね)。