Go 部落格
Go Protobuf:新的 Opaque API
[協議緩衝區 (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 也是如此!
背景:現有的開放結構 (Open Struct) API
我們現在將現有 API 稱為開放結構 (Open Struct) API,因為生成的結構型別允許直接訪問。在下一節中,我們將看到它與新的不透明 (Opaque) API 有何不同。
要使用協議緩衝區,您首先建立一個 .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 wire 格式。
您可以在生成程式碼 API 文件中找到更多詳細資訊。
(現有)開放結構 (Open Struct) API:欄位存在性 (Field Presence)
此生成程式碼的一個重要方面是欄位存在性 (field presence)(即欄位是否被設定)是如何建模的。例如,上面的例子使用指標來建模存在性,因此您可以將 BackendServer
欄位設定為
proto.String("zrh01.prod")
:欄位已設定幷包含“zrh01.prod”proto.String("")
:欄位已設定(非nil
指標)但包含空值nil
指標:欄位未設定
如果您習慣於不帶指標的生成程式碼,您可能正在使用以 syntax = "proto3"
開頭的 .proto
檔案。欄位存在性行為多年來有所變化
syntax = "proto2"
預設使用顯式存在性 (explicit presence)syntax = "proto3"
預設使用隱式存在性 (implicit presence)(其中情況 2 和 3 無法區分,都由一個空字串表示),但後來被擴充套件為允許使用optional
關鍵字選擇顯式存在性edition = "2023"
,proto2 和 proto3 的繼承者,預設使用 顯式存在性 (explicit presence)
新的不透明 (Opaque) API
我們建立了新的不透明 (Opaque) API,以將生成程式碼 API 與底層的記憶體表示解耦。(現有)開放結構 (Open Struct) 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 訊息在記憶體中的佈局方式。解除這一限制可以帶來許多實現上的改進,我們將在下面看到。
新的不透明 (Opaque) 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() { … }
// …
使用不透明 (Opaque) API,結構欄位被隱藏,不再能直接訪問。取而代之的是,新的 accessor 方法允許獲取、設定或清除欄位。
不透明 (Opaque) 結構使用更少的記憶體
我們對記憶體佈局做的一個改變是更有效地建模基本欄位的欄位存在性
- (現有)開放結構 (Open Struct) API 使用指標,這為欄位的空間成本增加了 64 位字。
- 不透明 (Opaque) API 使用位欄位 (bit fields),每個欄位需要一位(忽略填充開銷)。
使用更少的變數和指標也降低了分配器和垃圾回收器的負載。
效能提升在很大程度上取決於您的協議訊息的形狀:此更改隻影響基本欄位,如整數、布林值、列舉和浮點數,但不影響字串、重複欄位或子訊息(因為對於這些型別來說 收益較低)。
我們的基準測試結果表明,基本欄位較少的訊息效能與之前一樣好,而基本欄位較多的訊息則以顯著更少的分配進行解碼
│ 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 帶有隱式存在性 (implicit presence),同樣不使用指標,因此如果您來自 proto3,您不會看到效能提升。如果您出於效能原因使用隱式存在性,放棄了區分空欄位和未設定欄位的便利性,那麼不透明 (Opaque) API 現在可以在不損失效能的情況下使用顯式存在性。
動機:延遲解碼 (Lazy Decoding)
延遲解碼 (Lazy decoding) 是一種效能最佳化,其中子訊息的內容在首次訪問時而不是在 proto.Unmarshal
期間進行解碼。延遲解碼可以透過避免不必要地解碼從未訪問過的欄位來提高效能。
(現有)開放結構 (Open Struct) API 無法安全地支援延遲解碼。雖然開放結構 API 提供了 getter,但將(未解碼的)結構欄位暴露在外將非常容易出錯。為了確保在欄位首次訪問之前立即執行解碼邏輯,我們必須將欄位設為私有,並透過 getter 和 setter 函式來中介所有對它的訪問。
這種方法使得使用不透明 (Opaque) 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)
動機:減少指標比較錯誤
使用指標建模欄位存在性容易引入與指標相關的 bug。
考慮一個在 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!
您發現 bug 了嗎?條件比較的是記憶體地址而不是值。因為每次呼叫 Enum()
accessor 都會分配一個新的變數,所以這個條件永遠不會為真。檢查應該這樣寫
if cv.GetDeviceType() == logpb.LogEntry_DESKTOP {
新的不透明 (Opaque) API 防止了這種錯誤:因為欄位被隱藏,所有訪問都必須透過 getter。
動機:減少意外共享錯誤
讓我們考慮一個稍微複雜一點的與指標相關的 bug。假設您正試圖穩定一個在高負載下失敗的 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")
}
您發現 bug 了嗎?第一行意外地複製了指標(從而在 logEntry
和 req
訊息之間共享了指向的變數),而不是它的值。它應該這樣寫
logEntry.IPAddress = proto.String(req.GetIPAddress())
新的不透明 (Opaque) 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
包。
新的不透明 (Opaque) API 防止了這個問題,因為訊息結構欄位被隱藏:意外使用 Go 反射將看到一個空訊息。這足夠明確,可以將開發者引導至 Protobuf 反射。
動機:實現理想的記憶體佈局
來自更高效的記憶體表示部分的基準測試結果已經表明,protobuf 效能很大程度上取決於具體的使用方式:訊息是如何定義的?哪些欄位被設定了?
為了讓 Go Protobuf 對所有人都儘可能快,我們不能實現只幫助一個程式但損害其他程式效能的最佳化。
Go 編譯器曾處於類似的情況,直到 Go 1.20 引入了配置檔案引導最佳化 (PGO)。透過記錄生產行為(透過效能分析)並將該配置檔案反饋給編譯器,我們允許編譯器針對特定程式或工作負載做出更好的權衡。
我們認為使用配置檔案來最佳化特定工作負載是進一步最佳化 Go Protobuf 的一個有前景的方法。不透明 (Opaque) API 使之成為可能:程式程式碼使用 accessor,並且在記憶體表示改變時無需更新,因此我們可以例如將很少設定的欄位移動到一個溢位結構中。
遷移
您可以按照自己的計劃遷移,或者根本不遷移——(現有)開放結構 (Open Struct) API 不會被移除。但是,如果您不使用新的不透明 (Opaque) API,您將無法從其改進的效能或未來針對它的最佳化中受益。
我們建議您在新開發中選擇不透明 (Opaque) API。Protobuf Edition 2024(如果您還不熟悉,請參閱Protobuf 版本概述)將使不透明 (Opaque) API 成為預設。
混合 API (Hybrid API)
除了開放結構 (Open Struct) API 和不透明 (Opaque) API 外,還有混合 API (Hybrid API),它透過保持結構欄位匯出,使現有程式碼能夠工作,同時透過新增新的 accessor 方法,也使得遷移到不透明 (Opaque) API 成為可能。
使用混合 API (Hybrid API),protobuf 編譯器將在兩個 API 級別生成程式碼:.pb.go
檔案使用混合 API,而 _protoopaque.pb.go
版本使用不透明 (Opaque) API,可以透過使用 protoopaque
構建標籤進行構建來選擇。
將程式碼重寫為不透明 (Opaque) API
有關詳細說明,請參閱遷移指南。主要步驟是
- 啟用混合 API (Hybrid API)。
- 使用
open2opaque
遷移工具更新現有程式碼。 - 切換到不透明 (Opaque) API。
已釋出生成程式碼的建議:使用混合 API (Hybrid API)
protobuf 的小型使用可以完全在同一倉庫內,但通常 .proto
檔案會在不同團隊擁有的不同專案之間共享。一個明顯的例子是涉及不同公司的情況:要呼叫 Google API(使用 protobuf),請在您的專案中使用Go 版 Google Cloud 客戶端庫。將 Cloud 客戶端庫切換到不透明 (Opaque) API 是不可行的,因為這將是一個破壞性的 API 更改,但切換到混合 API (Hybrid API) 是安全的。
我們對此類釋出生成程式碼(.pb.go
檔案)的包的建議是:請切換到混合 API (Hybrid API)!請同時釋出 .pb.go
和 _protoopaque.pb.go
檔案。protoopaque
版本允許您的消費者按自己的計劃遷移。
啟用延遲解碼 (Lazy Decoding)
一旦您遷移到不透明 (Opaque) API,延遲解碼即可用(但未啟用)! 🎉
要啟用:在您的 .proto
檔案中,使用 [lazy = true]
註釋來標註您的訊息型別欄位。
要選擇退出延遲解碼(儘管有 .proto
註釋),protolazy
包文件描述了可用的選擇退出方式,這些方式會影響單個 Unmarshal 操作或整個程式。
下一步
在過去幾年中,透過自動化方式使用 open2opaque 工具,我們已將 Google 絕大多數的 .proto
檔案和 Go 程式碼轉換為不透明 (Opaque) API。隨著我們將越來越多的生產工作負載遷移到它,我們不斷改進不透明 API 的實現。
因此,我們預計您在嘗試不透明 (Opaque) API 時不會遇到問題。如果您確實遇到任何問題,請在 Go Protobuf 問題跟蹤器上告知我們。
Go Protobuf 的參考文件可在 protobuf.dev → Go 參考 中找到。
下一篇文章:2024 年下半年 Go 開發者調查結果
上一篇文章:Go 滿 15 週歲
部落格索引