API のベストプラクティス
proto3 用に更新されました。パッチを歓迎します!
このドキュメントは、Proto のベストプラクティスを補完するものです。Java/C++/Go およびその他の API のための処方箋ではありません。
コードレビューでこれらのガイドラインから逸脱している proto を見つけた場合は、著者にこのトピックを指摘し、情報を広めるのを手伝ってください。
注
これらのガイドラインは単なる指針であり、多くの場合、文書化された例外があります。例えば、パフォーマンスが重要なバックエンドを作成している場合、速度のために柔軟性や安全性を犠牲にしたいと思うかもしれません。このトピックは、トレードオフをよりよく理解し、あなたの状況に合った決定を下すのに役立ちます。ほとんどのフィールドとメッセージを正確かつ簡潔に文書化する
あなたが proto を作成または変更したときに何を考えていたかを知らない人々によって、あなたの proto が継承され使用される可能性が高いです。各フィールドを、あなたのシステムについてほとんど知識のない新しいチームメンバーやクライアントにとって役立つ言葉で文書化してください。
具体的な例
// Bad: Option to enable Foo
// Good: Configuration controlling the behavior of the Foo feature.
message FeatureFooConfig {
// Bad: Sets whether the feature is enabled
// Good: Required field indicating whether the Foo feature
// is enabled for account_id. Must be false if account_id's
// FOO_OPTIN Gaia bit is not set.
optional bool enabled;
}
// Bad: Foo object.
// Good: Client-facing representation of a Foo (what/foo) exposed in APIs.
message Foo {
// Bad: Title of the foo.
// Good: Indicates the user-supplied title of this Foo, with no
// normalization or escaping.
// An example title: "Picture of my cat in a box <3 <3 !!!"
optional string title [(max_length) = 512];
}
// Bad: Foo config.
// Less-Bad: If the most useful comment is re-stating the name, better to omit
// the comment.
FooConfig foo_config = 3;
各フィールドの制約、期待、および解釈を可能な限り少ない言葉で文書化してください。
カスタム proto アノテーションを使用できます。上記の例のmax_length
のようなクロス言語定数を定義するには、カスタムオプションを参照してください。proto2 と proto3 でサポートされています。
時間の経過とともに、インターフェースのドキュメントはどんどん長くなる可能性があります。その長さは明確さを損ないます。ドキュメントが本当に不明確な場合は修正しますが、全体的に見て簡潔さを目指してください。
ワイヤとストレージで異なるメッセージを使用する
クライアントに公開するトップレベルの proto が、ディスクに保存しているものと同じである場合、問題が発生します。時間の経過とともに、より多くのバイナリがあなたの API に依存するようになり、変更が難しくなります。クライアントに影響を与えることなく、ストレージ形式を変更する自由が必要になります。コードを階層化し、モジュールがクライアント proto、ストレージ proto、または変換のいずれかを扱うようにしてください。
なぜですか?基盤となるストレージシステムを交換したい場合があります。データを異なる方法で正規化(または非正規化)したい場合があります。クライアントに公開する proto の一部は RAM に保存するのが理にかなっているが、他の部分はディスクに保存するのが理にかなっていることに気づくかもしれません。
トップレベルのリクエストまたはレスポンス内に1つ以上のレベルでネストされた proto の場合、ストレージとワイヤの proto を分離する理由はそれほど強くなく、クライアントをそれらの proto にどれだけ密接に結合させるかによります。
変換レイヤーを維持するにはコストがかかりますが、クライアントができて最初のストレージ変更を行うと、すぐにそのコストは報われます。
Proto を共有し、「必要に応じて」分岐したい誘惑に駆られるかもしれません。分岐するコストが高いと感じられ、内部フィールドを置く明確な場所がないと、あなたの API にはクライアントが理解しないフィールドや、あなたの知らないうちに依存し始めるフィールドが蓄積されていくでしょう。
別々の proto ファイルから始めることで、チームは API を汚染することなく内部フィールドを追加する場所を知ることができます。初期の段階では、ワイヤ proto は自動変換レイヤー(バイトコピーや proto リフレクションなど)とタグごとに同一にすることができます。Proto アノテーションも自動変換レイヤーを強化できます。
以下の場合は例外です
proto フィールドが
google.type
やgoogle.protobuf
のような共通の型である場合、その型をストレージと API の両方として使用することは許容されます。サービスが極めてパフォーマンスに敏感な場合、実行速度のために柔軟性を犠牲にする価値があるかもしれません。サービスがミリ秒単位のレイテンシで数百万 QPS を持たない場合、おそらくあなたは例外ではありません。
以下のすべてが真である場合
- あなたのサービスがストレージシステムである
- あなたのシステムがクライアントの構造化データに基づいて決定を下さない
- あなたのシステムがクライアントのリクエストに応じて単に保存、ロード、そしておそらくクエリを提供する
ロギングシステムや汎用ストレージシステムを proto ベースのラッパーで実装している場合、依存関係の結合を作成しないように、クライアントのメッセージができるだけ不透明な形でストレージバックエンドに転送されるようにすることを目指したいでしょう。拡張機能またはバイナリ Proto シリアライゼーションをウェブセーフエンコードして不透明なデータを文字列にエンコードすることを検討してください。
ミューテーションの場合、部分更新または追記のみの更新をサポートし、完全な置き換えは行わない
Foo
のみを受け取るUpdateFooRequest
を作成しないでください。
クライアントが不明なフィールドを保持しない場合、GetFooResponse
の最新フィールドを持たず、ラウンドトリップでデータ損失につながります。一部のシステムは不明なフィールドを保持しません。Proto2 および proto3 の実装は、アプリケーションが不明なフィールドを明示的に破棄しない限り、不明なフィールドを保持します。一般的に、公開 API は、不明なフィールドを介したセキュリティ攻撃を防ぐために、サーバー側で不明なフィールドを破棄する必要があります。例えば、無駄な不明なフィールドは、将来サーバーがそれらを新しいフィールドとして使用し始めたときに、サーバーの障害を引き起こす可能性があります。
ドキュメントがない場合、オプションフィールドの処理は曖昧です。UpdateFoo
はフィールドをクリアしますか?それはクライアントがフィールドについて知らないときにデータ損失の可能性を残します。フィールドに触れませんか?では、クライアントはどうやってフィールドをクリアできますか?どちらも良くありません。
修正 #1: 更新フィールドマスクを使用する
クライアントにどのフィールドを変更したいかを渡し、更新リクエストにそれらのフィールドのみを含めるようにしてください。あなたのサーバーは他のフィールドには手を付けず、マスクによって指定されたもののみを更新します。一般的に、マスクの構造はレスポンス proto の構造を反映しているべきです。つまり、Foo
がBar
を含む場合、FooMask
はBarMask
を含みます。
修正 #2: 個々のピースを変更する、より狭いミューテーションを公開する
例えば、UpdateEmployeeRequest
の代わりに、PromoteEmployeeRequest
、SetEmployeePayRequest
、TransferEmployeeRequest
などがあるかもしれません。
カスタム更新メソッドは、非常に柔軟な更新メソッドよりも監視、監査、およびセキュリティの確保が容易です。また、実装と呼び出しも簡単です。それらの数が多いと、API の認知負荷が増加する可能性があります。
トップレベルのリクエストまたはレスポンス Proto にプリミティブ型を含めない
このドキュメントの他の場所で説明されている多くの落とし穴は、このルールで解決されます。例えば
繰り返しフィールドがストレージで未設定であるか、この特定の呼び出しで入力されていないかをクライアントに伝えるには、繰り返しフィールドをメッセージでラップすることで可能です。
リクエスト間で共有される共通のリクエストオプションは、このルールに従うことで自然に導き出されます。読み取りおよび書き込みフィールドマスクもこの結果です。
あなたのトップレベルの proto は、ほとんど常に独立して成長できる他のメッセージのコンテナであるべきです。
今日、単一のプリミティブ型しか必要ない場合でも、それをメッセージでラップすることで、その型を拡張し、同様の値を返す他のメソッド間でその型を共有するための明確なパスが得られます。例えば
message MultiplicationResponse {
// Bad: What if you later want to return complex numbers and have an
// AdditionResponse that returns the same multi-field type?
optional double result;
// Good: Other methods can share this type and it can grow as your
// service adds new features (units, confidence intervals, etc.).
optional NumericResult result;
}
message NumericResult {
optional double real_value;
optional double complex_value;
optional UnitType units;
}
トップレベルのプリミティブに関する1つの例外: proto をエンコードするが、サーバー上でのみ構築および解析される不透明な文字列(またはバイト)。継続トークン、バージョン情報トークン、ID はすべて文字列として返すことができます。ただし、その文字列が実際に構造化された proto のエンコーディングである場合に限ります。
現時点では2つの状態しかないが、後で増える可能性があるものにブール値を使用しない
フィールドにブール値を使用する場合は、そのフィールドが実際に2つの可能な状態のみを記述していることを確認してください(現在と近い将来だけでなく、常に)。多くの場合、Enum、整数、またはメッセージの柔軟性はそれだけの価値があることが判明します。
例えば、投稿のストリームを返す際に、開発者は UX からの現在のモックに基づいて、投稿が2列でレンダリングされるべきかどうかを示す必要があるかもしれません。今日必要なのはブール値だけですが、UX が将来のバージョンで2行の投稿、3列の投稿、または4スクエアの投稿を導入することを妨げるものはありません。
message GooglePlusPost {
// Bad: Whether to render this post across two columns.
optional bool big_post;
// Good: Rendering hints for clients displaying this post.
// Clients should use this to decide how prominently to render this
// post. If absent, assume a default rendering.
optional LayoutConfig layout_config;
}
message Photo {
// Bad: True if it's a GIF.
optional bool gif;
// Good: File format of the referenced photo (for example, GIF, WebP, PNG).
optional PhotoType type;
}
概念を混同する状態を Enum に追加することには注意してください。
状態が Enum に新しい次元を導入したり、複数のアプリケーションの動作を暗示したりする場合、ほぼ確実に別のフィールドが必要になります。
ID に整数フィールドを使用することはめったにない
オブジェクトの識別子として int64 を使用したくなるかもしれません。代わりに文字列を選択してください。
これにより、必要に応じて ID スペースを変更でき、衝突の可能性が減少します。2^64 は以前ほど大きくはありません。
構造化された識別子を文字列としてエンコードすることもできます。これにより、クライアントはそれを不透明なブロブとして扱うようになります。文字列を裏付ける proto は必要ですが、proto を文字列フィールドにシリアル化(ウェブセーフ Base64 でエンコード)することで、クライアントに公開される API から内部の詳細をすべて削除できます。この場合、以下のガイドラインに従ってください。
message GetFooRequest {
// Which Foo to fetch.
optional string foo_id;
}
// Serialized and websafe-base64-encoded into the GetFooRequest.foo_id field.
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;
}
ID を文字列として表現するために独自のシリアル化スキームで開始すると、すぐに奇妙なことになる可能性があります。そのため、文字列フィールドを裏付ける内部 proto から始めるのが最善であることがよくあります。
クライアントが構築または解析することを期待するデータを文字列にエンコードしない
ワイヤ上で非効率的であり、proto の消費側の作業が増え、ドキュメントを読む人にとっては混乱を招きます。クライアントはエンコーディングについても疑問に思わなければなりません。リストはコンマ区切りですか?この信頼できないデータを正しくエスケープしましたか?数値は10進数ですか?クライアントに実際のメッセージやプリミティブ型を送信させる方が良いでしょう。ワイヤ上ではよりコンパクトで、クライアントにとってもより明確です。
これは、サービスが複数の言語のクライアントを獲得した場合に特に悪化します。今やそれぞれが適切なパーサーまたはビルダーを選択するか、さらに悪いことに、自分で作成しなければなりません。
より一般的には、適切なプリミティブ型を選択してください。Protocol Buffer 言語ガイドの「スカラー値型」表を参照してください。
フロントエンド Proto で HTML を返さない
JavaScript クライアントを使用している場合、API のフィールドで HTML や JSON を返したくなるかもしれません。これは、API を特定の UI に結びつけるという危険な道です。ここに3つの具体的な危険性があります。
- 「粗悪な」非ウェブクライアントは、必要なデータを取得するためにあなたの HTML や JSON を解析することになり、フォーマットを変更した場合の脆弱性や、解析が不十分な場合の脆弱性につながります。
- その HTML がサニタイズされずに返された場合、あなたのウェブクライアントは XSS 攻撃に対して脆弱になります。
- あなたが返しているタグとクラスは、特定のスタイルシートと DOM 構造を期待しています。リリースごとにその構造は変更され、JavaScript クライアントがサーバーよりも古く、サーバーが返す HTML が古いクライアントで適切にレンダリングされなくなるバージョン不整合の問題が発生するリスクがあります。頻繁にリリースされるプロジェクトにとって、これは特殊なケースではありません。
最初のページロード以外では、通常、データを返し、クライアント側でテンプレートを使用してクライアント上で HTML を構築する方が良いです。
バイナリ Proto シリアライゼーションをウェブセーフエンコードすることで、不透明なデータを文字列にエンコードする
クライアントから見えるフィールドに不透明なデータ(継続トークン、シリアル化された ID、バージョン情報など)をエンコードする場合、クライアントがそれを不透明なブロブとして扱うべきであることを文書化してください。これらのフィールドには、常にバイナリ Proto シリアライゼーションを使用し、テキスト形式や独自の考案したものは決して使用しないでください。 不透明なフィールドにエンコードされたデータを拡張する必要がある場合、すでに使用していないと、プロトコルバッファのシリアル化を再発明することになります。
不透明なフィールドに含めるフィールドを保持するための内部 proto を定義し(1つのフィールドしか必要ない場合でも)、この内部 proto をバイトにシリアル化し、その結果をウェブセーフ Base64 エンコードして文字列フィールドに格納します。
Proto シリアライゼーションを使用する場合の稀な例外: ごくまれに、慎重に構築された代替フォーマットによるコンパクトさの利点が、その価値がある場合があります。
クライアントが利用できない可能性のあるフィールドを含めない
クライアントに公開する API は、システムとの対話方法を記述するためだけのものであるべきです。それ以外のものを含めると、理解しようとする人にとって認知負荷が増加します。
レスポンス proto でデバッグデータを返すことは以前は一般的な慣行でしたが、より良い方法があります。RPC レスポンス拡張(「サイドチャネル」とも呼ばれる)を使用すると、1つの proto でクライアントインターフェースを記述し、別の proto でデバッグサーフェスを記述できます。
同様に、レスポンス proto で実験名を返すことは、以前はロギングの便宜上のことでした。暗黙の契約は、クライアントがその後のアクションでそれらの実験を返送するというものでした。同じことを達成するための承認された方法は、分析パイプラインでログ結合を行うことです。
一つの例外
継続的でリアルタイムの分析が必要で、かつマシン予算が少ない場合、ログ結合を実行するのは非常にコストがかかるかもしれません。コストが決定要因となるケースでは、ログデータを事前に非正規化することが有効な場合があります。ログデータがラウンドトリップされてあなたに返される必要がある場合、それを不透明なブロブとしてクライアントに送信し、リクエストおよびレスポンスフィールドを文書化してください。
注意: すべてのリクエストで隠れたデータを返す、またはラウンドトリップする必要がある場合、それはサービス使用の真のコストを隠していることになり、これも良くありません。
めったにない 継続トークンなしでページネーション API を定義する
message FooQuery {
// Bad: If the data changes between the first query and second, each of
// these strategies can cause you to miss results. In an eventually
// consistent world (that is, storage backed by Bigtable), it's not uncommon
// to have old data appear after the new data. Also, the offset- and
// page-based approaches all assume a sort-order, taking away some
// flexibility.
optional int64 max_timestamp_ms;
optional int32 result_offset;
optional int32 page_number;
optional int32 page_size;
// Good: You've got flexibility! Return this in a FooQueryResponse and
// have clients pass it back on the next query.
optional string next_page_token;
}
ページネーション API のベストプラクティスは、内部 proto に裏打ちされた不透明な継続トークン(next_page_token
と呼ばれる)を使用し、それをシリアル化してからWebSafeBase64Escape
(C++)またはBaseEncoding.base64Url().encode
(Java)でエンコードすることです。この内部 proto は多くのフィールドを含むことができます。重要なのは、それが柔軟性をもたらし、選択すれば、クライアントの結果に安定性をもたらすことができる点です。
この proto のフィールドを信頼できない入力として検証することを忘れないでください(文字列への不透明データのエンコードの注記を参照)。
message InternalPaginationToken {
// Track which IDs have been seen so far. This gives perfect recall at the
// expense of a larger continuation token--especially as the user pages
// back.
repeated FooRef seen_ids;
// Similar to the seen_ids strategy, but puts the seen_ids in a Bloom filter
// to save bytes and sacrifice some precision.
optional bytes bloom_filter;
// A reasonable first cut and it may work for longer. Having it embedded in
// a continuation token lets you change it later without affecting clients.
optional int64 max_timestamp_ms;
}
関連するフィールドを新しいmessage
にグループ化する。凝集度の高いフィールドのみをネストする
message Foo {
// Bad: The price and currency of this Foo.
optional int price;
optional CurrencyType currency;
// Better: Encapsulates the price and currency of this Foo.
optional CurrencyAmount price;
}
凝集度の高いフィールドのみがネストされるべきです。フィールドが本当に相互に関連している場合、サーバー内でそれらをまとめて渡すことがよくあります。それらがメッセージ内でまとめて定義されていると、それが簡単になります。考慮事項:
CurrencyAmount calculateLocalTax(CurrencyAmount price, Location where)
あなたの CL が1つのフィールドを導入し、そのフィールドに後で関連フィールドが追加される可能性がある場合、これを避けるために予防的にそのフィールドを独自のメッセージに格納してください。
message Foo {
// DEPRECATED! Use currency_amount.
optional int price [deprecated = true];
// The price and currency of this Foo.
optional google.type.Money currency_amount;
}
ネストされたメッセージの問題は、CurrencyAmount
は API の他の場所で再利用される一般的な候補であるかもしれませんが、Foo.CurrencyAmount
はそうではないかもしれないということです。最悪の場合、Foo.CurrencyAmount
は再利用されるものの、Foo
固有のフィールドがそれに漏洩してしまうことです。
システムを開発する上で疎結合が一般的にベストプラクティスとして受け入れられていますが、.proto
ファイルを設計する際には常にその慣行が適用されるとは限りません。2つの情報単位を密接に結合する(1つの単位をもう1つの単位の内部にネストする)ことが理にかなう場合もあります。例えば、現時点ではかなり汎用的に見えるが、後で特殊なフィールドを追加する予定のあるフィールドのセットを作成している場合、メッセージをネストすることで、このまたは他の.proto
ファイル内の他の場所からそのメッセージを参照するのを抑止することができます。
message Photo {
// Bad: It's likely PhotoMetadata will be reused outside the scope of Photo,
// so it's probably a good idea not to nest it and make it easier to access.
message PhotoMetadata {
optional int32 width = 1;
optional int32 height = 2;
}
optional PhotoMetadata metadata = 1;
}
message FooConfiguration {
// Good: Reusing FooConfiguration.Rule outside the scope of FooConfiguration
// tightly-couples it with likely unrelated components, nesting it dissuades
// from doing that.
message Rule {
optional float multiplier = 1;
}
repeated Rule rules = 1;
}
読み取りリクエストにフィールド読み取りマスクを含める
// Recommended: use google.protobuf.FieldMask
// Alternative one:
message FooReadMask {
optional bool return_field1;
optional bool return_field2;
}
// Alternative two:
message BarReadMask {
// Tag numbers of the fields in Bar to return.
repeated int32 fields_to_return;
}
推奨されるgoogle.protobuf.FieldMask
を使用する場合、FieldMaskUtil
(Java/C++)ライブラリを使用して proto を自動的にフィルタリングできます。
読み取りマスクはクライアント側で明確な期待を設定し、クライアントがどれだけのデータを返してほしいかを制御させ、バックエンドがクライアントが必要とするデータのみをフェッチできるようにします。
許容される代替案は、常にすべてのフィールドを入力することです。つまり、すべてのフィールドが true に設定された暗黙の読み取りマスクがあるかのようにリクエストを処理します。これは proto が成長するにつれてコストがかかる可能性があります。
最悪の失敗モードは、メッセージを入力したメソッドによって異なる暗黙的(未宣言)な読み取りマスクを持つことです。このアンチパターンは、レスポンス proto からローカルキャッシュを構築するクライアントでの見かけ上のデータ損失につながります。
一貫性のある読み取りを可能にするためにバージョンフィールドを含める
クライアントが同じオブジェクトに対して書き込みを行った後、読み取りを行う場合、基礎となるストレージシステムにとってその期待が合理的でなくても、書き込んだ内容が返されることを期待します。
サーバーはローカル値を読み取り、ローカルのversion_info
が期待されるversion_info
よりも小さい場合、リモートレプリカから読み取って最新の値を見つけます。通常、version_info
は、ミューテーションが送信されたデータセンターとコミットされたタイムスタンプを含む、文字列としてエンコードされた proto です。
一貫性のあるストレージに裏打ちされたシステムでも、すべての読み取りでコストを発生させるのではなく、よりコストのかかる読み取り一貫性パスをトリガーするためのトークンを望むことがよくあります。
同じデータ型を返す RPC には一貫したリクエストオプションを使用する
失敗パターンの例として、各 RPC が同じデータ型を返すサービスのリクエストオプションがありますが、最大コメント数、埋め込みサポートされる型リストなどを指定するための個別のリクエストオプションがあるケースが挙げられます。
これをアドホックにアプローチするコストは、各リクエストの入力方法を把握するクライアント側の複雑性の増加と、N個のリクエストオプションを共通の内部オプションに変換するサーバー側の複雑性の増加です。実生活におけるバグのうち、かなりの数がこの例に起因しています。
代わりに、リクエストオプションを保持するための単一の独立したメッセージを作成し、それを各トップレベルのリクエストメッセージに含めます。より良いプラクティスの例を以下に示します。
message FooRequestOptions {
// Field-level read mask of which fields to return. Only fields that
// were requested will be returned in the response. Clients should only
// ask for fields they need to help the backend optimize requests.
optional FooReadMask read_mask;
// Up to this many comments will be returned on each Foo in the response.
// Comments that are marked as spam don't count towards the maximum
// comments. By default, no comments are returned.
optional int max_comments_to_return;
// Foos that include embeds that are not on this supported types list will
// have the embeds down-converted to an embed specified in this list. If no
// supported types list is specified, no embeds will be returned. If an embed
// can't be down-converted to one of the supplied supported types, no embed
// will be returned. Clients are strongly encouraged to always include at
// least the THING_V2 embed type from EmbedTypes.proto.
repeated EmbedType embed_supported_types_list;
}
message GetFooRequest {
// What Foo to read. If the viewer doesn't have access to the Foo or the
// Foo has been deleted, the response will be empty but will succeed.
optional string foo_id;
// Clients are required to include this field. Server returns
// INVALID_ARGUMENT if FooRequestOptions is left empty.
optional FooRequestOptions params;
}
message ListFooRequest {
// Which Foos to return. Searches have 100% recall, but more clauses
// impact performance.
optional FooQuery query;
// Clients are required to include this field. The server returns
// INVALID_ARGUMENT if FooRequestOptions is left empty.
optional FooRequestOptions params;
}
バッチ/マルチフェーズリクエスト
可能な限り、ミューテーションをアトミックにしてください。さらに重要なのは、ミューテーションを冪等にすることです。部分的な失敗の完全な再試行は、データを破損させたり重複させたりすべきではありません。
パフォーマンス上の理由から、複数の操作をカプセル化する単一の RPC が必要になることがあります。部分的な失敗の場合、どうすればよいでしょうか?一部が成功し、一部が失敗した場合、クライアントに知らせるのが最善です。
RPC を失敗として設定し、成功と失敗の両方の詳細を RPC ステータス proto で返すことを検討してください。
一般的に、部分的な失敗の処理を知らないクライアントでも正しく動作するようにし、知っているクライアントには追加の価値を得させたいものです。
小さなデータビットを返す、または操作するメソッドを作成し、クライアントが複数のリクエストをバッチ処理して UI を構成することを期待する
単一のラウンドトリップで、狭く指定された多くのデータビットをクエリする機能により、クライアントが必要なものを構成できるため、サーバーの変更なしで幅広い UX オプションが可能になります。
これは、フロントエンドおよびミドルティアのサーバーに最も関連性が高いです。
多くのサービスは独自のバッチ処理 API を公開しています。
モバイルまたはウェブで代替手段が連続するラウンドトリップである場合、一度限りの RPC を作成する
ウェブまたはモバイルクライアントが、データ依存関係のある2つのクエリを実行する必要がある場合、現在のベストプラクティスは、クライアントをラウンドトリップから保護する新しい RPC を作成することです。
モバイルの場合、2つのサービスメソッドを1つの新しいメソッドにまとめることで、クライアントに追加のラウンドトリップのコストを節約することは、ほとんど常に価値があります。サーバー間呼び出しの場合、状況はそれほど明確ではないかもしれません。それは、サービスがどれだけパフォーマンスに敏感か、そして新しいメソッドがどれだけの認知オーバーヘッドをもたらすかに依存します。
繰り返しフィールドは、スカラーや Enum ではなく、メッセージにする
一般的な進化は、単一の繰り返しフィールドが複数の関連する繰り返しフィールドになる必要があることです。繰り返しプリミティブから開始すると、選択肢は限られます。並列の繰り返しフィールドを作成するか、値を保持する新しいメッセージを持つ新しい繰り返しフィールドを定義し、クライアントをそれに移行させるかのどちらかです。
繰り返しメッセージから開始すると、進化は自明になります。
// Describes a type of enhancement applied to a photo
enum EnhancementType {
ENHANCEMENT_TYPE_UNSPECIFIED;
RED_EYE_REDUCTION;
SKIN_SOFTENING;
}
message PhotoEnhancement {
optional EnhancementType type;
}
message PhotoEnhancementReply {
// Good: PhotoEnhancement can grow to describe enhancements that require
// more fields than just an enum.
repeated PhotoEnhancement enhancements;
// Bad: If we ever want to return parameters associated with the
// enhancement, we'd have to introduce a parallel array (terrible) or
// deprecate this field and introduce a repeated message.
repeated EnhancementType enhancement_types;
}
以下の機能リクエストを想像してみてください。「ユーザーが実行した強化と、システムによって自動的に適用された強化を区別する必要があります。」
PhotoEnhancementReply
のenhancement
フィールドがスカラーまたは Enum であった場合、これをサポートするのははるかに困難になるでしょう。
これはマップにも同様に当てはまります。マップの値がすでにメッセージである場合、map
からmap
に移行するよりも、追加のフィールドをマップ値に追加する方がはるかに簡単です。
一つの例外
レイテンシが重要なアプリケーションでは、プリミティブ型の並列配列は単一のメッセージ配列よりも構築および削除が高速であることがわかります。また、[packed=true](フィールドタグの省略)を使用すれば、ワイヤ上でより小さくすることもできます。固定数の配列を割り当てることは、N個のメッセージを割り当てるよりも作業が少なくて済みます。特典: Proto3では、パッキングは自動で行われるため、明示的に指定する必要はありません。
Proto マップを使用する
Proto3にProto3 マップが導入される前は、サービスはスカラーフィールドを持つアドホックな KVPair メッセージを使用してデータをペアとして公開することがありました。最終的にクライアントはより深い構造を必要とし、何らかの方法で解析する必要があるキーや値を考案することになります。データを文字列にエンコードしないを参照してください。
したがって、値に(拡張可能な)メッセージ型を使用することは、単純な設計に対する即座の改善となります。
マップはすべての言語で proto2 にバックポートされたため、同じ目的で独自の KVPair を考案するよりも、map
を使用する方が優れています1。
構造を事前に知らない任意のデータを表現したい場合は、google.protobuf.Any
を使用してください。
冪等性を優先する
あなたのスタックのどこかで、クライアントがリトライロジックを持っているかもしれません。リトライがミューテーションである場合、ユーザーは驚くかもしれません。コメントの重複、ビルドリクエスト、編集などは、誰にとっても良くありません。
重複書き込みを避ける簡単な方法は、クライアントがクライアント作成のリクエスト ID を指定できるようにすることです。あなたのサーバーはこれに基づいて重複排除します(例えば、コンテンツのハッシュや UUID)。
サービス名に注意し、グローバルに一意にする
サービス名(つまり、.proto
ファイルのservice
キーワードの後の部分)は、サービス クラス名を生成するだけでなく、驚くほど多くの場所で使用されます。このため、この名前は想像以上に重要になります。
厄介なのは、これらのツールが、あなたのサービス名がネットワーク全体で一意であるという暗黙の仮定をしていることです。さらに悪いことに、彼らが使用するサービス名は、修飾サービス名(例えば、my_package.MyService
)ではなく、非修飾サービス名(例えば、MyService
)です。
このため、特定のパッケージ内で定義されている場合でも、サービス名の命名衝突を防ぐための措置を講じることが理にかなっています。例えば、Watcher
という名前のサービスは問題を引き起こす可能性が高く、MyProjectWatcher
のような名前の方が良いでしょう。
リクエストとレスポンスのサイズを制限する
リクエストとレスポンスのサイズは制限されるべきです。おおよそ8 MiBの制限を推奨し、2 GiBは多くの proto 実装が壊れる厳密な上限です。多くのストレージシステムにはメッセージサイズの制限があります。
また、無制限のメッセージは
- クライアントとサーバーの両方を肥大化させ、
- 高くて予測不可能なレイテンシを引き起こし、
- 単一のクライアントと単一のサーバー間の長時間接続に依存することで、回復力を低下させます。
API 内のすべてのメッセージを制限するためのいくつかの方法を以下に示します。
- 各 RPC 呼び出しが論理的に互いから独立している、制限されたメッセージを返す RPC を定義します。
- 無制限の、クライアント指定のオブジェクトリストではなく、単一のオブジェクトに対して操作を行う RPC を定義します。
- 文字列、バイト、または繰り返しフィールドに無制限のデータをエンコードすることを避けます。
- 長時間の操作を定義します。その結果を、スケーラブルで並行読み取り用に設計されたストレージシステムに保存します。
- ページネーション API を使用します(継続トークンなしでページネーション API を定義することはめったにないを参照)。
- ストリーミング RPC を使用します。
UI を扱っている場合は、小さなデータビットを返す、または操作するメソッドを作成するも参照してください。
ステータスコードを慎重に伝播する
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 契約に準拠すべきです。
メソッドごとに一意の Proto を作成する
各 RPC メソッドごとに一意のリクエストおよびレスポンス proto を作成します。後になってトップレベルのリクエストまたはレスポンスを分岐させる必要があると判明すると、コストがかかる可能性があります。これには「空の」レスポンスも含まれます。既知の Empty メッセージ型を再利用するのではなく、一意の空のレスポンス proto を作成してください。
メッセージの再利用
メッセージを再利用するには、複数のリクエストおよびレスポンス proto に含める共有の「ドメイン」メッセージ型を作成します。アプリケーションロジックは、リクエストおよびレスポンス型ではなく、それらの型で記述してください。
これにより、メソッドのリクエスト/レスポンス型を独立して進化させる柔軟性が得られますが、論理的なサブユニットのコードは共有できます。
付録
繰り返しフィールドの返却
繰り返しフィールドが空の場合、クライアントは、そのフィールドがサーバーによって単に投入されなかったのか、それともそのフィールドの裏付けデータが本当に空なのかを判断できません。言い換えれば、繰り返しフィールドにはhasFoo
メソッドがありません。
繰り返しフィールドをメッセージでラップすることは、hasFoo メソッドを取得する簡単な方法です。
message FooList {
repeated Foo foos;
}
それを解決するより全体的な方法は、フィールド読み取りマスクを使用することです。フィールドが要求された場合、空のリストはデータがないことを意味します。フィールドが要求されなかった場合、クライアントはレスポンスのそのフィールドを無視すべきです。
繰り返しフィールドの更新
繰り返しフィールドを更新する最悪の方法は、クライアントに置き換えリストを強制的に提供させることです。クライアントに配列全体を提供させることには多くの危険があります。不明なフィールドを保持しないクライアントはデータ損失を引き起こします。同時書き込みはデータ損失を引き起こします。これらの問題が適用されない場合でも、クライアントはサーバー側でフィールドがどのように解釈されるかを知るために、ドキュメントを注意深く読む必要があります。空のフィールドは、サーバーがそれを更新しないことを意味しますか、それともサーバーがそれをクリアすることを意味しますか?
修正 #1: 書き込み時に配列全体を提供することなく、クライアントが配列に要素を置換、削除、または挿入することを許可する繰り返し更新マスクを使用します。
修正 #2: リクエスト proto に個別の追加、置換、削除配列を作成します。
修正 #3: 追加またはクリアのみを許可します。これは、繰り返しフィールドをメッセージでラップすることで可能です。存在し、かつ空のメッセージはクリアを意味し、それ以外の場合、繰り返し要素があれば追加を意味します。
繰り返しフィールドにおける順序の独立性
一般的に、順序依存性を避けるように努めてください。それは脆弱性の追加レイヤーです。特に悪いタイプの順序依存性は並列配列です。並列配列は、クライアントが結果を解釈することをより困難にし、自身のサービス内で2つの関連フィールドを渡すことを不自然にします。
message BatchEquationSolverResponse {
// Bad: Solved values are returned in the order of the equations given in
// the request.
repeated double solved_values;
// (Usually) Bad: Parallel array for solved_values.
repeated double solved_complex_values;
}
// Good: A separate message that can grow to include more fields and be
// shared among other methods. No order dependence between request and
// response, no order dependence between multiple repeated fields.
message BatchEquationSolverResponse {
// Deprecated, this will continue to be populated in responses until Q2
// 2014, after which clients must move to using the solutions field below.
repeated double solved_values [deprecated = true];
// Good: Each equation in the request has a unique identifier that's
// included in the EquationSolution below so that the solutions can be
// correlated with the equations themselves. Equations are solved in
// parallel and as the solutions are made they are added to this array.
repeated EquationSolution solutions;
}
Proto がモバイルビルドにあることによる機能の漏洩
Android と iOS のランタイムは両方ともリフレクションをサポートしています。そのためには、フィールドとメッセージのフィルターされていない名前がアプリケーションバイナリ(APK、IPA)に文字列として埋め込まれます。
message Foo {
// This will leak existence of Google Teleport project on Android and iOS
optional FeatureStatus google_teleport_enabled;
}
いくつかの緩和戦略
- Android での ProGuard による難読化。2014年第3四半期現在。iOS には難読化オプションがありません。デスクトップに IPA があれば、
strings
を介してパイプすることで、含まれる proto のフィールド名が明らかになります。iOS Chrome の分解 - モバイルクライアントに送信されるフィールドを正確に厳選します。
- 許容可能な時間枠内で情報漏洩を防ぐことができない場合、機能オーナーからリスクを負うことの承認を得てください。
コード名でフィールドの意味を難読化する言い訳として、これを決して使用しないでください。情報漏洩を防ぐか、リスクを負うことの承認を得てください。
パフォーマンス最適化
場合によっては、型安全性や明確さを犠牲にしてパフォーマンスを向上させることができます。例えば、数百のフィールド、特にメッセージ型のフィールドを持つ proto は、フィールドの少ないものよりも解析が遅くなります。非常に深くネストされたメッセージは、メモリ管理だけでデシリアライズが遅くなる可能性があります。チームがデシリアライズを高速化するために使用したいくつかのテクニックは以下の通りです。
- より大きな proto をミラーリングするが、一部のタグのみを宣言する並列のトリミングされた proto を作成します。すべてのフィールドが必要ない場合に、これを解析に使用します。トリミングされた proto が番号付けの「穴」を蓄積しても、タグ番号が一致し続けることを強制するテストを追加します。
- フィールドを[lazy=true]で「遅延解析される」とアノテーションします。
- フィールドをバイトとして宣言し、その型を文書化します。フィールドを解析したいクライアントは手動でそれを行うことができます。このアプローチの危険性は、バイトフィールドに間違った型のメッセージを配置することを誰も妨げられないことです。これをログに書き込まれる proto で行うべきではありません。なぜなら、PII の精査やポリシーまたはプライバシー上の理由によるスクラブが妨げられるからです。
map
フィールドを含む proto の落とし穴。MapReduce でそれらをリデュースキーとして使用しないでください。proto3 のマップ項目のワイヤ形式とイテレーション順序は未指定であり、一貫性のないマップシャードにつながります。 ↩︎