Go 部落格

一種新的 Protocol Buffers 的 Go API

Joe Tsai、Damien Neil 和 Herbie Ong
2020 年 3 月 2 日

簡介

我們很榮幸地宣布,適用於 協定緩衝區(Google 的語言中立資料交換格式)的 Go API 的主要修訂版本已正式發布。

採用新 API 的動機

第一個適用於 Go 的協定緩衝區繫結是由 Rob Pike 在 2010 年 3 月宣布的。Go 1 一直到兩年後才發布。

在距離第一次發布後的十年間,這個套件隨著 Go 一起成長和發展。使用者的需求也不斷成長。

許多人都想要撰寫程式,用來使用反射檢查協定緩衝訊息。reflect 套件提供 Go 類型和值的檢視,但會遺漏掉協定緩衝類型系統中的資訊。例如,我們可能想撰寫一個函式橫跨一個記錄條目,並清除任何被標註為包含機密資料的欄位。這些標註並非 Go 類型系統的一部分。

另一個常見的渴望是使用非由協定緩衝編譯器產生的資料結構,例如一個動態訊息類型,其有能力表示在編譯時間其類型未知的訊息。

我們也觀察到一個常見造成問題的來源是 proto.Message 介面,其識別已產生的訊息類型的值,對於描述這些類型的行為的作用很小。當使用者建立實作那個介面的類型(常常是透過在另一個結構中內嵌一個訊息,而無意地這樣做),並將這些類型的值傳遞給預期已產生一個訊息值的函式,程式就會崩潰或出現難以預料的行為。

這三個問題有一個共同的原因和共同的解決方案:Message 介面應該完整地指定一個訊息的行為,而且在 Message 值上運作的函式應該可以自由地接收任何正確實作該介面的類型。

由於無法在維持套件 API 相容性的同時變更現有 Message 类型的定義,所以我們決定要開始處理協定緩衝模組的新,不向相容性版本。

今天,我們很榮幸釋出那個新的模組。我們希望您喜歡它。

反射

反射是新實作的旗艦功能。與 reflect 套件提供 Go 類型和值的檢視方式雷同,google.golang.org/protobuf/reflect/protoreflect 套件提供值在協定緩衝類型系統中的檢視。

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
})

範圍函式會用一個描述該欄位中協定緩衝類型 protoreflect.FieldDescriptor 的物件呼叫,再用一個包含欄位值的 protoreflect.Value 的物件呼叫。

protoreflect.FieldDescriptor.Options 方法會把欄位選項回傳為一個 google.protobuf.FieldOptions 訊息。

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

(為何會有型態判斷?因為產生的 descriptorpb 套件依賴於 protoreflectprotoreflect 套件無法回傳具體的選項類型而不造成導入迴圈。)

接下來可以檢查選項來看我們延伸的布林值

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

請注意,我們是在這裡看欄位的描述符,而不是欄位的。我們有興趣的資訊在於協定緩衝類型系統,而不是 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
    })
}

更完整的實作會遞迴下降到訊息值欄位。我們希望這個簡單的例子能讓您了解協定緩衝反射及其用途。

版本

我們把原始版本的 Go 協定緩衝稱為 APIv1,新的版本稱為 APIv2。因為 APIv2 不向下相容 APIv1,我們需要為它們使用不同的模組路徑。

(這些 API 版本與通訊協定緩衝語言版本不同: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 版本。它容許程式逐漸採用新 API,或完全不採用,但仍可獲得新實作的優點。最低版本選取原則表示程式可以停留在舊實作上,直到維護者選擇更新至新實作(直接或透過更新相依性)。

其他值得注意的功能

套件 google.golang.org/protobuf/encoding/protojson 使用 正規 JSON 繫結,將協定緩衝訊息轉換為 JSON 或從 JSON 轉換為協定緩衝訊息,並修正了舊 jsonpb 套件的許多問題,這些問題在不影響現有使用者的情況下很難更動。

套件 google.golang.org/protobuf/types/dynamicpb 為協定緩衝型別在執行階段衍生的訊息提供 proto.Message 的實作。

套件 google.golang.org/protobuf/testing/protocmp 提供使用 github.com/google/cmp 套件比對協定緩衝訊息的函式。

套件 google.golang.org/protobuf/compiler/protogen 提供支援來撰寫協定編譯器外掛程式。

結語

google.golang.org/protobuf 模組大幅修改了 Go 對協定緩衝的支援,提供一流的反思、自訂訊息實作以及簡潔的 API 介面支援。我們打算將舊的 API 無限期地維持為新的 API 的包裝器,讓使用者能依自己的步調逐漸採用新的 API。

此更新的目標是改善舊 API 的優點,同時解決其缺點。我們在完成新實作的每個組成部分時,便在 Google 的程式碼庫中使用該組成部分。此增量式推出讓我們確信新 API 的可用性,以及新實作的效能和正確性。我們相信它已準備就緒,可投入生產使用。

我們對此版本感到興奮,並希望它能為 Go 生態系服務未來十年,甚至更久!

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