Go 不透明 API 常見問題

關於不透明 API 的常見問題列表。

不透明 API 是 Go 程式語言的 Protocol Buffers 實作的最新版本。舊版本現在稱為 Open Struct API。請參閱 Go Protobuf:新的不透明 API 部落格文章以取得介紹。

這個常見問題解答回答了關於新 API 和遷移過程的常見問題。

建立新的 .proto 檔案時,我應該使用哪個 API?

我們建議您為新的開發專案選擇不透明 API。Protobuf Edition 2024(請參閱 Protobuf 版本總覽)將使不透明 API 成為預設選項。

如何為我的訊息啟用新的不透明 API?

在 Protobuf Edition 2023(撰寫本文時的最新版本)中,您可以透過在您的 .proto 檔案中將 api_level 版本功能設定為 API_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 將預設為不透明 API,這表示您將不再需要額外的匯入或選項。

edition = "2024";

package log;

message LogEntry {  }

Protobuf Edition 2024 的預計發布日期為 2025 年初。

為了您的方便,您也可以使用 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 級別,您需要先將該檔案遷移到版本。

如何啟用延遲解碼?

  1. 遷移您的程式碼以使用不透明實作。
  2. 在應該延遲解碼的 proto 子訊息欄位上設定 [lazy = true] 選項。
  3. 執行您的單元和整合測試,然後部署到預備環境。

延遲解碼會忽略錯誤嗎?

不會。proto.Marshal 始終會驗證線路格式資料,即使解碼延遲到第一次存取也是如此。

我可以在哪裡提問或回報問題?

如果您在使用 open2opaque 遷移工具時發現問題(例如程式碼重寫不正確),請在 open2opaque 問題追蹤器中回報。

如果您在使用 Go Protobuf 時發現問題,請在 Go Protobuf 問題追蹤器中回報。

不透明 API 有哪些優點?

不透明 API 具有許多優點

  • 它使用更有效率的記憶體表示法,從而降低記憶體和垃圾收集成本。
  • 它使延遲解碼成為可能,這可以顯著提升效能。
  • 它修復了許多尖銳的問題。使用不透明 API 時,可以防止因指標位址比較、意外共享或不希望使用的 Go 反射而導致的錯誤。
  • 它透過啟用設定檔驅動的最佳化,使理想的記憶體配置成為可能。

請參閱 Go Protobuf:新的不透明 API 部落格文章,以取得關於這些要點的更多詳細資訊。

Builder 或 Setter 哪個比較快?

一般來說,使用 builder 的程式碼

_ = pb.M_builder{
  F: &val,
}.Build()

比以下等效程式碼慢

m := &pb.M{}
m.SetF(val)

原因如下:

  1. Build() 呼叫會迭代訊息中的所有欄位(即使是未明確設定的欄位),並將它們的值(如果有的話)複製到最終訊息。這種線性效能在具有許多欄位的訊息中很重要。
  2. 可能會有額外的堆積配置 (&val)。
  3. 在存在 oneof 欄位的情況下,builder 可能會顯著變大並使用更多記憶體。Builder 每個 oneof 聯合成員都有一個欄位,而訊息可以將 oneof 本身儲存為單一欄位。

除了執行階段效能之外,如果您關心二進位大小,避免使用 builder 將會減少程式碼。

如何使用 Builder?

Builder 設計為作為使用,並立即呼叫 Build()。避免使用指向 builder 的指標或將 builder 儲存在變數中。

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 訊息時傾向於將 builder 類型傳遞到函式呼叫中。Go proto 訊息是可變的,因此無需將 builder 傳遞到函式呼叫中。只需傳遞 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)

Builder 設計為模仿 Open Struct API 的複合常值建構,而不是作為 proto 訊息的替代表示法。

建議的模式也更有效能。Build() 的預期用途是在 builder 結構常值上直接呼叫它,可以很好地進行最佳化。單獨呼叫 Build() 更難最佳化,因為編譯器可能不容易識別哪些欄位已填入。如果 builder 存活時間更長,那麼像純量這樣的小物件也很可能必須在堆積上配置,然後稍後需要由垃圾收集器釋放。

我應該使用 Builder 還是 Setter?

在建構空的 Protocol Buffer 時,您應該使用 new 或空的複合常值。兩者在 Go 中都是慣用的建構零初始化值的方式,並且比空的 builder 更有效能。

m1 := new(pb.M)
m2 := &pb.M{}
// BAD: avoid: unnecessarily complex
m1 := pb.M_builder{}.Build()

在您需要建構非空的 Protocol Buffer 的情況下,您可以選擇使用 setter 或 builder。兩者都可以,但大多數人會發現 builder 更易讀。如果您撰寫的程式碼需要效能良好,則setter 通常比 builder 稍有效能

// 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)

如果某些欄位在設定之前需要條件邏輯,您可以結合使用 builder 和 setter。

m1 := pb.M1_builder{
    Field1: value1,
}.Build()
if someCondition() {
    m1.SetField2(value2)
    m1.SetField3(value3)
}

我該如何影響 open2opaque 的 Builder 行為?

open2opaque 工具的 --use_builders 旗標可以具有以下值:

  • --use_builders=everywhere:始終使用 builder,沒有例外。
  • --use_builders=tests:僅在測試中使用 builder,否則使用 setter。
  • --use_builders=nowhere:永遠不要使用 builder。

我可以期待多少效能提升?

這很大程度上取決於您的工作負載。以下問題可以引導您探索效能:

  • Go Protobuf 佔用您 CPU 使用率的百分比有多大?在某些工作負載中,例如基於 Protobuf 輸入記錄計算統計資訊的日誌分析管線,Go Protobuf 可能會佔用約 50% 的 CPU 使用率。在這種工作負載中,效能提升可能會非常明顯。另一方面,在 Go Protobuf 中僅佔用 3-5% CPU 使用率的程式中,與其他機會相比,效能提升通常微不足道。
  • 您的程式有多適合延遲解碼?如果從未存取輸入訊息的很大一部分,延遲解碼可以節省大量工作。這種模式通常在代理伺服器(按原樣傳遞輸入)或具有高選擇性的日誌分析管線(根據高階述詞丟棄許多記錄)等工作中遇到。
  • 您的訊息定義是否包含許多具有明確存在的基礎欄位?不透明 API 對於整數、布林值、列舉和浮點數等基礎欄位使用更有效率的記憶體表示法,但不適用於字串、重複欄位或子訊息。

Proto2、Proto3 和版本與不透明 API 有何關聯?

術語 proto2 和 proto3 指的是您 .proto 檔案中不同的語法版本。Protobuf 版本是 proto2 和 proto3 的後繼者。

不透明 API 僅影響 .pb.go 檔案中的產生程式碼,而不影響您在 .proto 檔案中撰寫的內容。

不透明 API 的運作方式相同,與您的 .proto 檔案使用的語法或版本無關。但是,如果您想在每個檔案的基礎上選擇不透明 API(而不是在執行 protoc 時使用命令列旗標),您必須先將檔案遷移到版本。有關詳細資訊,請參閱「如何為我的訊息啟用新的不透明 API?」

為什麼只變更基本欄位的記憶體配置?

公告部落格文章的「不透明結構使用更少記憶體」章節解釋說:

這種效能提升 [更有效率地建模欄位存在] 很大程度上取決於您的 protobuf 訊息形狀:此變更僅影響整數、布林值、列舉和浮點數等基礎欄位,但不適用於字串、重複欄位或子訊息。

一個自然的後續問題是,為什麼字串、重複欄位和子訊息在不透明 API 中仍然是指標。答案是雙重的。

考量 1:記憶體用量

將子訊息表示為值而不是指標會增加記憶體用量:每個 Protobuf 訊息類型都攜帶內部狀態,即使子訊息實際上未設定,也會消耗記憶體。

對於字串和重複欄位,情況更微妙。讓我們比較使用字串值與使用字串指標的記憶體用量:

Go 變數類型已設定?字組#位元組
string2 (資料, 長度)16
string2 (資料, 長度)16
*string1 (資料) + 2 (資料, 長度)24
*string1 (資料)8

(slice 的情況類似,但 slice 標頭需要 3 個字組:資料、長度、容量。)

如果您的字串欄位絕大多數都未設定,則使用指標可以節省 RAM。當然,這種節省是以在程式中引入更多配置和指標為代價的,這會增加垃圾收集器的負擔。

不透明 API 的優點是我們可以變更表示法,而無需對使用者程式碼進行任何變更。目前的記憶體配置在我們引入它時對我們來說是最佳的,但如果我們今天或 5 年後進行測量,也許我們會選擇不同的配置。

如公告部落格文章的「使理想的記憶體配置成為可能」章節所述,我們的目標是在未來根據每個工作負載做出這些最佳化決策。

考量 2:延遲解碼

除了記憶體用量考量之外,還有另一個限制:啟用延遲解碼的欄位必須以指標表示。

Protobuf 訊息對於並行存取是安全的(但對於並行變更則不安全),因此如果兩個不同的 goroutine 觸發延遲解碼,它們需要以某種方式協調。這種協調是透過使用 sync/atomic 套件實作的,該套件可以原子地更新指標,但不能原子地更新 slice 標頭(超過一個字組)。

雖然 protoc 目前僅允許對(非重複)子訊息進行延遲解碼,但這種推理適用於所有欄位類型。