フィールド番号を変えたら、古いクライアントが黙って間違った値を読んだ
バイトを手で読み書きする
目標
protocもprotobufパッケージもないPodで、Pythonの標準ライブラリだけを使って、protobufの ワイヤーフォーマットのエンコーダーとデコーダーを自分で作ります。varint → タグ → 符号付き整数 → フィールド → メッセージの順に積み上げ、最後にサブメッセージと packed repeatedをデコードします。作ったツールは次のラボでも使います。
なぜ重要なのか
gRPC障害の半分は、「バイトには名前がない」という事実を知らないことから起きます。
ワイヤーにはフィールドの番号とワイヤータイプだけが載り、名前と宣言された型は
読み取る側の.protoが付けます。これを一度手でエンコードしてみると、なぜ番号を変えてはいけないのか、なぜ
int32の負の数が10バイトにもなるのか、なぜ古いクライアントが新しいフィールドをエラーなしで
読み飛ばせるのかが、計算で見えてきます。ライブラリが隠しているものを一度開けて見た人は、
スキーマレビューで別のところを見ます。
ステップ
/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進数です)。encode_tag(field, wire_type) -> bytesとdecode_tag(data, pos=0) -> (번호, 와이어타입, 다음 위치)(プレースホルダーは番号、ワイヤータイプ、次の位置です)を追加してください。1:02:23:216:0の4つのタグの16進数を、/root/grpc/02-tag.txtに번호:와이어타입=16진수の形式で書いてください(プレースホルダーは番号、ワイヤータイプ、16進数です)。zigzag_encode(n) -> intとzigzag_decode(u) -> intを追加し、int32 -1sint32 -1int32 -150sint32 -150を実際にエンコードして、/root/grpc/03-signed.txtに타입 값 = 16진수 (N bytes)の形式で書いてください(プレースホルダーは型、値、16進数です)。int32は64ビットの2の補数をvarintで、sint32はZigZagを通した値をvarintで送ります。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の値は、韓国語で「ハングルのメモ」を意味する文字列です)。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進数)。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の形式で書いてください。/opt/app/grpc/nested.binのフィールド6はCustomerサブメッセージです。そのペイロードをもう一度decode_messageでデコードし、/root/grpc/07-nested.txtにcustomer.bytes=16진수customer.name=이름customer.tier=숫자の3行を書いてください(プレースホルダーは16進数、名前、数字です)。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は要素ごとにタグを付けた形です)。
参考
- ルールの原本はProtocol Buffersのエンコーディングのドキュメントです。タグは
(field_number << 3) | wire_type、ワイヤータイプは0 VARINT・1 I64・2 LEN・5 I32です。 - 参照実装
/opt/app/grpc/pbmini.pyがあります。行き詰まったら読んでも構いませんが、このラボの採点ツールはあなたの/root/grpc/pb.pyファイルを読み込み、指示文にないランダムな値でも実行します。答えを埋め込んでおくと、その場で不合格になります。 - フィクスチャのバイト列は
python3 -c "print(open('/opt/app/grpc/order_v1.bin','rb').read().hex())"で見られます。 - よくあるミス:
decode_varintがpos引数を無視すること、文字列の長さを文字数で数えること(UTF-8のバイト数でなければなりません)、負のint32を32ビットで切り詰めること(64ビットです)。
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つの長さを比べてみてください。タグのコストがどれだけ減ったかがわかります。