2
头图

0 前言

JSON 是很多开发者工作中经常使用的数据格式,一般多用于配置文件或网络数据传递之类的场景。并且由于其简单易懂,可读性较好等特点,JSON 也成为了整个 IT 世界几乎最常见的格式之一了。对于这样的东西,Golang 和其他很多语言一样,也提供了标准库级别的支持,也就是 encoding/json

就像 JSON 本身简单易懂一样,用于操作 JSON 的 encoding/json 库也非常容易上手。但我相信许多小伙伴可能和我当初刚使用这个库时一样,都遇到过各种奇奇怪怪的问题或 bug。本文就我个人在使用 Golang 操作 JSON 时遇到的问题,犯的错误,做一个总结,希望能够帮助更多看到这篇文章的开发者能更顺利的掌握 Golang 操作 JSON 的技巧,少踩一些坑。

最后,本文的内容基于 Go 1.22 版本,不同版本之间可能会有些许差异,请读者们在阅读和使用时注意甄别。同时,本文所列举的所有案例使用的都是 encoding/json ,不涉及任何第三方 JSON 库。

1 基本使用

先简单讲一下 encoding/json 库的基本使用。

JSON 作为一种数据格式,它的核心动作就是两个:序列化,反序列化序列化就是把一个 Go 对象转化为 JSON 格式的字符串(或字节序列,这点区别不重要),反序列化则相反,把 JSON 格式的数据转化成 Go 对象

这里说的对象是一个广义的概念,不单指结构体对象,包括 slice、map 类型数据也支持 JSON 的序列化。

案例如下:

import (
    "encoding/json"
    "fmt"
)

type Person struct {
    ID   uint
    Name string
    Age  int
}

func MarshalPerson() {
    p := Person{
        ID:   1,
        Name: "Bruce",
        Age:  18,
    }
    output, err := json.Marshal(p)
    if err != nil {
        panic(err)
    }
    println(string(output))
}

func UnmarshalPerson() {
    str := `{"ID":1,"Name":"Bruce","Age":18}`
    var p Person
    err := json.Unmarshal([]byte(str), &p)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", p)
}

核心就是 json.Marshaljson.Unmarshal 两个函数,分别用于序列化和反序列化。两个函数都会返回 error,这里我简单的做了 panic。

用过 encoding/json 的读者可能知道,这个库还有一对比较常用的序列化组合: NewEncoderNewDecoder 。简单看过源码可以了解到,它俩的底层核心逻辑调用和 Marshal 它们是一样的,所以这里就不展开举例了。

2 坑

2.1 公开(public)成员字段

这点可能是所有刚熟悉 Go 或 JSON 库不久的开发者最容易犯的错了。即,如果我们用结构体来操作 JSON,那么结构体的成员字段必须为公开成员,也就是首字母大写,私有成员无法被解析

例子:

type Person struct {
    ID   uint
    Name string
    age  int
}

func MarshalPerson() {
    p := Person{
        ID:   1,
        Name: "Bruce",
        age:  18,
    }
    output, err := json.Marshal(p)
    if err != nil {
        panic(err)
    }
    println(string(output))
}

func UnmarshalPerson() {
    str := `{"ID":1,"Name":"Bruce","age":18}`
    var p Person
    err := json.Unmarshal([]byte(str), &p)
    if err != nil {
        panic(err)
    }
    fmt.Printf("%+v\n", p)
}

// Output Marshal:
{"ID":1,"Name":"Bruce"}

// Output Unmarshal:
{ID:1 Name:Bruce age:0}

这里 age 被设为了私有变量,于是序列化后的 JSON 串中没有 age 这个字段了。同理,从一个 JSON 字符串反序列化为 Person 后,也无法正确读取到 age 的值。

原因也很简单,如果我们深入 Marshal 的源码就能发现,它的底层实际上使用了反射对结构体对象进行动态解析:

// .../src/encoding/json/encode.go

func (e *encodeState) marshal(v any, opts encOpts) (err error) {
    // ...skip
    e.reflectValue(reflect.ValueOf(v), opts)
    return nil
}

而 Golang 从语言设计的层面上禁止反射访问结构体的私有成员,所以这种反射解析自然是失败的,反序列化同理。

2.2 少用 map

前文里提到,JSON 不仅能操作结构体,还能操作 slice、map 等类型的数据。slice 比较特殊,但 map 和结构体表现在 JSON 格式下其实是一样的:

{
    "ID": 1,
    "Name": "Bruce"
}

这种情况下,除非有特情况或需求,否则,少用 map。因为 map 会带来额外的开销,额外的代码量,以及额外的维护成本。

为什么?

首先,像上面的 Person 例子,由于 ID 和 Name 是不同类型,因此我们如果要用 map 反序列化这个 JSON 数据,就只能申明一个 map[string]any 类型的 map。any,也就是 interface{} ,就意味着我们如果要单独使用 Name 或 ID 时,需要用类型断言来转换类型:

var m map[string]any
// ...反序列化 JSON 数据,代码忽略...
// 获取成员
name, ok := m["Name"].(string)

类型断言本身就是一个额外的步骤,为防止 panic,我们还需要判断第二个参数 ok,这无疑增加了开发工作量以及代码负担。

另外,map 本身对数据就是无约束的。结构体中我们能够预先定义各成员字段以及类型,但 map 不行。这就意味着,我们只能通过文档或注释或代码本身来理解这个 map 里到底装了些什么东西。并且,结构体可以限制 JSON 数据的 key 和 value 类型不被乱改,而 map 同样无法约束 JSON 的变更,只能通过业务逻辑代码来检测。这其中的工作量和后期维护成本,想想就知道会有多少。

之所以我会提及这个坑,是因为我在使用 Go 开发之前,主语言是 Python。而 Python 嘛,你们懂的,没有结构体,只有 dict(map)来加载 JSON 数据。在我刚接触 Go 时,我也习惯性用 map 来与 JSON 交互。但因为 Go 是静态类型,必须要显式转换类型(类型断言),不能像 Python 一样直接用,就一度让我很头疼。

总之,少用,或尽量不要用 map 来操作 JSON。

2.3 小心结构体组合

Go 虽然面向对象,但没有 class ,只有结构体,并且结构体没有继承。因此 Go 采用了一种组合的方式来复用不同的结构体。很多时候,这种组合给我们带来了极大的便利,我们可以像操作结构体自己的成员一样去操作组合的其他结构体成员,就像这样:

type Person struct {
    ID   uint
    Name string
    address
}

type address struct {
    Code   int
    Street string
}

func (a address) PrintAddr() {
    fmt.Println(a.Code, a.Street)
}

func Group() {
    p := Person{
        ID:   1,
        Name: "Bruce",
        address: address{
            Code:   100,
            Street: "Main St",
        },
    }
    // 用 p 直接访问 Address 的成员和方法
    fmt.Println(p.Code, p.Street)
    p.PrintAddr()
}

// Output
100 Main St
100 Main St

很方便对吧,我也这么觉得。但当我们将组合融入到 JSON 的使用当中时,这里会有一个小坑需要注意。来看下面这段代码:

// 这里用的还是前面的结构体,就不重复写了。error 也不捕获了,节省篇幅。

func MarshalPerson() {
    p := Person{
        ID:   1,
        Name: "Bruce",
        address: address{
            Code:   100,
            Street: "Main St",
        },
    }
    // 用 MarshalIndent 打印更好看点
    output, _ := json.MarshalIndent(p, "", "  ")
    println(string(output))
}

func UnmarshalPerson() {
    str := `{"ID":1,"Name":"Bruce","address":{"Code":100,"Street":"Main St"}}`
    var p Person
    _ = json.Unmarshal([]byte(str), &p)
    fmt.Printf("%+v\n", p)
}

// Output MarshalPerson:
{
  "ID": 1,
  "Name": "Bruce",
  "Code": 100,
  "Street": "Main St"
}

// Ouptput UnmarshalPerson:
{ID:1 Name:Bruce address:{Code:0 Street:}}

这段代码信息量稍微有点多,我们一点一点来捋。

先看 MarshalPerson 函数。这里先申明了一个 Person 对象,然后用 MarshalIndent 美化一下序列化结果,并打印。从打印的结果中我们看到,整个 Person 对象被铺平了。对于 Person 结构体来说,尽管用了组合,但它看上去还是有一个 address 成员字段。所以有时候我们会想当然地以为 Person 序列化后的 JSON 长这样:

// 想象中的 JSON 序列化结果
{
  "ID": 1,
  "Name": "Bruce",
  "address": {
    "Code": 100,
    "Street": "Main St"
  }
}

但实际上并没有,它被铺平了。这一点倒是比较符合前面我们直接通过 Person 访问 address 成员时的感觉,即,address 的成员似乎直接变成了 Person 的成员。这是一个需要注意的地方,组合会让序列化后的 JSON 结果铺平。

另一个稍微有些反直觉的点是,address 结构体是一个私有结构体,而私有成员似乎不应该被序列化?没错,这就是组合这种形式有一点不太好的地方了:它会暴露私有组合对象的公共成员。所以这里就要注意了,这种暴露有时候是无意的,但它可能会造成不必要的数据泄漏

然后是 UnmarshalPerson 函数。有了上一个函数的解读,这个就好理解了,其实还是组合后 JSON 结果被铺平的问题。因此我们如果需要反序列化回 Person 时,也需要一个铺平后的 JSON 数据。

所以其实,我个人在这些年对 Go 的使用过程中,遇到这类需要转化成 JSON 的结构体时,通常不太会用组合,除非有一些特殊的情况。毕竟它太容易带来上面提及的问题了。并且,由于 JSON 是平铺的而结构体定义上没有平铺,一旦这个结构体组被定义的越来越复杂,那么它和原始铺平的 JSON 数据就越难去直观对比了,这样会使这个代码的可读性将直线下降。

如果没有特殊需求的话(譬如原始 JSON 数据就是平铺的,并且存在多个结构体有重复字段需要复用),从我个人的角度建议,尽量这么写:

type Person struct {
    ID      int
    Name    string
    Address address
}

当然,如果这个结构体并不涉及 JSON 序列化,那我还是更建议使用组合,确实方便。

2.4 部分成员反序列化时需要小心

直接看代码:

type Person struct {
    ID   uint
    Name string
}

// PartUpdateIssue 模拟了用同一个结构体解析两个不同的 JSON 字符串的场景
func PartUpdateIssue() {
    var p Person
    // 第一个数据有 ID 字段,且不为 0
    str := `{"ID":1,"Name":"Bruce"}`
    _ = json.Unmarshal([]byte(str), &p)
    fmt.Printf("%+v\n", p)
    // 第二个数据没有 ID 字段,再次用 p 反序列化,会保留上次的值
    str = `{"Name":"Jim"}`
    _ = json.Unmarshal([]byte(str), &p)
    // 注意输出的 ID 仍然是 1
    fmt.Printf("%+v\n", p)
}

// Output
{ID:1 Name:Bruce}
{ID:1 Name:Jim}

注释里解释的很明白了:当我们用同一个结构体去反复反序列化不同的 JSON 数据时,一旦某个 JSON 数据的值只包含部分成员字段的,那么未被覆盖到的成员就会残留上一次反序列化的值。其实就是个脏数据污染的问题。

这是一个很容易遭遇,而且一旦触发,又很隐蔽的问题。此前我曾写过一篇文章(Golang 中由零值和 gob 库的特性引起的 BUG),讲述的是 gob 库使用时遇到的一个类似的情况。当然 gob 的问题和零值有关,与今天讲的 JSON 问题还不太一样,但两者最终的表现比较像,都是部分成员字段被脏数据污染。

解决方案也很简单:每次反序列化 JSON 时,都使用全新的结构体对象来加载数据

总之,一定要小心这种情况。

2.5 处理指针成员

很多开发者一听到指针两个字就头大,其实大可不必,这玩意儿没那么复杂。但 Go 中的指针确确实实为开发者带来了 Go 程序里最常见一种 panic:空指针异常。而当指针和 JSON 结合在一起时,又会发生什么?

看这段代码:

type Person struct {
    ID      uint
    Name    string
    Address *Address
}

func UnmarshalPtr() {
    str := `{"ID":1,"Name":"Bruce"}`
    var p Person
    _ = json.Unmarshal([]byte(str), &p)
    fmt.Printf("%+v\n", p)
    // 下面这行会 panic
    // fmt.Printf("%+v\n", p.Address.Street)
}

// Output
{ID:1 Name:Bruce Address:<nil>}

我们将 Address 成员定义为一个指针,此时我们去反序列化一段不包含 Address 的 JSON 数据时,这个指针成员由于没有对应的数据,会被置为 nil。encoding/json 不会为该成员创建一个空的 &Address 。这个时候如果我们直接调用 p.Address.xxx ,程序就会因为 p.Address 为空而 panic。

所以,如果我们的结构体成员存在指针时,使用前请记得判断指针是否为空。这有些繁琐,但没有办法。毕竟与生产环境里的 panic 造成的损失相比,多写几行代码可能也没什么大不了的。

并且,在创建一个有指针字段的结构体时,指针字段的赋值也会相对麻烦一些:

type Person struct {
    ID   int    
    Name string 
    Age  *int   
}

func Foo() {
    p := Person{
        ID:   1,
        Name: "Bruce",
        Age:  new(int),
    }
    *p.Age = 20
    // ...
}

有人说了,那是不是建议 JSON 数据结构体的成员变量都尽量不要使用指针呢?这次我倒是不这么建议,因为指针确实有比非指针成员更适合的场景。一个是指针能够减少一些开销,另外就是下一节要讲的,零值相关的问题。

2.6 零值造成的混淆

所谓零值,是 Golang 中变量的一个特性,我们可以简单理解为默认值。即如果我们没有显式地为某个变量赋值,则 Golang 为为其赋一个默认值。譬如前文的例子中我们已经看到的,int 默认值 0,string 空字符串,指针零值为 nil 等等。

那么对于 JSON 的处理,零值又有什么坑呢?

看下面的例子:

type Person struct {
    Name        string
    ChildrenCnt int
}

func ZeroValueConfusion() {
    str := `{"Name":"Bruce"}`
    var p Person
    _ = json.Unmarshal([]byte(str), &p)
    fmt.Printf("%+v\n", p)

    str2 := `{"Name":"Jim","ChildrenCnt":0}`
    var p2 Person
    _ = json.Unmarshal([]byte(str2), &p2)
    fmt.Printf("%+v\n", p2)
}

// Output
{Name:Bruce ChildrenCnt:0}
{Name:Jim ChildrenCnt:0}

我们在 Person 结构体中添加了一个 ChildrenCnt 字段,用于统计该人物的子女数量。由于零值的存在,当 p 加载的 JSON 数据里没有 ChildrenCnt 数据时,该字段被赋予 0。此时就产生了误解:我们无法将这种数据缺失的对象,与子女数确实为 0 的对象区分开。如例子里的 Bruce 和 Jim,一个是数据缺失导致的子女数为 0,另一个是本来就为 0。而实际上 Bruce 的子女数量应该是“未知“,我们如果真当作 0 处理,在业务上可能就会产生问题。

这样的混淆在一些对数据要求严格的场景下是非常致命的。那么有没有什么办法能避免这种零值的干扰?还真有,就是上一节最后遗留的指针的使用场景。

我们把 PersonChildrenCnt 类型改为 *int ,看看会发生什么:

type Person struct {
    Name        string
    ChildrenCnt *int
}

// Output
{Name:Bruce ChildrenCnt:<nil>}
{Name:Jim ChildrenCnt:0xc0000124c8}

区别产生了。Bruce 没有数据,所以 ChildrenCnt 是个 nil,而 Jim 则是一个非空指针。此时就能明确地知晓,Bruce 的子女数量是未知了。

本质上这种方式还是利用了零值,指针的零值。这也算是用魔法打败魔法吧(大笑)。

2.7 标签的坑

终于讲到了标签。标签也是 Golang 中一个非常重要的特性,并且常与 JSON 相伴。而且其实用过 Go 标签的读者们应该知道,标签其实是一个非常灵活、好用的东西。那这样的好特性,在使用上会有什么坑要注意呢?

一个是名称问题。Tag 可以指定 JSON 数据中字段的名称显示,这点很灵活且实用,但它同时也容易出错,并且一定程度上对程序员本身增加了一些职业素养的要求。

譬如某个程序员有意或无意地定义了这么一个结构体:

type PersonWrong struct {
    FirstName string `json:"last_name"`
    LastName  string `json:"first_name"`
}

Tag 对调了 FirstName 和 LastName。遇到这样的代码你会不会想把这个程序员打一顿?别说,我还真在生产环境的代码中遇到过类似的。当然那次是无意的,属于某次代码变更时的失误。然而真遇到这种情况的时候,这样的 bug 通常也不太容易定位。主要是因为,这谁特么能想到?

反正各位读者千万别这么干,写的时候还是得多加留意。

另一个问题则与 omitempty + 零值的组合有关,看代码:

type Person struct {
    Name        string `json:"person_name"`
    ChildrenCnt int    `json:"cnt,omitempty"`
}

func TagMarshal() {
    p := Person{
        Name:        "Bruce",
        ChildrenCnt: 0,
    }
    output, _ := json.MarshalIndent(p, "", "  ")
    println(string(output))
}

// Output
{
  "person_name": "Bruce"
}

看出问题了么?我们在新建结构体对象 p 时,为 ChildrenCnt 赋值为 0。而因为 omitempty 标签的存在,它使得 JSON 被序列化或反序列化时,忽略空(empty)值。在序列化时的表现就是,输出的 JSON 数据里不包含 ChildrenCnt,看上去就像是没有这个数据。什么是空值?对了,就是零值

于是熟悉的混淆又产生了:Bruce 的子女数量为 0,并非没有数据。而输出的 JSON 则表示 Bruce 的子女数据不存在。

反序列化存在同样的问题,就不举例了。

这种 omitempty 的问题又该怎么解决呢?由于本质上还是零值惹得祸,所以,用指针。

3 总结

本文列举了 7 个使用 encoding/json 库时容易犯的错,这些问题我自己在工作中基本上都遇到过。如果你还没有遭遇过它们,那么恭喜你!同时也提醒你今后要小心对待 JSON;如果你也遇到过这些问题,并且为其感到困惑,希望这篇文章能够帮助到你。

本人技术有限,文章若有任何错误或不清晰的地方,还请各位不吝之处,感谢!


程序员小杜
1.3k 声望37 粉丝

会写 Python,会写 Go,正在学 Rust。