【翻译】Gob 的数据

原文在此:http://blog.golang.org/2011/03/gobs-of-data.html,来自 Golang 官方博客。

Gob 是 Golang 的包中带的一个数据结构序列化的编/解码工具。在实际应用中,已经有不少的编解码工具/包/库了,为什么 Golang 还要新开发一个 Gob?又是一个重复的轮子?Gob 做了哪些工作?Gob 的优势是什么?本文做了一个较为全面的解释。

—————-翻译分割线—————-

Gob 的数据

为了让某个数据结构能够在网络上传输或能够保存至文件,它必须被编码然后再解码。当然,已经有许多可用的编码方式了:JSON,XML,Google 的 protocol buffers,等等。而现在,又多了一种,由 Go 的 gob 包提供的方式。

为什么定义新的编码?这要做许多繁重的工作。为什么不使用某个现成的格式?呃,无论如何,我们这样做了!Go 已经有刚才提到的所有编码方式的包(protocol buffer 包在另外一个代码库中,但它是下载得最多的包之一)。并且在许多情况下,包括同其他语言编写的工具和系统通讯,这些都是正确的选择。

但是在特定的 Go 环境中,例如在两个 Go 编写的服务之间通讯,这需要某些东西使得其更加容易使用,并且可能更加有效率。

Gobs 协同 Go 语言的工作方式,对于那些外部定义的、同语言无关的编码方式来说无法做到。同时,从现有的系统中也吸取了很多教训。

目标

gob 包有在设计时有许多目标。

首先,也是最显然的,它被设计成为非常容易使用的。一方面,由于 Go 有反射(reflection),就没有必要弄一个单独的接口定义语言或“协议编译器”。数据结构本身提供了编码和解码所需要的全部信息。 另一方面,这种方法也意味着 gob 永远无法良好的同其他语言协同工作,但这没问题:gob 是厚颜无耻的以 Go 为中心(译注:呃,以XXX为中心,坚决贯彻XXX的领导……)。

效率也是非常重要的。基于文本形式的,如 XML 和 JSON ,应用于高效通讯网络会太慢了。二进制编码是必须的(译注:二进制神马的,是必须的!回音:必须的……)!

Gob 流必须可以自解释。每个 gob 流,从开始读取,整个流将由包含足够的信息,以便在终端对其内容毫不知情的前提下对整个流加以解析。这一特性意味,即便你忘记了保存在文件中的 gob 流表示什么,也总是可以对其解码。

同样,这里有一些从 Google protocol buffers 获得的经验。

Protocol buffer 的硬伤

Protocol buffers 对 gob 的设计产生了主要的影响,但是有三个特性被谨慎的避开了。(暂且不说 protocol buffer 不是自解释的:如果你不知道 protocol buffer 编码时的数据的定义,你就无法解析它。)

首先,protocol buffer 仅工作于 Go 的 struct 数据类型。不能在最顶级编码一个整数或者数组,只可以将其至于 struct 中作为一个字段。 至少在 Go 中,这个限制似乎没有什么意义。如果你希望传输的仅仅是一个数组或者整数,为什么你要先将其放到 struct 中?

其次,可能 protocol buffer 的定义指定字段 T.x 和 T.y 需要解析,无论是在编码还是解码类型 T 的值。虽然,这样的必须字段看起来是个好主意,但是由于编解码器中必须含有用于编码和解码的特定的数据结构,用于报告必须字段是否丢失,实现的开销是大的。这同样也产生了问题。过一段时间后,某人可能希望修改数据定义,移除了必须的字段,但这导致现有接收数据的客户端崩溃。最好是在编码时就根本没有这些字段。(Protocol buffer 也有可选字段。但是,如果我们没有必须字段,所有的字段就是可选的。等一下还会针对可选字段进行一些讨论。)

第三个 protocol buffer 的硬伤是默认值。当 protocol buffer 在某个“默认”字段上设置了默认值,而解码后的结构就像那个字段被设置了某个值一样。这个想法在有 getter 和 setter 控制字段的访问的时候非常棒,但是当容器是一个原始结构的时候就很难控制其保持清晰了。必须的字段也存在同样的麻烦:在哪定义默认值,它们的类型是什么(是UTF-8文本?无符号字节串?在浮点型中有几位?)尽管有许多看起来很简单,protocol buffer 的设计和实现还是有许多伴随的问题。我们决定让这些都远离 gob,并且回到我们的 Go 旅程中,一个很有效率的默认规则:除非你设置了一些内容,否则就是那个类型的“零值”,而这个不需要被传输。

所以 gob 最终看起来是个更加通用、简单的 protocol buffer。它又是如何工作的呢?

编码后的 gob 数据不是 int8 或者 uint16 的串。作为代替,其看起来更象是 Go 的常量,不论是有符号的还是无符号的整数值是虚拟的、无大小定义的数字。当你编码一个 int8 的时候,其值被转换为一个无大小定义的变长整数。当你对 int64 编码时,其值也是转换为一个无大小定义的变长整数。(有符号和无符号是相同处理区别对待的,但是无大小定义也适用于无符号值。)如果都是值 7,在线传输的位是一致的。当接收者解码其值,它将其放入接收者变量中,可能是任意的一个整数类型。因此,编码方发送了一个来自 int8 的 7,而接收方可能将其保存在 int64 中。这没有问题:这个值永远匹配于一个整数。(如果不匹配,会产生错误。)在变量的大小上解偶,为编码提供了一些灵活性:我们可以随着软件演化扩展整数类型,但是仍然可以解码旧的数据。

这种灵活性对于指针同样有效。在传输前,所有指针都进行整理。int8、*int8、**int8、****int8等等的值,被传输为可能被存储于任何大小的 int,或者 *int,或者 ******int等等的整数值。这同样是一种灵活性。

同样的原因,在解码一个 struct,当其字段由编码方发送,存储于目标方的时候,也体现出这种灵活性。给出这样一个值

type T struct { X, Y, Z int } // 只有导出字段(exported fields)被编码和解码。
var t = T{X: 7, Y: 0, Z: 8}

编码后仅发送 7 和 8。由于为零,Y 不会被发送;没有必要发送一个零值。

接收方可能用下面的结构解码:

type U struct { X, Y *int8 } // 注意:int8 的指针
var u U

而获得的 u 的值只有 X (值为 7 的 int8 变量的地址);Z 字段被忽略了——你应将其放到哪里呢?当解码一个 struct 的时候,字段会匹配其名字和类型,只有双方都有的字段会生效。这个简单的办法巧妙处理了“可选字段”问题:类型 T 添加了字段,过期的接收者仍然能处理它们知道的那部分。因此 gob 在可选字段上提供了重要的特性——无须任何额外的机制或标识。

从整数串可以构造其他类型:字节数组、字符串、数组、内存片段、Map,甚至浮点数组。IEEE 754 浮点位定义描述了浮点值存储为整数,在你知道其类型的时候,这会工作得很好,我们总是知道类型的吧。另外,这里的整数使用字节翻转的顺序发送,因为一般的浮点数字,就像是小整数数组,在低位上有许多个零是不用传递的。

gob 还有一个非常棒的特性是 Go 使得通过 GobEncoder 和 GobDecoder 接口使得自定义类型的编码成为可能,从某个意义上说类似于 JSON 包的 Marshaler 和 Unmarshaler,以及 fmt 包的 String 化接口。这个技巧使一些特殊功能成为可能,强制使用常量,或者传输数据的时候隐藏信息。阅读文档了解更多细节。

类型的传输

在第一次传输给定类型的时候,gob 包中包含了这个类型的描述。实际上,是这样的,编码器编码的是gob标准格式,而内部的 struct 则带有类型描述并给其标识一个唯一编号。(基本类型、类型描述结构的层级,在软件启动时已经定义好了。)在类型被描述后,它可以通过编号来引用。

因此,当我们发送类型 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
}

(这是一个让读者实践零默认值规则是如何工作的练习,尽管 gob 不会处理指针。)

带有了类型信息,gob 流就完全自说明了。除了那些初始类型,它们已经在开始的时候就定义好了。

编译机

在第一次传输给定类型的时候,gob 包会构造一个针对这个类型的小翻译机。在这个类型上使用了反射来构造这个翻译机,但是一旦翻译机构建完成,它就不再依赖反射。这个翻译机使用了 unsafe 和其他一些巧妙的机制来高速的将数据转化为编码后的字节流。也可以使用反射来避免 unsafe,但是会明显变慢。(受到 gob 实现的影响,Go 的 protocol buffer 使用了类似的机制提高速度。)而后的同样类型的值使用已经编译好的翻译机,这样就可以总是有一致的编码。

解码类似,但是略微复杂。当你解码一个数据,gob 包用一个字节片保存编码后的类型的值用于来解码,再加上得到解码的 Go 的值。gob 包构造一个翻译机用于这个过程:gob 类型在线传输用于 Go 类型的解码。一旦解码翻译机构造,一个没有反射的使用 unsafe 方法的引擎能提供最快的速度。

使用

在帽子里还有很多秘密,但是结果是得到一个用于数据传输的高效的,容易使用的编码系统。这里有一个完整的例子演示了不同类型的编码和解码。留意发送和接收数据是多么简单;你所需要做的一切,就是将值和变量置入 gob 包,然后它会完成所有的工作。

package main

import (
    "bytes"
    "fmt"
    "gob"
    "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)
}

你可以复制代码到 Go Playground 中,编译并执行这个例子。
rpc 包在 gob 的基础上将网络上的方法调用的编码/解码自动化。这是另一篇随笔的主题了。

细节

gob 包文档,尤其是 doc.go 文件解释了本文所说的许多细节,并且包含了完整的可运行的例子,用来演示如何对数据进行编码。如果你对 gob 的实现感兴趣,这是个不错的起点。

– Rob Pike, 三月 2011

Join the Conversation

10 Comments

  1. “有符号和无符号是相同处理的”原文是treated distinctly,应该是“有符号和无符号是区别对待的”

Leave a comment

Your email address will not be published. Required fields are marked *