gPRC: Protocol Buffers スタイル規約 & API ベストプラクティスまとめ
- gPRC: Protocol Buffers スタイル規約 & API ベストプラクティスまとめ
- ファイル & パッケージ構成
- Message
- Enum
- Service
- Proto ベストプラクティス
- API ベストプラクティス
- フィールドとメッセージを正確かつ簡潔に文書化する
- Wire (≒API?)と Storage でメッセージを使い分ける
- Mutations (更新 API) の場合、データの完全なリプレイスではなく、部分的な更新または追加のみの更新をサポートする
- トップレベルのリクエストまたはレスポンスの proto にプリミティブ型(基本のデータ型)を含めない
- (現在は 2 つの状態を持つが)後でさらに多くの状態を持つ可能性があるものには boolean を使用しない
- ID に整数フィールドをほとんど使用しない
- Web-Safe Encoding Binary Proto Serialization で不透明なデータを文字列でエンコードする
- クライアントが構築または解析すると予想される文字列内のデータをエンコードしない
- クライアントが使用できない可能性のあるフィールドを含めない
- 継続トークンなしでページ区切り API を定義しない
- 関連するフィールドを新しいメッセージにまとめる。親和性の高いフィールドだけをネストする
- 読み取り要求にフィールド読み取りマスクを含める
- 一貫した読み取りを可能にするバージョンフィールドを含める
- 同じデータ型を返す RPC には一貫したリクエストオプションを使用する
- Batch/Multi-phase リクエスト
- 小さなデータを返す/操作するメソッドを作成し、クライアントが複数の要求をまとめて UI を構成することを期待する
- モバイルやウェブで連続したラウンドトリップが必要な場合、1 回限りの RPC を作成する
- Proto Maps を使用する
- 冪等性を優先
- サービス名はグローバル(ネットワーク上)でユニークなものにする
- すべての RPC で(許可制の)期限を指定して強制する
- リクエストサイズとレスポンスサイズの境界
- ステータスコードの伝搬は慎重に
- Reapeated フィールドの Tips
- パフォーマンスの最適化
- ref:
gPRC: Protocol Buffers スタイル規約 & API ベストプラクティスまとめ
設計する上で、公式ドキュメント(英語)を翻訳機で訳してまとめただけ。
ファイル & パッケージ構成
- 全てのファイルにパッケージを定義
- 同じパッケージのファイルは全てパッケージ名と一致する同じディレクトリに入れる
- パッケージの最後のコンポーネントは バージョンにする
- パッケージ名の形式:lower_snake_case
- ファイル名の形式:lower_snake_case.proto
- 繰り返しフィールドに複数形の名前を使用
. └── proto ├── buf.yaml └── foo └── bar ├── bat │ └── v1 │ └── bat.proto // package foo.bar.bat.v1 └── baz └── v1 ├── baz.proto // package foo.bar.baz.v1 └── baz_service.proto // package foo.bar.baz.v1
- 同じパッケージ内の全 proto ファイルで、以下オプションは揃える(値と有無)
csharp_namespace
go_package
java_multiple_files
java_package
php_namespace
ruby_package
swift_prefix
// foo_one.proto syntax = "proto3"; package foo.v1; option go_package = "foov1"; option java_multiple_files = true; option java_package = "com.foo.v1";
// foo_two.proto syntax = "proto3"; package foo.v1; option go_package = "foov1"; option java_multiple_files = true; option java_package = "com.foo.v1";
Message
- Message 名は PascalCase
- フィールド名は lower_snake_case
syntax = "proto3"; message SearchRequest { string query = 1; int32 page_number = 2; int32 result_per_page = 3; oneof test_oneof { // セットで扱うものをまとめられる string name = 4; SubMessage sub_message = 5; } optional string song_name = 6; repeated string keys = 7; map<string, Project> projects = 8; // map に repeated は使えない reserved 2, 15, 9 to 11; // reserved: 予約済みフィールド reserved "foo", "bar"; // 主に削除済みのフィールドに使用 } message Project { ... }
Field
種類:
- oneof
- optional
- repeated
- map
- reserved
スカラー値の型:
- double/float
- int32/int64
- uint32/uint64:正の値
- sint32/sint64:int32/int64 より効率的に負の数をエンコードできる
- fixed32:常に 4bytes。228 を超える場合は uint32 より効率的にエンコード
- fixed64:常に 8bytes。256 を超える場合は uint64 より効率的にエンコード
- sfixed32:常に 4bytes。
- sfixed64:常に 8bytes。
- bool
- string
- bytes: 232 以下の任意のバイトシーケンス
ルール:
- 1 ~ 15 のフィールド番号は、(1byte のため)非常に頻繁に使用するフィールド用に予約する
- たまにしか使わないフィールドは、16 ~ 2047 (2byte) を使い、頻繁に使用するフィールドとして予約できる余地を残しておく必要がる
- フィールド番号の最大は 229 - 1 (= 536,870,911)
- 19000 から 19999 までの数字は ProtocolBuffers の実装のために予約されているため使用不可(使用した場合はコンパイラから警告)
Nested Type
できるが、ネストされた Message の使用は避ける
message SearchResponse { message Result { string url = 1; string title = 2; repeated string snippets = 3; } repeated Result results = 1; }
他のメッセージで利用する場合
message SomeOtherMessage { SearchResponse.Result result = 1; }
Any
- google/protobuf/any.proto をインポートする必要がある
- Any には、任意のシリアル化されたメッセージがバイトとして含まれ、そのメッセージのタイプのグローバル一意識別子として機能し、解決される URL が含まれる
- 言語の実装で、Any 値を型安全な方法でパックおよびアンパックするランタイムライブラリヘルパーがサポート
import "google/protobuf/any.proto"; message ErrorStatus { string message = 1; repeated google.protobuf.Any details = 2; }
Enum
- Enum には allow_alias オプションを設定しない
- Enum 名は PascalCase
- Enum 値の名前は UPPER_SNAKE_CASE
- Enum 値の名前の先頭には、Enum 名の UPPER_SNAKE_CASE を付ける
- すべての Enum の 0 の値の末尾には_UNSPECIFIED を付ける
enum Corpus { CORPUS_UNSPECIFIED = 0; CORPUS_UNIVERSAL = 1; CORPUS_WEB = 2; CORPUS_IMAGES = 3; CORPUS_LOCAL = 4; CORPUS_NEWS = 5; CORPUS_PRODUCTS = 6; CORPUS_VIDEO = 7; } message SearchRequest { string query = 1; int32 page_number = 2; int32 result_per_page = 3; Corpus corpus = 4; }
allow_alias:同じフィールド番号を許容
enum EnumAllowingAlias { option allow_alias = true; EAA_UNSPECIFIED = 0; EAA_STARTED = 1; EAA_RUNNING = 1; EAA_FINISHED = 2; } enum EnumNotAllowingAlias { ENAA_UNSPECIFIED = 0; ENAA_STARTED = 1; // ENAA_RUNNING = 1; // コメントを外すと警告メッセージ表示 ENAA_FINISHED = 2; }
※列挙子の数に言語固有の制限がある場合がある (1 つの言語で 1000 以下)
Service
- Service 名は PascalCase
- Service 名の末尾には Service を付ける
- RPC 名は PascalCase にする
- すべての RPC 要求および応答メッセージは、Protobuf スキーマ全体で一意である必要がある
- すべての RPC 要求および応答メッセージには、以下の命名にする
- < MethodName > Request
- < MethodName > Response
- < ServiceName >< MethodName > Request
- < ServiceName >< MethodName > Response
service FooService { rpc GetSomething(GetSomethingRequest) returns (GetSomethingResponse); rpc ListSomething(ListSomethingRequest) returns (ListSomethingResponse); }
※(議論の余地はあるが)RPC のストリーミングを避けることを推奨する。(これらは確かに非常に価値のある特定のユースケースを持っていますが、全 体としては多くの問題を引き起こし、RPC フレームワークのロジックをスタックに押し上げるため、通常はより信頼性の高いアーキテクチャの開発を妨げる)
Proto ベストプラクティス
- タグ番号は再利用しない
- フィールドのタイプは変更しない
- 必須フィールドを追加しない
- フィールドが多い(数百)メッセージは作らない
- Enum に 宣言の最初の値としてデフォルトの Unspecified Value(XXX_UNSPECIFIED)を含める
- デフォルト値が存在しない場合は最初に宣言された値が返されるため
- enum は最初の値がで 0 あることを要求するため値は 0 にする
- Well-Known Types と Common Types を使う
- 以下の共通型、共有型を埋め込むことを強く推奨
- Well-Known Types
- duration:符号付き固定長の時間スパン (例:42s)
- timestamp:例 2017-01-15T01:30:15.01Z
- field_mask:指定のフィールドのみ取るためのもの
- Common Types
- Well-Known Types
- 完全に適した共通型がすでに存在する場合、コード内で
int32 timestamp_seconds_since_epoch
やint64 timeout_millis
を使用しない
- 以下の共通型、共有型を埋め込むことを強く推奨
- 広く使用されることを期待する Message Type や Enum は別のファイルに定義し、誰でも簡単に使えるようにする
- 削除されたフィールドのタグ番号は予約(reserved)して、後で誤って再使用しないようにする
- フィールドのデフォルト値は変更しない(proto3 ではデフォルト値を設定する機能は削除)
- rotated から Scalar に移行しない。クラッシュはしないがデータ失われる(逆は大丈夫)
- 生成されたコードのスタイルガイドに従う
- テキスト形式のメッセージをやり取りに使用しない(テキスト形式は、人による編集とデバッグにのみ使用)
- ビルド間でのシリアライゼーションの安定性に決して依存しない
- proto シリアライズの安定性は、バイナリ間でも、同じバイナリのビルド間でも保証されない(ビルドする度中身が変わる)ため、キャッシュキーの構築等で依存してはいけない
ref: https://protobuf.dev/programming-guides/dos-donts/
API ベストプラクティス
公式ドキュメントの記載は、長期的でバグのない進化を優先するためにトレードオフを行った上での提案 ref: https://protobuf.dev/programming-guides/api/
フィールドとメッセージを正確かつ簡潔に文書化する
- 各フィールドの制約、期待、解釈をできるだけ短い言葉で文書化
- 時間経過とともに長くなるかもしれないが、全体的に見て簡潔にすることを目指す
Wire (≒API?)と Storage でメッセージを使い分ける
- クライアントに公開するトップレベルの proto が、ディスクに保存する proto を同じにしない
- クライアントに影響を与えることなく、保存形式を変更できる自由度が必要
- コードをレイヤー化し、モジュールが client protos、storage protos、translation のいずれかを処理するように分ける
- 例外
Mutations (更新 API) の場合、データの完全なリプレイスではなく、部分的な更新または追加のみの更新をサポートする
- (1) Update Field-mask を使用する。
- クライアントが変更するフィールドを渡し、それらのフィールドだけを更新要求に含める。
- サーバは他のフィールドをそのままにし、マスクで指定されたフィールドのみを更新する。
- 一般的に、マスクの構造は応答 proto の構造を反映する必要がある(= Foo に Bar が含まれている場合、FooMask には BarMask を含める)
- (2) 個々のピースを変化させる より狭い Mutations API を公開する
- 例:UpdateEmployeeRequest の代わりに、PromoteEmployeeRequest、SetEmployeePayRequest、TransferEmployeeRequest など(用途特化?)
- カスタム更新方法は、非常に柔軟な更新方法よりも監視、監査、およびセキュリティが容易かつ実装や呼び出しも簡単。ただし、その数が多いと API の認知負荷が増大する可能性があるため注意。
トップレベルのリクエストまたはレスポンスの proto にプリミティブ型(基本のデータ型)を含めない
- トップレベルの proto は、ほとんどの場合、独立して成長できる他のメッセージのコンテナである必要がある
- 必要なプリミティブ型が 1 つだけの場合でも、メッセージにラップすることで、その型を拡張し、類似した値を返す他のメソッド間で型を共有するた めの明確なパスが得られる
message MultiplicationResponse { // 悪い例:複素数を返す必要があり、同じ複数フィールド型を返すレスポンスが必要な場合に // どちらにもプリミティブ型のフィールドを追加するのは避けたいはず optional double result; // 良い例:他のメソッドはこのタイプを共有でき、成長可能 // サービスは、新しい機能 (単位、信頼区間など) を容易に追加可能。 optional NumericResult result; } message NumericResult { optional double real_value; optional double complex_value; optional UnitType units; }
トップレベルプリミティブの例外
- プロトをエンコードするが、サーバー上でのみ構築および解析される不透明な文字列 (またはバイト)
- 継続トークン、バージョン情報トークン、ID は、文字列が実際に構造化プロトのエンコードである場合、すべて文字列として返すことができる。
(現在は 2 つの状態を持つが)後でさらに多くの状態を持つ可能性があるものには boolean を使用しない
フィールドに boolean を使用する場合は、フィールドが(将来的にも)実際に 2 つの可能な状態だけになるかを確認する。
message GooglePlusPost { // 悪い例:この投稿を2つのカラムにまたがってレンダリングするかどうか optional bool big_post; // 良い例:この投稿を表示するクライアントのためのレンダリングヒント // クライアントは、この投稿をどの程度目立つように表示するかを決定するために、これを使用。 // ない場合は、デフォルトのレンダリングを想定。 optional LayoutConfig layout_config; } message Photo { // 悪い例: GIFかどうか optional bool gif; // 良い例: 写真のファイル形式(例:GIF、WebP、PNG)。 optional PhotoType type; }
概念を混同する enum に state を追加することには、注意が必要
- もし state が enum に新しい次元を導入する場合や、複数のアプリケーションの動作を意味する場合、ほぼ間違いなく別のフィールドが必要
ID に整数フィールドをほとんど使用しない
- オブジェクトの識別子として int 64 を使いたくなるが、文字列を選択する
- 必要に応じて ID スペースを変更し、衝突の可能性を減らせる(264 は今では大きくはない)
- 構造化識別子を文字列としてエンコードすることで、クライアントに不透明な Blob として扱うように促すことも可能。
message GetFooRequest { // Which Foo to fetch. optional string foo_id; } // websafe-base64-encoded & シリアライズして、 GetFooRequest.foo_idフィールドにセット message InternalFooRef { // Only one of these two is set. Foos that have already been // migrated use the spanner_foo_id and Foos still living in // Caribou Storage Server have a classic_foo_id. optional bytes spanner_foo_id; optional int64 classic_foo_id; }
Web-Safe Encoding Binary Proto Serialization で不透明なデータを文字列でエンコードする
- クライアントから見えるフィールドの不透明なデータ(継続トークン、シリアル化された ID、バージョン情報など)をエンコードする場合は、クライア ントが不透明な Blob として扱う必要があることを文書化する。
- これらのフィールドには、必ずバイナリ形式のプロトシリアライゼーションを使用し、テキスト形式や独自の工夫をしない。不透明なフィールドにエンコードされたデータを拡張する必要がある場合、まだ使用していなければ、プロトコルバッファのシリアル化を再発明することになるため。
- 不透明なフィールドに入るフィールドを保持する内部 proto を定義し (フィールドが 1 つだけ必要な場合でも) 、この内部 proto をバイトにシリア ル化し、その結果を web-safe base-64 で文字列フィールドにエンコードします。
- プロトシリアライゼーションを使用するまれな例外:非常に時折、慎重に構築された代替形式からのコンパクトさの勝利は価値がある。
クライアントが構築または解析すると予想される文字列内のデータをエンコードしない
ネットワーク上では効率が悪く、proto の利用者にとっては手間がかかり、ドキュメントを読んでいる人にとっては混乱を招く。
クライアントはエンコーディングについても気にする必要がある
- リストはコンマ区切りか?
- この信頼できないデータは正しくエスケープされているか?
- 数字は 10 を底としているか?
クライアントに実際のメッセージやプリミティブタイプを送信させる方がよい。その方が、ネットワーク全体でよりコンパクトになり、クライアントにとってより明確になる
- サービスで複数言語のクライアントを取得した場合に特に悪化する。各自が適切なパーサーやビルダーを選択しなければならなくなり、最悪の場合それを書かなければならなくなる。
- より一般的には、正しいプリミティブ型を選択する。
- 『Protocol Buffer Language Guide』の 「Scalar Value Types」 の表を参照
フロントエンド proto で HTML を返す
JavaScript クライアントには、API のフィールドで HTML や JSON を返さない。 (返すと API を特定の UI に結びつけるため良くない)
具体的な 3 つの危険性:
- 「スクラッピー」 な非 Web クライアントは、HTML や JSON を解析して、フォーマットを変更した場合の脆弱性や、解析が悪い場合の脆弱性につなが るデータを取得することになる
- Web クライアントは、その HTML がサニタイズされずに返された場合、XSS エクスプロイトに対して脆弱になる。
- 返されるタグとクラスは、特定のスタイルシートと DOM 構造を想定しているが、リリースごとに構造が変化し、JavaScript クライアントがサーバーより古くなると、サーバーが返す HTML が古いクライアントで正しくレンダリングされなくなるバージョンスキューの問題が発生するリスクがある。(リリース頻度の高いプロジェクトでは、これはエッジケースではない)
最初のページロード以外では、通常データを返しクライアント側のテンプレートを使用してクライアント上で HTML を構築する方がよい
クライアントが使用できない可能性のあるフィールドを含めない
クライアントに公開する API には、
- システムとの対話方法を記述するためだけのもののみ含める
- その中に他の何かを含めると、それを理解しようとする人に認知的オーバーヘッドが加わる
以前は応答プロトコルでデバッグデータを返すのが一般的でしたが、今はより良い方法がある
- RPC レスポンス拡張(「サイドチャネル」とも呼ばれる)により、あるプロトでクライアントインタフェースを記述し、別のプロトでデバッグサーフェスを記述することができる
- (同様に応答 proto で実験名を返すのは、以前はログ記録の利便性があり、不文律の契約では、クライアントは後続のアクションでそれらの実験を送 り返していた)同じことを実現する方法は、分析パイプラインでログの結合を行うこと。
1つの例外:
- 継続的なリアルタイム分析が必要で、マシンの予算が少ない場合は、ログ結合の実行は困難な場合がある。
- コストが決定要因である場合は、ログデータを事前に非正規化することが有効な場合がある。
- ログデータのラウンドトリップが必要な場合は、不透明な Blob としてクライアントに送信し、要求フィールドと応答フィールドを文書化する。
Caution:
- 要求のたびに隠しデータを返したり往復したりするのは、サービスを利用するための本当のコストを隠していることになり、良くない。
継続トークンなしでページ区切り API を定義しない
message FooQuery { // Bad:最初のクエリと2番目のクエリの間でデータが変更された場合、 // これらの各戦略によって結果を見逃す可能性があります。 // 最終的に一貫性のある世界(つまり、Bigtableにバックアップされたストレージ)では、 // 新しいデータの後に古いデータが表示されることは珍しくない。 // また、オフセットベースとページベースのアプローチはすべてソート順を前提としているため、 // ある程度の柔軟性が失われる。 optional int64 max_timestamp_ms; optional int32 result_offset; optional int32 page_number; optional int32 page_size; // Good: 柔軟性がある。これを FooQueryResponse で返し、 // クライアントが次のクエリでそれを返すようにする。 optional string next_page_token; }
- シリアル化する内部プロトに裏打ちされた不透明な継続トークン (next_page_token) を使用してから、WebSafeBase64Escape (C++) または BaseEncoding.base64Url().encode (Java) を使用すること。
- その内部プロトは多くの分野を含む可能性がある。重要なのは、それが柔軟性をもたらし、選択すれば、クライアントに安定した結果をもたらすということ。
message InternalPaginationToken { // これまでに確認されたIDを追跡する // これにより、継続トークンが大きくなる代わりに、完璧な想起が可能になる // --特に、ユーザがページを戻したとき repeated FooRef seen_ids; // seen_idsストラテジーに似ているが、seen_idsをBloomフィルタにかけることで // バイトを節約し、精度を犠牲にする optional bytes bloom_filter; // 合理的な最初のカットであり、より長く機能する可能性がある. // 継続トークンに埋め込んでおけば、後でクライアントに影響を与えることなく変更できる optional int64 max_timestamp_ms; }
関連するフィールドを新しいメッセージにまとめる。親和性の高いフィールドだけをネストする
message Foo { // Bad: optional int price; optional CurrencyType currency; // Better: Fooの価格と通貨をカプセル化 optional CurrencyAmount price; }
後で関連するフィールドを持つ可能性がある場合、これを回避するために事前に入れる。
message Foo { // DEPRECATED! Use currency_amount. optional int price [deprecated = true]; // The price and currency of this Foo. optional google.type.Money currency_amount; }
疎結合はシステムを開発する際のベストプラクティスだが、.proto ファイルを設計する際には必ずしもそのプラクティスが適用されない場合がある。
message Photo { // Bad: PhotoMetadataはPhotoの範囲外で再利用される可能性が高いので、 // 入れ子にせず、アクセスしやすくしておくといいかもしれませんね。 message PhotoMetadata { optional int32 width = 1; optional int32 height = 2; } optional PhotoMetadata metadata = 1; } message FooConfiguration { // Good: FooConfiguration.RuleをFooConfigurationのスコープ外で再利用すると、 // 無関係なコンポーネントと密接に結合する可能性が高いため、ネスティングして再利用を防ぐ。 message Rule { optional float multiplier = 1; } repeated Rule rules = 1; }
読み取り要求にフィールド読み取りマスクを含める
読み取りマスク
- クライアント側に明確な期待値を設定し、返すデータの量を制御し、バックエンドがクライアントが必要とするデータのみを取得できるようにする。
- すべてのフィールドが true に設定された暗黙の読み取りマスクがあるかのように要求を処理する(proto が大きくなるにつれてコストが大きくなる)
- 暗黙的な (宣言されていない) 読み取りマスク は Bad
これは、プロトコールが大きくなるにつれてコストがかかる可能性があります。
// Recommended: use google.protobuf.FieldMask // Alternative one: message FooReadMask { optional bool return_field1; optional bool return_field2; } // Alternative two: message BarReadMask { // 返すフィールドのタグ番号。 repeated int32 fields_to_return; }
一貫した読み取りを可能にするバージョンフィールドを含める
(分散システムの話?)
クライアントが書き込みを行った後に同じオブジェクトを読み込む場合、書き込んだ内容が戻ってくることを期待するが、その期待値が基となるストレージシステムにとって妥当でない場合もある。
サーバーはローカルの値を読み、ローカルの version_info が、期待される version_info より小さい場合、リモートレプリカから読み取って最新の値を見つける。
version_info は文字列としてエンコードされたプロトで、変異が起こったデータセンターとコミットされたタイムスタンプを含む。(エンコードは「Web-Safe Encoding Binary Proto Serialization で不透明なデータを文字列でエンコードする」参照)
同じデータ型を返す RPC には一貫したリクエストオプションを使用する
要求オプションを保持する単一の個別のメッセージを作成し、それを最上位の各要求メッセージに含める。
message FooRequestOptions { // フィールドレベル読み取りマスク。要求されたフィールドのみ返す。 // クライアントは(バックエンドが要求を最適化するために)必要なフィールドのみ要求 optional FooReadMask read_mask; // 最大でこの数のコメントが返す。 // スパムとしてマークされたコメントは、最大コメント数にカウントされない。 // デフォルトでは、コメントは返されない。 optional int max_comments_to_return; // このサポートされている型リストにない埋め込みを含むfooは、このリストで指定された埋め込みにダウンコンバートされた埋め込みを持つ。サポートされるタイプリストが指定されない場合、埋め込みは返されない。埋め込みをるサポートされている型のいずれかにダウンコンバートできない場合、埋め込みは返されない。クライアントは、EmbedTypes.protoから少なくともTHING_V2埋め込み型を常に含めることを強くお勧めします。 repeated EmbedType embed_supported_types_list; } message GetFooRequest { // ビューアーがFooにアクセスできない場合、またはFooが削除されている場合、 // 応答は空になるが成功する。 optional string foo_id; // クライアントはこのフィールドを含める必要がある // FooRequestOptions が空のままだと、サーバの返却は INVALID_ARGUMENT になる optional FooRequestOptions params; } message ListFooRequest { // 検索では100%が再現されるが、より多くの句がパフォーマンスに影響する。 optional FooQuery query; // クライアントはこのフィールドを含める必要がある // FooRequestOptionsが空のままだと、サーバーはINVALID_ARGUMENTを返します。 optional FooRequestOptions params; }
Batch/Multi-phase リクエスト
- 可能な限り、Mutations を 原始的なものにする。さらに重要なのは、Mutations を冪等性にすること。部分的な失敗の完全な再試行は、データを破損 したり複製したりしてはならない。
- 時に、パフォーマンス上の理由から、複数の操作をカプセル化した単一の RPC が必要になることがある。部分的な失敗の場合(あるものが成功し、あ るものが失敗した場合)クライアントに知らせるのが一番。
- RPC を failed に設定し、成功と失敗の両方の詳細を RPC status proto で返すことを検討する。
小さなデータを返す/操作するメソッドを作成し、クライアントが複数の要求をまとめて UI を構成することを期待する
1 回のラウンドトリップで多くの厳密に指定されたデータのビット?を照会できるため、クライアントが必要なものを構成することで、サーバーを変更せずに幅広い UX オプションを提供することができる
これは、フロントエンドや middle-tier サーバーに最も関係がある。(多くのサービスが独自のバッチング API を公開している)
モバイルやウェブで連続したラウンドトリップが必要な場合、1 回限りの RPC を作成する
一般的な進化は、1 つの繰り返しフィールドが複数の関連した繰り返しフィールドになる必要があることである。つまり、並列の繰り返しフィールドを作成するか、値を保持する新しいメッセージで新しい繰り返しフィールドを定義し、クライアントをそのフィールドに移行するかである。
繰り返しのメッセージから始めると、進化は些細なことになる
// 写真に適用される補正の種類 enum EnhancementType { ENHANCEMENT_TYPE_UNSPECIFIED; RED_EYE_REDUCTION; SKIN_SOFTENING; } message PhotoEnhancement { optional EnhancementType type; } message PhotoEnhancementReply { // Good: PhotoEnhancementは、enumだけでなく、 // より多くのフィールドを必要とする拡張機能を記述して成長可能 repeated PhotoEnhancement enhancements; // Bad: もし、エンハンスメントに関連するパラメータを返したい場合は、 // 並列配列を導入するか(酷い)、このフィールドを非推奨とし、繰り返しメッセージを導入する必要がある。 repeated EnhancementType enhancement_types; }
例外:
- レイテンシが重要なアプリケーションでは、プリミティブ型の並列配列の方がメッセージの単一配列よりも構築と削除が高速である
- また
[packed=true]
(フィールドタグを除外する) を使用する場合は、ネットワーク上で小さくすることもできる。固定数の配列を割り当てる方が、N 個のメッセージを割り当てるよりも手間がかからない。※Proto 3 では packing は自動;明示的に指定する必要はありません。
Proto Maps を使用する
- proto3 からは
Map<scalar, **message**>
を使用する - 事前に構造がわからない任意のデータを表す場合は、google.protobuf.Any を使用
冪等性を優先
- クライアントが再試行ロジックを持つ場合がある。
- 再試行が Mutations である場合、データ重複などに繋がる。
- 重複書き込みを避ける簡単な方法は、クライアントが作成したリクエスト ID を指定できるようにすることで、サーバーがそれを元に重複排除すること(例えば、コンテンツのハッシュや UUID など)。
サービス名はグローバル(ネットワーク上)でユニークなものにする
- サービス名(つまり、.proto ファイルの service キーワードの後の部分)は、サービスクラス名を生成するためだけでなく、意外と多くの場所で使われる。そのため、この名前は重要。
- 厄介なのは、これらのツールは、サービス名がネットワーク上で一意であるという暗黙の前提を置いていること。さらに悪いことに、これらのツールが使用するサービス名は、修飾されたサービス名(例:my_package.MyService)ではなく、修飾されていないサービス名(例:MyService)です。
- このため、たとえ特定のパッケージ内で定義されたサービスであっても、サービス名の命名衝突を防ぐための措置を講じることが理にかなっている。例えば、Watcher という名前のサービスは問題を起こす可能性が高いため、MyProjectWatcher のようなものが良い。
すべての RPC で(許可制の)期限を指定して強制する
- デフォルトでは、RPC はタイムアウトを持たない。リクエストは完了時にのみ解放される。
- バックエンドリソースを占有するかもしれないため、すべてのリクエストが終了できるようにデフォルトの期限を設定することは良い防御方法。(過去には、これを実施しなかったために、主要なサービスにおいて深刻な問題が発生したこともある)
- RPC クライアントは、発信する RPC に期限を設定すべきであり、標準的なフレームワークを使用する場合は、通常デフォルトで設定される。
- デッドラインは、リクエストに付けられたより短いデッドラインによって上書きされることがあり、通常は上書きされる。
rpc Foo(FooRequest) returns (FooResponse) { option deadline = x; // グローバルに通用するデフォルトは存在しない }
リクエストサイズとレスポンスサイズの境界
- リクエストとレスポンスのサイズには上限を設ける必要がある
- 8MiB 程度の制限を推奨。2GiB は多くの proto の実装が壊れるハードリミット
- 無制限のメッセージは
- クライアントとサーバーの双方を肥大化させる
- 予測不可能な高いレイテンシーを引き起こす
- 単一クライアントと単一サーバー間の長時間接続に依存することで、回復性を低下させる
- API 内のすべてのメッセージを境界付けるいくつかの方法
ステータスコードの伝搬は慎重に
RPC サービスは、エラーを調査するために RPC 境界で注意を払い、意味のあるステータスエラーを呼び出し元に返す必要がある
例:
引数をとらない ProductService.GetProducts を呼び出すクライアントを考える。 GetProducts の一部として、ProductService はすべての製品を取得し、各製品に対して LocaleService.LocaliseNutritionFacts を呼び出すかもしれな い。
digraph toy_example { node [style=filled] client [label="Client"]; product [label="ProductService"]; locale [label="LocaleService"]; client -> product [label="GetProducts"] product -> locale [label="LocaliseNutritionFacts"] }
ProductService の実装が不適切な場合、LocaleService に誤った引数を送信し、INVALID_ARGUMENT が発生する可能性がある。
ProductService が不用意に呼び出し側にエラーを返すと、ステータスコードが RPC 境界を越えて伝播するため、クライアントは INVALID_ARGUMENT を受け取る。しかし、クライアントは ProductService.GetProducts に何の引数も渡していない。つまり、このエラーは役に立たないどころか、大きな混乱を招くことになる
その代わりに、ProductService は、RPC 境界(つまり、実装している ProductService RPC ハンドラ)で受け取ったエラーを照会する必要がある。呼び 出し元から無効な引数を受け取った場合は、INVALID_ARGUMENT を返すようにする。下流のものが無効な引数を受け取った場合、INVALID_ARGUMENT を INTERNAL に変換してから呼び出し元にエラーを返すべきである。
不用意にステータスエラーを伝播させると、混乱を招き、デバッグに多大なコストがかかることになる。さらに悪いことに、すべてのサービスがクライアントエラーを転送し、何のアラートも発生させないという、見えない停止につながる可能性がある。
一般的なルールとして、RPC の境界では、エラーを問い合わせ、適切なステータスコードで、意味のあるステータスエラーを呼び出し側に返すように注意する。意味を伝えるために、各 RPC メソッドは、どのような状況でどのようなエラーコードを返すかを文書化する必要がある。各メソッドの実装は、文 書化された API 契約に準拠する必要がある。
Reapeated フィールドの Tips
Repeated フィールド の返し方
- repeated フィールドには hasXxx メソッドは存在しない(repeated フィールドが空の場合、クライアントはそのフィールドがサーバによって入力されなかっただけか、フィールドのバッキングデータが本当に空なのか判断できない)
- 対処:メッセージ内の repeated フィールドをラップすることは、hasXxx メソッドの代替となる
message FooList { repeated Foo foos; }
- より全体的な解決方法は、フィールド読み取りマスクを使用すること
- フィールドが要求された場合、空のリストはデータがないことを意味する。
- フィールドが要求されなかった場合、クライアントは応答のフィールドを無視する必要がある。
Repeated フィールドの修正(更新)
Bad:クライアントに代替リストを供給することを強制すること。
- クライアントに配列全体の供給を強制することの危険性は何倍にもなる。
- 不明なフィールドを保持しないクライアントは、データ損失の原因となります。
- 同時書き込みはデータ損失の原因となる。
- これらの問題が当てはまらない場合でも、クライアントはドキュメントを注意深く読み、サーバー側でフィールドがどのように解釈されるかを知る必要がある。
修正方法
- 繰り返し更新のマスクを使用し、書き込み時に配列全体を供給することなく、クライアントが配列に要素を置換、削除、挿入できるようにする。
- リクエストプロトで、append, replace, delete の各配列を個別に作成する。
- リクエストでは、追加またはクリアのみを許可する。
- これを行うには、repeated フィールドをメッセージでラップする。
- メッセージがあるが空であればクリア、そうでなければ repeated 要素は追加を意味する。
repeated フィールドにおける順序の非依存性
message BatchEquationSolverResponse { // Bad: 解決された値は、リクエストで指定された方程式の順序で返される repeated double solved_values; // (Usually) Bad: solved_valuesの並列配列 repeated double solved_complex_values; } // Good: より多くのフィールドを含むように成長し、他のメソッド間で共有できる独立したメッセージ // リクエストとレスポンスの間の順序依存性が なく、複数のrepeatedフィールド間の順序依存性がない message BatchEquationSolverResponse { // 非推奨。2014年第2四半期までは、この項目が回答として入力され続ける。 repeated double solved_values [deprecated = true]; // Good: リクエストの各方程式には一意の識別子があり、 // 以下のEquationSolutionに含まれている一意の識別子があり、解答は方程式そのものと関連付けることができる。方程式は並行して解かれ、解が作 られるとこの配列に追加される。 repeated EquationSolution solutions; }
パフォーマンスの最適化
場合によっては、型の安全性や明確性を性能の向上と引き換えにすることができる。
例えば
- 何百ものフィールド、特にメッセージ型フィールドを持つプロトは、より少ないフィールドを持つものよりも解析が遅くなる。
- 非常に深くネストされたメッセージは、メモリ管理だけでデシリアライズに時間がかかることがある。
デシリアライズを高速化するためのテクニック:
- 大きなプロトをミラーリングするが、一部のタグのみを持ち、平行にトリミングされたプロトを作成する。
- これを、すべてのフィールドが必要でない場合の解析に使用する。
- トリミングされたプロトはナンバリングの "穴 "が蓄積していくため、タグの番号が一致し続けることを強制するテストを追加する。
[lazy=true]
でフィールドを "lazily parsed "として注釈をつける。- フィールドをバイトとして宣言し、その型を文書化する。フィールドを解析しようとするクライアントは、手動でそれを行うことができる。
- この方法の危険性は、誰かが間違った型のメッセージを bytes フィールドに入れることを防ぐものがないこと。
- PII のためにプロトが吟味されたり、ポリシーやプライバシーのためにスクラブされたりするのを防ぐため、ログに書き込まれるプロトでは決してこの方法を取るべきではない