JSON 标准库的使用技巧

encoding/json/v2 即将在 go1.25 作为实验特性推出,真的非常让人期待。笔者回顾日常开发中,对 v1(encoding/json) 的使用,顺势总结了一些的技巧,而经过测试,这些技巧在 encoding/json/v2 仍然是有效的,这同样让笔者非常开心。

关于 v2 的相关介绍,可查看:手把手带你玩转GOEXPERIMENT=jsonv2:Go下一代JSON库初探 | Tony Bai

基本的序列化和反序列化

先简单回顾下 JSON 这种数据交换格式在 go 中的使用:

type User struct {
	UserId   int64     `json:"userId"`
	Password string    `json:"password"`
	Birthday time.Time `json:"birthday"`
}

func main() {
	user := User{
		UserId:   20060102150405,
		Password: "zwei",
		Birthday: time.Unix(1136214245, 0),
	}

	data, _ := json.Marshal(user)
	fmt.Println(string(data))
	// {"userId":20060102150405,"password":"zwei","birthday":"2006-01-02T15:04:05Z"}
}

通过 tag 为结构体字段编写元信息,这是 go 语言的语法,并在运行时通过反射解析。

json tag 的编写规则

标准库 encoding/json 规定了 json tag 的编写规则:

  1. 如果值为 - ,意味着跳过该字段的解析:json:"-"
  2. 公有的结构体字段才会被解析。未导出的字段,即便有 json tag,也无法生效。
  3. tag 的语法为:json:"${name},${opt1},${opt2}"

其中 ${name} 用于指定 JSON 的键名,如果为空,取结构体字段名。此外,标准库还提供了可选项,用于开发者控制 JSON 编解码的结果。

  • , 分割选项,可以没有选项,也可以有多个选项。目前的选项可选值为 string, omitempty, omitzero 这 3 个。
  • 选项 string,表示该字段生成的 json键值的类型是字符串,这个选项只对 整型、浮点型、布尔型、字符型有效。
  • 选项 omitempty 表示如果该字段为空值,生成的 JSON 键值对将会被忽略。
  • 选项 omitzero 表示如果该字段为零值,生成的 JSON 键值对将会被忽略。

上述的规则,都能够从标准库的源码 /src/encoding/json/encode.go 中找到对应的解析过程:

sf := f.typ.Field(i)
...
// 跳过结构体未导出的字段
if !sf.IsExported() {
   continue
}
...
tag := sf.Tag.Get("json")
if tag == "-" {
   continue
}
// 解析 json tag 的值,获取字段名和拓展选项
name, opts := parseTag(tag)
...
// 只有 strings, floats, integers, and booleans 才可以被 "" 包裹.
quoted := false
if opts.Contains("string") {
   switch ft.Kind() {
   case reflect.Bool, reflect.Int, reflect.String, ...:
      quoted = true
   }
}

...
// 如果 json tag 的值为空, 取结构体字段名
if name == "" {
   name = sf.Name
}
field := field{
   name:      name,
   omitEmpty: opts.Contains("omitempty"),
   omitZero:  opts.Contains("omitzero"),
   quoted:    quoted,
}

omitempty 选项是 go1.1 版本就提供的功能,用于跳过空值的字段。但由于预定义的空值判断,无法满足所有实际场景的需求,因此在 go1.24 的时候,引入 omitzero 选项。具体的可查阅:JSON包新提案:用“omitzero”解决编码中的空值困局 | Tony Bai

希望 JSON 标准库能够提供一个接口,用于指定 json tag 为空时的键名规则,而不是默认使用结构体字段值,这样可以少写很多 json tag

自定义 JSON 的编解码实现

JSON 标准库提供了 Marshaler 和 Unmarshaler 这两个接口定义:

type Marshaler interface {
	MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
	UnmarshalJSON([]byte) error
}

如果在序列化和反序列化的过程中,JSON 标准库检测到类型实现了上述接口,那么就会使用接口的方法进行编解码,这也为 JSON 的编解码提供更多的功能,下面笔者将会介绍相关的应用技巧。

使用的例子都以 User 结构体为原型:

type User struct {
	UserId   int64     `json:"userId"`
	Password string    `json:"password"`
	Birthday time.Time `json:"birthday"`
}

动态添加字段

如果我们希望得到的 JSON 对象还包含 "age": ${age} 这个键值对,age 表示当前用户的年龄,应该根据当前时间 time.Now 减去 User.Birthday 得到。很明显,为 User 结构体增加 Age 字段会增加维护成本,这时候为 User 结构体实现标准库定义的 json.Marshaler 接口,就可以一劳永逸:

func (u User) MarshalJSON() ([]byte, error) {
	type Format struct {
		UserId   int64  `json:"userId"`
		Password string `json:"password"`
		Age      int    `json:"age"`
	}

	age := time.Now().Year() - u.Birthday.Year()
	if time.Now().YearDay() < u.Birthday.YearDay() {
		age--
	}

	return json.Marshal(Format{
		UserId:   u.UserId,
		Password: u.Password,
		Age:      age,
	})
}

func main() {
	user := User{
		UserId:   20060102150405,
		Password: "zwei",
		Birthday: time.Unix(1136214245, 0),
	}

	data, _ := json.Marshal(user)
	fmt.Println(string(data))
	// {"userId":20060102150405,"password":"zwei","age":19}
}

如果字段比较多,全部重写就比较麻烦了,这时候可以通过「类型定义」和「结构体内嵌」的方式来简化:

func (u User) MarshalJSON() ([]byte, error) {
	type NewUser User

	type Format struct {
		NewUser
		Age int `json:"age"`
	}

	age := time.Now().Year() - u.Birthday.Year()
	if time.Now().YearDay() < u.Birthday.YearDay() {
		age--
	}

	return json.Marshal(Format{
		NewUser: NewUser(u),
		Age:   age,
	})
}

这里之所以使用类型定义:type NewUser User,是因为 Format 直接内嵌 User 结构体的话,就会继承其 MarshalJSON 的方法,那么就会陷入无限循环中,最后导致栈溢出。而使用类型定义,只会复用原始结构体字段,而不会继承其方法。

go 官方文档里就很明确的提到了这一点:Type_definitions

修改字段序列化格式

User.Birthday 的类型为 time.Time,JSON 标准库只会序列化为 RFC3339 格式(time.RFC3339 = "2006-01-02T15:04:05Z07:00"),如果需要序列化为时间戳或其他格式,在实现 json.Marshaler 接口时,效仿 修改键值类型 的操作,即可实现:

func (u *User) MarshalJSON() ([]byte, error) {
	type NewUser User

	type Format struct {
		*NewUser
		Birthday int64 `json:"birthday"`
	}

	return json.Marshal(Format{
		NewUser:    (*NewUser)(u),
		Birthday: u.Birthday.Unix(),
	})
}

重新运行程序:

func main() {
	user := User{
		UserId:   20060102150405,
		Password: "zwei",
		Birthday: time.Unix(1136214245, 0),
	}

	data, _ := json.Marshal(&user)
	fmt.Println(string(data))
	// {"userId":20060102150405,"password":"zwei","birthday":1136214245}
}

可以看到 Format.Birthday 成功覆盖了 Format.NewUser.Birthday,这是因为外层的字段优先级更高。具体缘由,读者可以在标准库的源码中查看:encoding/json/encode.go

当前部分的接口实现和 修改键值类型 的略有不同,笔者将值接收者 func (u User) 改成了指针接收者 func (u *User),这也导致了 json.Marshal(user) 需要写成 json.Marshal(&user)

用于体现了「值接收」和「指针接收」的区别,具体的可查看 go 团队的 FAQ :Why do T and *T have different method sets?

敏感字段处理

在前面的例子中,实现了字段的新增和修改,这一部分则是介绍如何隐藏 JSON 的键值对。像密码 Password 作为敏感数据,是不应该返回到前端的,但仍然需要反序列化,因此不能使用 json:"-",因为这个 tag 会直接跳过 Password 的反序列化。那么就可以在实现接口时,将 Password 的值置空:

func (u User) MarshalJSON() ([]byte, error) {
	type NewUser User

	newUser := NewUser(u)
	newUser.Password = ""

	return json.Marshal(newUser)
}

但也有不足之处,如果有多个结构体都含有 Password 字段,那么修改起来就变得非常繁琐了。

那么在介绍解决办法之前,还请读者做个题,下面代码的运行结果为:

func main() {
	var password *string

	err1 := json.Unmarshal([]byte(`"zwei"`), &password)
	if err1 != nil {
		panic(err1)
	}

	output, err2 := json.Marshal(*password)
	if err2 != nil {
		panic(err2)
	}

	fmt.Println(string(output))

	// 上面代码在尝试对字符串进行 json.Unmarshal 和 json.Marshal
	// 看有几种可能:
	// A. err1 != nil && panic(err1)
	// B. *password -> panic: nil pointer
	// C. err2 != nil && panic(err2)
	// D. fmt.Println(string(output))
}

假设程序能够正常运行,这对置空 Password 的实现会有什么帮助呢?

一个简单的想法,如果能为 Password 实现 json.Marshaler 接口,将序列化的值设置为空字符串,就不用担心 Password 的泄露了。

而事实上,上述代码是能够正常运行的,因为字符串本就是 JSON 的数据类型,所以字符串也是可以被序列化和反序列化的。上面的代码在执行:json.Unmarshal 后,password 的值就是 zwei 了。再执行 json.Marshal,就会序列化为 "zwei",这也就是 output 的值。

为了帮助 Password 实现 json.Marshaler 接口,需要先定义 type Password string,因为 string 作为内置类型,不支持为其编写方法。

具体实现也极其简单:

type Password string

func (p Password) MarshalJSON() ([]byte, error) {
	return []byte(`""`), nil
}

通过这样实现 Password 的隐藏,也更贴近设计模式中的单一职责。为 Password 拓展功能也会变得更加简单,比如实现标准库的 sql.Scanner (database/sql.go) 接口 和 driver.Valuer (database/sql/types.go) 接口,实现自动加密和解密。

笔者以 driver.Valuer 为例:

import "golang.org/x/crypto/bcrypt"

func (p Password) Value() (driver.Value, error) {
	if len(p) == 0 {
		return nil, nil
	}

	hash, err := bcrypt.GenerateFromPassword([]byte(p), bcrypt.DefaultCost)
	if err != nil {
		return nil, err
	}

	return string(hash), nil
}

Password 不为空时,就能转化为数据库所需要的加密字符串。

Licensed under CC BY-NC-SA 4.0
发表了9篇文章 · 总计7.98k字