Go中JSON操作的5个常见陷阱

前言

JSON 是许多开发人员在工作中经常使用的一种数据格式。它一般用于配置文件或网络数据传输等场景。由于其简单、易懂、可读性好,JSON 已成为整个 IT 界最常用的格式之一。对于这种情况,Golang 和许多其他语言一样,也提供了标准库级别的支持,

就像 JSON 本身很容易理解一样,用于操作 JSON 的编码/JSON 库也非常容易使用。但我相信,很多开发者可能会像我第一次使用这个库时一样,遇到各种奇怪的问题或 bug。本文总结了我个人在使用 Golang 操作 JSON 时遇到的问题和错误。希望能帮助更多阅读本文的开发者掌握 Golang 的使用技巧,更正确地操作 JSON,避免掉入不必要的"坑"。

本文内容基于 Go 1.22。不同版本之间可能存在细微差别。读者在阅读和使用时请注意。同时,本文列出的所有案例均使用 encoding/json,不涉及任何第三方 JSON 库。

基本用法

先来看下 encoding/json 的基本用法。作为一种数据格式,JSON 的核心操作只有两个:序列化和反序列化。序列化是将 Go 对象转换为 JSON 格式的字符串(或字节序列)。反序列化则相反,是将 JSON 格式的数据转换成 Go 对象。

这里提到的对象是一个宽泛的概念。它不仅指结构对象,还包括切片和映射类型的数据。它们也支持 JSON 序列化。

package main

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,分别用于序列化和反序列化。这两个函数都会返回错误,在这里我只是简单地 panic 一下。使用过 encoding/json 的读者可能知道,还有另一对工具会经常用到:NewEncoder 和 NewDecoder。简单看一下源代码就会发现,这两个工具的底层核心逻辑调用与 Marshal 是一样的,所以我就不在这里举例说明了。

常见的坑

1. public or private 字段处理

这可能是刚接触 Go 的开发人员最容易犯的错误。也就是说,如果我们使用结构体处理 JSON,那么结构体的成员字段必须是公有的,即首字母大写的,而私有成员是无法解析的。

例如:

package main

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)
}

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

在这里,age 被设置为私有变量,因此序列化的 JSON 字符串中没有 age 字段。同样,在将 JSON 字符串反序列化为 Person 时,也无法正确读取 age 的值。原因很简单。如果我们深入研究 Marshal 下的源代码,就会发现它实际上使用了 reflect 来动态解析 struct 对象:

// .../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. 警惕结构体组合

Go 是面向对象的,但它没有类,只有结构,而结构没有继承性。因此,Go 使用一种组合来重用不同的结构。在许多情况下,这种组合非常方便,因为我们可以像操作结构本身的成员一样操作组合中的其他成员,就像下面这样:

package main

import (
	"fmt"
)

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",
		},
	}
	// Access all address's fields and methods directly
	fmt.Println(p.Code, p.Street)
	p.PrintAddr()
}

// Output
//100 Main St
//100 Main St

结构体组合使用起来确实挺方便。不过,在使用 JSON 解析时,我们需要注意一个小问题。请看下面的代码:

package main

import (
	"encoding/json"
	"fmt"
)

// The structure used here is the same as the previous one,
// so I won't repeat it. error is also not captured to save space.

func MarshalPerson() {
	p := Person{
		ID:   1,
		Name: "Bruce",
		address: address{
			Code:   100,
			Street: "Main St",
		},
	}
	// It would be more pretty by 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:}}

这里先声明一个 Person 对象,然后使用 MarshalIndent 美化序列化结果并打印出来。从打印输出中我们可以看到,整个 Person 对象都被扁平化了。就 Person 结构而言,尽管进行了组合,但它看起来仍有一个地址成员字段。因此,有时我们会想当然地认为 Person 的序列化 JSON 看起来是这样的:

// The imagine of JSON serialization result
{
  "ID": 1,
  "Name": "Bruce",
  "address": {
    "Code": 100,
    "Street": "Main St"
  }
}

但事实并非如此,它被扁平化了。这更符合我们之前直接通过 Person 访问地址成员时的感觉,即地址成员似乎直接成为了 Person 的成员。这一点需要注意,因为这种组合会使序列化后的 JSON 结果扁平化。

另一个有点违反直觉的问题是,地址结构是一个私有结构,而私有成员似乎不应该被序列化?没错,这也是这种组合结构体做 JSON 解析的缺点之一:它暴露了私有组合对象的公共成员。如果没有特殊需要(例如,原始 JSON 数据已被扁平化,并且有多个结构体的重复字段需要重复使用),从我个人的角度来看,建议这样编写:

type Person struct {
  ID      int
  Name    string
  Address address
}

3. 反序列化部分成员字段

直接查看代码:

package main

import (
	"encoding/json"
	"fmt"
)

type Person struct {
	ID   uint
	Name string
}

// PartUpdateIssue simulates parsing two different
// JSON strings with the same structure
func PartUpdateIssue() {
	var p Person
	// The first data has the ID field and is not 0
	str := `{"ID":1,"Name":"Bruce"}`
	_ = json.Unmarshal([]byte(str), &p)
	fmt.Printf("%+v\n", p)
	// The second data does not have an ID field,
	// deserializing it again with p preserves the last value
	str = `{"Name":"Jim"}`
	_ = json.Unmarshal([]byte(str), &p)
	// Notice the output ID is still 1
	fmt.Printf("%+v\n", p)
}

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

从代码注释中可以知道,当我们重复使用同一结构来反序列化不同的 JSON 数据时,一旦某个 JSON 数据的值只包含部分成员字段,那么未包含的成员就会使用最后一次反序列化的值,会产生脏数据污染问题。

4. 处理指针字段

许多开发人员一听到指针这个词就头疼不已,其实大可不必。但 Go 中的指针确实给开发人员带来了 Go 程序中最常见的 panic 之一:空指针异常。当指针与 JSON 结合时会发生什么呢?

看下面的代码:

package main

import (
	"encoding/json"
	"fmt"
)

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)
	// It would panic this line
	// fmt.Printf("%+v\n", p.Address.Street)
}

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

我们将 Address 成员定义为指针,当我们反序列化一段不包含 Address 的 JSON 数据时,指针字段会被设置为 nil,因为它没有对应的数据。如果我们直接调用 p.Address.xxx,程序会因为 p.Address 为空而崩溃。

因此,如果有一个指针指向我们结构中的一个成员,请记住在使用它之前先确定指针是否为 nil。这有点繁琐,但也没办法。毕竟,编写几行代码与生产环境中的 panic 所造成的损失相比可能并不算什么。

此外,在创建带有指针字段的结构时,指针字段的赋值也会相对繁琐:

package main

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

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

5. 零值(默认值)可能造成的问题

零值是 Golang 变量的一个特性,我们可以简单地将其视为默认值。也就是说,如果我们没有显式地给变量赋值,Golang 就会给它赋一个默认值。例如,我们在前面的例子中看到,int 的默认值为 0string 的默认值为空字符串,指针的零值为 nil,等等。

处理带有零值的 JSON 有哪些隐患?请看下面的例子:

package main

import (
	"encoding/json"
	"fmt"
)

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。在 BruceJim 的例子中,由于数据缺失,其中一个的子女数为 0,而另一个的子女数为 0。而实际上布鲁斯的子女数应该是 "未知",如果我们真的将其视为 0,可能会在业务上造成问题。在一些对数据有严格要求的场景中,这种混淆是非常致命的。那么,有没有办法避免这种零值干扰呢?让我们将 PersonChildrenCnt 类型改为 *int 并看看会发生什么:

type Person struct {
  Name        string
  ChildrenCnt *int
}
// Output
{Name:Bruce ChildrenCnt:<nil>}
{Name:Jim ChildrenCnt:0xc0000124c8}

不同之处在于 Bruce 没有数据,因此 ChildrenCnt 为零,而 Jim 是一个非零指针。这样就很明显了,Bruc 的孩子数量是未知的。从本质上讲,这种方法仍然使用零值,即指针的零值,有点像以毒攻毒(笑)。

总结

在这篇文章中,我列举了自己在使用编码/json 库时犯过的 7 个错误,其中大部分都是我在工作中遇到的。如果你还没有遇到过,那么恭喜你!这也提醒我们今后在使用 JSON 时要小心谨慎;如果你遇到过这些问题,并为此感到困惑,希望本文能对你有所帮助。

关于我
loading