在 Go 中使用 gob 来传输数据
Rob Pike
2011 年 3 月 24 日
介绍#
为了在网络上传输数据结构或将其存储在文件中,必须对其进行编码,然后再次对其进行解码。当然,有许多可用的编码形式: JSON, XML, Google 的 协议缓冲区 等。现在又多了一个 Go 的 gob 程序包.
为什么要定义新的编码?这项工作繁重并且很多余。为什么不只使用现有格式之一?好吧,一方面,我们做到了!Go 的 packages 支持刚才提到的所有编码 (protocol 缓冲软件包 位于单独的存储库中,但是下载次数最多的一种). 出于多种目的,包括与使用其他语言编写的工具和系统进行通信,它们是正确的选择.
但是对于特定于 Go 的环境,例如在用 Go 编写在两个服务器之间进行通信的程序,就有机会构建更易于使用且可能更高效的东西.
使用 Gobs 时外部定义的独立于语言的编码无法使用这种语言。另外从现有系统中可以吸取了一些经验和教训.
目标#
Gob 程序包设计之初考虑了很多.
首先,也是最明显的,它必须非常易于使用。首先,由于 Go 具有反射功能,因此不需要单独的接口定义语言或 "协议编译器". 数据结构本身就是程序包需要弄清楚如何对其进行编码和解码的所有操作。另一方面,这种方法意味着 gobs 无法与其他语言一起很好地工作,但这没关系: gobs 就是死不要脸地围着 Go 团团转.
效率也很重要。以 XML 和 JSON 为例的文本表示太慢,无法放在高效通信网络的中心。二进制编码是必需的.
Gob 必须是自描述的。从一开始就读取的每个 gob 流都包含足够的信息,可以由不知道其内容先验的代理可以解析整个流。此属性意味着即使忘记了它代表的数据很长时间,您也将始终能够解码存储在文件中的 gob 流.
我们还从 Google 协议缓冲的经验中学习到了一些东西.
协议缓冲功能不足#
协议缓冲区对 gobs 的设计有很大影响,但是有三个功能是有意规避的. (撇开协议缓冲区不是自描述的属性:如果您不知道用于编码协议缓冲区的数据定义,则你可能无法解析它.)
首先,协议缓冲区仅适用于我们在 Go 中称为结构体的数据类型。您不能在顶级对整数或数组进行编码,而只能对其中具有字段的结构进行编码。至少在 Go 中,这似乎是毫无意义的限制。如果要发送的是整数数组,为什么必须首先将其放入结构中?
接下来,协议缓冲区定义可以指定每当对 T
类型的值进行编码或解码时,都必须存在字段 T.x
和 T.y
. 尽管这样的必填字段似乎是个好主意,但实现成本很高,因为编解码器在编码和解码时必须维护单独的数据结构,以便在缺少必填字段时进行报告。这在后续维护时也是一个问题。随着时间的流逝,人们可能想修改数据定义以删除必填字段,但这可能会导致数据的现有客户端崩溃。最好不要在编码中完全包含它们. (协议缓冲区也有可选字段。但是,如果我们没有必填字段,那么所有字段都是可选字段,仅此而已。稍后将对可选字段进行更多说明.)
第三个协议缓冲区不足之处是默认值。如果协议缓冲区省略了 "默认" 字段的值,则解码后的结构的行为就像该字段被设置为该值一样。当您具有 getter 和 setter 方法来控制对字段的访问时,此想法很好地工作,但是当容器只是一个普通的惯用结构时,则很难干净地处理它。必填字段的实现也很棘手:在哪里定义默认值,它们具有什么类型 (文本 UTF-8? 未解释的字节?浮点数是多少?), 尽管看起来很简单,但还是有很多协议缓冲区的设计和实现中的复杂性。我们决定将它们排除在 gob 之外,然后回到 Go 的琐碎但有效的默认规则:除非另行设置,否则它具有该类型的 "零值" - 不需要传输.
所以 gob 最终看起来像是一种广义的简化协议缓冲区。那么它们如何工作?
值#
编码后的 gob 数据与 int8
和 uint16
之类的类型无关。取而代之的是,它有点类似于 Go 中的常量,它的整数值是有符号或无符号的抽象无大小数。当您对 int8
进行编码时,其值将作为无大小的可变长度整数传输。当您对 int64
进行编码时,其值也将作为无大小的可变长度整数传输. (有符号和无符号被区别对待,但相同的 unsize-ness 也适用于无符号值.) 如果两个值都为 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 } // 注意: 指向 int8s 的指针
var u U
并获取仅设置 X
的值 u
的值 (将 int8
变量的地址设置为 7); Z
字段将被忽略 - 您将其放在哪里呢?解码结构时,字段将按名称和兼容类型进行匹配,并且仅影响两个字段中都存在的字段。这种简单的方法解决了 "可选字段" 的问题:随着类型 T
通过添加字段而发展,过期的接收者仍将使用它们识别的部分类型。因此,gob 提供了可选字段的重要结果 - 可扩展性 - 无需任何其他机制或符号.
通过整数,我们可以构建所有其他类型:字节,字符串,数组,切片,映射,甚至是浮点数。浮点值由它们的 IEEE 754 浮点位模式表示,以整数形式存储,只要您知道它们的类型,它就可以正常工作。顺便说一下,该整数以字节相反的顺序发送,因为浮点数的公共值 (例如小整数) 在低端有很多零,我们可以避免发送这些零.
Go 使得 gob 的一个不错的功能是它们允许您通过自行定义编码来满足你的类型需求 GobEncoder 和 GobDecoder 接口,其方式类似于 JSON 包的 Marshaler 和 Unmarshaler 以及 Stringer 接口来自程序包 fmt. 此功能使您可以在传输数据时表示特殊功能,强制执行约束或隐藏秘密。有关更多信息,可参阅 文档.
线上的类型#
第一次发送给定类型时,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
}
(这是一个让读者发现零默认规则如何使这项工作起作用的练习,即使 gob 不代表指针亦是如此.)
使用类型信息,gob 数据流完全自描述,除了引导程序类型集 (这是一个定义明确的起点) 之外.
编译机器#
第一次对给定类型的值进行编码时,gob 包会构建一个特定于该数据类型的解释机。它在类型上使用反射来构造该机器,但是一旦构建了机器,它就不再依赖于反射。机器使用不安全的软件包和一些技巧来将数据高速转换为编码的字节。它可以使用反射并避免不安全的情况,但速度会大大降低. (Go 的协议缓冲区支持采用了类似的高速方法,其设计受 gob 的实现影响.) 相同类型的后续值使用已编译的机器,因此可以立即对其进行编码.
[更新:从 Go 1.4 开始,gob 软件包不再使用 unsafe 程序包,但性能会有所下降.]
解码是类似的,但是比较困难。解码值时,gob 数据包将保留一个字节片,该字节片表示要解码的给定编码器定义的类型的值以及要对其解码的 Go 值. gob 程序包为该对设备构建了一个机器:通过有线传输的 gob 类型与为解码提供的 Go 类型交叉。但是,一旦构建了解码器,它便再次成为无反射引擎,它使用不安全的方法来获得最大速度.
使用#
虽然幕后花了很多时间,但是结果是一个有效的,易于使用的用于传输数据的编码系统。这是一个完整的示例,显示了不同的编码和解码类型。注意发送和接收值有多容易。您需要做的就是为 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}.", q.Name, *q.X, *q.Y)
}
您可以在 Go Playground 中编译并运行此示例代码.
rpc 程序包 构建于 gob 之上,可将这种编码 / 解码自动化转变为通过网络进行方法调用的传输。但这是另一篇文章的主题了.
细节#
gob 程序包文档 , 尤其是文件 doc.go, 对此处描述的许多细节进行了扩展,并包括一个完整的示例,展示了编码如何表示数据。如果您对 gob 实现的内在方式感兴趣,那么这是一个不错的起点.
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: