TT Lab
はじめる
学ぶ 学習パス コース

フィールド番号を変えたら、古いクライアントが黙って間違った値を読んだ

バイトを手で読み書きする

TT Labで続きを見る

目標

protocもprotobufパッケージもないPodで、Pythonの標準ライブラリだけを使って、protobufの ワイヤーフォーマットのエンコーダーとデコーダーを自分で作ります。varint → タグ → 符号付き整数 → フィールド → メッセージの順に積み上げ、最後にサブメッセージと packed repeatedをデコードします。作ったツールは次のラボでも使います。

なぜ重要なのか

gRPC障害の半分は、「バイトには名前がない」という事実を知らないことから起きます。 ワイヤーにはフィールドの番号とワイヤータイプだけが載り、名前と宣言された型は 読み取る側の.protoが付けます。これを一度手でエンコードしてみると、なぜ番号を変えてはいけないのか、なぜ int32の負の数が10バイトにもなるのか、なぜ古いクライアントが新しいフィールドをエラーなしで 読み飛ばせるのかが、計算で見えてきます。ライブラリが隠しているものを一度開けて見た人は、 スキーマレビューで別のところを見ます。

ステップ

  1. /root/grpc/pb.pyを作成し、encode_varint(n) -> bytesとdecode_varint(data, pos=0) -> (값, 다음 위치)(プレースホルダーは値と次の位置です)を実装してください。1、150、300、16384をエンコードした16進数を、/root/grpc/01-varint.txtに값=16진수の形式で1行ずつ書いてください(プレースホルダーは値と16進数です)。
  2. encode_tag(field, wire_type) -> bytesとdecode_tag(data, pos=0) -> (번호, 와이어타입, 다음 위치)(プレースホルダーは番号、ワイヤータイプ、次の位置です)を追加してください。1:0 2:2 3:2 16:0の4つのタグの16進数を、/root/grpc/02-tag.txtに번호:와이어타입=16진수の形式で書いてください(プレースホルダーは番号、ワイヤータイプ、16進数です)。
  3. zigzag_encode(n) -> intとzigzag_decode(u) -> intを追加し、int32 -1 sint32 -1 int32 -150 sint32 -150を実際にエンコードして、/root/grpc/03-signed.txtに타입 값 = 16진수 (N bytes)の形式で書いてください(プレースホルダーは型、値、16進数です)。int32は64ビットの2の補数をvarintで、sint32はZigZagを通した値をvarintで送ります。
  4. encode_field(field, kind, value) -> bytesを追加してください。kindはint32 int64 uint32 uint64 sint32 sint64 bool string bytesです。その関数を使ってid(1)=4242, qty(2)=17, note(3)="한글 메모"の注文を作る/root/grpc/04-make.pyを書いて実行し、/root/grpc/04-order.binを残してください(noteの値は、韓国語で「ハングルのメモ」を意味する文字列です)。
  5. decode_message(data) -> [(번호, 와이어타입, 원시값), ...](プレースホルダーは番号、ワイヤータイプ、生の値です)を追加してください。VARINTはint、LENはbytes、I32/I64は4・8バイトのbytesのままです。/opt/app/grpc/order_v1.binをデコードして、/root/grpc/05-decode.txtにfield=N wire=W value=Vの形式で1行ずつ書いてください(LENの値は16進数)。
  6. encode_records(records) -> bytes(decode_messageの逆関数)を追加してください。/opt/app/grpc/order_v2.binをデコードし、レコードごとにv1スキーマ(/opt/app/grpc/order_v1.proto)が知っている番号かどうかを、/root/grpc/06-unknown.txtにfield=N wire=W knownまたはunknownの形式で書いてください。
  7. /opt/app/grpc/nested.binのフィールド6はCustomerサブメッセージです。そのペイロードをもう一度decode_messageでデコードし、/root/grpc/07-nested.txtにcustomer.bytes=16진수 customer.name=이름 customer.tier=숫자の3行を書いてください(プレースホルダーは16進数、名前、数字です)。
  8. decode_packed_varints(raw) -> [int, ...]を追加して/opt/app/grpc/packed.binのフィールド7(packed repeated int32)を展開し、/root/grpc/08-packed.txtにtags=쉼표목록 packed_record=16진수(필드 7 레코드 전체) expanded_record=16진수(원소마다 태그를 붙인 형태)の3行を書いてください(プレースホルダーはカンマ区切りの一覧と16進数で、packed_recordはフィールド7のレコード全体、expanded_recordは要素ごとにタグを付けた形です)。

参考

varintを作って読む

/root/grpc/pb.pyにencode_varint(n)とdecode_varint(data, pos=0)を実装し、1・150・300・16384の16進数を/root/grpc/01-varint.txtに값=16진수の形式で書いてください(プレースホルダーは値と16進数です)。

値を7ビットずつに切って下位から順に出力し、後ろにまだ続きがあればそのバイトの最上位ビット(0x80)を立てます。150は2進数で10010110で、下位7ビット0010110に継続の印を付けて0x96、残った1が0x01になります。だから96 01です。

decode_varintはposから読み始めて(값, 다음 위치)(プレースホルダーは値と次の位置です)を返す必要があります。採点ツールはpos=3のように途中からも呼び出します。

検算: python3 -c "import sys; sys.path.insert(0,'/root/grpc'); import pb; print(pb.encode_varint(300).hex())"の出力がac02になるはずです。

タグにフィールド番号とワイヤータイプを入れる

encode_tag(field, wire_type)とdecode_tag(data, pos=0)を/root/grpc/pb.pyに追加し、1:0 2:2 3:2 16:0の16進数を/root/grpc/02-tag.txtに번호:와이어타입=16진수の形式で書いてください(プレースホルダーは番号、ワイヤータイプ、16進数です)。

タグは単なるvarintです。(field << 3) | wire_typeをencode_varintに渡せば終わりです。読むときは、varintを読んだ後、下位3ビットがワイヤータイプ、残りを3ビット右にシフトしたものが番号です。

番号1–15はタグが1バイト、16からは2バイトになります。よく使うフィールドに小さい番号を割り当てるというドキュメントの推奨は、ここから来ています。

(1 << 3) | 0は8なので08、(2 << 3) | 2は18なので12です。

負のint32は10バイト、sint32は1–2バイト

zigzag_encode(n)とzigzag_decode(u)を追加し、int32 -1 sint32 -1 int32 -150 sint32 -150を実際にエンコードして、/root/grpc/03-signed.txtに타입 값 = 16진수 (N bytes)の形式で書いてください(プレースホルダーは型、値、16進数です)。

int32の負の数は、64ビットの2の補数をunsignedと見なしてからvarintで送ります。Pythonではn & 0xFFFFFFFFFFFFFFFFをencode_varintに渡せば済みます。最上位ビットが立っているので、10バイトをすべて使います。

sint32は先にZigZagを通します。ドキュメントの式は(n << 1) ^ (n >> 31)で、Pythonの整数は算術シフトなので、この式をそのまま使えば-1 → 1、1 → 2、-2 → 3になります。元に戻す式は(u >> 1) ^ -(u & 1)です。

行の形式は、たとえばsint32 -1 = 01 (1 bytes)です。

フィールド1つをレコードにする

encode_field(field, kind, value)を追加してください(kind: int32 int64 uint32 uint64 sint32 sint64 bool string bytes)。/root/grpc/04-make.pyでその関数を使い、id(1)=4242, qty(2)=17, note(3)="한글 메모"をつなげて/root/grpc/04-order.binに保存してください(noteの値は、韓国語で「ハングルのメモ」を意味する文字列です)。

レコードはタグ+値です。varint系はencode_tag(f, 0) + encode_varint(값)(プレースホルダーは値です)、stringはencode_tag(f, 2) + encode_varint(len(raw)) + rawで、rawはUTF-8でエンコードしたbytesであり、長さもそのバイト数です。「ハングルのメモ」は5文字ですが13バイトです。

int32/int64の負の数はステップ3と同じく64ビットマスク、sint系はZigZag(64ビットはn >> 63)、boolは0または1です。

メッセージはレコードをそのままつなげたものです。区切り文字もヘッダーもありません。

バイト列をレコードの一覧に戻す

decode_message(data)を追加して、[(번호, 와이어타입, 원시값), ...](プレースホルダーは番号、ワイヤータイプ、生の値です)を返すようにしてください。/opt/app/grpc/order_v1.binをデコードし、/root/grpc/05-decode.txtにfield=N wire=W value=Vの形式で書いてください(LENの値は16進数)。

終わるまで繰り返します。タグを読み、ワイヤータイプに応じて値を読みます。0ならvarint、2なら長さのvarintの後ろにその長さ分のbytes、5なら4バイト、1なら8バイトです。生の値は解釈しません。ここでは名前も宣言された型も知らないからです。

この関数がposを正確に進められないと、次のタグを見当違いの位置から読むことになります。採点ツールがランダムなメッセージ12個で試します。

フィクスチャの確認: python3 -c "print(open('/opt/app/grpc/order_v1.bin','rb').read().hex())"。

知らないフィールドを読み飛ばしても失わない

encode_records(records)(decode_messageの逆関数)を追加してください。/opt/app/grpc/order_v2.binをデコードし、レコードごとにv1スキーマ(/opt/app/grpc/order_v1.proto)が知っている番号かどうかを、/root/grpc/06-unknown.txtにfield=N wire=W knownまたはunknownの形式で書いてください。

古いパーサーが新しいフィールドをエラーなしで読み飛ばせるのは、ワイヤータイプが長さを教えてくれるからです。そしてproto3のパーサーは、その知らないフィールドを保持して、再シリアライズするときに含めます。encode_recordsがその役割を担います。レコードごとにタグを書き直し、VARINTならvarint、LENなら長さ+bytes、I32/I64ならbytesをそのまま書きます。

採点ツールは、ランダムなメッセージをあなたのdecode_message → encode_recordsで往復させ、元とバイトが同じかを確認します。

v1が知っている番号は1・2・3だけです。

サブメッセージはLENの中の別のメッセージ

/opt/app/grpc/nested.binのフィールド6(Customerサブメッセージ)のペイロードをもう一度decode_messageでデコードし、/root/grpc/07-nested.txtにcustomer.bytes=16진수 customer.name=이름 customer.tier=숫자の3行を書いてください(プレースホルダーは16進数、名前、数字です)。

サブメッセージはstringとまったく同じLENレコードです。ただ、そのbytesがまた1つのメッセージなので、同じ関数でもう一度デコードすれば済みます。スキーマ(/opt/app/grpc/nested.proto)を見ると、Customerはstring name = 1; int32 tier = 2;です。

customer.bytesは、フィールド6の生の値(タグと長さを除いたペイロード)を.hex()したものです。

packed repeatedを展開する

decode_packed_varints(raw)を追加し、/opt/app/grpc/packed.binのフィールド7(packed repeated int32)を展開して、/root/grpc/08-packed.txtにtags=쉼표목록 packed_record=16진수 expanded_record=16진수の3行を書いてください(プレースホルダーはカンマ区切りの一覧と16進数です)。

packedは、タグ1つのLENレコードの中にvarintがつながっているものです。ペイロードが終わるまでdecode_varintを繰り返せば、一覧が得られます。

packed_recordはpacked.binにあるフィールド7のレコード全体(タグ+長さ+ペイロード)の16進数、expanded_recordは同じ一覧を要素ごとにencode_field(7, "int32", v)で作ってつなげた16進数です。2つの長さを比べてみてください。タグのコストがどれだけ減ったかがわかります。