フィールド番号を変えたら、古いクライアントが黙って間違った値を読んだ
古いクライアントの前でスキーマを変える
目標
v1スキーマをコードに埋め込んだ「旧クライアント」はそのままにして、スキーマをv2に変更します。
新しいフィールドを新しい番号で追加し、削除したフィールドをreservedで封印し、わざと番号を
入れ替えたバイト列を旧クライアントがエラーなしでどう誤って読むかを再現します。その後、
デフォルト値とpresenceの落とし穴を実測し、optionalとreservedを備えた最終
スキーマと互換性の表を残します。
なぜ重要なのか
クライアントとサーバーは、決して同じ瞬間にはデプロイされません。ロールバックもあり、ログに 残った古いバイトもあります。そのため、スキーマ変更の安全性は「コンパイルが通るか」では なく、「古いバイナリが新しいバイトを、新しいバイナリが古いバイトを正しく読めるか」で 判断しなければなりません。ワイヤーには名前がなく番号だけがあるので、番号を変えることは フィールドを削除して新しく作ることと同じです。ところがパーサーは何のエラーも出さず、 変更された番号の値を古い名前で読みます。このラボは、その事故を再現して記憶に 刻むことが目的です。
ステップ
python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.binを実行し、出力された4行を/root/grpc/evolve/01-old.txtにそのまま保存してください。/root/grpc/evolve/v2.protoを書いてください。syntax = "proto3";で始め、message Orderにv1のint32 id = 1; int32 qty = 2; string note = 3;をそのまま残し、int64 unit_priceとstring currencyを新しい番号で追加します。- v2.protoから
noteを削除し、reserved 3;とreserved "note";を残してください。残りはそのままです。 /root/grpc/evolve/make_v2.pyで/opt/app/grpc/pbmini.pyを使い、id=9001, qty=2, unit_price=15000, currency="KRW"をv2.protoの番号どおりにエンコードして/root/grpc/evolve/order_v2.binに保存し、旧クライアントでそのファイルを読んだ出力を/root/grpc/evolve/04-old-reads-v2.txtに保存してください。/opt/app/grpc/renumbered.binは、/opt/app/grpc/renumbered.proto(番号を振り直したスキーマ)で作った同じ注文です。旧クライアントで読んだ出力を/root/grpc/evolve/05-renumbered.txtに保存し、その下にtruth: id=<진짜 id> qty=<진짜 qty>を1行(プレースホルダーは本当のidと本当のqtyです)、why: <왜 오류 없이 틀린 값이 나왔는지>を1行(プレースホルダーは、なぜエラーなしで誤った値が出たのかの説明です)書いてください。- pbminiで、
id=9002, currency="KRW"かつqty=0の注文を2通り作ってください。/root/grpc/evolve/06-zero.binは暗黙的なpresence(0なら送らない)、/root/grpc/evolve/06-zero-explicit.binは明示的なpresence(0も送る)です。/root/grpc/evolve/06-presence.txtに、implicit=<16진수>explicit=<16진수>implicit_qty=<옛 클라이언트가 읽은 qty>explicit_qty=<옛 클라이언트가 읽은 qty>の4行を書いてください(プレースホルダーは16進数と、旧クライアントが読んだqtyです)。 - 最終の
/root/grpc/evolve/Order.protoを書いてください。optional int32 qty = 2;、reserved 3;とreserved "note";、unit_priceとcurrency、そしてmessage Customer { string name = 1; int32 tier = 2; }とOrderのCustomer customer = <새 번호>;が入ります(プレースホルダーは新しい番号です)。 /root/grpc/evolve/compat.mdにマークダウンの表を書いてください。最初の列はadd-new-numberremove-and-reservereuse-numberrenumberint32-to-stringrename-onlyの6つのキー、2列目はsafeまたはunsafe、3列目は理由を1行で書きます。
参考
- ルールの原本: proto3の言語ガイド — メッセージ型の更新、フィールドpresence。
old_client.pyは、v1スキーマをコードに埋め込んだプログラムです。出力はid=qty=note=unknown=の4行です。ステップ6で、2つのファイルをそれぞれ読ませてみてください。- pbminiの使い方:
import sys; sys.path.insert(0, "/opt/app/grpc"); import pbmini as pb; pb.encode_message([(1, "int32", 9001), (2, "int32", 2)])。 - 採点ツールは.protoを寛容に読みます(コメントや空白は自由)。ただし、番号の再利用、reservedの欠落、型の変更は必ず不合格になります。
- ステップ8の
rename-onlyは、バイナリのワイヤーを基準にしています。ワイヤーに名前がないので、名前だけを変えてもバイトに影響はありません(JSONやテキスト形式では異なります)。
旧クライアントでv1のバイト列を読む
python3 /opt/app/grpc/old_client.py /opt/app/grpc/order_v1.binの出力の4行を、/root/grpc/evolve/01-old.txtにそのまま保存してください。
mkdir -p /root/grpc/evolveの後に、出力を>で保存すれば済みます。旧クライアントはフィールド1をid、2をqty、3をnoteだと信じて読み、それ以外の番号はunknownに書き出します。手で書き写さずに、出力を保存してください。採点ツールが同じコマンドをもう一度実行して比較します。
新しいフィールドは新しい番号で
/root/grpc/evolve/v2.protoにv1の3つのフィールドをそのまま残し、int64 unit_priceとstring currencyを新しい番号で追加してください。
既存のフィールドの番号・名前・型は、1文字も変えません。新しいフィールドには、これまで使われたことのない番号を割り当てます。4と5が自然ですが、6と7でも構いません。19,000–19,999は実装が予約している区間なので使えず、1–15はタグが1バイトなので、よく使うフィールドに向いています。
採点ツールはsyntax = "proto3";とmessage Order { ... }を探し、フィールド1・2がv1と同じか、unit_priceがint64でcurrencyがstringか、その番号が1・2・3ではないかを確認します。
削除した番号を封印する
v2.protoからnoteを削除し、reserved 3;とreserved "note";を残してください。
削除すること自体は、ワイヤー上で安全です。危険なのは後で誰かが3をもう一度使うことで、reservedは、protocがそれをコンパイルエラーで止めるための仕組みです。番号と名前は1つの文に混ぜて書けないので、2行に分けて書きます。
reserved 3;は、messageブロックの中のどこにあっても構いません。
v2のバイト列を旧クライアントで読む
/root/grpc/evolve/make_v2.pyでpbminiを使い、id=9001, qty=2, unit_price=15000, currency="KRW"をv2.protoの番号どおりにエンコードして/root/grpc/evolve/order_v2.binに保存し、旧クライアントで読んだ出力を/root/grpc/evolve/04-old-reads-v2.txtに保存してください。
pb.encode_message([(1, "int32", 9001), (2, "int32", 2), (4, "int64", 15000), (5, "string", "KRW")])のように、v2.protoで選んだ番号をそのまま使います。note(3)は削除したフィールドなので入れません。
旧クライアントは4・5を知りませんが、ワイヤータイプから長さがわかるので読み飛ばし、id・qtyは正しく読みます。unknown=の行に、その2つの番号が表示されるはずです。
番号を入れ替えたバイト列を旧クライアントで読む
旧クライアントで/opt/app/grpc/renumbered.binを読んだ出力を/root/grpc/evolve/05-renumbered.txtに保存し、truth: id=<진짜> qty=<진짜>とwhy: <이유>の2行を書き足してください(プレースホルダーは本当の値と理由です)。
まず/opt/app/grpc/renumbered.protoを読んでください。誰かが番号を振り直しています。同じ注文(order_v1.binと同じ値)をそのスキーマでシリアライズしたものがrenumbered.binです。
旧クライアントは、番号1を相変わらずidだと信じています。ワイヤータイプが同じ(どちらもVARINT)なので、エラーも警告もなく、値が入れ替わって出力されます。truthの行は、renumbered.protoのとおりに読んだ本当の値です。
qty=0は送ったのか、送っていないのか
id=9002, currency="KRW", qty=0の注文を、/root/grpc/evolve/06-zero.bin(暗黙的なpresence、0は送らない)と/root/grpc/evolve/06-zero-explicit.bin(明示的なpresence、0も送る)として作り、/root/grpc/evolve/06-presence.txtにimplicit= explicit= implicit_qty= explicit_qty=の4行を書いてください。
proto3の修飾子のないスカラーは、デフォルト値(0、"")をシリアライズしません。そのため、暗黙的な側はフィールド2のレコードをまったく入れず、明示的(optional)な側は(2, "int32", 0)を入れます。10 00の2バイトが余分に載ります。
2つのファイルを旧クライアントでそれぞれ読ませて、qtyを書いてください。どちらも0になります。旧クライアントは「0を受け取った」と「何も受け取れなかった」を区別できません。これがoptionalが必要な理由です。
optionalとreservedを備えた最終スキーマ
/root/grpc/evolve/Order.protoを書いてください。optional int32 qty = 2;、reserved 3;とreserved "note";、int64 unit_priceとstring currency、message Customer { string name = 1; int32 tier = 2; }とOrderのCustomer customer = <새 번호>;を含めます(プレースホルダーは新しい番号です)。
optional修飾子を付けると、qtyにexplicit presenceが生まれ、0を送ったことと送っていないことを区別できるようになります。バイナリのワイヤーでは、修飾子を付けること自体はバイトを変えません(0が送られるようになるだけです)。
Customerは別のmessageにして、Orderから型として使います。customerの番号は、これまで使われたことがなく、reservedでもないものにしてください。
互換性の表を書く
/root/grpc/evolve/compat.mdにマークダウンの表を書いてください。最初の列はadd-new-number remove-and-reserve reuse-number renumber int32-to-string rename-only、2列目はsafe/unsafe、3列目は理由です。
このラボで自分の目で見たことを整理するステップです。ステップ4(新しい番号で追加)、ステップ3(削除してreserved)、ステップ5(番号の入れ替え)が、それぞれ1行の根拠になります。型の変更は、ワイヤータイプが変わる場合(int32 VARINT → string LEN)、古いパーサーが値を別の意味で読みます。名前だけを変えるのは、バイナリのワイヤーに名前がないので、バイトは同じです。
判定の語はsafeかunsafeのどちらか1つだけを使ってください。採点ツールは、行の中で最初に出てくる判定の語を見ます。