Go Opaque API FAQ
Opaque API 是 Protocol Buffers Go 語言實現的最新版本。舊版本現稱為 Open Struct API。有關介紹,請參閱 Go Protobuf:新的 Opaque API 部落格文章。
本 FAQ 回答了關於新 API 和遷移過程的常見問題。
建立新的 .proto 檔案時應使用哪個 API?
我們建議您為新開發選擇 Opaque API。
Protobuf Edition 2024(請參閱 Protobuf Editions 概述)已將 Opaque API 設定為預設。
如何為我的訊息啟用新的 Opaque API?
從 Protobuf Edition 2023 開始,您可以透過在 .proto 檔案中將 api_level editions 功能設定為 API_OPAQUE 來選擇 Opaque API。這可以按檔案或按訊息設定。
edition = "2023";
package log;
import "google/protobuf/go_features.proto";
option features.(pb.go).api_level = API_OPAQUE;
message LogEntry { … }
Protobuf Edition 2024 預設使用 Opaque API,這意味著您不再需要額外的匯入或選項。
edition = "2024";
package log;
message LogEntry { … }
為方便起見,您還可以使用 protoc 命令列標誌覆蓋預設的 API 級別:
protoc […] --go_opt=default_api_level=API_HYBRID
要為特定檔案 (而不是所有檔案) 覆蓋預設 API 級別,請使用 apilevelM 對映標誌 (類似於用於匯入路徑的 M 標誌):
protoc […] --go_opt=apilevelMhello.proto=API_HYBRID
命令列標誌也適用於仍使用 proto2 或 proto3 語法的 .proto 檔案,但如果您想在 .proto 檔案中選擇 API 級別,則需要先將該檔案遷移到 editions。
如何啟用延遲解碼?
- 將您的程式碼遷移到使用 Opaque 實現。
- 在應延遲解碼的 proto 子訊息欄位上設定
[lazy = true]選項。 - 執行您的單元測試和整合測試,然後部署到暫存環境。
延遲解碼時會忽略錯誤嗎?
否。proto.Marshal 始終會驗證 wire format 資料,即使解碼被推遲到第一次訪問。
在哪裡可以提問或報告問題?
如果您在 open2opaque 遷移工具中發現問題(例如程式碼重寫不正確),請在 open2opaque 問題跟蹤器 中報告。
如果您在 Go Protobuf 中發現問題,請在 Go Protobuf 問題跟蹤器 中報告。
Opaque API 有哪些優點?
Opaque API 帶來了許多好處。
- 它使用了更高效的記憶體表示,從而降低了記憶體和垃圾回收的成本。
- 它使得延遲解碼成為可能,這可以顯著提高效能。
- 它修復了許多棘手的問題。在使用 Opaque API 時,可以避免因指標地址比較、意外共享或不當使用 Go 反射而導致的錯誤。
- 它透過啟用基於配置檔案的最佳化,實現了理想的記憶體佈局。
有關這些方面的更多詳細資訊,請參閱 Go Protobuf:新的 Opaque API 部落格文章。
構建器還是 Setter 更快?
通常,使用構建器的程式碼
_ = pb.M_builder{
F: &val,
}.Build()
比以下等效程式碼
m := &pb.M{}
m.SetF(val)
慢,原因如下:
Build()呼叫會迭代訊息中的所有欄位(即使是未顯式設定的欄位),並將它們的值(如果有)複製到最終訊息中。對於具有許多欄位的訊息,這種線性效能很重要。- 存在潛在的額外堆分配(
&val)。 - 當存在 oneof 欄位時,構建器可能要大得多,並且使用更多的記憶體。構建器為每個 oneof 聯合成員都有一個欄位,而訊息可以將 oneof 本身儲存為單個欄位。
除了執行時效能之外,如果二進位制檔案大小對您來說很重要,避免使用構建器將導致程式碼更少。
如何使用構建器?
構建器設計為用作*值*,並立即呼叫 Build()。避免使用構建器的指標或將構建器儲存在變數中。
m := pb.M_builder{
// ...
}.Build()
// BAD: Avoid using a pointer
m := (&pb.M_builder{
// ...
}).Build()
// BAD: avoid storing in a variable
b := pb.M_builder{
// ...
}
m := b.Build()
在某些其他語言中,Proto 訊息是不可變的,因此使用者在構造 proto 訊息時傾向於將構建器型別傳遞給函式呼叫。Go proto 訊息是可變的,因此無需將構建器傳遞給函式呼叫。只需傳遞 proto 訊息即可。
// BAD: avoid passing a builder around
func populate(mb *pb.M_builder) {
mb.Field1 = proto.Int32(4711)
//...
}
// ...
mb := pb.M_builder{}
populate(&mb)
m := mb.Build()
func populate(mb *pb.M) {
mb.SetField1(4711)
//...
}
// ...
m := &pb.M{}
populate(m)
構建器旨在模仿 Open Struct API 的複合字面量構造,而不是作為 proto 訊息的替代表示。
推薦的模式也更具效能。Build() 在構建器結構體字面量上直接呼叫的預期用途可以很好地最佳化。單獨呼叫 Build() 更難最佳化,因為編譯器可能無法輕鬆識別哪些欄位已填充。如果構建器存活時間更長,小物件(如標量)很可能需要分配在堆上,之後需要由垃圾回收器釋放。
應使用構建器還是 Setter?
在構造空的 protocol buffer 時,您應該使用 new 或空的複合字面量。兩者在 Go 中都可以慣用地構造零初始化的值,並且比空的構建器效能更高。
m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()
在需要構造非空 protocol buffer 的情況下,您可以選擇使用 Setter 或使用構建器。兩者都可以,但大多數人會覺得構建器更易讀。如果您編寫的程式碼需要高效能,Setter 通常比構建器效能略高。
// Recommended: using builders
m1 := pb.M1_builder{
Submessage: pb.M2_builder{
Submessage: pb.M3_builder{
String: proto.String("hello world"),
Int: proto.Int32(42),
}.Build(),
Bytes: []byte("hello"),
}.Build(),
}.Build()
// Also okay: using setters
m3 := &pb.M3{}
m3.SetString("hello world")
m3.SetInt(42)
m2 := &pb.M2{}
m2.SetSubmessage(m3)
m2.SetBytes([]byte("hello"))
m1 := &pb.M1{}
m1.SetSubmessage(m2)
您可以在需要對某些欄位進行條件邏輯處理後再設定的情況下,結合使用構建器和 Setter。
m1 := pb.M1_builder{
Field1: value1,
}.Build()
if someCondition() {
m1.SetField2(value2)
m1.SetField3(value3)
}
如何影響 open2opaque 的構建器行為?
open2opaque 工具的 --use_builders 標誌可以具有以下值:
--use_builders=everywhere:始終使用構建器,無例外。--use_builders=tests:僅在測試中使用構建器,否則使用 Setter。--use_builders=nowhere:從不使用構建器。
可以期待多大的效能提升?
這在很大程度上取決於您的工作負載。以下問題可以指導您的效能探索:
- 您的 CPU 使用率中有多少百分比是 Go Protobuf?某些工作負載,例如基於 Protobuf 輸入記錄計算統計資訊的日誌分析管道,可能花費 50% 的 CPU 使用率在 Go Protobuf 上。在這種工作負載中,效能改進可能會非常明顯。另一方面,在 Go Protobuf 只佔 CPU 使用率 3-5% 的程式中,效能改進通常與其他機會相比微不足道。
- 您的程式對延遲解碼的適應性如何?如果大部分輸入訊息從未被訪問過,延遲解碼可以節省大量工作。這種模式通常出現在代理伺服器(按原樣傳遞輸入)或具有高選擇性的日誌分析管道(根據高階謂詞丟棄許多記錄)等作業中。
- 您的訊息定義是否包含許多具有顯式存在的原生欄位?Opaque API 對整數、布林值、列舉和浮點數等原生欄位使用更有效的記憶體表示,但對字串、重複欄位或子訊息不適用。
Proto2、Proto3 和 Editions 與 Opaque API 有何關係?
proto2 和 proto3 指的是 .proto 檔案中的不同語法版本。Protobuf Editions 是 proto2 和 proto3 的後繼者。
Opaque API 隻影響 .pb.go 檔案中的生成程式碼,而不影響您在 .proto 檔案中編寫的程式碼。
Opaque API 的工作方式相同,與您的 .proto 檔案使用哪種語法或版本無關。但是,如果您想按檔案選擇 Opaque API(而不是在執行 protoc 時使用命令列標誌),則必須先將檔案遷移到 editions。有關詳細資訊,請參閱 如何為我的訊息啟用新的 Opaque API?。
為什麼只更改基本欄位的記憶體佈局?
關於“Opaque 結構體使用記憶體更少”的 公告部落格文章的“Opaque 結構體使用記憶體更少”部分解釋了:
這種效能改進(更有效地模擬欄位存在性)很大程度上取決於您的 protobuf 訊息形狀:更改僅影響整數、布林值、列舉和浮點數等原生欄位,而不影響字串、重複欄位或子訊息。
自然而然的後續問題是,為什麼字串、重複欄位和子訊息在 Opaque API 中仍然是指標。答案是雙重的。
考慮因素 1:記憶體使用
將子訊息表示為值而不是指標會增加記憶體使用量:每種 Protobuf 訊息型別都包含內部狀態,即使子訊息未實際設定,這些狀態也會佔用記憶體。
對於字串和重複欄位,情況更加微妙。讓我們比較一下使用字串值與使用字串指標的記憶體使用情況:
| Go 變數型別 | 已設定? | 字(Word) | 位元組數 |
|---|---|---|---|
string | 是 | 2(資料,長度) | 16 |
string | 否 | 2(資料,長度) | 16 |
*string | 是 | 1(資料)+ 2(資料,長度) | 24 |
*string | 否 | 1(資料) | 8 |
(情況對於切片也類似,但切片頭需要 3 個字:資料、長度、容量。)
如果您的字串欄位絕大多數未設定,則使用指標可以節省 RAM。當然,這種節省是以引入更多分配和指標到程式中為代價的,這會增加垃圾回收器的負擔。
Opaque API 的優勢在於,我們可以在不更改使用者程式碼的情況下更改表示。當前記憶體佈局在我們引入它時是最優的,但如果我們今天或 5 年後進行測量,也許我們會選擇不同的佈局。
正如 公告部落格文章的“實現理想記憶體佈局”部分中所述,我們將來打算在每個工作負載的基礎上做出這些最佳化決策。
考慮因素 2:延遲解碼
除了記憶體使用方面的考慮,還有另一個限制:已啟用 延遲解碼 的欄位必須由指標表示。
Protobuf 訊息對於併發訪問是安全的(但不是併發修改),因此如果兩個不同的 goroutine 觸發延遲解碼,它們需要以某種方式進行協調。這種協調是透過使用 sync/atomic 包來實現的,該包可以原子地更新指標,但不能更新超過一個 字 的切片頭。
雖然 protoc 目前只允許對(非重複)子訊息進行延遲解碼,但這種推理適用於所有欄位型別。