Proto 最佳實踐

分享編寫 Protocol Buffers 的經過驗證的最佳實踐。

客戶端和伺服器永遠不會在同一時間完全更新——即使你嘗試同時更新它們。其中之一可能會被回滾。不要假設你可以進行破壞性更改,並且因為客戶端和伺服器同步而沒問題。

不要 重複使用標籤號

絕不重複使用標籤號。它會破壞反序列化。即使你認為沒有人使用該欄位,也不要重複使用標籤號。如果更改曾經生效,你的 proto 的序列化版本可能存在於某個日誌中。或者另一個伺服器中可能存在舊程式碼,這會造成破壞。

為已刪除的欄位保留標籤號

當你刪除不再使用的欄位時,請保留其標籤號,以免將來有人意外重複使用它。只需 reserved 2, 3; 就足夠了。不需要型別(這可以減少依賴!)。你還可以保留名稱以避免回收現已刪除的欄位名:reserved "foo", "bar";

為已刪除的列舉值保留數字

當你刪除不再使用的列舉值時,請保留其數字,以免將來有人意外重複使用它。只需 reserved 2, 3; 就足夠了。你還可以保留名稱以避免回收現已刪除的值名:reserved "FOO", "BAR";

將新的列舉別名放在最後

當你新增新的列舉別名時,將新名稱放在最後,以便服務有時間接收它。

要安全地刪除原始名稱(如果它用於交換,而它不應該),你必須執行以下操作

  • 將新名稱新增到舊名稱下方並棄用舊名稱(序列化器將繼續使用舊名稱)

  • 在所有解析器都推出了模式之後,交換兩個名稱的順序(序列化器將開始使用新名稱,解析器接受兩者)

  • 在所有序列化器都擁有該版本的模式之後,你可以刪除已棄用的名稱。

注意: 雖然理論上客戶端不應該將舊名稱用於交換,但遵循上述步驟仍然是禮貌的,特別是對於廣泛使用的列舉名稱。

不要 更改欄位的型別

幾乎不要更改欄位的型別;它會破壞反序列化,就像重複使用標籤號一樣。protobuf 文件概述了少數可以接受的情況(例如,在 int32uint32int64bool 之間轉換)。但是,更改欄位的訊息型別將導致破壞,除非新訊息是舊訊息的超集。

不要 新增必需欄位

絕不新增必需欄位,而是新增 // required 來記錄 API 契約。必需欄位被許多人認為是有害的,以至於它們完全從 proto3 中刪除。使所有欄位可選或重複。你永遠不知道訊息型別會持續多久,以及是否有人在四年後被迫用空字串或零填充你的必需欄位,而此時它在邏輯上不再是必需的,但 proto 仍然表示它是必需的。

對於 proto3,沒有 required 欄位,因此此建議不適用。

不要 建立包含大量欄位的訊息

不要建立包含“大量”(數百個)欄位的訊息。在 C++ 中,無論是否填充,每個欄位都會給記憶體物件大小增加大約 65 位(8 位元組用於指標,如果欄位宣告為可選,則在位域中再增加一位用於跟蹤欄位是否已設定)。當你的 proto 變得太大時,生成的程式碼甚至可能無法編譯(例如,在 Java 中,方法的大小存在硬限制)。

在列舉中包含一個未指定的值

列舉應在宣告中包含一個預設的 FOO_UNSPECIFIED 值作為第一個值。當新的值新增到列舉中時,舊客戶端會將該欄位視為未設定,並且 getter 將返回預設值或如果不存在預設值則返回第一個宣告的值。為了與 proto 列舉保持一致的行為,第一個宣告的列舉值應該是一個預設的 FOO_UNSPECIFIED 值,並且應該使用標籤 0。將此預設值宣告為一個有語義意義的值可能很誘人,但作為一般規則,不要這樣做,以幫助隨著時間新增新的列舉值時協議的演變。在容器訊息下宣告的所有列舉值都在同一個 C++ 名稱空間中,因此用列舉的名稱作為未指定值的字首以避免編譯錯誤。如果你永遠不需要跨語言常量,int32 將保留未知值並生成更少的程式碼。請注意,proto 列舉要求第一個值為零,並且可以往返(反序列化,序列化)未知列舉值。

不要 將 C/C++ 宏常量用於列舉值

使用已由 C++ 語言定義的詞——特別是其標頭檔案(如 math.h)中定義的詞,如果其中一個頭檔案的 #include 語句出現在 .proto.h 之前,可能會導致編譯錯誤。避免使用宏常量,如“NULL”、“NAN”和“DOMAIN”作為列舉值。

使用知名型別和常用型別

強烈建議使用以下常見共享型別。例如,當已經存在完全合適的通用型別時,不要在程式碼中使用 int32 timestamp_seconds_since_epochint64 timeout_millis

  • 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 是一個年份中的月份(例如,四月)。
  • dayofweek 是一週中的某一天(例如,星期一)。
  • timeofday 是一天中的時間(例如,10:42:23)。
  • field_mask 是一組符號欄位路徑(例如,f.b.d)。
  • postal_address 是一個郵政地址(例如,1600 Amphitheatre Parkway Mountain View, CA 94043 USA)。
  • money 是一個金額及其貨幣型別(例如,42 美元)。
  • latlng 是一對經緯度(例如,37.386051 緯度和 -122.083855 經度)。
  • color 是 RGBA 顏色空間中的一種顏色。

注意: 儘管“知名型別”(如 DurationTimestamp)包含在 Protocol Buffers 編譯器中,但“常用型別”(如 DateMoney)則不包含。要使用常用型別,你可能需要新增對 googleapis 倉庫的依賴。

在單獨的檔案中定義訊息型別

定義 proto 模式時,每個檔案應包含一個訊息、列舉、擴充套件、服務或一組迴圈依賴。這使得重構更容易。當檔案分離時,移動檔案比從包含其他訊息的檔案中提取訊息要容易得多。遵循此實踐還有助於使 proto 模式檔案更小,從而提高可維護性。

如果它們將在你的專案之外廣泛使用,請考慮將它們放在自己的檔案中,不帶任何依賴項。這樣,任何人都可以輕鬆使用這些型別,而無需引入其他 proto 檔案中的傳遞依賴項。

有關此主題的更多資訊,請參閱1-1-1 規則

不要 更改欄位的預設值

幾乎不要更改 proto 欄位的預設值。這會導致客戶端和伺服器之間的版本偏差。當客戶端和伺服器的構建跨越 proto 更改時,讀取未設定值的客戶端將看到與讀取相同未設定值的伺服器不同的結果。Proto3 取消了設定預設值的能力。

不要 從重複到標量

雖然這不會導致崩潰,但你會丟失資料。對於 JSON,重複性不匹配將丟失整個*訊息*。對於數字 proto3 欄位和 proto2 packed 欄位,從重複到標量將丟失該*欄位*中的所有資料。對於非數字 proto3 欄位和未註解的 proto2 欄位,從重複到標量將導致最後反序列化的值“獲勝”。

在 proto2 和 proto3 中,從標量到重複是允許的,前提是 [packed=false],因為對於二進位制序列化,標量值會變成一個單元素列表。

遵循生成程式碼的樣式指南

Proto 生成的程式碼在正常程式碼中被引用。確保 .proto 檔案中的選項不會導致生成違反樣式指南的程式碼。例如

不要 將文字格式訊息用於交換

基於文字的序列化格式,如文字格式和 JSON,將欄位和列舉值表示為字串。因此,當欄位或列舉值被重新命名,或新增新欄位、列舉值或擴充套件時,使用舊程式碼以這些格式反序列化協議緩衝區將會失敗。儘可能使用二進位制序列化進行資料交換,而文字格式僅用於人工編輯和除錯。

如果你在 API 中使用轉換為 JSON 的 proto 或儲存資料,你可能根本無法安全地重新命名欄位或列舉。

絕不 依賴跨構建的序列化穩定性

proto 序列化的穩定性在跨二進位制檔案或同一二進位制檔案的跨構建之間不保證。例如,在構建快取鍵時不要依賴它。

不要 在與其他程式碼相同的 Java 包中生成 Java Protobuf

將 Java proto 原始檔生成到與你手動編寫的 Java 原始檔不同的包中。packagejava_packagejava_alt_api_package 選項控制生成 Java 原始檔的位置。確保手動編寫的 Java 原始檔不與它們位於同一包中。一種常見做法是將你的 proto 生成到專案中的 proto 子包中,該子包包含這些 proto(即,沒有手動編寫的原始檔)。

從 .proto 包派生 Java 包(如果被覆蓋)

設定 java_package 可能會在生成程式碼中引入 .proto 語義中不存在的完全限定名稱衝突。例如,這兩個檔案可能在生成程式碼中建立衝突,儘管原始模式中的完全限定名稱沒有衝突

package x;
option java_package = "com.example.proto";
message Abc {}
package y;
option java_package = "com.example.proto";
message Abc {}

為避免這些問題,您絕不應在設定了不同 .proto 包的兩個檔案中設定相同的 java_package

最佳實踐是建立一種本地命名模式,其中包名稱派生自 .proto 包。例如,一個包含 package y 的最佳實踐檔案可能一致地設定 option java_package = "com.example.proto.y"

此指南也適用於任何其他可能存在包覆蓋的特定語言選項。

避免將語言關鍵字用於欄位名

如果訊息、欄位、列舉或列舉值的名稱是讀取/寫入該欄位的語言中的關鍵字,則 protobuf 可能會更改欄位名稱,並且訪問它們的方式可能與普通欄位不同。例如,請參閱關於 Python 的此警告

你也應避免在檔案路徑中使用關鍵字,因為這也可能導致問題。

為 RPC API 和儲存使用不同的訊息

為 API 和長期儲存重用相同的訊息可能看起來很方便,減少了樣板檔案和訊息之間轉換的開銷。

然而,長期儲存和即時 RPC 服務的需求往往會隨著時間的推移而分歧。即使最初它們大部分是重複的,使用不同的型別也為你提供了更改儲存格式的自由,而不會影響你的外部客戶端。分層你的程式碼,以便模組處理客戶端 proto、儲存 proto 或轉換。

維護翻譯層需要成本,但一旦你有了客戶端並必須進行第一次儲存更改,它很快就會得到回報。

不要 將布林值用於現在只有兩種狀態,但以後可能更多狀態的事物

如果你將布林值用於某個欄位,請確保該欄位確實只描述兩種可能的狀態(永遠如此,而不僅僅是現在和不久的將來)。使用列舉的未來靈活性通常是值得的,即使它最初只有兩個值。

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;
}

使用 java_outer_classname(2024 版本之前)

每個 proto 模式定義檔案都應將選項 java_outer_classname 設定為轉換為 TitleCase 且刪除“.”的 .proto 檔名。例如,檔案 student_record_request.proto 應設定

option java_outer_classname = "StudentRecordRequestProto";

2024 版本檔案的預設行為已與此建議保持一致,因此在使用 2024 版本或更高版本時無需設定任何選項。