APIのベストプラクティス

将来性のあるAPIを正しく作成するのは驚くほど難しいです。このドキュメントの提案は、長期的なバグのない進化を優先するためのトレードオフを行います。

proto3用に更新されました。パッチ歓迎!

このドキュメントは、Protoのベストプラクティスを補完するものです。Java/C++/Goおよびその他のAPIに対する処方箋ではありません。

コードレビューでこれらのガイドラインから逸脱している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にどの程度密接に結合させる意思があるかによって異なります。

変換レイヤーを維持するコストはありますが、クライアントを持ち、最初のストレージ変更を行う必要が生じると、すぐにペイオフが得られます。

protoを共有し、「必要なときに」分岐したくなるかもしれません。分岐のコストが高いと感じ、内部フィールドを配置する明確な場所がないため、APIにはクライアントが理解していないか、知らないうちに依存し始めるフィールドが蓄積されます。

個別のprotoファイルから始めることで、チームはAPIを汚染することなく、内部フィールドをどこに追加するかを知ることができます。初期の頃は、ワイヤーprotoはタグごとに同一であり、自動変換レイヤー(バイトコピーまたはprotoリフレクションなど)を使用できます。Protoアノテーションは、自動変換レイヤーを強化することもできます。

以下はルールの例外です

  • protoフィールドがgoogle.typegoogle.protobufなどの一般的なタイプである場合、そのタイプをストレージとAPIの両方として使用することは許容されます。

  • サービスが非常にパフォーマンスに敏感な場合は、実行速度のために柔軟性を犠牲にする価値があるかもしれません。サービスにミリ秒のレイテンシで数百万QPSがない場合は、おそらく例外ではありません。

  • 次のすべてが当てはまる場合

    • あなたのサービスはストレージシステムである
    • システムは、クライアントの構造化データに基づいて意思決定を行わない
    • システムは、クライアントのリクエストに応じて、単に保存、ロード、およびおそらくクエリを提供する

    ロギングシステムや汎用ストレージシステムを中心としたprotoベースのラッパーのようなものを実装している場合、クライアントのメッセージが可能な限り不透明にストレージバックエンドに移行するようにすることを目指して、依存関係のネクサスを作成しないようにすることをお勧めします。拡張機能またはウェブセーフエンコーディングバイナリProtoシリアライゼーションによってオペークデータを文字列でエンコードするの使用を検討してください。

変更には、完全な置換ではなく、部分的な更新または追加のみの更新をサポートする

Fooのみを受け取るUpdateFooRequestを作成しないでください。

クライアントが不明なフィールドを保持しない場合、GetFooResponseの最新フィールドを持たないため、ラウンドトリップでデータが失われます。一部のシステムは不明なフィールドを保持しません。Proto2およびproto3の実装では、アプリケーションが不明なフィールドを明示的にドロップしない限り、不明なフィールドを保持します。一般に、パブリックAPIは、不明なフィールドを介したセキュリティ攻撃を防ぐために、サーバー側で不明なフィールドをドロップする必要があります。たとえば、ガベージ不明フィールドは、サーバーが将来新しいフィールドとして使用し始めると、サーバーが失敗する原因となる可能性があります。

ドキュメントがない場合、オプションフィールドの処理は曖昧です。UpdateFooはフィールドをクリアしますか? それは、クライアントがフィールドについて知らない場合にデータ損失が発生する可能性があります。フィールドに触れないのですか? それでは、クライアントはどのようにフィールドをクリアできますか? どちらも良くありません。

修正#1:更新フィールドマスクを使用する

クライアントに修正したいフィールドを渡し、更新リクエストにそれらのフィールドのみを含めるようにします。サーバーは他のフィールドをそのままにし、マスクで指定されたフィールドのみを更新します。一般に、マスクの構造はレスポンスprotoの構造を反映する必要があります。つまり、FooBarが含まれている場合、FooMaskにはBarMaskが含まれます。

修正#2:個々の部分を変更する、より狭い範囲の変更を公開する

たとえば、UpdateEmployeeRequestの代わりに、PromoteEmployeeRequestSetEmployeePayRequestTransferEmployeeRequestなどを使用できます。

カスタム更新メソッドは、非常に柔軟な更新メソッドよりも監視、監査、およびセキュリティ保護が容易です。また、実装と呼び出しも簡単です。ただし、多数のカスタム更新メソッドは、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、int、またはメッセージの柔軟性が価値があることが判明します。

たとえば、投稿のストリームを返す際に、開発者は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は以前ほど大きくありません。

また、構造化された識別子を文字列としてエンコードすることもできます。これにより、クライアントはそれをオペークなblobとして扱うようになります。それでも文字列をバックアップする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進数ですか? クライアントに実際のメッセージまたはプリミティブ型を送信させる方が良いでしょう。ワイヤー上でよりコンパクトになり、クライアントにとってより明確になります。

サービスが複数の言語でクライアントを取得すると、これは特に悪化します。これで、それぞれが適切なパーサーまたはビルダーを選択するか、さらに悪いことに、パーサーまたはビルダーを作成する必要があります。

より一般的には、適切なプリミティブ型を選択してください。プロトコルバッファ言語ガイドのスカラー値型の表を参照してください。

フロントエンドProtoでHTMLを返さない

JavaScriptクライアントを使用すると、APIのフィールドにHTMLまたはJSONを返したくなります。これは、APIを特定のUIに結び付ける滑りやすい坂道です。具体的な危険性を3つ挙げます。

  • 「スクラッピー」な非ウェブクライアントは、必要なデータを取得するためにHTMLまたはJSONを解析することになり、形式を変更した場合の脆弱性や、解析が不適切な場合のリスクにつながります。
  • そのHTMLがサニタイズされていない状態で返された場合、ウェブクライアントはXSSエクスプロイトに対して脆弱になります。
  • 返しているタグとクラスは、特定のスタイルシートとDOM構造を想定しています。リリースごとに、その構造は変化し、JavaScriptクライアントがサーバーよりも古く、サーバーが返すHTMLが古いクライアントで適切にレンダリングされなくなるバージョンずれの問題が発生するリスクがあります。頻繁にリリースするプロジェクトの場合、これはエッジケースではありません。

最初のページロード以外は、通常、データを返し、クライアント側のテンプレートを使用してクライアントでHTMLを構築する方が優れています。

ウェブセーフエンコーディングバイナリProtoシリアライゼーションによって、オペークデータを文字列でエンコードする

オペークデータをクライアントから見えるフィールド(継続トークン、シリアライズされたID、バージョン情報など)でエンコードする場合は、クライアントがそれをオペークなblobとして扱う必要があることを文書化してください。これらのフィールドには、常にバイナリprotoシリアライゼーションを使用し、テキスト形式や独自の考案したものを使用しないでください。オペークフィールドでエンコードされたデータを拡張する必要がある場合は、まだ使用していない場合、プロトコルバッファシリアライゼーションを再発明することになります。

オペークフィールドに入るフィールドを保持する内部protoを定義し(フィールドが1つしか必要ない場合でも)、この内部protoをバイトにシリアライズしてから、結果をウェブセーフbase-64エンコードして文字列フィールドにします。

protoシリアライゼーションを使用する場合のまれな例外:非常にまれに、慎重に構築された代替形式からのコンパクトさが価値がある場合があります。

クライアントが使用する可能性のないフィールドを含めない

クライアントに公開するAPIは、システムとの対話方法を記述するためだけのものである必要があります。それ以外のものを含めると、それを理解しようとする人の認知負荷が増加します。

レスポンスprotoでデバッグデータを返すことは以前は一般的な慣習でしたが、より良い方法があります。RPCレスポンス拡張機能(「サイドチャネル」とも呼ばれます)を使用すると、1つのprotoでクライアントインターフェースを記述し、別のprotoでデバッグサーフェスを記述できます。

同様に、レスポンスprotoで実験名を返すことは、以前はロギングの利便性でした。暗黙の契約は、クライアントが後続のアクションでこれらの実験を返送することでした。同じことを達成するための受け入れられている方法は、分析パイプラインでログ結合を行うことです。

1つの例外

継続的なリアルタイム分析が必要であり、小さなマシン予算しかない場合は、ログ結合の実行が法外になる可能性があります。コストが決定要因となる場合、ログデータを事前に非正規化することは有利になる可能性があります。ログデータをラウンドトリップする必要がある場合は、オペークなblobとしてクライアントに送信し、リクエストフィールドとレスポンスフィールドを文書化してください。

注意:すべてのリクエストで隠しデータを返すか、ラウンドトリップする必要がある場合は、サービスの真のコストを隠蔽していることになり、それも良くありません。

継続トークンなしでページネーション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 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つの情報ユニットを(一方のユニットを他方のユニット内にネストすることによって)密結合することが理にかなう場合があります。たとえば、現時点ではかなり一般的なフィールドのセットを作成しているが、後で特殊なフィールドを追加することを想定している場合は、メッセージをネストすると、他の人がこのメッセージまたは他の.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を使用する場合、FieldMaskUtilJava/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を公開しています。

代替案がモバイルまたはウェブでのシリアルラウンドトリップである場合は、1回限りの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の拡張機能フィールドがスカラーまたはenumの場合、これをサポートするのははるかに難しくなります。

これはマップにも同様に当てはまります。マップ値がmap<string, string>からmap<string, MyProto>に移行する必要がある場合よりも、マップ値がすでにメッセージである場合、マップ値にフィールドを追加する方がはるかに簡単です。

1つの例外

レイテンシクリティカルなアプリケーションでは、プリミティブ型の並列配列は、メッセージの単一配列よりも構築および削除が高速であることがわかります。また、[packed=true](フィールドタグを省略)を使用する場合、ワイヤー上で小さくすることもできます。固定数の配列を割り当てることは、N個のメッセージを割り当てるよりも手間がかかりません。ボーナス:Proto3では、パッキングは自動です。明示的に指定する必要はありません。

Protoマップを使用する

Proto3でのProto3マップの導入以前は、サービスはスカラーフィールドを持つアドホックKVPairメッセージを使用してデータをペアとして公開することがありました。最終的にクライアントはより深い構造を必要とし、何らかの方法で解析する必要のあるキーまたは値を考案することになります。文字列でデータをエンコードしないでくださいを参照してください。

したがって、(拡張可能な)メッセージ型を値に使用することは、ナイーブな設計よりもすぐに改善されます。

マップはすべての言語でproto2にバックポートされたため、同じ目的で独自のKVPairを発明するよりも、map<scalar, **message**>を使用する方が優れています1

構造を事前に把握していない任意のデータを表したい場合は、google.protobuf.Anyを使用してください。

冪等性を優先する

スタックの上の方で、クライアントに再試行ロジックがある場合があります。再試行が変更である場合、ユーザーは驚くかもしれません。コメント、ビルドリクエスト、編集などの重複は誰にとっても良くありません。

重複した書き込みを回避する簡単な方法は、クライアントがサーバーが重複排除するクライアント作成のリクエストID(たとえば、コンテンツのハッシュまたはUUID)を指定できるようにすることです。

サービス名を意識し、グローバルに一意にする

サービス名(つまり、.protoファイルのserviceキーワードの後の部分)は、サービスクラス名を生成するだけでなく、驚くほど多くの場所で使用されます。これにより、この名前は考えられているよりも重要になります。

難しいのは、これらのツールがサービス名がネットワーク全体で一意であるという暗黙の仮定をしていることです。さらに悪いことに、彼らが使用するサービス名は、非修飾サービス名(たとえば、MyService)であり、修飾サービス名(たとえば、my_package.MyService)ではありません。

このため、特定のパッケージ内で定義されている場合でも、サービス名の命名の衝突を防ぐための措置を講じることは理にかなっています。たとえば、Watcherという名前のサービスは問題を引き起こす可能性があります。MyProjectWatcherのようなものがより良いでしょう。

リクエストとレスポンスのサイズを制限する

リクエストとレスポンスのサイズは制限する必要があります。約8 MiBの範囲の制限をお勧めします。2 GiBは、多くのproto実装が壊れるハードリミットです。多くのストレージシステムには、メッセージサイズの制限があります。

また、無制限のメッセージは

  • クライアントとサーバーの両方を肥大化させ、
  • 高くて予測不可能なレイテンシを引き起こし、
  • 単一のクライアントと単一のサーバー間の長寿命の接続に依存することにより、回復力を低下させます。

API内のすべてのメッセージを制限するためのいくつかの方法を次に示します。

  • 各RPC呼び出しが論理的に他の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 が不注意にエラーを呼び出し元に返した場合、クライアントは INVALID_ARGUMENT を受け取ります。なぜなら、ステータスコードは RPC の境界を越えて伝播するからです。しかし、クライアントは ProductService.GetProducts に引数を何も渡していません。そのため、このエラーは役に立たないどころか、大きな混乱を引き起こすでしょう!

代わりに、ProductService は RPC 境界で受け取るエラーを精査すべきです。つまり、ProductService が実装する RPC ハンドラーです。ユーザーには意味のあるエラーを返す必要があります。もし呼び出し元から無効な引数を受け取ったのであれば、INVALID_ARGUMENT を返す必要があります。もしダウンストリームの何かが無効な引数を受け取ったのであれば、INVALID_ARGUMENTINTERNAL に変換してから呼び出し元にエラーを返す必要があります。

不注意にステータスエラーを伝播させると混乱を招き、デバッグに非常にコストがかかる可能性があります。さらに悪いことに、すべてのサービスがクライアントエラーを転送し、アラートが何も発生しないという、目に見えない機能停止につながる可能性があります。

一般的なルールは、RPC 境界でエラーを注意深く精査し、適切なステータスコードとともに、意味のあるステータスエラーを呼び出し元に返すことです。意味を伝えるために、各 RPC メソッドは、どのような状況でどのようなエラーコードを返すかをドキュメント化する必要があります。各メソッドの実装は、ドキュメント化された API コントラクトに準拠する必要があります。

メソッドごとに一意のProtoを作成する

各 RPC メソッドに対して、一意のリクエストとレスポンスの proto を作成してください。後になってトップレベルのリクエストまたはレスポンスを分岐する必要があると気づくのは、コストがかかる可能性があります。これには「空」のレスポンスも含まれます。well-known Empty message type を再利用するのではなく、一意の空のレスポンス proto を作成してください。

メッセージの再利用

メッセージを再利用するには、複数のリクエストおよびレスポンス proto に含めるための共有「ドメイン」メッセージタイプを作成します。アプリケーションロジックは、リクエストおよびレスポンスタイプではなく、これらのタイプに関して記述してください。

これにより、メソッドのリクエスト/レスポンスタイプを独立して進化させる柔軟性が得られますが、論理的なサブユニットのコードを共有できます。

付録

繰り返しのフィールドを返す

繰り返しのフィールドが空の場合、クライアントはフィールドがサーバーによって単に設定されなかったのか、それともフィールドのバックアップデータが本当に空なのかを判断できません。言い換えれば、繰り返しのフィールドには hasFoo メソッドがありません。

繰り返しのフィールドをメッセージでラップすると、hasFoo メソッドを簡単に取得できます。

message FooList {
  repeated Foo foos;
}

より包括的な解決策は、フィールド read mask を使用することです。フィールドがリクエストされた場合、空のリストはデータがないことを意味します。フィールドがリクエストされなかった場合、クライアントはレスポンスのフィールドを無視する必要があります。

繰り返しのフィールドを更新する

繰り返しのフィールドを更新する最悪の方法は、クライアントに置換リストを強制的に提供させることです。クライアントに配列全体を強制的に提供させることの危険性は多岐にわたります。不明なフィールドを保持しないクライアントはデータ損失を引き起こします。同時書き込みはデータ損失を引き起こします。これらの問題が当てはまらない場合でも、クライアントはフィールドがサーバー側でどのように解釈されるかを知るために、ドキュメントを注意深く読む必要があります。空のフィールドは、サーバーがそれを更新しないことを意味するのか、それともサーバーがそれをクリアすることを意味するのでしょうか?

修正 #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 を作成します。すべてのフィールドが必要ない場合は、解析にこれを使用します。トリミングされた proto が番号の「穴」を蓄積するにつれて、タグ番号が引き続き一致することを強制するテストを追加します。
  • フィールドを [lazy=true] で「遅延解析」としてアノテーションを付けます。
  • フィールドを bytes として宣言し、そのタイプをドキュメント化します。フィールドを解析したいクライアントは、手動で解析できます。このアプローチの危険性は、bytes フィールドに間違ったタイプのメッセージを入れるのを防ぐものが何もないことです。PII の検査や、ポリシーまたはプライバシー上の理由でスクラブするために proto が精査されるログに書き込まれる proto では、これを絶対に行わないでください。

  1. map<k,v> フィールドを含む proto の落とし穴。MapReduce の reduce キーとして使用しないでください。proto3 マップアイテムのワイヤーフォーマットと反復順序は指定されていません。これにより、マップシャードの不整合が発生します。 ↩︎