Go 部落格

Go Protobuf:新的不透明 API

Michael Stapelberg
2024 年 12 月 16 日

[Protocol Buffers (Protobuf) 是 Google 的語言無關資料交換格式。參見 protobuf.dev。]

早在 2020 年 3 月,我們就釋出了 google.golang.org/protobuf 模組,這是 Go Protobuf API 的一次重大改版。該包引入了一等公民反射支援、一個dynamicpb 實現以及用於簡化測試的protocmp 包。

該版本釋出了一個具有新 API 的新 Protobuf 模組。今天,我們為生成的程式碼釋出了一個額外的 API,即由協議編譯器 (protoc) 生成的 .pb.go 檔案中的 Go 程式碼。本博文將解釋我們建立新 API 的動機,並向您展示如何在專案中使用它。

需要明確的是:我們不會移除任何內容。我們將繼續支援現有生成的程式碼 API,就像我們仍然支援舊的 Protobuf 模組一樣(透過包裝 google.golang.org/protobuf 實現)。Go 致力於向後相容,這也適用於 Go Protobuf!

背景:(現有)開放結構 API

我們現在將現有 API 稱為開放結構 API,因為生成的結構體型別可以被直接訪問。在下一節中,我們將看到它與新的不透明 API 有何不同。

要使用 Protocol Buffers,您首先需要建立一個 .proto 定義檔案,如下所示:

edition = "2023";  // successor to proto2 and proto3

package log;

message LogEntry {
  string backend_server = 1;
  uint32 request_size = 2;
  string ip_address = 3;
}

然後,您需要執行協議編譯器 (protoc) 來生成如下程式碼(在一個 .pb.go 檔案中):

package logpb

type LogEntry struct {
  BackendServer *string
  RequestSize   *uint32
  IPAddress     *string
  // …internal fields elided…
}

func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) GetRequestSize() uint32   { … }
func (l *LogEntry) GetIPAddress() string     { … }

現在,您可以從 Go 程式碼中匯入生成的 logpb 包,並呼叫像 proto.Marshal 這樣的函式,將 logpb.LogEntry 訊息編碼為 Protobuf 線格式。

您可以在生成的程式碼 API 文件中找到更多詳細資訊。

(現有)開放結構 API:欄位存在性

此生成程式碼的一個重要方面是如何模擬欄位存在性(欄位是否已設定)。例如,上面的示例使用指標來模擬存在性,因此您可以將 BackendServer 欄位設定為

  1. proto.String("zrh01.prod"):欄位已設定幷包含“zrh01.prod”。
  2. proto.String(""):欄位已設定(非 nil 指標)但包含空值。
  3. nil 指標:欄位未設定。

如果您習慣於生成的程式碼不包含指標,那麼您可能正在使用以 syntax = "proto3" 開頭的 .proto 檔案。欄位存在性的行為多年來一直在變化:

新的不透明 API

我們建立了新的不透明 API,以將生成的程式碼 API 與底層的記憶體表示分離開來。 (現有)開放結構 API 沒有這種分離:它允許程式直接訪問 Protobuf 訊息記憶體。例如,可以使用 flag 包將命令列標誌值解析到 Protobuf 訊息欄位中。

var req logpb.LogEntry
flag.StringVar(&req.BackendServer, "backend", os.Getenv("HOST"), "…")
flag.Parse() // fills the BackendServer field from -backend flag

這種緊密耦合的問題在於,我們永遠無法改變 Protobuf 訊息在記憶體中的佈局方式。解除此限制可以實現許多實現上的改進,我們將在下文看到。

新的不透明 API 有什麼變化?上述示例中的生成程式碼將發生如下變化:

package logpb

type LogEntry struct {
  xxx_hidden_BackendServer *string // no longer exported
  xxx_hidden_RequestSize   uint32  // no longer exported
  xxx_hidden_IPAddress     *string // no longer exported
  // …internal fields elided…
}

func (l *LogEntry) GetBackendServer() string { … }
func (l *LogEntry) HasBackendServer() bool   { … }
func (l *LogEntry) SetBackendServer(string)  { … }
func (l *LogEntry) ClearBackendServer()      { … }
// …

使用不透明 API,結構體欄位是隱藏的,無法再直接訪問。取而代之的是,新的訪問器方法允許獲取、設定或清除欄位。

不透明結構體佔用更少的記憶體

我們對記憶體佈局所做的一項更改是更有效地模擬基本欄位的欄位存在性。

  • (現有)開放結構 API 使用指標,這為欄位的空間成本增加了 64 位字。
  • 不透明 API 使用位欄位,每個欄位需要一位(忽略填充開銷)。

使用更少的變數和指標還可以降低分配器和垃圾回收器的負載。

效能提升在很大程度上取決於協議訊息的形狀:更改僅影響整數、布林值、列舉和浮點數等基本欄位,而不影響字串、重複欄位或子訊息(因為對於這些型別利潤較低)。

我們的基準測試結果顯示,具有少量基本欄位的訊息的效能與以前一樣好,而具有更多基本欄位的訊息的解碼分配次數則顯著減少。

             │ Open Struct API │             Opaque API             │
             │    allocs/op    │  allocs/op   vs base               │
Prod#1          360.3k ± 0%       360.3k ± 0%  +0.00% (p=0.002 n=6)
Search#1       1413.7k ± 0%       762.3k ± 0%  -46.08% (p=0.002 n=6)
Search#2        314.8k ± 0%       132.4k ± 0%  -57.95% (p=0.002 n=6)

減少分配也使得解碼 Protobuf 訊息更加高效。

             │ Open Struct API │             Opaque API            │
             │   user-sec/op   │ user-sec/op  vs base              │
Prod#1         55.55m ± 6%        55.28m ± 4%  ~ (p=0.180 n=6)
Search#1       324.3m ± 22%       292.0m ± 6%  -9.97% (p=0.015 n=6)
Search#2       67.53m ± 10%       45.04m ± 8%  -33.29% (p=0.002 n=6)

(所有測量均在 AMD Castle Peak Zen 2 上進行。ARM 和 Intel CPU 上的結果類似。)

注意:具有隱式存在性的 proto3 同樣不使用指標,因此如果您是從 proto3 遷移過來的,將不會看到效能提升。如果您出於效能原因而使用隱式存在性,放棄區分空欄位和未設定欄位的便利性,那麼不透明 API 現在可以在沒有效能損失的情況下使用顯式存在性。

動機:延遲解碼

延遲解碼是一種效能最佳化,其中子訊息的內容在首次訪問時解碼,而不是在 proto.Unmarshal 期間解碼。透過避免不必要地解碼從未訪問過的欄位,延遲解碼可以提高效能。

(現有)開放結構 API 無法安全地支援延遲解碼。雖然開放結構 API 提供了 getter,但讓(未解碼的)結構體欄位暴露出來會非常容易出錯。為了確保解碼邏輯在欄位首次訪問之前立即執行,我們必須使欄位私有化,並透過 getter 和 setter 函式來協調對其的所有訪問。

這種方法使得使用不透明 API 實現延遲解碼成為可能。當然,並非所有工作負載都能從此最佳化中受益,但對於那些受益的,結果可能非常顯著:我們已經看到了日誌分析管道,它們根據頂級訊息條件(例如,`backend_server` 是否是執行新 Linux 核心版本的機器之一)來丟棄訊息,並且可以跳過對深度巢狀訊息子樹的解碼。

例如,這是我們包含的微基準測試的結果,它演示了延遲解碼如何節省超過 50% 的工作量和超過 87% 的分配!

                  │   nolazy    │                lazy                │
                  │   sec/op    │   sec/op     vs base               │
Unmarshal/lazy-24   6.742µ ± 0%   2.816µ ± 0%  -58.23% (p=0.002 n=6)

                  │    nolazy    │                lazy                 │
                  │     B/op     │     B/op      vs base               │
Unmarshal/lazy-24   3.666Ki ± 0%   1.814Ki ± 0%  -50.51% (p=0.002 n=6)

                  │   nolazy    │               lazy                │
                  │  allocs/op  │ allocs/op   vs base               │
Unmarshal/lazy-24   64.000 ± 0%   8.000 ± 0%  -87.50% (p=0.002 n=6)

動機:減少指標比較錯誤

使用指標模擬欄位存在性會引起與指標相關的錯誤。

考慮一個在 LogEntry 訊息中宣告的列舉:

message LogEntry {
  enum DeviceType {
    DESKTOP = 0;
    MOBILE = 1;
    VR = 2;
  };
  DeviceType device_type = 1;
}

一個簡單的錯誤是像這樣比較 device_type 列舉欄位:

if cv.DeviceType == logpb.LogEntry_DESKTOP.Enum() { // incorrect!

您發現錯誤了嗎?條件比較的是記憶體地址而不是值。因為 Enum() 訪問器每次呼叫都會分配一個新變數,所以條件永遠不會為真。檢查應該改為:

if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {

新的不透明 API 可防止此錯誤:由於欄位是隱藏的,所有訪問都必須透過 getter。

動機:減少意外共享錯誤

讓我們考慮一個稍微複雜一點的與指標相關的錯誤。假設您正在嘗試穩定一個在高負載下會失敗的 RPC 服務。請求中介軟體的以下部分看起來是正確的,但當只有一位客戶傳送大量請求時,整個服務仍然會崩潰:

logEntry.IPAddress = req.IPAddress
logEntry.BackendServer = proto.String(hostname)
// The redactIP() function redacts IPAddress to 127.0.0.1,
// unexpectedly not just in logEntry *but also* in req!
go auditlog(redactIP(logEntry))
if quotaExceeded(req) {
    // BUG: All requests end up here, regardless of their source.
    return fmt.Errorf("server overloaded")
}

您發現錯誤了嗎?第一行意外地複製了指標(從而在 logEntryreq 訊息之間共享了所指向的變數),而不是其值。它應該改為:

logEntry.IPAddress = proto.String(req.GetIPAddress())

新的不透明 API 可防止此問題,因為 setter 接受一個值(string)而不是指標。

logEntry.SetIPAddress(req.GetIPAddress())

動機:修復尖角:反射

要編寫不僅適用於特定訊息型別(例如 logpb.LogEntry),而且適用於任何訊息型別的程式碼,就需要某種形式的反射。上一個示例使用了一個函式來遮蔽 IP 地址。為了處理任何型別的訊息,它可以定義為 func redactIP(proto.Message) proto.Message { … }

多年前,實現像 redactIP 這樣的函式,您的唯一選擇就是使用Go 的 reflect,這導致了非常緊密的耦合:您只有生成器的輸出,並且需要逆向工程輸入 Protobuf 訊息定義可能是什麼樣子。 google.golang.org/protobuf 模組釋出(來自 2020 年 3 月)引入了Protobuf 反射,這應該總是首選:Go 的 reflect 包遍歷資料結構的表示形式,這應該是實現細節。Protobuf 反射遍歷協議訊息的邏輯樹,而不考慮其表示形式。

不幸的是,僅僅提供 Protobuf 反射是不夠的,仍然會暴露一些尖角:在某些情況下,使用者可能會意外地使用 Go 反射而不是 Protobuf 反射。

例如,使用 encoding/json 包(它使用 Go 反射)編碼 Protobuf 訊息在技術上是可能的,但結果不是規範的 Protobuf JSON 編碼。請改用protojson 包。

新的不透明 API 可防止此問題,因為訊息結構體欄位是隱藏的:意外使用 Go 反射將看到一個空訊息。這足以引導開發人員轉向 Protobuf 反射。

動機:實現理想的記憶體佈局

更高效的記憶體表示部分中的基準測試結果已經表明,Protobuf 的效能在很大程度上取決於具體用法:訊息如何定義?哪些欄位已設定?

為了讓 Go Protobuf 對所有人都儘可能快,我們不能實現僅對一個程式有幫助但會損害其他程式效能的最佳化。

Go 編譯器以前也處於類似的情況,直到 Go 1.20 引入了基於配置檔案的最佳化 (PGO)。透過記錄生產行為(透過分析)並將該配置檔案反饋給編譯器,我們可以讓編譯器為特定程式或工作負載做出更好的權衡。

我們認為使用配置檔案針對特定工作負載進行最佳化是進一步最佳化 Go Protobuf 的有前途的方法。不透明 API 使這些成為可能:程式程式碼使用訪問器,並且在記憶體表示更改時不需要更新,因此我們可以例如將不常用的欄位移到一個溢位結構體中。

遷移

您可以根據自己的進度進行遷移,甚至不必遷移——(現有)開放結構 API 不會被移除。但是,如果您不使用新的不透明 API,您將無法受益於其改進的效能,或針對它的未來最佳化。

我們建議您為新開發選擇不透明 API。Protobuf Edition 2024(如果您還不熟悉,請參見Protobuf Editions Overview)將使不透明 API 成為預設選項。

混合 API

除了開放結構 API 和不透明 API 之外,還有混合 API,它透過保持結構體欄位匯出使現有程式碼保持正常工作,同時還透過新增新的訪問器方法來支援遷移到不透明 API。

使用混合 API 時,Protobuf 編譯器將在兩個 API 級別上生成程式碼:.pb.go 檔案位於混合 API 上,而 _protoopaque.pb.go 版本位於不透明 API 上,並且可以透過使用 protoopaque 構建標籤進行選擇。

重寫程式碼以使用不透明 API

有關詳細說明,請參閱遷移指南。高階步驟是:

  1. 啟用混合 API。
  2. 使用 open2opaque 遷移工具更新現有程式碼。
  3. 切換到不透明 API。

已釋出生成程式碼的建議:使用混合 API

Protobuf 的小型用法可以完全存在於同一個儲存庫中,但通常 .proto 檔案會在由不同團隊擁有的不同專案之間共享。一個明顯的例子是涉及不同公司時:要呼叫 Google API(使用 Protobuf),請從您的專案中使用 Go 版 Google Cloud 客戶端庫。將 Cloud 客戶端庫切換到不透明 API 不是一個選項,因為那將是一個破壞性的 API 更改,但切換到混合 API 是安全的。

我們對釋出生成程式碼(.pb.go 檔案)的此類包的建議是,請切換到混合 API!請同時釋出 .pb.go_protoopaque.pb.go 檔案。protoopaque 版本允許您的使用者根據自己的進度進行遷移。

啟用延遲解碼

遷移到不透明 API 後,即可使用(但尚未啟用)延遲解碼!🎉

要啟用:在您的 .proto 檔案中,使用 [lazy = true] 註釋標記您的訊息型別欄位。

要選擇退出延遲解碼(儘管有 .proto 註釋),protolazy 包文件描述了可用的選擇退出方式,這些方式會影響單個 Unmarshal 操作或整個程式。

後續步驟

透過在過去幾年中以自動化方式使用 open2opaque 工具,我們將 Google 的絕大多數 .proto 檔案和 Go 程式碼轉換為不透明 API。隨著我們將越來越多的生產工作負載遷移到不透明 API,我們不斷改進其實現。

因此,我們預計您在嘗試不透明 API 時不會遇到問題。萬一您確實遇到了任何問題,請在 Go Protobuf 問題跟蹤器上告知我們

Go Protobuf 的參考文件可以在 protobuf.dev → Go Reference 上找到。

下一篇文章: Go 開發者調查 2024 H2 結果
上一篇文章: Go 迎來 15 週年
部落格索引