Go Protobuf 新增全新不透明 API
Protobuf 是 Google 的語言中立的數據交換格式。早在 2020 年 3 月,就發佈了 google.golang.org/protobuf 模塊,這是對 Go protobuf API 的重大修訂。該包引入了對反射的一流支持、一個 dynamicpb 實現和 protocmp 包,以便於測試。
現在,我們將爲生成的代碼發佈一個新的 API,我們將說明創建新 API 的動機,並展示如何在項目中使用它。
需要明確的是:我們不會刪除任何內容。我們將繼續支持現有的 API 生成代碼,Go 致力於向後兼容性,這也適用於 Go Protobuf。
我們將現有的 API 稱爲開放結構體 API,因爲生成的結構類型可以直接訪問。下一步,我們將看到新的不透明 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 文件,先看下開放結構體 API:
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 函數將消息轉換爲 protobuf 格式。
在 Go 中,如果結構體首字母大寫,那麼結構體是導出的,是可以直接訪問結構體字段的。此生成代碼的一個重要方面是如何對字段存在進行建模。例如,示例中使用指針進行建模,因此你可以將 BackendServer 字段設置爲:
proto.String("zrh01.prod"),該字段已設置幷包含 “zrh01.prod”。
proto.String(""),該字段已設置,但包含空值。
nil,字段未設置,是 nil 指針。
如果你習慣生成沒有指針的代碼,那麼你可能使用以 syntax="proto3" 開頭的. proto 文件。在這幾年中,字段存在行爲在發生變化。例如:
syntax="proto2",默認使用顯示存在
syntax="proto3",默認使用隱式存在,後來擴展允許使用可選關鍵字選擇顯示存在
edition="2023" 是 proto2 和 proto3 的繼承者,默認情況下使用顯示存在。
現在,我們創建了新的不透明 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() { … }
// …
使用 Opaque API,結構體字段被隱藏,無法直接訪問。相反,新的訪問器方法允許獲取、設置或清除字段。
接下來,我們將描述不透明 API 帶來的好處:
一、使用更少的內存
我們對內存佈局所做的一個更改是更有效的存儲字段。開放結構體 API 使用指針,該指針將 64 位字添加到字段的空間開銷中。不透明 API 使用 bit 字段,每個字段需要一個比特。使用更少的變量和指針會降低分配器和垃圾回收器的負載。性能的提高在很大程度上取決於協議消息的形狀:更改隻影響整數、布爾、枚舉和浮點數等基本字段,而不影響字符串、重複字段或子消息。
我們的基準測試結果表明,基本字段較少的消息的性能與以前一樣好,而基本字段較多的消息的解碼分配要少得多。
注意:具有隱式存在的 proto3 同樣不使用指針,因此如果你來自 proto3,你不會看到性能的提高。如果您出於性能原因使用隱式存在,放棄了區分空字段和未設置字段的便利性,那麼 Opaque API 現在可以使用顯式存在而不會帶來性能損失。
二、延遲解碼
延遲解碼是一種性能優化,其中子消息的內容在首次訪問時而不是在原型期間被解碼。延遲解碼可以通過避免對從未訪問過的字段進行不必要的解碼來提高性能。現有的 Open Struct API 無法安全地支持延遲解碼。雖然 Open Struct API 提供 getter,但暴露(未編碼的)結構字段將是非常錯誤的。爲了確保解碼邏輯在字段首次被訪問之前立即運行,我們必須將字段設置爲私有,並通過 getter 和 setter 函數協調對它的所有訪問。
這種方法使得使用 Opaque API 實現延遲解碼成爲可能。當然,並不是每個工作負載都能從這種優化中受益,但對於那些受益的工作負載來說,結果可能是驚人的:我們已經看到日誌分析管道根據頂級消息條件(例如 backend_server 是否是運行新 Linux 內核版本的機器之一)丟棄消息,並且可以跳過解碼深度嵌套的消息子樹。
例如,我們包含的微基準測試的結果,展示了懶惰解碼如何節省 50% 以上的工作和 87% 以上的分配!
三、減少指針比較錯誤
使用指針對字段存在進行建模會導致與指針相關的錯誤。我們來看下 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 {
新的 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")
}
你發現問題了嗎?第一行意外地複製了指針(從而在 LogEntry 和 req 消息之間共享指向的變量),而不是它的值。它應該是這樣的:
logEntry.IPAddress = proto.String(req.GetIPAddress())
新的 Opaque API 防止了這個問題,因爲 setter 採用一個值(字符串)而不是指針:
logEntry.SetIPAddress(req.GetIPAddress())
五、修復銳邊反射
要編寫不僅適用於特定消息類型(例如 logpb.LogEntry),而且適用於任何消息類型的代碼,需要某種反射。
前面的示例使用了一個函數來編輯 IP 地址。要處理任何類型的消息,它可以被定義爲
func redactIP(proto.Message)proto.Message{
}
許多年前,實現 redactIP 等函數的唯一選擇是使用 Go 的反射包,這導致了非常緊密的耦合:你只有生成器輸出,必須對輸入 protobuf 消息定義進行逆向工程。google.golang.org/protobuf 模塊版本(從 2020 年 3 月開始)引入了 protobuf 反射,這應該始終是首選:Go 的反射包遍歷數據結構的表示,這應該是一個實現細節。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 使這些成爲可能:程序代碼使用訪問器,並且當內存表示發生變化時不需要更新,因此我們可以將很少設置的字段移動到溢出結構中。
您可以按照自己的計劃進行遷移,甚至根本不遷移(現有的)Open Struct API 也不會被刪除。但是,如果您不使用新的 Opaque API,您將不會從其改進的性能或未來針對它的優化中受益。
我們建議您選擇不透明 API 進行新的開發。Protobuf Edition2024(如果您還不熟悉,請參閱 ProtobufeditionsOverview)將使不透明 API 成爲默認版本。
在過去的幾年裏,通過以自動化的方式使用 open2opaque 工具,我們已經將絕大多數谷歌的. proto 文件和 Go 代碼轉換爲不透明 API。隨着越來越多的生產工作負載轉移到 Opaque API,我們不斷改進其實現。
因此,我們希望您在嘗試 Opaque API 時不會遇到問題。如果您確實遇到任何問題,請在 Go Protobuf 問題跟蹤器上告訴我們。
更多細節內容,請參考:
https://golang.google.cn/blog/protobuf-opaque
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/yjdkvftp8cwA3q3Wu-_Xdg