Go 部落格

Go 語言的新 Protocol Buffers API

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

引言

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

新 API 的動機

Go 的第一個 protocol buffer 繫結由 Rob Pike 於 2010 年 3 月宣佈。Go 1 在此之後兩年才釋出。

自首次釋出以來的十年裡,該包與 Go 一同發展壯大。其使用者的需求也隨之增長。

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

另一個普遍的需求是使用 protocol buffer 編譯器生成的結構以外的資料結構,例如能夠在編譯時型別未知的情況下表示訊息的動態訊息型別。

我們還注意到,一個常見的問題來源是 proto.Message 介面,該介面標識生成的 mesaj 型別的值,但它對這些型別的行為描述非常少。當用戶建立實現該介面的型別(通常是在另一個 struct 中嵌入訊息時無意間建立的)並將這些型別的值傳遞給期望生成 mesaj 值 的函式時,程式會崩潰或行為不可預測。

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

由於在保持包 API 相容性的同時無法更改 Message 型別的現有定義,我們決定是時候開始開發一個與現有版本不相容的新的 major version 的 protobuf 模組了。

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

反射

反射是新實現的核心特性。類似於 reflect 包提供 Go 型別和值的檢視,google.golang.org/protobuf/reflect/protoreflect 包根據 protocol buffer 型別系統提供值的檢視。

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 函式,它接受任意的 mesaj 值並移除所有敏感欄位。

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

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

type ProtoMessage interface{
    ProtoReflect() Message
}

為了避免佔用生成的 mesaj 的名稱空間,該介面只包含一個返回 protoreflect.Message 的方法,該方法提供對 mesaj 內容的訪問。

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

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

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

範圍函式會隨同描述欄位 protocol buffer 型別的一個 protoreflect.FieldDescriptor 以及包含欄位值的一個 protoreflect.Value 一起被呼叫。

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

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

(為什麼進行型別斷言?因為生成的 descriptorpb 包依賴於 protoreflect,所以 protoreflect 包無法在不引起匯入迴圈的情況下返回具體的 options 型別。)

然後我們可以檢查 options 來檢視我們的擴充套件 boolean 的值

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

請注意,這裡我們檢視的是欄位 descriptor,而不是欄位 value。我們感興趣的資訊位於 protocol buffer 型別系統中,而非 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
    })
}

更完整的實現可能會遞迴地深入到 mesaj 型別 的欄位中。我們希望這個簡單的例子能讓您初步瞭解 protocol buffer 反射及其用途。

版本

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

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

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

google.golang.org/protobuf 模組是 APIv2。我們藉此機會改變了 import path,以便切換到一個不繫結特定託管提供商的路徑。(我們曾考慮使用 google.golang.org/protobuf/v2 來明確這是 API 的第二個 major version,但最終選擇了更短的路徑,認為這對長期發展更有利。)

我們知道並非所有使用者都會以相同的速度遷移到包的新 major version。有些使用者會快速切換;另一些可能會無限期地停留在舊版本上。即使在同一個程式中,某些部分可能使用一個 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 buffer 實現。它允許程式逐步採用新 API,或者完全不採用,同時仍然獲得新實現的優勢。最小版本選擇原則意味著程式可以停留在舊實現上,直到維護者選擇更新到新實現(直接更新或透過更新依賴項)。

其他值得注意的特性

google.golang.org/protobuf/encoding/protojson 包使用 規範 JSON 對映 將 protocol buffer 訊息轉換為 JSON 並從 JSON 轉換回來,並解決了舊的 jsonpb 包中的許多難以更改且不會給現有使用者帶來問題的問題。

google.golang.org/protobuf/types/dynamicpb 包為在執行時推匯出其 protocol buffer 型別的 mesaj 提供 proto.Message 實現。

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

google.golang.org/protobuf/compiler/protogen 包提供了編寫 protocol 編譯器外掛的支援。

結論

google.golang.org/protobuf 模組是 Go 對 protocol buffers 支援的一次重大改進,為反射、自定義 mesaj 實現以及更清晰的 API 介面提供了 一流的支援。我們打算無限期地維護舊 API 作為新 API 的包裝層,允許使用者按照自己的節奏逐步採用新 API。

此次更新的目標是在提升舊 API 優勢的同時解決其不足。當我們完成新實現的每個元件時,我們都會在 Google 的程式碼庫中投入使用。這種增量式推廣使我們對新 API 的可用性以及新實現的效能和正確性充滿信心。我們相信它已可用於生產環境。

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

下一篇文章:Go、Go 社群與疫情
上一篇文章:Go 1.14 已釋出
部落格索引