のんびり精進

調べた情報などをおすそ分けできれば。

.protoファイルのアップデートにおける注意点まとめ(protobuf)

Protocol Buffers には様々な用途があるようですが、個人的には gRPC での利用を考えています。 昨年末には Go や Dart/Flutter で gRPC を扱う方法を調べ、使っていけそうだと感じました。

そのときに記事にまとめて Qiita へ投稿しましたので、興味のある方はご覧ください。

qiita.com qiita.com

使い方を概ね把握しても、プロトコル定義ファイル(.proto)で一度定義した message を将来変えたくなったときにどうするのかを知らないまま本格的に使い始めるのは不安があります。 前もってアップデート時の注意点を把握し、備忘のためにメモしておくことにしました。

免責

次のことを理解した上でご覧ください。

  • 本記事は、忘れてもさっと復習できるようにまとめているだけです。網羅できていない可能性もあります。
  • 公式ドキュメント(主に proto3 の Language Guide) を基にしていますが、その更新を常に反映していくとは限りません。

用語

  • シリアライズ
    • 構造化されたデータ(.proto)を Protocol Buffers でバイナリ等に変換すること
    • バイナリではなくJSONにすることもできる(*1
  • シリアライズ
    • バイナリ等から構造化されたデータに戻すこと
  • パース

気をつけるポイント

f:id:kabochapo:20200108151946p:plain

既存のフィールド

  • 番号を変えないこと
  • 同じ番号のまま名前を変えないこと

新たなフィールド

  • フィールドを追加すれば message の形式が変わるが、古いコードでシリアライズされた message は新たなコードでパースできる
  • 同様に、逆(新しいコードで生成された message を古いコードでパース)も可能
  • 新たに追加されたフィールドは Unknown field(付録参照)
    • 古いバイナリではパース時に単に無視されるので、既存コードへの影響を気にする必要はない
  • デフォルト値を考慮
    • 新しいコードでも古いコードで生成された message と適切にやり取りできるよう、要素のデフォルト値について考慮すること(付録を参照のこと)

フィールドの削除

  • 削除したフィールドの番号が再利用されない限り OK
    • message をバイナリ形式にしたときにフィールドを特定するための「ユニークな」番号
    • ユニークでなければならないのに再利用してしまえば当然問題が生じる
  • 番号の再利用を防ぐ方法
    • 再利用を防ぎたい番号や範囲を reserved で指定する
      • 使おうとしてしまったときに警告が出て気づける
      • 番号だけでなく名前で指定することもできるが、番号と名前を混ぜて指定することはできない
      • max というキーワードで番号の最大値までを指定することもできる
    • 削除する代わりにフィールドをリネーム(OBSOLETE_ という接頭辞をつける等)する
  • 削除の前には [deprecated=true]オプション を使うと良いかもしれない
    • 一部言語ではアノテーションになり、そのフィールドの使用を控えさせることができる(*2
    • 対応していない言語では何の効果もないが、将来的に対応する可能性はある
reserved 2, 15, 9 to 11, 40 to max;
reserved "FOO", "BAR";

将来を考えた附番

  • サイズを意識
    • エンコードすると 115 は 1 バイト、162047 は 2 バイトになる(番号と型を含めて)
    • 頻繁に使う message 要素には 1 ~ 15 を使うのが良い
    • 使用頻度の高い要素が将来追加されたときのために 1 ~ 15 の範囲にいくらか空きを残しておくこと

int32 / uint32 / int64 / uint64 / bool

  • どれも互換
    • 前方/後方互換性を壊さずに相互に入れ替えることが可能
    • ただし、ビット数が足りなければ切り詰められる(64 ビットの数が int32 として読まれたときなど)

sint32 と sint64

  • 互換だが、他の整数型とは非互換

string と bytes

  • bytes が有効な UTF-8 である限り互換

埋め込まれた message

  • message のエンコードされたバージョンが bytes に含まれていればその message と bytes は互換

fixed32 と sfixed32

  • 互換

fixed64 と sfixed64

  • 互換

enum

  • int32 / uint32 / int64 / uint64 と互換だが、サイズが合わなければ切り詰められる

oneof

複数のフィールドがあってもそのうちの一度に一つしかセットすることがない場合、oneof を使うとメモリを節約できる。 しかし、アップデートにおいては注意が必要な点が多い。

  • フィールドを oneof に追加したり oneof の外に移動したりする場合
    • message がシリアライズされてパースされると情報が一部失われることがある(一部のフィールドがクリアされる)
      • 既存の oneof に入れるのは安全でない
    • ただし、一つのフィールドを新たな oneof に移すのは安全
    • 一つしかセットされないのがわかっているなら複数フィールドを移すのも安全
  • oneof のフィールドを一つ消して追加し直す場合
    • message がシリアライズされてパースされると、セットされている oneof フィールドがクリアされることがある
  • oneof を分離/統合した場合
    • 通常のフィールドを移動したときと同様の問題が起こる

map

map<key_type, value_type> map_field = N;

これは wire フォーマット上では下記に相当する。

message MapFieldEntry {
  key_type key = 1;
  value_type value = 2;
}

repeated MapFieldEntry map_field = N;

map をサポートしていない実装でも扱えるので、そういった言語にも対応させられるかという心配は無用。

.proto ファイルの変更

定義の記述を別ファイルに移す(下記例では old.proto から new.proto へ)とき、元のファイル(old.proto)で import public を使って新たなファイル(new.proto)を読み込めば クライアントでは読み込み対象を変えずに済む。

new.proto

// old.proto に書かれた定義をすべてこのファイルに移動

old.proto

// どのクライアントもこのファイルをインポートしている
import public "new.proto";
import "other.proto";

client.proto

import "old.proto";
// old.proto と new.proto で定義したものは使えるが、other.proto の定義は使えない

なお、protoc コマンドでコンパイルするとき、-I--proto_path で指定したディレクトリがインポートファイルの検索対象になる(指定しないと実行時のカレントディレクトリ)。 それを使ってプロジェクトのルートを指定し、インポートで完全修飾名を使うのが良い。

付録

デフォルト値

  • string
    • 空文字列
  • bytes
    • 空バイト列
  • bool
    • false
  • 数値型
    • 0
  • enum
    • 定義されている最初の値(= 0)
  • message フィールド
  • repeated フィールド
    • 空(各言語の空リスト)
  • スカラー型のフィールド
    • パースされると、フィールドにデフォルト値が入れられていたのか何も入っていなかったのか区別できないことに留意しておくこと
      • 例えば bool 型の場合、false のときの振る舞いがデフォルトの振る舞いにもなる
      • それが困るなら bool 型は避けること

proto2 と proto3

  • proto1 は無い
  • 今は proto3
    • proto2 と完全互換ではない
    • proto2 もサポートは続けられるが、使いやすくて対応言語も多い proto3 のほうが良い
  • proto3 で proto2 の message 型を使うこともその逆も可能
    • ただし proto2 の enum を proto3 で直接使うことはできない
      • proto2 の message で使い、それを proto3 でインポートするのは OK
  • syntax の指定を省略すると proto2 とみなされる
syntax = "proto3";

proto3 での変更点

  • requiredoptional は廃止
  • repeatedpacked がデフォルトで true なので指定不要
    • proto2 ではエンコーディングを効率的に行うために [packed=true] のオプションが必要だった
  • enum の先頭要素は 0
    • 先頭要素でなければならないのは proto2 との互換性のため(proto2では最初の要素が常にデフォルト)
  • Unknown fields の扱いが v3.5 で変わった
    • シリアライズしてパーサが認識しないフィールドを表したデータのこと
      • 例えば、新たなフィールドを持つ新たなバイナリから送られたデータを古いバイナリでパースすると、古いバイナリではその新たなフィールドは Unknown field
      • proto2 ではパースしても保持されていた
      • v3.5 より前の proto3 では破棄されるようになっていた
      • v3.5 で保持の仕組みが再導入された(パース時に保持され、シリアライズされた結果に含まれる)

上記は一部です。 全体的な変更点は次の記事が参考になると思います。

Proto2 vs Proto3 - Qiita

*1:gRPCのシリアライゼーション形式をJSONにする - Qiita

*2:ドキュメントには Java の @Deprecated のことしか書かれていないので他の言語については不明・未確認。