使用gob包来序列化与反序列化海量数据

本文翻译自《Gobs of data》。

Rob Pike

2011/03/24

介绍

要在网络上传输某种数据结构或将它存储在文件中,必须对它进行编码,然后解码。当然,有很多可用的编码格式:JSONXML、Google的protocol buffer等等。现在还有另一种,由Go的gob包提供。

为什么要定义一种新的编码格式?这需要大量的工作,而且多余。为什么不使用现有的格式之一?好吧,有一件事,我们做到了!Go本就支持刚才提到的所有编码的protocol buffer包在一个单独的存储库中,但它是下载频率最高的存储库之一)。对于许多目的,包括与用其他语言编写的工具和系统进行通信,它们都是正确的选择。

但对于Go特定的环境,例如用Go编写的两个服务器之间的通信,有机会构建一种更易于使用且可能更高效的编码格式。

Gob以一种外部定义的、独立于语言的编码所不能做到的方式工作。与此同时,还可以从现有的系统中吸取教训。

目标

gob包的设计考虑到了许多目标。

第一,也是最显然的一点,它必须非常易于使用。首先,因为Go支持反射机制,所以无需单独的接口定义或“协议编译器”。数据结构本身就是该包所需要的全部内容,以了解如何对其进行编码和解码。另一方面,这种方式意味着gob永远不会与其他编程语言通用,但没关系:gob就是以Go语言为中心的。

效率也很重要。以XML和JSON为例的文本形式的编码太慢了,无法在高效通信的网络中使用。二进制编码是必须的。

gob的数据流必须是自我描述的。从一开始读取的每个gob数据流都包含足够的信息,使得整个gob数据流可以由对其内容一无所知的代理程序解析。此特性意味着你将始终能够解码存储在文件中的gob数据流,即使在很久以后你已经忘记了它代表什么数据。

从我们使用Google的protocol buffer的经验中也学到了一些东西。

Protocol buffer的缺点

protocol buffer对gob的设计有很大影响,故意避免了它的三个特性。(protocol buffer不是自我描述的:如果你不知道用来编码成protocol buffer的数据的具体定义,你可能无法解码它。)

首先,protocol buffer只适用于我们在Go中称为结构体的数据类型。不能直接对整数或数组进行编码,只能对包含字段的结构体进行编码。这似乎是一个毫无意义的限制,至少在Go中是这样。如果你只想发送一个整数数组,为什么必须先将它放入一个结构体中?

其次,protocol buffer可以指定,每当对类型T的值进行编码或解码时,字段T.x和T.y都必需存在。尽管这样的必需字段(required field)看起来是个好主意,但它们的实现成本很高,因为编解码器在编码和解码时必须保留一个独立的数据结构,以便能够在所需字段丢失时进行报告。这也是一个维护问题。随着时间的推移,可能需要修改数据结构定义以删除必需字段,但这可能会导致现有客户端运行崩溃。最好不要在编码中包含必需字段。(protocol buffer也可以定义可选字段。如果我们没有定义必需字段,那么所有字段都是可选的。稍后将有更多关于可选字段的内容。)

第三个protocol buffer的错误特性是默认值。如果protocol buffer省略了“默认”字段的值,则解码该结构体的行为就好像这些字段默认被设置为该值一样。当你有getter和setter方法来控制对字段的访问时,这种想法非常有效,但当容器只是一个普通的结构体(译者注:没有getter和setter方法)时,就很难干净地处理了。必需字段的实现也很棘手:默认值在哪里定义,它们有什么类型(文本是UTF-8编码的吗?还是没有解释的字节码?一个浮点数用几个bit位来表示?)尽管看起来很简单,但它们在protocol buffer的设计和实现中有许多复杂之处。我们决定把这一特性排除在外,回到Go语言的琐碎但有效的默认规则:除非你另有设置,否则字段的值为其类型的“零值”,不需要传输。

因此,gob最终看起来像是一种通用的、简化的protocol buffer。那么它是如何工作的呢?

字段的值

编码的gob数据与int8或uint16之类的类型无关。相反,有点类似于Go中的常量,它的整数值是抽象的、无尺寸(sizeless)的数字,有符号或无符号。当你对int8字段进行编码时,它的值将作为一个无尺寸、可变长的整数来传输。当你对int64字段进行编码时,它的值也会作为一个无尺寸、可变长的整数进行传输。(有符号和无符号被区别对待,但无符号值也是无尺寸的。)如果两者的值都为7,则在网络上发送的位将相同。当接收者解码该值时,将其放入接收者的变量中,该变量可以是任意整数类型。因此,编码器发送来自int8字段的值7,接收者可以将其存储在int64字段中。这很好:这个值是一个整数,只要尺寸合适,一切都可行。(如果不合适,就会产生错误。)这种变量与其尺寸的解耦为编码器的实现提供了一些灵活性:随着软件的发展,我们可以扩展整数变量的类型,但仍然能够解码旧数据。

这种灵活性也用于指针。在传输之前,所有指针都被压平。int8、*int8、**int8、***int8等类型的值都作为整数值传输,然后可以将其存储在任何尺寸的int、*int或******int等中。

在解码结构体时,只有编码器发送的字段才会存储在目标字段中。给定值

type T struct{ X, Y, Z int } // 只有导出的(公有的)字段才会被编码然后解码。
var t = T{X: 7, Y: 0, Z: 8}

t的编码后的数据仅发送7和8。因为字段Y的值是零,所以它甚至不会被发送;没有必要发送零值。

相反,接收者可以将该值解码成这样的结构体:

type U struct{ X, Y *int8 } //注意:Y是*int8类型
var u U

并且仅设置X字段的值(int8类型的名为X的变量设置为7);Z字段被忽略了——没有哪个字段是Z?在解码结构体时,字段按名称和兼容的类型进行匹配,并且只有两者中都存在的字段才会受到影响。这种简单的方法巧妙地解决了“可选字段”问题:随着T类型通过添加字段而演变,过时的接收者仍可以使用它能够识别的类型的一部分来解码数据。因此,gob提供了可选字段的重要特性——可扩展性——而不需要任何额外的机制或注释。

使用整型数,我们可以构建所有其他类型:字节、字符串、数组、切片、映射,甚至浮点数。浮点数由IEEE 754标准的浮点位模式表示,存储为整数,只要你知道它的类型,它就可以正常工作。顺便说一句,该整数是以字节反转的顺序发送的,因为浮点数的常见值,如小整数,在低位端有很多零,我们可以避免发送这些零。

gob包的一个很好的特性是,它允许你通过让你的类型满足GobEncoderGobDecoder接口来定义自己的编码,行为类似于JSON包的MarshallerUnmarshaler函数,也类似于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
}

(这是给读者的一个练习,可以发现零默认规则(zero-defaulting rule)是如何实现这一点的,尽管gob并不呈现指针。)

有了类型信息,gob数据流是完全自我描述的,除了几个引导类型之外,引导类型是一个明确定义的起点。

构建一个小型解释器

第一次对给定类型的值进行编码时,gob包会构建一个特定于该数据类型的小型解释器。Go通过对该类型的反射来构建解释器,但是一旦构建了解释器,它就不依赖于反射。该解释器使用unsafe包和一些技巧将数据高速转换为编码字节。我们也可以使用反射并避免unsafe包,但速度会慢得多。(Go的protocol buffer实现也采用了类似的高速方法,其设计受到了gob实现的影响。)相同类型的后续值使用已经编译得到的解释器,因此可以立即对它们进行编码。

[更新:从Go 1.4开始,unsafe包不再被gob包使用,性能略有下降。]

解码的过程是相似的,但更难实现。当你解码一个值时,gob包会保存一个字节切片,代表给定编码器要解码的类型的值,再加上一个要解码的Go值。gob包也会构建一个解码器:gob类型与对应的解码器代码一起在网络上发送。然而,一旦解码器构建完成,由于它是一个不使用反射机制的引擎,使用不安全的方法来获得最大速度。

用法

虽然幕后做了很多事情,但gob是一个高效、易于使用的数据传输编码系统。下面是一个完整的示例,显示了对不同类型地编码和解码。注意发送和接收值是多么的容易;你所需要做的就是向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() {
    // 初始化编码器和解码器。一般来说,enc和dec会被绑定到网络连接,并且可能运行在不同的协程里。
    var network bytes.Buffer        // 代表一个网络连接
    enc := gob.NewEncoder(&network) // 将写入网络
    dec := gob.NewDecoder(&network) // 将从网络读取
    // 编码(发送)值
    err := enc.Encode(P{3, 4, 5, "Pythagoras"})
    if err != nil {
        log.Fatal("encode error:", err)
    }
    // 解码(接收)值
    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)
}

rpc包构建在gob之上,将这种编码/解码自动化转变为跨网络的方法调用以及数据传输。这将是另一篇文章的主题。

细节

gob包的文档,尤其是文件doc.go,扩展了这里描述的许多细节,并包括一个完整的示例,显示了编码如何表示数据。如果你对gob实现的内部结构感兴趣,那么这是一个很好的起点。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注