Go 部落格

資料塊

Rob Pike
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 protocol buffers 的經驗中,還有一些東西需要學習。

Protocol buffer 的缺點

Protocol buffers 對 gobs 的設計產生了重大影響,但有三個特性是故意避免的。(撇開 protocol buffers 不是自描述的特性不談:如果你不知道用於編碼 protocol buffer 的資料定義,你可能無法解析它。)

首先,protocol buffers 只處理我們在 Go 中稱為 struct 的資料型別。你不能在頂層編碼整數或陣列,只能編碼一個包含欄位的 struct。這似乎是一個毫無意義的限制,至少在 Go 中是這樣。如果你只想傳送一個整數陣列,為什麼必須先把它放到一個 struct 中呢?

其次,protocol buffer 定義可以指定每當編碼或解碼型別 T 的值時,欄位 T.xT.y 必須存在。儘管這些必需欄位看起來是個好主意,但實現成本很高,因為編解碼器在編碼和解碼時必須維護一個單獨的資料結構,以便能夠報告必需欄位缺失的情況。它們也是一個維護問題。隨著時間的推移,人們可能想要修改資料定義以刪除必需欄位,但這可能會導致現有資料的客戶端崩潰。最好根本不要在編碼中包含它們。(Protocol buffers 也有可選欄位。但如果我們沒有必需欄位,那麼所有欄位都是可選的,就這樣。關於可選欄位,稍後還會詳細介紹。)

第三個 protocol buffer 的缺點是預設值。如果 protocol buffer 省略了“預設”欄位的值,那麼解碼後的結構會表現得好像該欄位已設定為該值一樣。當你有 getter 和 setter 方法來控制欄位訪問時,這個想法效果很好,但當容器只是一個普通的慣用 struct 時,就很難乾淨地處理了。必需欄位也難以實現:預設值在哪裡定義,它們有什麼型別(文字是 UTF-8 嗎?未解釋的位元組?浮點數有多少位?),儘管看起來很簡單,但 protocol buffers 在設計和實現中存在一些複雜性。我們決定在 gobs 中省略它們,轉而採用 Go 簡單但有效的預設規則:除非你另行設定,否則它會具有該型別的“零值”——並且不需要傳輸。

因此,gobs 最終看起來像一種通用、簡化的 protocol buffer。它們是如何工作的?

編碼後的 gob 資料與 int8uint16 等型別無關。相反,與 Go 中的常量有點類似,它的整數值是抽象的、無大小的數字,可以是帶符號或無符號的。當你編碼一個 int8 時,它的值會作為無大小、可變長度的整數傳輸。當你編碼一個 int64 時,它的值也會作為無大小、可變長度的整數傳輸。(帶符號和無符號是分開處理的,但同樣的無大小特性也適用於無符號值。)如果兩者都有值 7,那麼在線上傳輸的位元位將是相同的。當接收方解碼該值時,它會將其放入接收方的變數中,該變數可以是任意整數型別。因此,編碼器可以傳送一個來自 int8 的 7,但接收方可以將其儲存在 int64 中。這很好:值是一個整數,只要它能容納,一切都能正常工作。(如果不能容納,就會出錯。)這種與變數大小的解耦為編碼帶來了一些靈活性:隨著軟體的發展,我們可以擴充套件整數變數的型別,但仍然能夠解碼舊資料。

這種靈活性也適用於指標。傳輸前,所有指標都被展平。型別為 int8*int8**int8****int8 等的值都作為整數值傳輸,然後可以儲存在任何大小的 int 中,或 *int,或 ******int 等等。同樣,這提供了靈活性。

靈活性還體現在,在解碼 struct 時,只有編碼器傳送的欄位才儲存在目標中。給定值

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

並獲取一個只有 X 被設定(指向設定為 7 的 int8 變數地址)的 u 值;Z 欄位被忽略——你會把它放在哪裡?在解碼 struct 時,欄位按名稱和相容型別匹配,只有在兩者中都存在的欄位會受到影響。這種簡單的方法巧妙地解決了“可選欄位”問題:隨著型別 T 透過新增欄位而演進,舊的接收方仍然可以處理它們識別的那部分型別。因此,gobs 在沒有任何額外機制或符號的情況下,提供了可選欄位的重要結果——可擴充套件性。

從整數我們可以構建所有其他型別:位元組、字串、陣列、切片、對映,甚至是浮點數。浮點值由其 IEEE 754 浮點位模式表示,儲存為整數,只要您知道它們的型別(我們總是知道),這就能很好地工作。順便說一下,該整數是以位元組反轉的順序傳送的,因為浮點數的常見值(例如小整數)在低位有很多零,我們可以避免傳輸這些零。

Go 使 gobs 成為可能的一個很好的特性是,它們允許您透過讓您的型別實現 GobEncoderGobDecoder 介面來定義自己的編碼,其方式類似於 JSON 包的 MarshalerUnmarshaler 以及 fmt 包Stringer 介面。此功能使得在傳輸資料時可以表示特殊特性、強制執行約束或隱藏秘密。詳情請參見文件

線上傳輸的型別

首次傳送給定型別時,gob 包會在資料流中包含該型別的描述。實際上,發生的情況是使用編碼器以標準 gob 編碼格式編碼一個內部 struct,該 struct 描述了型別併為其賦予一個唯一編號。(基本型別以及型別描述結構的佈局由軟體預定義以進行引導。)描述了型別之後,就可以透過其型別編號引用它。

因此,當我們傳送第一個型別 T 時,gob 編碼器會發送 T 的描述併為其標記一個型別編號,例如 127。然後,所有值(包括第一個值)都以該編號為字首,因此 T 值的流看起來像

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

這些型別編號使得描述遞迴型別併發送這些型別的值成為可能。因此,gobs 可以編碼樹等型別

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

(如何利用零值預設規則使其工作,即使 gobs 不表示指標,這是留給讀者的一個練習。)

有了型別資訊,除了引導型別集(這是一個明確的起點)之外,gob 流是完全自描述的。

編譯一臺機器

首次編碼給定型別的值時,gob 包會構建一個專門用於該資料型別的小型直譯器機器。它利用型別的反射來構建該機器,但機器一旦構建完成就不再依賴反射。該機器使用 unsafe 包和一些技巧以高速將資料轉換為編碼位元組。它可以使用反射並避免 unsafe,但會顯著變慢。(Go 對 protocol buffer 的支援也採用了類似的高速方法,其設計受到了 gobs 實現的影響。)相同型別的後續值使用已編譯的機器,因此可以立即進行編碼。

[更新:自 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 包基於 gobs 構建,將這種編碼/解碼自動化轉化為跨網路方法呼叫的傳輸方式。這是另一篇文章的主題。

詳情

gob 包文件,尤其是檔案 doc.go,擴充套件了此處描述的許多細節,幷包含一個完整的示例,展示了編碼如何表示資料。如果您對 gob 實現的內部機制感興趣,那是一個很好的起點。

下一篇文章:Godoc:為 Go 程式碼編寫文件
上一篇文章:C? Go? Cgo!
部落格索引