Go 部落格

Protocol Buffers 的新 Go API

Joe Tsai, Damien Neil, and Herbie Ong
2020 年 3 月 2 日

引言

我們很高興地宣佈,Google 的語言中立資料交換格式 Protocol Buffers 的 Go API 已釋出重大修訂版。

新 API 的動機

Rob Pike 於 2010 年 3 月 宣佈 了 Go 的第一個 Protocol Buffers 繫結。兩年後 Go 1 才釋出。

自首次釋出以來,該包與 Go 一起不斷發展。使用者需求也在增長。

許多人希望編寫使用反射來檢查 Protocol Buffers 訊息的程式。reflect 包提供了 Go 型別和值的檢視,但省略了 Protocol Buffers 型別系統中的資訊。例如,我們可能想編寫一個函式來遍歷日誌條目並清除任何被註釋為包含敏感資料的欄位。這些註解不屬於 Go 型別系統。

另一個常見需求是使用 Protocol Buffers 編譯器生成的型別以外的資料結構,例如動態訊息型別,能夠表示在編譯時未知的訊息型別。

我們還注意到,一個常見的問題是,標識生成的訊息型別的值的 proto.Message 介面,幾乎沒有描述這些型別的行為。當用戶建立實現該介面的型別(通常是透過將訊息嵌入其他結構體而不經意間實現的)並將這些型別的值傳遞給期望生成的訊息值的函式時,程式會崩潰或行為異常。

這三個問題都有一個共同的原因和一個共同的解決方案:Message 介面應完全指定訊息的行為,並且操作 Message 值的函式應自由接受正確實現該介面的任何型別。

由於在保持包 API 相容性的情況下,無法更改 Message 型別的現有定義,因此我們決定是時候開始著手開發一個新的、不相容的主要版本的 protobuf 模組了。

今天,我們很高興釋出了這個新模組。希望您喜歡它。

反射

反射是新實現的主要功能。與 reflect 包提供 Go 型別和值檢視的方式類似,google.golang.org/protobuf/reflect/protoreflect 包根據 Protocol Buffers 型別系統提供了值的檢視。

protoreflect 包的完整描述對於這篇博文來說太長了,但讓我們看看如何編寫前面提到的日誌清理函式。

首先,我們將編寫一個 .proto 檔案,該檔案定義了 google.protobuf.FieldOptions 型別的擴充套件,以便我們可以將欄位標記為包含敏感資訊或不包含。

syntax = "proto3";
import "google/protobuf/descriptor.proto";
package golang.example.policy;
extend google.protobuf.FieldOptions {
    bool non_sensitive = 50000;
}

我們可以使用此選項將某些欄位標記為非敏感。

message MyMessage {
    string public_name = 1 [(golang.example.policy.non_sensitive) = true];
}

接下來,我們將編寫一個 Go 函式,該函式接受任意訊息值並刪除所有敏感欄位。

// Redact clears every sensitive field in pb.
func Redact(pb proto.Message) {
   // ...
}

此函式接受一個 proto.Message,這是一個由所有生成的訊息型別實現的介面型別。此型別是 protoreflect 包中定義的型別的別名。

type ProtoMessage interface{
    ProtoReflect() Message
}

為避免填充生成訊息的名稱空間,該介面僅包含一個返回 protoreflect.Message 的方法,該方法提供對訊息內容的訪問。

(為什麼是別名?因為 protoreflect.Message 有一個返回原始 proto.Message 的相應方法,我們需要避免這兩個包之間的匯入迴圈。)

protoreflect.Message.Range 方法會為訊息中的每個已填充欄位呼叫一個函式。

m := pb.ProtoReflect()
m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
    // ...
    return true
})

呼叫範圍函式時會傳遞一個描述欄位 Protocol Buffers 型別的 protoreflect.FieldDescriptor 和包含欄位值的 protoreflect.Value

protoreflect.FieldDescriptor.Options 方法將欄位選項返回為 google.protobuf.FieldOptions 訊息。

opts := fd.Options().(*descriptorpb.FieldOptions)

(為什麼進行型別斷言?由於生成的 descriptorpb 包依賴於 protoreflect,因此 protoreflect 包在不導致匯入迴圈的情況下無法返回具體的選項型別。)

然後,我們可以檢查選項以檢視我們擴展布爾值的值。

if proto.GetExtension(opts, policypb.E_NonSensitive).(bool) {
    return true // don't redact non-sensitive fields
}

請注意,我們這裡檢視的是欄位的*描述符*,而不是欄位的*值*。我們感興趣的資訊位於 Protocol Buffers 型別系統中,而不是 Go 型別系統中。

這也代表了我們簡化 proto 包 API 的一個方面。原始的 proto.GetExtension 返回值和錯誤。新的 proto.GetExtension 只返回一個值,如果欄位不存在,則返回該欄位的預設值。擴充套件解碼錯誤將在 Unmarshal 時報告。

一旦我們確定了需要編輯的欄位,清除它就很簡單。

m.Clear(fd)

將以上所有內容放在一起,我們完整的編輯函式是:

// Redact clears every sensitive field in pb.
func Redact(pb proto.Message) {
    m := pb.ProtoReflect()
    m.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool {
        opts := fd.Options().(*descriptorpb.FieldOptions)
        if proto.GetExtension(opts, policypb.E_NonSensitive).(bool) {
            return true
        }
        m.Clear(fd)
        return true
    })
}

更完整的實現可能會遞迴地深入到訊息型別欄位。我們希望這個簡單的例子能讓您一窺 Protocol Buffers 反射及其用法。

版本

我們將 Go Protocol Buffers 的原始版本稱為 APIv1,新版本稱為 APIv2。由於 APIv2 與 APIv1 不相容,因此我們需要為每個版本使用不同的模組路徑。

(這些 API 版本與 Protocol Buffers 語言版本:proto1proto2proto3 不同。APIv1 和 APIv2 是 Go 中的具體實現,都支援 proto2proto3 語言版本。)

github.com/golang/protobuf 模組是 APIv1。

google.golang.org/protobuf 模組是 APIv2。我們利用了更改匯入路徑的需要,切換到一個不與特定託管提供商繫結的路徑。(我們曾考慮過 google.golang.org/protobuf/v2,以明確這是 API 的第二個主要版本,但最終選擇了較短的路徑,認為這是長期而言更好的選擇。)

我們知道並非所有使用者都會以相同的速度遷移到包的新主版本。有些人會快速切換;有些人可能會無限期地停留在舊版本上。即使在同一個程式中,某些部分可能使用一個 API,而其他部分使用另一個 API。因此,我們必須繼續支援使用 APIv1 的程式。

  • github.com/golang/protobuf@v1.3.4 是 APIv1 的最新 APIv2 之前的版本。

  • github.com/golang/protobuf@v1.4.0 是一個基於 APIv2 實現的 APIv1 版本。API 相同,但底層實現由新版本支援。此版本包含在 APIv1 和 APIv2 proto.Message 介面之間進行轉換的函式,以簡化兩者之間的遷移。

  • google.golang.org/protobuf@v1.20.0 是 APIv2。此模組依賴於 github.com/golang/protobuf@v1.4.0,因此任何使用 APIv2 的程式都將自動選擇一個與之整合的 APIv1 版本。

(為什麼從版本 v1.20.0 開始?為了提供清晰度。我們不希望 APIv1 達到 v1.20.0,所以版本號本身應該足以明確區分 APIv1 和 APIv2。)

我們打算無限期地維護對 APIv1 的支援。

這種組織結構確保了任何給定的程式無論使用哪個 API 版本,都只會使用一個 Protocol Buffers 實現。它允許程式逐步採用新 API,或者根本不採用,同時仍然獲得新實現的優勢。最小版本選擇原則意味著程式可以停留在舊實現上,直到維護者選擇更新到新實現(直接更新或透過更新依賴項)。

其他值得注意的功能

google.golang.org/protobuf/encoding/protojson 包使用 規範的 JSON 對映 將 Protocol Buffers 訊息與 JSON 相互轉換,並修復了舊 jsonpb 包中的許多問題,這些問題如果不更改可能會給現有使用者帶來問題。

google.golang.org/protobuf/types/dynamicpb 包為執行時派生的 Protocol Buffers 型別的訊息提供了 proto.Message 的實現。

google.golang.org/protobuf/testing/protocmp 包提供了使用 github.com/google/cmp 包比較 Protocol Buffers 訊息的函式。

google.golang.org/protobuf/compiler/protogen 包支援編寫 Protocol Compiler 外掛。

結論

google.golang.org/protobuf 模組是對 Go 對 Protocol Buffers 支援的重大改版,提供了對反射、自定義訊息實現和已清理的 API 表面的頭等支援。我們打算無限期地維護舊 API 作為新 API 的包裝器,允許使用者以自己的步調漸進式地採用新 API。

我們在此次更新中的目標是在提高舊 API 的優勢的同時,解決其不足之處。當我們完成了新實現中的每個元件後,都會將其整合到 Google 的程式碼庫中使用。這種增量式推出使我們對新 API 的可用性以及新實現的效能和正確性充滿信心。我們相信它是生產就緒的。

我們對這次釋出感到興奮,並希望它能在未來十年及更長時間內為 Go 生態系統服務!

下一篇文章:Go、Go 社群與大流行病
上一篇文章:Go 1.14 釋出
部落格索引