フィールド番号を変えたら、古いクライアントが黙って間違った値を読んだ
番号は名前より長生きする
一言でいうと
フィールド番号は名前よりも長く生き続けます。新しいフィールドは新しい番号で追加し、削除した番号はreservedで封印し、番号を変えることは絶対にしません。さらに、proto3のスカラーはデフォルト値(0、空文字列)を送らないため、「0を送った」と「何も送らなかった」を区別するにはoptionalが必要です。
なぜ必要なのか
ベストプラクティスのドキュメントの最初の一文が、このモジュールの前提です。クライアントとサーバーは、決してまったく同じ瞬間に更新されません。一緒にデプロイしようとしても、片方がロールバックされることがありますし、ログのどこかには古いスキーマでシリアライズされたバイトが残っています。ですから、「今は両方が同じスキーマだから大丈夫」という仮定が成り立ったことはありません。
前のモジュールで見たとおり、ワイヤーには番号しかありません。そのため、スキーマ変更の安全性は、コンパイルが通るかどうかではなく、古いバイナリが新しいバイトを読み、新しいバイナリが古いバイトを読んだときに意味が保たれるかで判断しなければなりません。proto3の言語ガイドは、この基準で変更を3つに分類しています。ワイヤー上で安全なもの、安全でないもの、条件付きで互換なものです。
どう動くのか
安全な変更: 新しいフィールドの追加は安全です。古いコードが作ったバイトは新しいコードがそのまま読め(新しいフィールドはデフォルト値)、新しいコードが作ったバイトは古いコードが読めますが、知らない番号は不明なフィールド(unknown field)として扱われます。proto3は不明なフィールドを保持して、再シリアライズするときに含めます。つまり、間に挟まった古いサービスが新しいフィールドを消してしまうことはありません。ただし、JSONに変換したり、フィールドを1つずつ別のメッセージに移し替えたりすると、その保持は失われます。
フィールドの削除も安全です。条件が1つあります。その番号を再び使わないことです。ドキュメントは、削除した番号をreservedの一覧に入れるよう求めています。番号と名前を一緒に予約できますが、1つの文に両方を混ぜることはできません。
message Order {
reserved 3; // 지운 note 의 번호. 9 to 11 처럼 범위도 된다
reserved "note"; // 이름 예약은 별도 문장으로
int32 id = 1;
int32 qty = 2;
int64 unit_price = 4;
string currency = 5;
}
reservedは、コンパイラが守る約束です。後で誰かがstring memo = 3;と書くと、protocが拒否します。名前の予約はバイナリには影響せず、TextProtoやJSONのように名前がシリアライズされる形式のためのものです。
安全でない変更: 既存のフィールドの番号を変えることです。ドキュメントはこれを「そのフィールドを削除して、同じ型の新しいフィールドを作ることと同じ」と定義しています。問題は、パーサーがその事実を知る術がないことです。ドキュメントが挙げる結果は次のとおりです。デバッグに費やされる時間、パースとマージのエラー(これが最良の場合です)、個人情報の漏えい、データの破損。番号の再利用によくある原因も2つ書かれています。見栄えを良くするために番号を振り直すことと、削除した番号を予約しないことです。
条件付きの互換: int32・uint32・int64・uint64・boolは互いに読めますが、値が切り詰められることがあります(64ビットの値をint32で読むと32ビットに切り詰められます)。sint32とsint64は互いにだけ互換で、他の整数型とは互換ではありません。ZigZagを通すためです。stringとbytesは、バイトが有効なUTF-8のときだけです。fixed32とsfixed32、fixed64とsfixed64は、それぞれの組の間だけです。この分類は、デプロイの順序を制御できるときだけ使うようドキュメントが明記していますし、ベストプラクティスのドキュメントは、そもそも「型はほとんど変えないように」と述べています。int32をstringに変えると、ワイヤータイプがVARINTからLENに変わるため、この分類にも入りません。
デフォルト値とpresence: proto3で修飾子のないスカラーは、暗黙的なpresenceに従います。デフォルト値ならシリアライズしません。数値は0、文字列とbytesは空の値、boolはfalseです。そのためqty = 0を送ると、ワイヤー上にはフィールド2のレコードがまったく載らず、受け取る側は「0を送った」と「送らなかった」を区別できません。ドキュメントは、この状態ではhas_メソッドもないと書いています。
optional修飾子を付けると、明示的なpresenceになります。明示的に設定した値は、デフォルト値でもシリアライズされ(10 00の2バイト)、設定されたかどうかを問い合わせられます。ドキュメントは、proto3の基本型には常にoptionalを付けることを勧めています。Editionsへの移行がなめらかになり、部分更新(patch)で「0に変更せよ」を表現できるからです。暗黙的なpresenceではデフォルト値がマージされないため、FieldMaskのような外部の仕組みが必要になります。修飾子を変えること自体はバイナリ互換ですが、片方がhas_を信頼している場合、もう片方を経由した往復でその情報が失われうることを、ドキュメントが例で示しています。
番号の範囲: 1から536,870,911までで、19,000–19,999は実装用の予約なのでコンパイラが拒否します。タグのうち3ビットをワイヤータイプが使うため、32ビットではなく29ビットです。
現場での姿
タイトルにある事故は、こうして起きます。注文サービスのチームがスキーマを整理する際に、qtyを1番、idを2番に変更しました。新しいサーバーは新しいスキーマでシリアライズし、デプロイは無事に終わりました。ところが、精算バッチは3か月前のビルドです。そのバッチは番号1を相変わらずidだと信じて読み、注文7788の数量が3個ではなく7788個として集計されました。ワイヤータイプがどちらもVARINTなので、エラーが起きる余地すらありませんでした。ラボのrenumbered.binが、まさにこのバイト列です。
2つ目のタイプは、「0が消える」事故です。在庫サービスがqty = 0で更新を送ったのですが、暗黙的なpresenceなのでフィールドがまったく載らず、受け取る側のマージロジックは「届いたものだけを上書きする」でした。その結果、数量は以前の値から変わりませんでした。ドキュメントが「部分更新ではデフォルト値を表現できない」と警告している、まさにそのケースです。optionalを付けていれば、10 00の2バイトが載って、0が伝わっていました。
3つ目は、削除後の再利用です。あるチームがnoteを削除するときに番号3を予約せず、半年後に別の人が3番にstring memoを入れました。ログの再処理中に、古い注文のnoteがmemoとして読まれました。名前は違っても型が同じなので、このときもエラーは出ませんでした。ベストプラクティスのドキュメントが「その変更が一度でも本番で動いていたなら、どこかのログにシリアライズされたバージョンがある」と書く理由です。
次のラボですること
v1スキーマをコードに埋め込んだ旧クライアントはそのままにして、スキーマをv2に変更します。新しいフィールドを新しい番号で追加し、旧クライアントがそれを読み飛ばすのを確認し、noteを削除してreservedを残します。その後、番号を入れ替えたrenumbered.binを旧クライアントに読ませて、idとqtyが入れ替わって出力されるのを記録します。タイトルにある事故です。qty = 0を暗黙的なpresenceと明示的なpresenceでそれぞれ作ってバイトを比較し、optionalとreservedを備えた最終スキーマと互換性の表を残します。