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

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

契約の検査はレビュアーではなくスクリプトがやる

TT Labで続きを見る

一言でいうと

番号が1つ変わっただけのdiffは、人の目には留まりません。そのため、スキーマの互換性チェックはレビュアーではなくスクリプトが行うべきです。2つの.protoをパースしてメッセージごとに番号を突き合わせ、ドキュメントの「安全でない変更」の一覧を、機械が読める形に書き写します。

なぜ必要なのか

前のモジュールで、タイトルにある事故を再現しました。番号を変える変更は、コンパイルが通り、単体テストも通り、コードレビューでは数字が2つ変わった行にしか見えません。レビュアーが毎回「この番号は以前は何だったか」を覚えているわけにはいきません。半年前に削除したフィールドの番号を覚えている人は、なおさらいません。ベストプラクティスのドキュメントが「絶対に番号を再利用してはならない。誰も使わないと思っていても」と書いているのは、その記憶が信頼できないという意味です。

機械は覚えています。古い.protoと新しい.protoを並べて番号を突き合わせれば、人が見逃すものをすべて捕まえられます。このチェックは、ドキュメントのメッセージ型の更新ルールを書き写したものなので、判断が入り込む余地がなく、CIに入れておけばマージの前に動きます。このモジュールでは、そのチェッカーを標準ライブラリだけで自分で作ります。

どう動くのか

何を比較するのか: ワイヤーの互換性は、メッセージごとに、フィールド番号をキーにして見ます。名前は二次的なものです。そのため、チェッカーのデータ構造は、{메시지: {번호: (이름, 타입)}}(プレースホルダーはメッセージ、番号、名前、型です)に、予約番号と予約名の集合を加えたもので十分です。ネストしたメッセージはOuter.Innerと名前を付けて、別々に比較します。外側のメッセージだけを見ると、内側の削除を見逃してしまいます。

ルールはドキュメントから導かれる: 次の表がチェッカーが出す判定で、右側が根拠です。

判定 条件 根拠
REMOVED_NOT_RESERVED 古い番号が新しいファイルになく、reservedでもない 削除は安全だが、番号を再び使ってはいけない → reservedで防ぐ
RENUMBERED 同じ名前が別の番号に 番号の変更は、削除して新しく作ることと同じ。安全でない
TYPE_CHANGED 同じ番号・同じ名前で、型が違う 型はほとんど変えない(一部は条件付きで互換)
NUMBER_REUSED 同じ番号に、別の名前・別の型 番号の再利用は、デコードを曖昧にする
REUSED_RESERVED 古いファイルのreserved番号を、新しいファイルがフィールドとして使う 予約リストから番号を取り出して使ってはいけない
WARN RENAMED 同じ番号・同じ型で、名前だけが違う ワイヤーには名前がない。バイナリは無事で、JSON・TextProtoには影響する

名前の変更を警告にとどめる理由は、エンコーディングのドキュメントにあります。バイトには番号とワイヤータイプしかなく、名前は読み取る側が.protoを見て付けます。名前だけを変えても、バイトは1ビットも変わりません。ただし、ProtoJSONのように名前をシリアライズする形式を使う消費者にとっては壊れる変更なので、チェッカーは知らせはしても、止めはしません。この区別ができないと、チェッカーが毎回赤信号を出すことになり、赤信号が頻繁に出るチェッカーは無視されます。

ルールの優先順位: 1つの原因に対して1行が出力されなければなりません。idが1から2に移ると、「1がなくなった(REMOVED)」と「2に別の名前が来た(RENAMED)」も同時に真ですが、原因は1つ、つまり番号を変えたことなので、RENUMBEREDだけを出力し、その番号はそれ以上見ません。番号が新しいファイルに残っていれば、REMOVEDではありません。

パーサーは寛容に、判定は厳格に: 実際の.protoには、コメント、複数行にわたる宣言、[deprecated = true]のようなオプション、reserved 9 to 11のような範囲が混ざっています。パーサーがこうしたものにつまずくと、チェッカーをオフにすることになります。一方、判定は一歩も譲りません。形式が少し違っても読み取りますが、番号の再利用は必ず不合格にします。Pythonでは、コメントを正規表現で消し、message 이름 {(プレースホルダーはメッセージ名です)を探して中括弧を数えて対応を取り、内側を타입 이름 = 번호;(プレースホルダーは型、名前、番号です)の正規表現で読めば済みます。

FIELD = re.compile(r"(?:\b(optional|repeated)\s+)?([A-Za-z_][\w.]*)\s+([A-Za-z_]\w*)\s*=\s*(\d+)\s*(?:\[[^\]]*\])?\s*;")

出力の契約: チェッカーはツールです。人が読む行と、機械が読む終了コードの両方を出力します。違反は1行に1つで、判定名で始まり、メッセージ名と番号を含めます。違反があればexit 1、なければ最後の行にOKを出力してexit 0です。警告は終了コードに影響しません。この契約があるから、シェルスクリプトでディレクトリを巡回して、1つでも失敗すればCIを止められます。

チェッカーが捕まえられないもの: ドキュメントの条件付き互換の一覧、つまりint32をint64に変えることは、デプロイの順序を制御できるときだけ安全です。チェッカーはその順序を知らないので、TYPE_CHANGEDで止めるほうが正解です。逆に、デフォルト値の意味の変更(「0は今後『未定』ではなく『無料』を意味する」)は、バイトもスキーマのテキストも変わらないので、どんなチェッカーも捕まえられません。ベストプラクティスのドキュメントが「フィールドのデフォルト値はほとんど変えないように」と別に書いている理由です。チェッカーはレビューの代わりではなく、レビュアーが数字の突き合わせに時間を使わないようにするものです。

現場での姿

スキーマの互換性チェックを初めてCIに入れる日に、たいてい経験することがあります。リポジトリにある古いスキーマが何十個も、一斉に赤信号になるのです。予約なしで削除された番号が、何年分も溜まっているからです。このときルールをオフにすると、ツールがないのと同じになります。代わりに、古いファイルにreservedを書き足す整理のPRを先に出します。削除した番号を予約することは、いつ行っても安全な変更なので、リスクはありません。

2つ目は「警告疲れ」です。名前の変更をエラーにすると、誤字を直すたびにチェックが止まり、人々は--no-verifyを覚えます。その後は、本物の違反も一緒にすり抜けていきます。警告とエラーを分けることは、贅沢ではなく、チェッカーが生き残るための条件です。

3つ目は、ネストしたメッセージです。チェッカーが最上位のメッセージしか見ていなかったため、Order.Item.countの型の変更がそのままマージされました。パーサーを書くときにOuter.Innerと名前を付ける1行が、この事故を防ぎます。

次のラボですること

/root/grpc/compat/protocheck.pyを作ります。まず--dumpで、パーサーがフィクスチャと初めて見るファイル(コメント・範囲の予約・ネスト・オプション)を読めるかを確認し、ルールを1つずつ追加します。予約のない削除、番号の変更、型の変更と番号の再利用、予約番号の再利用、そして名前の変更は警告だけです。フィクスチャ8組を実行してレポートを作り、最後にディレクトリ単位で動くcheck-all.shを作ります。採点ツールは、フィクスチャ以外にも指示文にない.protoの組を作ってあなたのスクリプトに渡すので、フィクスチャ名を埋め込むことはできません。