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 中,而其他部分則適合儲存在磁碟上。
當涉及到在頂層請求或回應中巢狀結構一或多個層級的 Proto 時,分離儲存和線路 Proto 的理由並不那麼強烈,並且取決於您願意將您的客戶端與這些 Proto 緊密耦合的程度。
維護轉譯層需要付出代價,但一旦您有了客戶端並且必須進行第一次儲存變更,它很快就會帶來回報。
您可能會想共用 Proto 並在「需要時」分歧。由於分歧的感知成本很高且沒有明確的地方可以放置內部欄位,您的 API 會累積客戶端不了解或在您不知情的情況下開始依賴的欄位。
從單獨的 Proto 檔案開始,您的團隊會知道在何處新增內部欄位,而不會污染您的 API。在早期,線路 Proto 可以透過自動轉譯層(想想:位元組複製或 Proto 反射)與標記相同的標記。Proto 註釋也可以為自動轉譯層提供支援。
以下是此規則的例外情況
如果 Proto 欄位是常見類型之一,例如
google.type
或google.protobuf
,那麼同時使用該類型作為儲存和 API 是可以接受的。如果您的服務對效能極為敏感,則可能值得犧牲彈性來換取執行速度。如果您的服務沒有數百萬的 QPS 和毫秒延遲,您可能不是例外。
如果以下所有條件皆成立
- 您的服務是儲存系統
- 您的系統不會根據客戶端的結構化資料做出決策
- 您的系統只是儲存、載入,或許會根據客戶端的請求提供查詢
請注意,如果您正在實作類似記錄系統或基於 Proto 的通用儲存系統封裝器,那麼您可能希望讓您的客戶端訊息盡可能不透明地傳輸到您的儲存後端,這樣您就不會建立相依性連結。考慮使用擴充或使用 Web 安全編碼二進位 Proto 序列化在字串中編碼不透明資料。
對於變更,支援部分更新或僅附加更新,而非完整替換
請勿建立只採用 Foo
的 UpdateFooRequest
。
如果客戶端沒有保留未知欄位,他們將不會擁有 GetFooResponse
的最新欄位,導致在往返時資料遺失。有些系統不會保留未知欄位。除非應用程式明確捨棄未知欄位,否則 Proto2 和 proto3 實作會保留未知欄位。一般而言,公用 API 應在伺服器端捨棄未知欄位,以防止透過未知欄位進行安全性攻擊。例如,垃圾未知欄位可能會導致伺服器在未來開始將它們用作新欄位時失敗。
在沒有文件的情況下,選用欄位的處理方式含糊不清。UpdateFoo
會清除該欄位嗎?當客戶端不了解該欄位時,這會讓您面臨資料遺失的風險。它不會觸碰欄位嗎?那麼客戶端如何清除該欄位?兩者都不好。
修正 #1:使用更新欄位遮罩
讓您的客戶端傳遞它想要修改的欄位,並且僅在更新請求中包含這些欄位。您的伺服器會保持其他欄位不變,並且只更新遮罩指定的欄位。一般而言,您的遮罩結構應反映回應 Proto 的結構;也就是說,如果 Foo
包含 Bar
,則 FooMask
包含 BarMask
。
修正 #2:公開更多變更個別部分的狹義變更
例如,您可以擁有 PromoteEmployeeRequest
、SetEmployeePayRequest
、TransferEmployeeRequest
等,而不是 UpdateEmployeeRequest
。
自訂更新方法比非常彈性的更新方法更容易監控、稽核和保護。它們也更容易實作和呼叫。大量的方法會增加 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;
}
頂層基本類型的一個例外:僅在伺服器上建構和剖析的編碼 Proto 的不透明字串(或位元組)。接續符記、版本資訊符記和 ID 都可以做為字串傳回,如果該字串實際上是結構化 Proto 的編碼。
永遠不要將布林值用於現在只有兩種狀態,但稍後可能會更多的事物
如果您正在將布林值用於欄位,請確保該欄位的確只描述了兩種可能的狀態(適用於所有時間,而不僅僅是現在和不久的將來)。通常,列舉、整數或訊息的彈性證明是值得的。
例如,在傳回一系列文章時,開發人員可能需要根據 UX 的目前模擬來指示是否應以兩欄或非兩欄來呈現文章。即使今天只需要一個布林值,也無法阻止 UX 在未來版本中引入兩行文章、三欄文章或四方格文章。
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;
}
請謹慎將狀態新增到會混淆概念的列舉中。
如果一個狀態在枚舉中引入新的維度,或暗示多個應用程式行為,您幾乎肯定需要另一個欄位。
很少使用整數字段作為 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 的消費者來說工作量較大,並且對於閱讀您文件的人來說會感到困惑。您的用戶端也必須考慮編碼:列表是否以逗號分隔?我是否正確逸脫了這個不受信任的資料?數字是十進位嗎?最好讓用戶端傳送實際的訊息或基本類型。它在網路上更緊湊,並且對您的用戶端更清晰。
當您的服務以多種語言獲得用戶端時,情況會變得特別糟糕。現在每個用戶端都必須選擇正確的剖析器或建構器,或者更糟的是,自己寫一個。
更一般地說,請選擇正確的基本類型。請參閱Protocol Buffer 語言指南中的純量值類型表。
在前端 Proto 中傳回 HTML
對於 JavaScript 用戶端來說,在您的 API 欄位中傳回 HTML 或 JSON 是很誘人的。這是將您的 API 綁定到特定 UI 的滑坡。以下是三個具體的危險
- 一個「簡陋」的非網頁用戶端最終會剖析您的 HTML 或 JSON 以取得他們想要的資料,如果您變更格式,這會導致脆弱性;如果他們的剖析有問題,則會導致漏洞。
- 如果該 HTML 從未經過清理,您的網頁用戶端現在很容易受到 XSS 漏洞的攻擊。
- 您傳回的標籤和類別需要特定的樣式表和 DOM 結構。從一個版本到另一個版本,該結構會發生變化,並且您有版本偏差問題的風險,其中 JavaScript 用戶端比伺服器舊,而伺服器傳回的 HTML 不再在舊用戶端上正確呈現。對於經常發布的專案來說,這不是邊緣情況。
除了初始頁面載入之外,通常最好傳回資料並使用用戶端範本在用戶端上建構 HTML。
使用 Web 安全編碼二進位 Proto 序列化在字串中編碼不透明資料
如果您確實將不透明資料編碼在用戶端可見的欄位中(繼續權杖、序列化的 ID、版本資訊等等),請記錄用戶端應將其視為不透明的 Blob。始終使用二進位 proto 序列化,對於這些欄位,切勿使用文字格式或您自己設計的任何內容。當您需要擴充不透明欄位中編碼的資料時,如果您尚未在使用它,您會發現自己正在重新發明 Protocol Buffer 序列化。
定義一個內部 proto 來保存將放入不透明欄位的欄位(即使您只需要一個欄位),將這個內部 proto 序列化為位元組,然後將結果以網頁安全 base-64 編碼到您的字串欄位中。
使用 proto 序列化的一個罕見例外:非常偶爾,從精心建構的替代格式中獲得的緊湊性是值得的。
請勿包含您的客戶端不可能使用的欄位
您向用戶端公開的 API 應該僅用於描述如何與您的系統互動。在其中包含任何其他內容會增加試圖理解它的人的認知負擔。
在回應 proto 中傳回偵錯資料曾經是一種常見的做法,但我們有更好的方法。RPC 回應擴充功能(也稱為「側通道」)可讓您使用一個 proto 描述您的用戶端介面,並使用另一個 proto 描述您的偵錯介面。
同樣地,在回應 proto 中傳回實驗名稱曾經是一種記錄的便利方式——未寫的合約是用戶端會在後續操作中傳回這些實驗。實現相同目的的公認方法是在分析管道中進行記錄聯結。
一個例外
如果您需要持續、即時的分析且機器預算較少,則執行記錄聯結可能會受到限制。在成本是決定性因素的情況下,提前對記錄資料進行反正規化可能會是一種勝利。如果您需要將記錄資料來回傳送給您,請將其作為不透明的 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 的最佳做法是使用不透明的繼續權杖(稱為 next_page_token),並由您序列化然後 WebSafeBase64Escape
(C++) 或 BaseEncoding.base64Url().encode
(Java) 的內部 proto 支援。該內部 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 引入一個欄位,但該欄位稍後可能會有相關的欄位,請提前將其放入自己的訊息中以避免這種情況
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
檔案時,該做法可能並不總是適用。在某些情況下,緊密耦合兩個資訊單元(將一個單元巢狀在另一個單元內部)可能是有意義的。例如,如果您正在建立一組現在看起來相當通用的欄位,但您預計稍後會將專用欄位新增到其中,則巢狀訊息會阻止其他人在這個或其他 .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。
當替代方案是行動裝置或 Web 上的連續往返時,建立一次性 RPC
在網頁或行動用戶端需要在它們之間具有資料相依性的情況下,目前最佳做法是建立新的 RPC,以保護用戶端免受往返的影響。
就行動裝置而言,通常值得讓您的用戶端免去額外往返的成本,方法是將兩個服務方法捆綁到一個新的方法中。對於伺服器對伺服器的呼叫,情況可能不明確;這取決於您的服務對效能的敏感程度以及新方法引入多少認知負擔。
將重複欄位設為訊息,而非純量或列舉
一個常見的演變是單個重複欄位需要變成多個相關的重複欄位。如果您從重複的基本類型開始,您的選擇會受到限制——您可以建立並行的重複欄位,或使用保存這些值的新訊息來定義新的重複欄位,並將用戶端遷移到它。
如果您從重複的訊息開始,則演變會變得非常簡單。
// 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
中的增強欄位是純量或枚舉,則會更難以支援。
這同樣適用於地圖。如果地圖值已經是一個訊息,則將其他欄位新增到地圖值會容易得多,而無需從 map<string, string>
遷移到 map<string, MyProto>
。
一個例外
延遲關鍵應用程式會發現,基本類型的平行陣列比單個訊息陣列更快建構和刪除;如果您使用 [packed=true](省略欄位標籤),它們在網路上也可能更小。分配固定數量的陣列比分配 N 個訊息的工作量更少。好處:在 Proto3 中,打包是自動的;您不需要明確指定它。
使用 Proto 對應
在 Proto3 中引入 Proto3 maps 之前,服務有時會使用具有純量欄位的臨時 KVPair 訊息來公開資料。最終,客戶端會需要更深的結構,並最終設計出需要以某種方式解析的鍵或值。請參閱 不要將資料編碼為字串。
因此,使用(可擴展的)訊息類型來表示值,比單純的設計直接改進。
Maps 已在所有語言中回溯移植到 proto2,因此使用 map<純量, **訊息**>
比為相同目的發明您自己的 KVPair 更好1。
如果您想表示事先不知道其結構的任意資料,請使用 google.protobuf.Any
。
偏好冪等性
在您上方的堆疊中的某處,客戶端可能具有重試邏輯。如果重試是變更,則使用者可能會感到驚訝。重複的評論、建置請求、編輯等對任何人來說都不是好事。
避免重複寫入的一個簡單方法是允許客戶端指定一個由客戶端建立的請求 ID,您的伺服器會對其進行重複資料刪除(例如,內容的雜湊或 UUID)。
留意您的服務名稱,並使其成為全域唯一
服務名稱(也就是您的 .proto
檔案中 service
關鍵字之後的部分)在很多地方都被使用,而不僅僅是產生服務類別名稱。這使得這個名稱比人們想像的更重要。
棘手的是,這些工具隱含地假設您的服務名稱在整個網路上是唯一的。更糟糕的是,它們使用的服務名稱是未限定的服務名稱(例如,MyService
),而不是限定的服務名稱(例如,my_package.MyService
)。
因此,即使您的服務名稱是在特定套件中定義的,採取措施防止服務名稱上的命名衝突也是有意義的。例如,名為 Watcher
的服務很可能會導致問題;類似 MyProjectWatcher
的名稱會更好。
確保每個 RPC 都指定並強制執行(寬鬆的)期限
預設情況下,RPC 沒有逾時。由於請求可能會佔用僅在完成時才會釋放的後端資源,因此設定允許所有行為良好的請求完成的預設期限是一種良好的防禦性做法。過去不強制執行這一點曾對主要服務造成嚴重問題。RPC 客戶端仍然應該在傳出的 RPC 上設定期限,並且當它們使用標準框架時,通常會預設執行此操作。期限可能會,而且通常會被附加到請求的較短期限覆蓋。
設定 deadline
選項可以清楚地將 RPC 期限傳達給您的客戶端,並且標準框架會尊重並強制執行此選項。
rpc Foo(FooRequest) returns (FooResponse) {
option deadline = x; // there is no globally good default
}
選擇期限值尤其會影響您的系統在負載下的行為方式。對於現有服務,在強制執行新的期限以避免破壞客戶端之前,評估現有的客戶端行為至關重要(請諮詢 SRE)。在某些情況下,事後可能無法強制執行較短的期限。
限制請求和回應大小
請求和回應大小應該有界限。我們建議將界限設定在大約 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
隨意將錯誤傳回給其呼叫者,則客戶端將收到 INVALID_ARGUMENT
,因為狀態代碼會在 RPC 邊界傳播。但是,客戶端沒有將任何參數傳遞給 ProductService.GetProducts
。因此,錯誤比沒有用更糟糕:它會造成很大的混亂!
相反,ProductService
應該查詢它在 RPC 邊界收到的錯誤;也就是說,它實作的 ProductService
RPC 處理常式。它應該向使用者傳回有意義的錯誤:如果它從呼叫者收到無效參數,則應該傳回 INVALID_ARGUMENT
。如果下游的某個地方收到無效參數,則應該在將錯誤傳回給呼叫者之前,將 INVALID_ARGUMENT
轉換為 INTERNAL
。
隨意傳播狀態錯誤會導致混亂,這可能會造成非常昂貴的除錯成本。更糟糕的是,它可能會導致隱形的停機,其中每個服務都會轉發客戶端錯誤,而不會觸發任何警示。
一般規則是:在 RPC 邊界,請注意查詢錯誤,並以適當的狀態代碼向呼叫者傳回有意義的狀態錯誤。為了傳達意義,每個 RPC 方法都應該記錄它在哪些情況下傳回哪些錯誤代碼。每個方法的實作都應符合記錄的 API 合約。
為每個方法建立獨特的 Proto
為每個 RPC 方法建立唯一的請求和回應 proto。稍後發現您需要變更頂層請求或回應可能會很昂貴。這包括「空的」回應;建立唯一的空回應 proto,而不是重複使用 廣為人知的 Empty 訊息類型。
重複使用訊息
若要重複使用訊息,請建立共用的「網域」訊息類型,以包含在多個請求和回應 proto 中。根據這些類型而不是請求和回應類型來撰寫您的應用程式邏輯。
這讓您可以彈性地獨立演進您的方法請求/回應類型,但可以共用邏輯子單元的程式碼。
附錄
傳回重複欄位
當重複欄位為空時,客戶端無法判斷該欄位是否只是未由伺服器填入,還是該欄位的後備資料確實為空。換句話說,重複欄位沒有 hasFoo
方法。
將重複欄位包裝在訊息中是取得 hasFoo 方法的簡單方法。
message FooList {
repeated Foo foos;
}
更整體性的解決方案是使用欄位讀取遮罩。如果要求了欄位,則空清單表示沒有資料。如果未要求該欄位,則客戶端應忽略回應中的欄位。
更新重複欄位
更新重複欄位的最糟糕方法是強制客戶端提供替換清單。強制客戶端提供整個陣列的危險有很多。不保留未知欄位的客戶端會導致資料遺失。並行寫入會導致資料遺失。即使這些問題不適用,您的客戶端也需要仔細閱讀您的文件,才能知道如何在伺服器端解譯該欄位。空白欄位是指伺服器不會更新它,還是伺服器會清除它?
修正 #1:使用重複的更新遮罩,允許客戶端在寫入時替換、刪除或插入陣列中的元素,而無需提供整個陣列。
修正 #2:在請求 proto 中建立單獨的附加、替換、刪除陣列。
修正 #3:僅允許附加或清除。您可以透過將重複欄位包裝在訊息中來執行此操作。存在但空的訊息表示清除,否則,任何重複元素都表示附加。
重複欄位中的順序獨立性
一般而言,嘗試避免順序相依性。這是一種額外的脆弱性層。一種特別糟糕的順序相依性類型是平行陣列。平行陣列使客戶端更難以解譯結果,並且在您自己的服務內傳遞兩個相關欄位是不自然的。
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] 將欄位註解為「延遲剖析」。
- 將欄位宣告為位元組並記錄其類型。想要剖析該欄位的客戶端可以手動執行此操作。這種方法存在的危險是,沒有任何措施可以阻止某人在位元組欄位中放入錯誤類型的訊息。您絕對不應該對寫入任何記錄的 proto 執行此操作,因為它會阻止對 proto 進行 PII 審查或因政策或隱私原因進行清理。
包含
map<k,v>
欄位的 proto 的一個陷阱。請勿將它們用作 MapReduce 中的縮減鍵。proto3 地圖項目的線路格式和迭代順序是未指定的,這會導致不一致的地圖分片。 ↩︎