protobuf.dev の Proto Best Practices を読んで

この記事はANDPAD Advent Calendar 2024の 4 日目の記事です。

メリークリスマス🎄 バックエンドエンジニアの武山 (bushiyama) です。
現在はANDPAD請求管理のバックエンドを担当しています。

なんの記事

protobuf 公式の Proto Best Practices ドキュメントを、私が理解しやすいように要約したものです。
protobuf.dev

なぜ読んだか・書いたか

弊社の API 定義は Protocol Buffers を利用することが多いです。

このレビューにおいてレビュアーとレビューイが共通の判断基準を持つことは、
コードの品質向上、チームの効率化、そしてプロジェクトの成功に大きく貢献します。

この判断基準として当該記事がよさそうだということをチームで話し合い、
ただ読むのでは味気ないので、この場を借りてアウトプットしようという試みです。

以下から本文開始です。


Proto Best Practices

クライアントとサーバーを同時にリリースすれば、破壊的変更をしても大丈夫だとは思わないでください。
まったく同時にデプロイされることはありませんし、どちらかがロールバックをする可能性もあります。

タグ番号を再利用しないこと

https://protobuf.dev/programming-guides/dos-donts/#reuse-number

デシリアライズが台無しになる。
たとえ誰もそのフィールドを使っていないと思っても、タグ番号を再利用しないこと。

これは、gRPCプロトコルがタグ番号を識別子として使用しているためです。

削除されたフィールドのタグ番号を予約しておく

https://protobuf.dev/programming-guides/dos-donts/#reserve-tag-numbers

使用されなくなったフィールドを削除するときは、タグ番号を予約しておくこと。
タグ番号を予約するだけで十分で、型は必要ありません(依存関係を削除できます)。

reserved 2, 3; 

削除されたフィールド名の再利用を避けるために、名前予約もできます。

reserved "foo", "bar";

削除されたenum型のタグ番号を予約しておく

https://protobuf.dev/programming-guides/dos-donts/#reserve-deleted-numbers

使用されなくなったenum値を削除するときは、その番号を予約しておく。

reserved 2, 3; 

また、削除された値の名前を再利用しないように予約もできます。

reserved "foo", "bar";

フィールドの型を変更しないこと

https://protobuf.dev/programming-guides/dos-donts/#change-type

タグ番号の再利用と同じように、デシリアライズが台無しになります。
型変更をすると新しいメッセージが、古いメッセージのスーパーセットでない限り、壊れてしまいます。
protobuf のドキュメントでは、問題ないケースをいくつか概説しています(たとえば、int32 uint32 int64 boolの間を行き来する場合など)。

必須フィールドを追加しないこと

https://protobuf.dev/programming-guides/dos-donts/#add-required

proto version3からはそもそも required 指定が無くなったので割愛します。

フィールドの多いメッセージを作らないこと

https://protobuf.dev/programming-guides/dos-donts/#lots-of-fields

フィールドが "たくさん"(数百ほど)あるメッセージは作らないこと。

C++では、すべてのフィールドは、それが入力されているかどうかにかかわらず、メモリ内のオブジェクト・サイズに約65ビットを追加します。
(ポインタ用の8バイトと、フィールドがオプションとして宣言されている場合、フィールドが設定されているかどうかを追跡するビットフィールドの別のビット)

メッセージのサイズが大きくなりすぎると、生成されたコードはコンパイルすらできない場合もあります。(たとえばJavaではメソッドのサイズにハードリミットがあります)。

そもそも数百フィールドもある場合、殆どの場合でメッセージの分割を検討すべきでしょう。

enumにUnspecifiedの値を含めること

https://protobuf.dev/programming-guides/dos-donts/#unspecified-enum

enumの最初の値として、デフォルトで *_UNSPECIFIED = 0; 値を設定しましょう。
「この値は未指定である」以外の意味を持たないことが推奨されます。

言語間の定数が必要ない場合は、int32 で代用すると未知の値が保持され、生成されるコードも少なくなります。
enum型は最初の値を0にする必要があり、未知の値はラウンドトリップされることに注意してください。

enum値にC/C++マクロ定数を使用しないこと

https://protobuf.dev/programming-guides/dos-donts/#macro-constants

C/C++のマクロ定数をenum値として使用しないこと.
具体的にはmath.hなどのヘッダですでに定義されている単語を使用したり、.proto.h#include文より先にヘッダの#include文があるとコンパイルエラーを発生することがあります。
"NULL"、"NAN"、"DOMAIN "などのマクロ定数を列挙値として使用することは避けてください。

共通の型を使おう

https://protobuf.dev/programming-guides/dos-donts/#well-known-common

以下の共通型を積極的に利用しましょう。
これにより、コードの可読性と一貫性を向上させることができます。

  • duration: 期間を表す型(例: 42秒)
  • timestamp: タイムスタンプを表す型(例: 2017-01-15T01:30:15.01Z)
  • interval: 時間間隔を表す型(例: 2017-01-15T01:30:15.01Z - 2017-01-16T02:30:15.01Z)
  • date: 日付を表す型(例: 2005-09-19)
  • month: 月を表す型(例: 4月)
  • dayofweek: 曜日を表す型(例: 月曜日)
  • timeofday: 時刻を表す型(例: 10:42:23)
  • field_mask: フィールドマスクを表す型(例: f.b.d)
  • postal_address: 住所を表す型(例: 1600 Amphitheatre Parkway Mountain View, CA 94043 USA)
  • money: 金額を表す型(例: 42 USD)
  • latlng: 緯度経度を表す型(例: 緯度37.386051、経度-122.083855)
  • color: 色を表す型(RGBAカラー空間)

これらは以下のように import して利用できます。

import "google/type/color.proto";

message Item {
 google.type.Color color = 1;
}

メッセージタイプは別々のファイルで定義しよう

https://protobuf.dev/programming-guides/dos-donts/#separate-files

1つのファイルにつき1つの message、enum、extension、service、または依存関係を持つグループに分割しましょう。
これにより、リファクタリングが容易になります。
ファイルが分離されていると、ファイルを移動することやメッセージを抽出するのも簡単です。
またファイルを小さく保つことで、メンテナンス性を向上させることができます。

特に、他のプロジェクトで広く使用される予定のメッセージは、依存関係のない独自のファイルに配置することを検討してください。
これにより、他のプロトコルファイルに推移的な依存関係を導入することなく、誰でも簡単にこれらの型を使用できます。

1-1-1ルールとは

この原則を 1-1-1ルール と呼びます。
- ひとつの proto ファイルに - ひとつのビルドターゲット - 各 proto ファイルは、それぞれ独立したビルド単位として扱われるべきということ。 - ひとつのトップレベル要素(メッセージ、enum、拡張)

ここまでしっかり分離するのは難しいので、実際は service 単位などでファイル分割されることが多い印象です。

フィールドのデフォルト値を変更しないこと

https://protobuf.dev/programming-guides/dos-donts/#change-default-value

proto version3からはデフォルト値を設定する機能が無くなったので割愛します。

繰り返しフィールドからスカラーフィールドへの変更を避けること

https://protobuf.dev/programming-guides/dos-donts/#repeated-to-scalar

繰り返しフィールドをスカラーフィールドに変更すると、データ損失の発生する可能性があります。

  • JSON: フォーマット不整合により、メッセージ全体を消失する可能性があります。
  • 数値型(proto3): フィールド内のすべてのデータが失われます。
  • 非数値型(proto3): 直近にデシリアライズされた値のみが保持され、それ以前の値は失われます。

スカラフィールドから繰り替えしフィールドへの変更

スカラフィールドを繰り返しフィールドへ変更することは、proto2およびproto3の[packed=false]を明示的に指定している場合に可能です。
バイナリシリアライゼーションでは、スカラー値は1要素のリストとして扱われるためです。

packed 指定については proto3 では標準で true の扱いになるようです。
繰り返しを想定して付与しておくなども難しそうで、使い所はあまりなさそうな印象です。

生成コードのスタイルガイドに従う

https://protobuf.dev/programming-guides/dos-donts/#follow-style-guide

生成されたコードは通常のコードで参照されるため、.proto ファイルの設定によってスタイルガイドに違反するコードが生成されないようにしましょう。

  • Ruby のパッケージ名: ruby_package オプションは、ドット区切りのネスト構造 (Foo::Bar::Baz) を使用するように設定します 。 (ドット区切りでない Foo.Bar.Baz は避ける)。
// bad
option ruby_package = "Protos.";

// good
option ruby_package = "Protos::";

テキスト形式のメッセージはデータ交換に使用しないでください。

https://protobuf.dev/programming-guides/dos-donts/#text-format-interchange

テキスト形式やJSONなどのテキストベースのシリアライズでは、フィールドやenumが文字列として表現されます。

その結果、これらの形式のプロトコルバッファを古いコードでデシリアライズすると以下のようなケースで失敗します。

  • フィールドやenumの名前が変更された場合
  • 新しい フィールド enum extensions が追加された場合

可能な限りバイナリシリアル化をデータ交換に使用し、テキスト形式は人間による編集とデバッグのみに使用してください。

APIやデータの保存にJSON変換されたプロトコルバッファを使用している場合、フィールドや列挙値の名前を変更をできない場合があります。

あまり意図して使うことは少ない印象ですが、Goでいうと以下のパッケージで、テキストシリアライズできます。

https://pkg.go.dev/google.golang.org/protobuf/encoding/prototext

connect プロトコルでは

connect プロトコルでは、このよくないとされるテキスト形式のメッセージになります。
弊チームでも、実際にフィールド名を変更してみたところ不具合が発生しました。

メリットも大きいライブラリですが、こういう仕様もありますという言及まで。

https://connectrpc.com/

ビルド間のシリアライズの安定性に依存しないこと

https://protobuf.dev/programming-guides/dos-donts/#serialization-stability

ビルド間でのシリアル化の安定性は保証されていません。
キャッシュキーの作成など、シリアル化の安定性を前提とした設計は避けてください。

Javaの生成コードは、他のJava コードとは別のパッケージにしてください

https://protobuf.dev/programming-guides/dos-donts/#generate-java-protos

Javaに限らず、パッケージは分けたほうがよかろうと思われます。

言語のキーワードをフィールド名に使用しないでください

https://protobuf.dev/programming-guides/dos-donts/#avoid-keywords

メッセージ フィールド enum enum valueの名前が、実装されるプログラム言語のキーワードと一致する場合、protobuf はフィールド名を変更する可能性があります。

フィールド名が変更されると、通常とは異なるアクセス方法が必要になることがあります。

また、ファイルパスにもキーワードを使用しないようにしてください。問題発生する可能性があります。

Appendix

API Best Practices

本文書では、破損を引き起こす可能性が極めて高い変更のみが列挙されている。
プロトAPIの作り方に関するより高度なガイダンスについては、API Best Practicesを参照してください。

lint

上記には linter で検知できる内容もあり CI などで自動実行するとよいでしょう。
https://buf.build/docs/lint/overview/


私自身、 swagger を書いていた時期が長く、いまだに混同しそうなときもあります。
あらためて、整理のつくよい機会でした。

元ネタには続きのドキュメントもありますので、一読しておくとよさそうです。

https://protobuf.dev/programming-guides/api/