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
的编写规则:
- 如果值为
-
,意味着跳过该字段的解析:json:"-"
。 - 公有的结构体字段才会被解析。未导出的字段,即便有
json tag
,也无法生效。 - 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
不为空时,就能转化为数据库所需要的加密字符串。