Open6

gRPC-webがどのようにリクエストをシリアライズしているのか

kiddiknkiddikn

事前に、gRPC-webを介して通信するためのシリアライズされたリクエストを大量に生成しておきたいお気持ちになったのでどうするか調べるというモチベーション

kiddiknkiddikn

gRPCとは

  • そもそもgRPCを見ていると2つのプロトコルがある

https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md
https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md

Due to browser limitation, the Web client library implements a different protocol than the native gRPC protocol.

ブラウザではHTTP2で通信できない(browser limitation)ので、通信できるようにしたプロトコル

ざっと抜き出した特徴

  • Content-Typeをapplication/grpc-webまたはapplication/grpc-web-textとすることでテキスト形式で通信できる
  • HTTP2によらずHTTP/*で通信可能
kiddiknkiddikn

gRPC-Webとは

gRPCをブラウザークライアントから使用できるようにしたJavaScriptの実装
https://github.com/grpc/grpc-web

responseのデコード方法

responseをどのようにdecodeするかは以下のコメントがわかりやすかったので訳してメモ
https://github.com/grpc/grpc-web/issues/634#issuecomment-535242751

レスポンスをブラウザ上で確認すると↓のようになっている

AAAAAAcKBWhlbGxvgAAAAehncnBjLX....
  1. これはbase64でエンコードされているのでまずはデコードする
  2. デコード結果はバイト列になっている
  3. バイト列をxxdなどを使って各バイトの値を見ると以下のようになる
echo "AAAAAAcKBWhlbGxvgAAAAehncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6T0sNCnNlYy1mZXRjaC1tb2RlOmNvcnMNCngtdXNlci1hZ2VudDpncnBjLXdlYi1qYXZhc2NyaXB0LzAuMQ0KY3VzdG9tLWhlYWRlci0xOnZhbHVlMQ0KdXNlci1hZ2VudDpNb3ppbGxhLzUuMCAoWDExOyBMaW51eCB4ODZfNjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS83Ny4wLjM4NjUuOTAgU2FmYXJpLzUzNy4zNg0KYWNjZXB0OmFwcGxpY2F0aW9uL2dycGMtd2ViLXRleHQNCngtZ3JwYy13ZWI6MQ0Kb3JpZ2luOmh0dHA6Ly9sb2NhbGhvc3Q6ODA4MQ0Kc2VjLWZldGNoLXNpdGU6c2FtZS1zaXRlDQpyZWZlcmVyOmh0dHA6Ly9sb2NhbGhvc3Q6ODA4MS9lY2hvdGVzdC5odG1sDQphY2NlcHQtbGFuZ3VhZ2U6ZW4tVVMsZW47cT0wLjkNCngtZm9yd2FyZGVkLXByb3RvOmh0dHANCngtcmVxdWVzdC1pZDo5OTM2YmVjMS0zZjVmLTRkZGMtYjg1OS1iZDAxZTdjOGQ1YjUNCg==" | base64 -d | xxd
00000000: 0000 0000 070a 0568 656c 6c6f 8000 0001  .......hello....
00000010: e867 7270 632d 7374 6174 7573 3a30 0d0a  .grpc-status:0..
00000020: 6772 7063 2d6d 6573 7361 6765 3a4f 4b0d  grpc-message:OK.
00000030: 0a73 6563 2d66 6574 6368 2d6d 6f64 653a  .sec-fetch-mode:
...(以下省略)

いくつかの塊になっているが、この塊は以下のフォーマットとなっておりこの塊が繰り返して出力される

  • marker
  • 4バイトで示された長さ
  • 示された長さバイトデータとtailer

実際に↑に合わせて解析してみる。

  1. この例でいうmarkerとして00から始まっている。次はデータフレームだよということを示すマーカー

  2. 次の4バイトが00 00 00 07となっており、データフレームの長さが7バイトであることを示す

  3. 2で指定された7バイトを読むと0a 05 68 65 6c 6c 6f
    これがprotobufのseq=1フィールドに与えられたメッセージがエンコードされたバイト配列

    ちなみにこれはprotocの--decode_rawオプションを使ってデコードできる

     ~$ echo -n -e '\x0a\x05\x68\x65\x6c\x6c\x6f' | protoc --decode_raw
    1: "hello"
    
  4. 次が80でこれがtrailerフレームとなっている

  5. 次の4バイト00 00 01 e8がtrailerの長さ
    1e8は16進数で488なので次の488バイトがtrailerフーレムの長さとなる

  6. 以下はバイト列の解析なので省略

kiddiknkiddikn

protobuf objects から grpc-web-text (base64)に変換するにはencodeProtobufを使用すると良い

const toBytesInt32 = (num: number): Uint8Array => {
  // an Int32 takes 4 bytes
  const arr = new ArrayBuffer(4)
  const view = new DataView(arr)
  // byteOffset = 0; litteEndian = false
  view.setUint32(0, num, false)
  return new Uint8Array(arr)
}

const createHeader = (name: string, value: string): Buffer => {
  const buffers: Array<number> = []
  for (const char of name) {
    buffers.push(char.charCodeAt(0))
  }
  buffers.push(':'.charCodeAt(0))
  for (const char of value) {
    buffers.push(char.charCodeAt(0))
  }
  buffers.push('\r'.charCodeAt(0))
  buffers.push('\n'.charCodeAt(0))
  return Buffer.from(buffers)
}

export const encodeProtobuf = <
  GRPCReply extends { serializeBinary: () => Uint8Array },
>(
  reply: GRPCReply,
): string => {
  const replyBuffer = reply.serializeBinary()
  const replyLength = toBytesInt32(replyBuffer.length)

  const headers = Buffer.from([
    ...createHeader('grpc-status', '0'),
    ...createHeader('grpc-message', 'OK'),
  ])
  const headersLength = toBytesInt32(headers.length)

  const response = Buffer.from([
    0, // mark response start ("data" frame coming next)
    ...replyLength,
    ...replyBuffer,
    128, // end of "data" frame, trailer frame coming next
    ...headersLength,
    ...headers,
  ])

  return response.toString('base64')
}

function main(): void {
  // UserPB is protobuf object created with protoc-gen-grpc
  // Any protobuf object with a serializeBinary function 
  // which returns an Uint8Array will do
  const reply = new UserPB.SetUserReply()
  reply.setUserId('mock-user-id')
  reply.setFirstName('foo')
  reply.setLastName('bar')
  const encoded = encodeProtobuf(reply)
  console.log(encoded)
}

main()
kiddiknkiddikn

リクエストをUint8Arrayにしているクライアントの実装もあった
これを使ってUint8Arrayにした後にbase64でencodeすると、grpc-web-testとして使用できるリクエスト文字列が取得できる
https://github.com/improbable-eng/grpc-web/blob/master/client/grpc-web/src/util.ts#L3-L9

[結論]
これを利用するのが一番良かった
k6でgrpcとしての接続ではなく、gRPC-webを介して負荷試験をしたい場合などは、事前にこの方法でリクエストデータを作成しておくことでhttp.postできる
ただし、今回の負荷試験ではレスポンスの解析については深くは必要ないのでこれ以上は追わない