今天小编给大家分享一下Golang中由零值和gob库特性引起BUG怎么解决的相关知识点,内容详细,逻辑清晰,相信大部分人都还太了解这方面的知识,所以分享这篇文章给大家参考一下,希望大家阅读完这篇文章后有所收获,下面我们一起来了解一下吧。
1 gob 与零值
先简单介绍一下
gob
和零值。1.1 零值
零值是 Go 语言中的一个特性,简单说就是:Go 语言会给一些没有被赋值的变量提供一个默认值。譬如下面这段代码:
package main
import (
"fmt"
)
type person struct {
name string
gender int
age int
}
func main() {
p := person{}
var list []byte
var f float32
var s string
var m map[string]int
fmt.Println(list, f, s, m)
fmt.Printf("%+v", p)
}
/* 结果输出
[] 0 map[]
{name: gender:0 age:0}
*/
零值在很多时候确实为开发者带来了方便,但也有许多不喜欢它的人认为零值的存在使得代码从语法层面上不严谨,带来了一些不确定性。譬如我即将在后文中详细描述的问题。
1.2 gob
gob
是 Go 语言自带的标准库,在encoding/gob
中。gob
其实是go binary
的简写,因此从它的名称我们也可以猜到,gob
应当与二进制相关。实际上
gob
是 Go 语言独有的以二进制形式序列化和反序列化程序数据的格式,类似 Python 中的 pickle
。它最常见的用法是将一个对象(结构体)序列化后存储到磁盘文件,在需要使用的时候再读取文件并反序列化出来,从而达到对象持久化的效果。例子我就不举了,本篇也不是
gob
的使用专题。这是它的**官方文档**,对gob
用法不熟悉的朋友们可以看一下文档中的Example
部分,或者直接看我后文中描述问题用到的例子。2 问题
2.1 需求
在本文的开头,我简单叙述了问题的起源,这里我用一个更简单的模型来展开描述。
首先我们定义一个名为
person
的结构体:type person struct {
// 和 json 库一样,字段首字母必须大写(公有)才能序列化
ID int
Name string // 姓名
Gender int // 性别:男 1,女 0
Age int // 年龄
}
围绕这个结构体,我们会录入若干个人员信息,每一个人员都是一个
person
对象。但出于一些原因,我们必须使用gob
将这些人员信息持久化到本地磁盘,而不是使用 MySQL 之类的数据库。接着,我们有这样一个需求:
遍历并反序列化本地存储的
gob
文件,然后判断男女性别的数量,并统计。2.2 代码
根据上面的需求和背景,代码如下(为了节省篇幅,这里省略了
package, import, init()
等代码):defines.go
// .gob 文件所在目录
const DIR = "./persons"
type person struct {
// 和 json 库一样,字段首字母必须大写(公有)才能序列化
ID int
Name string // 姓名
Gender int // 性别:男 1,女 0
Age int // 年龄
}
// 需要持久化的对象们
var persons = []person{
{0, "Mia", 0, 21},
{1, "Jim", 1, 18},
{2, "Bob", 1, 25},
{3, "Jenny", 0, 16},
{4, "Marry", 0, 30},
}
serializer.go
// serialize 将 person 对象序列化后存储到文件,
// 文件名为 ./persons/${p.id}.gob
func serialize(p person) {
filename := filepath.Join(DIR, fmt.Sprintf("%d.gob", p.ID))
buffer := new(bytes.Buffer)
encoder := gob.NewEncoder(buffer)
_ = encoder.Encode(p)
_ = ioutil.WriteFile(filename, buffer.Bytes(), 0644)
}
// unserialize 将 .gob 文件反序列化后存入指针参数
func unserialize(path string, p *person) {
raw, _ := ioutil.ReadFile(path)
buffer := bytes.NewBuffer(raw)
decoder := gob.NewDecoder(buffer)
_ = decoder.Decode(p)
}
main.go
func main() {
storePersons()
countGender()
}
func storePersons() {
for _, p := range persons {
serialize(p)
}
}
func countGender() {
counter := make(map[int]int)
// 用一个临时指针去作为文件中对象的载体,以节省新建对象的开销。
tmpP := &person{}
for _, p := range persons {
// 方便起见,这里直接遍历 persons ,但只取 ID 用于读文件
id := p.ID
filename := filepath.Join(DIR, fmt.Sprintf("%d.gob", id))
// 反序列化对象到 tmpP 中
unserialize(filename, tmpP)
// 统计性别
counter[tmpP.Gender]++
}
fmt.Printf("Female: %+v, Male: %+v
", counter[0], counter[1])
}
执行代码后,我们得到了这样的结果:
// 对象们
var persons = []person{
{0, "Mia", 0, 21},
{1, "Jim", 1, 18},
{2, "Bob", 1, 25},
{3, "Jenny", 0, 16},
{4, "Marry", 0, 30},
}
// 结果输出
Female: 1, Male: 4
嗯?1 个女性,4 个男性?
BUG
出现了,这样的结果显然与我们的预设数据不符。是哪里出了问题?2.3 定位
我们在
countGender()
函数中的for
循环里添加一行打印语句,将每次读取到的person
对象读出来,然后得到了这样的结果:// 添加行
fmt.Printf("%+v
", tmpP)
// 结果输出
&{ID:0 Name:Mia Gender:0 Age:21}
&{ID:1 Name:Jim Gender:1 Age:18}
&{ID:2 Name:Bob Gender:1 Age:25}
&{ID:3 Name:Jenny Gender:1 Age:16}
&{ID:4 Name:Marry Gender:1 Age:30}
好家伙,Jenny 和 Marry 都给变成男人了!但神奇的是,除了
Gender
这一项外,其他所有的数据都正常!看到这一结果,如果大家和我一样,平时经常和 JSON、Yml 之类的配置文件打交道,很可能会想当然地认为:上面的 gob 文件读取正常,应当是存储出了问题。但
gob
文件是二进制文件,我们难以像 JSON 文件那样用肉眼去验证。即便在 Linux 下使用xxd
之类的工具,也只能得到这样一种模棱两可的输出:>$ xxd persons/1.gob
0000000: 37ff 8103 0101 0670 6572 736f 6e01 ff82 7......person...
0000010: 0001 0401 0249 4401 0400 0104 4e61 6d65 .....ID.....Name
0000020: 010c 0001 0647 656e 6465 7201 0400 0103 .....Gender.....
0000030: 4167 6501 0400 0000 0eff 8201 0201 034a Age............J
0000040: 696d 0102 0124 00 im...$.
>$ xxd persons/0.gob
0000000: 37ff 8103 0101 0670 6572 736f 6e01 ff82 7......person...
0000010: 0001 0401 0249 4401 0400 0104 4e61 6d65 .....ID.....Name
0000020: 010c 0001 0647 656e 6465 7201 0400 0103 .....Gender.....
0000030: 4167 6501 0400 0000 0aff 8202 034d 6961 Age..........Mia
0000040: 022a 00 .*.
也许我们可以尝试去硬解析这几个二进制文件,来对比它们之间的差异;或者反序列化两个除了 Gender 外一模一样的对象到
gob
文件中,然后对比。大家如果有兴趣的话可以尝试一下。当时的我们因为时间紧迫等原因,没有尝试这种做法,而是修改数据继续测试。2.4 规律
由于上文中出问题的两个数据都是女性,程序员的直觉告诉我这也许并不是巧合。于是我尝试修改数据的顺序,将男女完全分开,然后进行测试:
// 第一组,先女后男
var persons = []person{
{0, "Mia", 0, 21},
{3, "Jenny", 0, 16},
{4, "Marry", 0, 30},
{1, "Jim", 1, 18},
{2, "Bob", 1, 25},
}
// 结果输出
&{ID:0 Name:Mia Gender:0 Age:21}
&{ID:3 Name:Jenny Gender:0 Age:16}
&{ID:4 Name:Marry Gender:0 Age:30}
&{ID:1 Name:Jim Gender:1 Age:18}
&{ID:2 Name:Bob Gender:1 Age:25}
// 第二组,先男后女
var persons = []person{
{1, "Jim", 1, 18},
{2, "Bob", 1, 25},
{0, "Mia", 0, 21},
{3, "Jenny", 0, 16},
{4, "Marry", 0, 30},
}
// 结果输出
&{ID:1 Name:Jim Gender:1 Age:18}
&{ID:2 Name:Bob Gender:1 Age:25}
&{ID:2 Name:Mia Gender:1 Age:21}
&{ID:3 Name:Jenny Gender:1 Age:16}
&{ID:4 Name:Marry Gender:1 Age:30}
吊诡的现象出现了,先女后男时,结果一切正常;先男后女时,男性正常,女性全都不正常,甚至 Mia 原本为 0 的 ID 这里也变成了 2!
经过反复地测试和对结果集的观察,我们得到了这样一个有规律的结论:所有男性数据都正常,出问题的全是女性数据!
进一步公式化描述这个结论就是:如果前面的数据为非 0 数字,同时后面的数据数字为 0 时,则后面的 0 会被它前面的非 0 所覆盖。
3 答案
再次审计程序代码,我注意到了这一句:
// 用一个临时指针去作为文件中对象的载体,以节省新建对象的开销。
tmpP := &person{}
为了节省额外的新建对象的开销,我用了同一个变量来循环加载文件中的数据,并进行性别判定。结合前面我们发现的 BUG 规律,答案似乎近在眼前了:所谓后面的数据 0 被前面的非 0 覆盖,很可能是因为使用了同一个对象加载文件,导致前面的数据残留。
验证的方法也很简单,只需要将那个公共对象放到下面的
for
循环里,使每一次循环都重新创建一个对象用于加载文件数据,以切断上一个数据的影响。我们修改一下代码(省略了多余部分):
for _, p := range persons {
// ...
tmpP := &person{}
// ...
}
// 结果输出
&{ID:0 Name:Mia Gender:0 Age:21}
&{ID:1 Name:Jim Gender:1 Age:18}
&{ID:2 Name:Bob Gender:1 Age:25}
&{ID:3 Name:Jenny Gender:0 Age:16}
&{ID:4 Name:Marry Gender:0 Age:30}
Female: 3, Male: 2
对了!
结果确实如我们推想,是数据残留的原因。但这里又有一个问题了:为什么先 0 后非 0 (先女后男)的情况下,老方法读取的数据又一切正常呢?以及,除了 0 会被影响外,其他的数字(年龄)又都不会被影响?
所有的问题现在似乎都在指向 0 这个特殊数字!
直到此时,零值这个特性才终于被我们察觉。于是我赶紧阅读了
gob
库的**官方文档**,发现了这么一句话:If a field has the zero value for its type (except for arrays; see above), it is omitted from the transmission.
翻译一下:
如果一个字段的类型拥有零值(数组除外),它会在传输中被省略。
这句话的前后文是在说
struct
,因此这里的field
指的也是结构体中的字段,符合我们文中的例子。根据我们前面得到的结论,以及官方文档的说明,我们现在终于可以得出一个完整的结论了:
gob
库在操作数据时,会忽略数组之外的零值。而我们的代码一开始使用一个公共对象来加载文件数据,由于零值不被传输,因此原数据中为零值的字段就不会读到,我们看到的实际上是上一个非零值的对象数据。解决方法也很简单,就是我上面做的,不要使用公共对象去加载就好了。