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

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

1行のバイトにフィールド番号が入っている

TT Labで続きを見る

一言でいうと

protobufのバイトにはフィールドの名前がありません。タグの1バイト08は「フィールド1、varint」という意味で、その後ろの96 01が150です。名前と宣言された型は、読み取る側の.protoが付けます。そのため番号を変えると、パーサーは何のエラーも出さずに別のフィールドの値を読んでしまいます。

なぜ必要なのか

JSONは{"qty": 3}のように名前も一緒に送ります。人間が読みやすく、フィールド名を変えればすぐに気づけます。その代わり、メッセージごとに名前を文字列として載せなければならず、数値も文字列で書く必要があります。トラフィックの多い内部サービスでは、このコストが目に見えてきます。

Protocol Buffersは逆の選択をしました。名前は送らず、番号だけを送ります。値もテキストではなく、最も短いバイナリ表現で送ります。その代償として、読み取る側は必ずスキーマ(.proto)を知っている必要があり、スキーマの番号がそのまま契約になります。このコース全体が、その代償についての話です。バイトを一度手で読んでみれば、なぜそうなるのかが体でわかります。

このラボのイメージには、protocもprotobufのPythonパッケージもありません。むしろ好都合です。エンコーディングのドキュメントが定めるルールは、Pythonの標準ライブラリで100行もあればすべて再現できます。ライブラリが隠しているものを自分の手で開けて見ることが、このモジュールの目的です。

どう動くのか

ドキュメントの最初の例をそのまま追ってみましょう。message Test1 { int32 a = 1; }にa = 150を入れてシリアライズすると、3バイトが出力されます。

08 96 01

まずvarintです。整数を7ビットずつに切り、下位から順に出力し、後ろにまだ続きがあればそのバイトの最上位ビット(MSB)を立てます。150は2進数で10010110です。下位7ビット0010110に継続の印を付けると10010110 = 0x96、残った1は0x01になります。だから96 01です。1は1バイトの01、300はac 02、16384は80 80 01で、値が小さいほど短くなります。以下の値は、この文章を書きながらPythonで実際に計算したものです。

    1 → 01
  127 → 7f
  128 → 80 01
  150 → 96 01
  300 → ac 02
16384 → 80 80 01

タグは、varintでエンコードされた(field_number << 3) | wire_typeです。下位3ビットがワイヤータイプ、残りがフィールド番号です。08は0000 1000なので、ワイヤータイプ0、番号1です。ワイヤータイプは6種類あり、そのうち実際に出会うのは4つです。

番号 名前 使う型
0 VARINT int32, int64, uint32, uint64, sint32, sint64, bool, enum
1 I64 fixed64, sfixed64, double
2 LEN string, bytes, サブメッセージ(submessage), packed repeated
5 I32 fixed32, sfixed32, float

(3 SGROUPと4 EGROUPは、廃止されたグループ用です。)フィールド番号1から15まではタグが1バイトで、16からは2バイトになります。(16 << 3) | 0は128なので80 01です。proto3の言語ガイドが「よく使うフィールドに1から15までを割り当てるように」と勧める理由は、この計算にあります。

ワイヤータイプの役割は、長さを教えることです。VARINTはMSBが0のバイトまで、I64は8バイト、I32は4バイト、LENは直後のvarintが示す長さです。これのおかげで、知らない番号が来てもパーサーはどれだけ読み飛ばせばよいかがわかります。古いバイナリが新しいフィールドをエラーなしで読み飛ばせる仕組みがこれで、この性質は次のモジュールで実測します。

LENレコードは、タグの後ろに長さのvarint、その次にペイロードが続きます。message Test2 { string b = 2; }に"testing"を入れると次のようになります。

12 07 74 65 73 74 69 6e 67
│  │  └── "testing" 의 UTF-8 7바이트
│  └───── 길이 7
└──────── 태그 (2 << 3) | 2 = 0x12

長さは文字数ではなくバイト数です。「ハングルのメモ」(ハングル4文字と空白1つ)は5文字ですが、UTF-8では13バイトなので、長さのvarintも13です。これを文字数で数えると、パーサーは次のタグを見当違いの位置から読んでしまいます。

サブメッセージもLENです。message Test3 { Test1 c = 3; }でc.a = 150なら1a 03 08 96 01になります。タグ1a(3:LEN)、長さ3、そして先ほどの3バイトがそのまま入っています。ペイロードを同じ関数でもう一度デコードすれば、内側のメッセージが得られます。

負の数が落とし穴です。int32とint64は負の数を2の補数で扱いますが、ドキュメントはこれを「unsigned 64ビット整数として最上位ビットが立っているので、10バイトをすべて使う」と説明しています。-1はff ff ff ff ff ff ff ff ff 01の10バイトです。負の数が多いフィールドにはsint32を使います。sintは先にZigZagで変換します。正の数pは2p、負の数nは2|n|-1です。0→0、-1→1、1→2、-2→3と交互に並び、32ビットの式は(n << 1) ^ (n >> 31)です。すると-1はvarintの1、つまり01の1バイトになります。-150はZigZagで299になり、ab 02の2バイトです。同じ値が10バイトと2バイトに分かれることを、ラボで自分で測ります。

欠けているフィールドは、単にレコードを書きません。ヘッダーもフィールド数もありません。メッセージはレコードをつなげただけのものなので、フィールドの順序も保証されず、パーサーはどんな順序で来ても読めなければなりません。ドキュメントは「シリアライズ結果のバイトが安定していると仮定してはならない」と明記しています。同じメッセージを2回シリアライズしてもバイトが同じである保証はないので、その上にハッシュやCRCを載せてはいけません。

packed repeatedは、Edition 2023からデフォルトです。スカラーの繰り返しフィールドは、要素ごとにタグを付ける代わりに、1つのLENレコードに値をつなげて入れます。repeated int32 e = 5に1、2、3を入れると2a 03 01 02 03になり、タグ1つに3つの値が入ります。パーサーは、packedで来ても要素ごとに来ても、どちらも読めなければならないとドキュメントは求めています。

現場での姿

最もよくある事故は「データが壊れているのにエラーが出ない」です。あるチームがスキーマを整理する際に、見栄えを良くしようとフィールド番号を振り直しました。コンパイルは通り、テストも通りました。ところが、まだ古いバイナリを使っている消費者側のサービスが、注文数量の位置に注文番号を読み始めました。パーサーから見れば、番号2にvarintが来たので、ルールどおりに読んだだけです。名前はワイヤー上にないので、「これはqtyではない」と教えてくれる情報そのものがありません。この手の事故はログに例外が出ないため、発見までに数日かかります。

2つ目はサイズの思い込みです。「整数1つにつき4バイト」と仮定して容量を計算したところ、負のint32が10バイトずつ出力され、見積もりの2倍以上になったことがあります。逆にsint32に変えたら半分以下に減ることもあります。型を選ぶときに値の分布を見る習慣は、ここから生まれます。

3つ目は文字列の長さです。自分でパーサーを書いた人が最初に経験するバグは、ほぼ例外なくUTF-8のバイト数と文字数の取り違えです。ASCIIだけでテストすると決して表に出ず、最初のハングルのメモで爆発します。

次のラボですること

/root/grpc/pb.pyに、varint、タグ、ZigZag、フィールド、メッセージのエンコーダーとデコーダーを順に作ります。150が96 01になること、int32 -1が10バイトでsint32 -1が1バイトになることを、自分で測ります。その後、フィクスチャのバイト列をデコードしてv1スキーマが知らないフィールドを見分け、サブメッセージとpacked repeatedを2通りにデコードします。採点ツールはあなたのpb.pyを読み込み、指示文にないランダムな値で往復させるので、値を埋め込んで済ませることはできません。ルールを実装する必要があります。