Go 部落格

成堆的資料

羅伯·派克
2011 年 3 月 24 日

簡介

若要在網路上傳輸資料結構或將之儲存在檔案中,必須先對其編碼,然後再將之解碼。當然,有許多編碼可供使用:JSONXML、Google 的 Protocol Buffers 等等。而現在,有了另一個來自 Go 之 gob 套件。

為什麼要定義新的編碼?這是一項繁重且多餘的工作。為什麼不直接使用現有的格式之一?嗯,我們確實有!Go 有 套件 支援剛才提到的所有編碼(Protocol Buffer 套件 在個別的存放庫中,但它是下載次數最多的套件之一)。對於許多目的(包括與以其他語言編寫的工具和系統通訊),這些套件都是正確的選擇。

但是,對於 Go 專屬的環境(例如在兩個以 Go 編寫的伺服器之間進行通訊),就有機會打造更易於使用,也可能更有效率的功能。

Gobs 以與外部定義語言無關的不同編碼方式來使用語言。同時,從現有系統中可以獲取一些教訓。

目標

gob 套件的設計考慮到了許多目標。

首先,最明顯的是,它必須非常容易使用。首先,因為 Go 具備反射,所以不需要獨立的介面定義語言或「協定編譯器」。資料結構本身是所有套件找出編碼和解碼方法的依據。另一方面,這種方法意即 gobs 永遠無法與其他語言同樣契合,但沒有關係:gobs 毫不掩飾地以 Go 為中心。

效率也很重要。文字表示法(以 XML 和 JSON 為例)太慢,無法成為有效率的通訊網路的核心。二進位編碼是必要的。

gob 串流必須是自我描述的。每個從頭開始讀取的 gob 串流,包含足夠的資訊讓代理程式解析整個串流,代理程式不需要事先知道該串流的內容。此特性意即你永遠可以解碼儲存在檔案中的 gob 串流,即使你早已忘記它代表哪些資料。

我們也從使用 Google 協定緩衝區的經驗學到一些事情。

協定緩衝區的功能不全

協定緩衝區對 gobs 的設計有重大的影響,但有三個功能被刻意避免。(撇開協定緩衝區並非自我描述的特性不談:如果你不知道用於編碼協定緩衝區的資料定義,你可能無法解析它。)

首先,協定緩衝區只對我們在 Go 中稱為結構的資料類型起作用。你無法編碼頂層的整數或陣列,只能編碼其中有欄位的結構。這似乎是一個沒有必要的限制,至少在 Go 中是如此。如果你只需要傳送一個整數陣列,為何你必須先將它放入結構中?

接著,協定緩衝區定義可能會指定欄位 T.xT.y 在對 T 類型的值進行編碼或解碼時必須存在。儘管這些必須的欄位乍看之下似乎是個好主意,但實作它們需要成本,因為編解碼器在編碼和解碼時必須維護一個獨立的資料結構才能報告哪些必須的欄位遺失。它們也構成維護上的問題。一段時間過後,你可能會想要修改資料定義來移除一個必須的欄位,但這可能會導致使用該資料的現有客戶端發生當機。最好不要將這些欄位放入編碼中。(協定緩衝區也有選擇性的欄位。但如果我們沒有必須的欄位,所有欄位都是選擇性的,事情就是如此。稍微後方會有更多關於選擇性欄位的說明。)

第三個協定緩衝器功能為預設值。假如協定緩衝器省略預設欄位的數值,已解碼的結構會表現得好像欄位已設定為該數值。當您有 getter 與 setter 方法來控制欄位的存取時,這個概念運作良好;但若容器只是一個明顯的慣用語結構,就比較難以乾淨地處理。必需欄位也難以實作:預設值在什麼地方定義、它們有哪些類型(文字為 UTF-8 嗎?未解讀的位元組?浮點數有幾個位元?)以及儘管預設值顯然簡單,但在協定緩衝器的設計和實作仍有一些複雜的地方。我們決定在 gobs 中省略它們,並退回至 Go 微小但有效的預設規則:除非您另行設定某個東西,否則該類型會擁有「零值」,且此值不需要傳輸。

因此 gobs 最後看起來像一種泛化的、簡化的協定緩衝器。它們如何運作?

數值

已編碼的 gob 資料並未涉及像 int8uint16 這樣的類型。反之,類似於 Go 中的常數,它的整數值是抽象的、無大小的數字,可能是帶正負號的或不帶正負號的。當您編碼 int8 時,它的數值會作為非大小化、可變長度的整數傳輸。當您編碼 int64 時,它的數值也會作為非大小化、可變長度的整數傳輸。(帶正負號與不帶正負號有不同的處理方式,但同樣的非大小化規則也適用於不帶正負號的數值。)如果兩個數值都為 7,在網路上傳送的位元將會完全相同。當接收者解碼數值時,它會將數值放入接收者的變數中,而此變數可能是任意整數類型。因此編碼器可能會傳送來自 int8 的 7,但接收者可能會將它儲存在 int64 中。這樣做沒問題:這個值是一個整數,只要它能符合的話,一切都運作良好。(如果無法符合,就會產生錯誤。)這種與變數大小解離的做法賦予編碼一些彈性:隨著軟體的演化,我們可以擴充整數變數的類型,但仍然能夠解碼舊資料。

這個彈性也適用於指標。在傳輸之前,所有指標都被扁平化。類型為 int8*int8**int8****int8 等數值都傳輸為整數值,進而可能會儲存在任何大小的 int*int******int 等。再一次地,這會提供彈性。

彈性也會發生在解碼結構時,只會將編碼器傳送的那些欄位儲存在目的地。給定數值

type T struct{ X, Y, Z int } // Only exported fields are encoded and decoded.
var t = T{X: 7, Y: 0, Z: 8}

t 的編碼只傳送 7 和 8。因為 Y 為零,所以它的值甚至不會傳送;不需要傳送零值。

接收者反而可以將此值解碼到這個結構中

type U struct{ X, Y *int8 } // Note: pointers to int8s
var u U

並取得一個值為 u,其中只設定 X (設定為 8 位元變數的位址,且該變數設定為 7);Z 欄位會被忽略——您會把它放在哪裡?解碼結構時,欄位會依名稱和相容型別來配對,而且只有在雙方都存在的欄位才會受到影響。這種簡單方法可以巧妙解決「選用欄位」的問題:隨著型別 T 新增欄位而演進,過時的接收器仍可使用它們所辨識的該型別部分。因此,gob 提供了選用欄位的重要結果––擴充性––而無需任何其他機制或符號。

根據整數,我們可以建立所有其他型別:位元組、字串、陣列、區塊、對應、甚至是浮點數。浮點值是以其 IEEE 754 Floating-Point 位元模式表示,儲存為整數,只要您知道它們的型別,這個方法就會運作良好,而且我們總是知道。順帶一提,此整數會傳送為位元組反轉順序,因為浮點數字的常見值(例如小整數)在低位會有很多個 0,而我們可以避免傳送這些 0 值。

Go 的 gob 所具有的其中一個絕佳特色是,它允許您定義自己的編碼,方法是讓您的型別滿足 GobEncoderGobDecoder 介面,這與 JSON 封裝的 MarshalerUnmarshaler 的方式類似,也類似於 封裝 fmt 中的 Stringer 介面。使用此功能可以表示特殊特性、強制約束或在傳輸資料時隱藏秘密。有關詳情,請參閱 文件

網路上傳輸的型別

您第一次傳送給定型別時,gob 封裝會在資料串流中包含對該型別的說明。事實上,發生的事情是,編碼器用標準 gob 編碼格式對描述該型別並給它一個唯一數字的內部結構進行編碼。(基本型別,加上型別說明結構的配置,是由啟動軟體預先定義的。)描述型別後,就可以透過其型別數字來參照它。

因此,當我們傳送我們的最初型別 T 時,gob 編碼器會傳送 T 的說明,並加上其型別數字標籤,假設為 127。所有值(包括最初的值)之後都會加上這個數字做為開頭,所以 T 值的串流看起來會像這樣

("define type id" 127, definition of type T)(127, T value)(127, T value), ...

這些型別數字使得我們能夠描述遞迴型別並傳送這類型別的值。因此,gob 可以編碼如樹狀結構等型別

type Node struct {
    Value       int
    Left, Right *Node
}

(讀者可以練習找出預設為零的規則如何讓這個方式順利運作,即使 gobs 沒有表示函式。)

有了類型資訊,gob 串流就能完全地自我描述,唯一的例外是引導類型集合,也就是定義緊密的起始點。

編譯機器

第一次對指定類型的值編碼時,gob 套件會產生一個適用於該資料類型的少量解釋機器。它使用反射對類型來建構該機器,但建立機器後,便不再仰賴反射。機器會使用套件 unsafe 和一些小技巧,高速度地將資料轉成已編碼的位元組。它可以使用反射並避免 unsafe,但會明顯地更慢。(類似的高速方法是由協定緩衝器支援 Go 所採用,其設計受到 gob 實作的影響。) 之後同類型的值使用先前已編譯的機器,因此可以直接編碼。

[更新:自 Go 1.4 起,gob 套件不再使用套件 unsafe,效能有些許下降。]

解碼類似,但較為困難。解碼值時,gob 套件會儲存以位元組片段代表由編碼器定義類型值來解碼,加上用來解碼的 Go 值。gob 套件針對該配對建立機器:傳送至網路上的 gob 類型與提供用於解碼的 Go 類型交互。建立解碼機器後,它又是一個無反射引擎,使用 unsafe 方法來取得最高速度。

用途

底層進行了許多運算,但是結果是一個有效率、容易使用的編碼系統,可傳輸資料。以下是顯示不同編碼與解碼類型的完整範例。請注意傳送與接收值的容易程度;您只需將值與變數提供給 gob 套件,它便會完成所有工作。

package main

import (
    "bytes"
    "encoding/gob"
    "fmt"
    "log"
)

type P struct {
    X, Y, Z int
    Name    string
}

type Q struct {
    X, Y *int32
    Name string
}

func main() {
    // Initialize the encoder and decoder.  Normally enc and dec would be
    // bound to network connections and the encoder and decoder would
    // run in different processes.
    var network bytes.Buffer        // Stand-in for a network connection
    enc := gob.NewEncoder(&network) // Will write to network.
    dec := gob.NewDecoder(&network) // Will read from network.
    // Encode (send) the value.
    err := enc.Encode(P{3, 4, 5, "Pythagoras"})
    if err != nil {
        log.Fatal("encode error:", err)
    }
    // Decode (receive) the value.
    var q Q
    err = dec.Decode(&q)
    if err != nil {
        log.Fatal("decode error:", err)
    }
    fmt.Printf("%q: {%d,%d}\n", q.Name, *q.X, *q.Y)
}

您可以在 Go Playground 中編譯並執行這個範例程式碼。

rpc 套件 延續 gob,將這種編碼/解碼自動化轉換成跨網路傳送方法呼叫。那是另一篇文章的主題。

詳細資訊

gob 套件文件,特別是檔案 doc.go,會擴充這裡描述的許多詳細資訊,並包含一個顯示編碼如何表述資料的完整實際範例。如果您有興趣了解 gob 實作的內部運作,這是一個良好的起點。

下一篇文章: Godoc:文件化 Go 程式碼
前一篇: C?Go?Cgo!
部落格索引