Go Opaque API FAQ

關於 Opaque API 的常見問題解答。

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。

如何啟用延遲解碼?

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

延遲解碼時會忽略錯誤嗎?

否。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)

慢,原因如下:

  1. Build() 呼叫會迭代訊息中的所有欄位(即使是未顯式設定的欄位),並將它們的值(如果有)複製到最終訊息中。對於具有許多欄位的訊息,這種線性效能很重要。
  2. 存在潛在的額外堆分配(&val)。
  3. 當存在 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)位元組數
string2(資料,長度)16
string2(資料,長度)16
*string1(資料)+ 2(資料,長度)24
*string1(資料)8

(情況對於切片也類似,但切片頭需要 3 個字:資料、長度、容量。)

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

Opaque API 的優勢在於,我們可以在不更改使用者程式碼的情況下更改表示。當前記憶體佈局在我們引入它時是最優的,但如果我們今天或 5 年後進行測量,也許我們會選擇不同的佈局。

正如 公告部落格文章的“實現理想記憶體佈局”部分中所述,我們將來打算在每個工作負載的基礎上做出這些最佳化決策。

考慮因素 2:延遲解碼

除了記憶體使用方面的考慮,還有另一個限制:已啟用 延遲解碼 的欄位必須由指標表示。

Protobuf 訊息對於併發訪問是安全的(但不是併發修改),因此如果兩個不同的 goroutine 觸發延遲解碼,它們需要以某種方式進行協調。這種協調是透過使用 sync/atomic來實現的,該包可以原子地更新指標,但不能更新超過一個 的切片頭。

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