Go 博客
大量数据
引言
要通过网络传输数据结构或将其存储在文件中,必须先对其进行编码,然后再进行解码。当然,有许多可用的编码方式:JSON、XML、Google 的 protocol buffers 等等。现在又多了一种,由 Go 的 gob 包提供。
为什么要定义一种新的编码方式?这很费力,而且是多余的。为什么不直接使用现有的格式之一呢?嗯,首先,我们确实在使用!Go 拥有支持所有这些已提及编码的包(protocol buffer 包位于单独的仓库中,但它是下载次数最多的之一)。对于许多目的,包括与其他语言编写的工具和系统进行通信,它们是正确的选择。
但对于特定于 Go 的环境,例如在两个用 Go 编写的服务器之间进行通信,就有机会构建一种更易于使用且可能更高效的解决方案。
Gob 的工作方式能够与语言深度集成,而外部定义的、与语言无关的编码方式则无法做到。同时,也可以从现有系统中吸取经验教训。
目标
gob 包的设计考虑了几个目标。
首先,也是最明显的,它必须非常易于使用。首先,由于 Go 具有反射机制,因此无需单独的接口定义语言或“协议编译器”。数据结构本身就是包需要了解如何对其进行编码和解码的全部信息。另一方面,这种方法意味着 gob 与其他语言的兼容性永远不如与其他语言无关的编码方式好,但这没关系:gob 毫不掩饰地以 Go 为中心。
效率也很重要。以 XML 和 JSON 为例的文本表示对于构成高效通信网络的中心来说太慢了。二进制编码是必不可少的。
Gob 流必须是自描述的。每个 gob 流从头开始读取,都包含足够的信息,以便能够由一个事先不知道其内容的代理来解析整个流。这个属性意味着您将始终能够解码存储在文件中的 gob 流,即使在您忘记它代表什么数据很久之后。
从我们使用 Google protocol buffers 的经验中也吸取了一些教训。
Protocol buffer 的不足之处
Protocol buffers 对 gob 的设计产生了重大影响,但有三个特性被刻意避免。(暂且不考虑 protocol buffers 不自描述的特性:如果您不知道用于编码 protocol buffer 的数据定义,您可能无法解析它。)
首先,protocol buffers 只处理我们在 Go 中称为 struct 的数据类型。您不能在顶层编码一个整数或数组,只能编码一个包含字段的 struct。这似乎是一种不必要的限制,至少在 Go 中是这样。如果您只想发送一个整数数组,为什么必须先将其放入一个 struct 中?
接下来,protocol buffer 定义可以指定在编码或解码类型 `T` 的值时,字段 `T.x` 和 `T.y` 必须存在。尽管这种必需字段可能看起来是个好主意,但它们的实现成本很高,因为编解码器在编码和解码时必须维护一个单独的数据结构,以便能够报告必需字段的缺失。它们也是一个维护问题。随着时间的推移,您可能希望修改数据定义以删除必需字段,但这可能会导致现有客户端崩溃。最好根本不要在编码中包含它们。(Protocol buffers 也有可选字段。但如果我们没有必需字段,所有字段都是可选的,就这样。稍后将会有更多关于可选字段的内容。)
第三个 protocol buffer 的不足之处是默认值。如果 protocol buffer 省略了“已默认”字段的值,那么解码后的结构的行为就好像该字段被设置为该值一样。这个想法在您拥有 getter 和 setter 方法来控制字段访问时效果很好,但在容器只是一个普通的惯用法 struct 时,处理起来更困难。必需字段的实现也很棘手:默认值在哪里定义,它们的类型是什么(文本是 UTF-8?未解释的字节?一个浮点数有多少位?)尽管表面上很简单,但在 protocol buffers 的设计和实现中存在许多复杂性。我们决定将它们排除在 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 } // 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` 被设置(指向一个设置为 7 的 `int8` 变量的地址);`Z` 字段被忽略——您能把它们放在哪里呢?在解码 struct 时,字段是按名称和兼容类型匹配的,只有同时存在的字段才会受到影响。这种简单的方法解决了“可选字段”问题:随着类型 `T` 通过添加字段而演变,过时的接收者仍然可以处理它们识别的部分类型。因此,gob 在没有任何额外机制或符号的情况下,提供了可选字段的重要结果——可扩展性。
从整数我们可以构建所有其他类型:字节、字符串、数组、切片、映射,甚至是浮点数。浮点数值由其 IEEE 754 浮点数位模式表示,存储为整数,只要知道它们的类型(我们总是知道),就可以正常工作。顺便说一下,这个整数是以字节反转的顺序发送的,因为浮点数的常见值,例如小整数,在低位有很多零,我们可以避免传输它们。
gob 的一个不错的功能是 Go 使之成为可能,即允许您通过让您的类型满足 GobEncoder 和 GobDecoder 接口来定义您自己的编码,这与 JSON 包的 Marshaler 和 Unmarshaler 以及 fmt 包的 Stringer 接口类似。此功能可以表示特殊功能、强制约束或在传输数据时隐藏秘密。有关详细信息,请参阅文档。
线上的类型
第一次发送给定类型时,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`,但速度会明显慢一些。(Go 的 protocol buffer 支持也采用了类似的超高速方法,其设计受到了 gob 实现的影响。)后续相同类型的值会使用已编译好的机器,因此可以立即进行编码。
[更新:截至 Go 1.4,`unsafe` 包不再由 gob 包使用,性能略有下降。]
解码是类似的,但更难。当您解码一个值时,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!
博客索引