重新认识 Golang 中的 json 编解码

欢迎访问我的个人小站 莹的网络日志 ,不定时更新文章和技术博客~

json 是我的老朋友,上份工作开发 web 应用时就作为前后端数据交流的协议,现在也是用 json 数据持久化到数据库。虽然面熟得很但还远远达不到知根知底,而且在边界的探索上越发束手束脚。比如之前想写一个范型的结构提高通用性,但是不清楚对范型的支持如何,思来想去还是用了普通类型;还有项目中的规范不允许使用指针类型的字段存储,我一直抱有疑问。归根结底还是不熟悉 json 编解码的一些特性,导致我不敢尝试也不敢使用,生怕出了问题。所以近些日子也是狠狠研究了一把,补习了很多之前模棱两可的概念。

有一句话说的好:"多和旧人做新事",我想我和 json 大概也属于这种关系吧(?)

json 解析时字段名称保持一致

这个疑问是,假如我们编码不太规范,不给字段添加 Tag,序列化和反序列化后的字段字符串会是什么?

go 复制代码
type Object struct {
	ID      string
	VaLuE2T int64
}

func TestFunc(t *testing.T) {
	obj := Object{
		ID:      "the-id",
		VaLuE2T: 7239,
	}
	marshal, err := json.Marshal(obj)
	assert.Nil(t, err)
	fmt.Println(string(marshal))
}
text 复制代码
{"ID":"the-id","VaLuE2T":7239}

用代码验证的结果是,json 编码并不会将程序中定义的字段名称改成驼峰或者什么特殊大小写规则,而是完完全全使用原本的字符。如果是我目前的这个需求,即仅用来保存数据,编码和解码都在后端进行,那这样完全可用不需要考虑更多,但如果是需要前后端数据对齐,而且有特殊的字段名称规范,那就要使用 tag 对编码字段进行规定,比如下方的代码。

go 复制代码
type Object struct {
	ID      string `json:"id"`
	VaLuE2T int64  `json:"value2t"`
}

func TestFunc(t *testing.T) {
	obj := Object{
		ID:      "the-id",
		VaLuE2T: 7239,
	}
	marshal, err := json.Marshal(obj)
	assert.Nil(t, err)
	fmt.Println(string(marshal))
}
text 复制代码
{"id":"the-id","value2t":7239}

但这只是编码,对于解码来说,是大小写不敏感的,就算传过来的是某种形式的妖魔鬼怪也可以解析出来,比如

go 复制代码
type Object struct {
	CaSeTesT string
	CAsEteSt string
}

func TestFunc(t *testing.T) {
	newObj := Object{}
	testString := `{"cAsEteSt":"test"}`
	err := json.Unmarshal([]byte(testString), &newObj)
	assert.Nil(t, err)
	fmt.Println("CaSeTesT:", newObj.CaSeTesT, " CAsEteSt:", newObj.CAsEteSt)
}
text 复制代码
CaSeTesT: test  CAsEteSt: 

也因为如此,最好不要在相关结构体里定义名称相同的字段,即便有大小写的区别,也会导致不可预料的情况发生。而且严格按照驼峰格式命名的话,不存在大小写区别,相同字母的字段就是唯一的。

而 Go 团队也将在 json/v2 中默认大小写敏感,规范的行为肯定会带来更少的 bug ~ 关于 json/v2 具体可以参考:A new experimental Go API for JSON

哦哦还有一点,如果不想某个字段参与解码编码可以使用特殊的 tag。

go 复制代码
type Object struct {
	Value string `json:"-"`
}

可以编解码接口和范型

我们知道 json 官方包底层是依靠反射实现的,所以获取到传入接口的结构体类型不是问题,就可以使用原结构体类型去编解码,所以只要是 Golang 支持的类型都可以,甚至是范型。当然也有一些反例需要注意,比如 func 这种类型就不行。

go 复制代码
type Object struct {
	Func func()
}

func TestFunc(t *testing.T) {
	obj := Object{
		Func: func() {},
	}
	marshal, err := json.Marshal(obj)
	fmt.Println(err)
}
text 复制代码
json: unsupported type: func()

omitempty 和字段类型

  • 当字段是结构体类型的,那么 omitempty 无效。
  • 当字段是指针类型的,如果值是 nil,那么有 omitempty 就不进行编码,没有 omitempty 会编码成 null。
  • 经过测试不仅是指针类型的结构体,指针类型的基础类型比如 string 或者 int64 也是如此。
go 复制代码
type Object struct {
	TheStructO AObject  `json:"theStructO,omitempty"`
	TheStruct  AObject  `json:"theStruct"`
	ThePointO  *AObject `json:"thePointO,omitempty"`
	ThePoint   *AObject `json:"thePoint"`
}

type AObject struct {
	Values interface{}
}

func TestFunc(t *testing.T) {
	obj := Object{}
	marshal, err := json.Marshal(obj)
	assert.Nil(t, err)
	fmt.Println(string(marshal))
}
text 复制代码
{"theStructO":{"Values":null},"theStruct":{"Values":null},"thePoint":null}

结构体类型和指针类型性能比较

使用 Benchmark 测试结构体类型和指针类型的性能。结论是在 CPU 性能上两者差不多,但是一个指针类型的字段会多进行一次内存分配,在一定程度上增加了 GC 的压力,所以看起来小的结构体还是结构体值类型更合适。

go 复制代码
type ObjectStruct struct {
	TheStruct AObject `json:"theStruct"`
}

type ObjectPoint struct {
	TheStruct *AObject `json:"theStruct"`
}

func BenchmarkFunc(b *testing.B) {
	data := []byte(`{"theStruct":{"valueString":"text","valueInt":123,"valueFloat":3.14}}`)
	b.Run("unmarshal-struct", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			_ = json.Unmarshal(data, &ObjectStruct{})
		}
	})
	b.Run("unmarshal-point", func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			_ = json.Unmarshal(data, &ObjectPoint{})
		}
	})
}
text 复制代码
BenchmarkFunc
BenchmarkFunc/unmarshal-struct
BenchmarkFunc/unmarshal-struct-8  	  457996	 2518 ns/op	 304 B/op	 8 allocs/op
BenchmarkFunc/unmarshal-point
BenchmarkFunc/unmarshal-point-8    	  471489	 2517 ns/op	 312 B/op	 9 allocs/op
PASS

自定义 json 编解码方式

可以实现 json 规定的接口,使结构体执行特定的编解码方式,假设下面一种情况,我希望业务代码开发中使用方便查询和操作的map,然后存储或者通讯使用占用空间更少的数组或者切片,但同时我又不想增加开发人员的心智负担,想要之前怎么使用现在就如何使用,或者无法更改一些库的执行方式只能绕路。也就是说平时开发时需要直接调用 json.Marshaljson.UnMarshal,而不需要额外操作,这时就可以通过实现接口的方式达成目的,见如下代码。

go 复制代码
type Object struct {
	UserMap map[string]struct{}
}

func (o Object) MarshalJSON() ([]byte, error) {
	list := make([]string, 0, len(o.UserMap))
	for key := range o.UserMap {
		list = append(list, key)
	}
	return json.Marshal(list)
}

func (o *Object) UnmarshalJSON(b []byte) error {
	var list []string
	err := json.Unmarshal(b, &list)
	if err != nil {
		return err
	}
	o.UserMap = make(map[string]struct{}, len(list))
	for i := range list {
		o.UserMap[list[i]] = struct{}{}
	}
	return nil
}

type ObjectNormal struct {
	UserMap map[string]struct{}
}

func TestFunc(t *testing.T) {
	userMap := map[string]struct{}{
		"user1": {},
		"user2": {},
		"user3": {},
	}
	obj1 := &Object{
		UserMap: userMap,
	}
	obj2 := &ObjectNormal{
		UserMap: userMap,
	}
	marshal1, err := json.Marshal(obj1)
	assert.Nil(t, err)
	fmt.Println("len:", len(marshal1), string(marshal1))
	marshal2, err := json.Marshal(obj2)
	assert.Nil(t, err)
	fmt.Println("len:", len(marshal2), string(marshal2))
}
text 复制代码
len: 25 ["user1","user2","user3"]
len: 46 {"UserMap":{"user1":{},"user2":{},"user3":{}}}

此处还有一个小 Tips,UnmarshalJSON 用指针接收器没问题,因为需要修改调用这个方法的结构体的字段值,但是 MarshalJSON 尽量用值接收器,因为这样在调用 json.Marshal 时无论传入的是值还是指针都能正常编码,同时也避免了传入的是 nil 导致 panic。

被遗忘在角落的 gob

在 golang 源码的 encoding 包下有很多编解码方式,比如 json、xml、base64 等等,但其中也有一个 gob,假如你之前没有接触过 golang 这门编程语言那你大概率没有听说过这种编码解码方式,因为它就独属于 golang,其他语言基本上可以说无法解析。

go 复制代码
type G struct {
	Value string
}

func TestGOB(t *testing.T) {
	g := &G{Value: "hello"}

	var buf bytes.Buffer
	enc := gob.NewEncoder(&buf)
	if err := enc.Encode(g); err != nil {
		panic(err)
	}
	fmt.Println("Gob encoded bytes:", buf.Bytes())

	var decoded G
	dec := gob.NewDecoder(&buf)
	if err := dec.Decode(&decoded); err != nil {
		panic(err)
	}
	fmt.Println("Decoded struct:", decoded)
}

使用方式大差不差,但与 json 的行为相比需要依赖 bytes.Buffer,也正因如此可以连续向 Buffer 编码多个结构体,然后连续解码多个结构体。此外和 json 一样也可以实现特定的接口来自定义编解码行为,具体可以参考pkg.go.dev/encoding/go...

向 json 和 xml 这种编码方式方便让我们肉眼观察,但因此也牺牲了性能和空间,而 gob 类似 protobuf 都是生成二进制,但是 gob 仅存在于 golang 生态中,普及度远远不及可以生成多种语言代码的 protobuf。

go 复制代码
type User struct {
	Name string
}

func Benchmark(b *testing.B) {
	b.Run("gob", func(b *testing.B) {
		var buf bytes.Buffer
		enc := gob.NewEncoder(&buf)
		dec := gob.NewDecoder(&buf)
		user := User{Name: "hello"}
		for i := 0; i < b.N; i++ {
			_ = enc.Encode(user)
			_ = dec.Decode(&user)
		}
	})
	b.Run("json", func(b *testing.B) {
		user := User{Name: "hello"}
		for i := 0; i < b.N; i++ {
			marshal, _ := json.Marshal(user)
			_ = json.Unmarshal(marshal, &user)
		}
	})
	b.Run("protobuf", func(b *testing.B) {
		user := ttt.User{Name: "hello"}
		for i := 0; i < b.N; i++ {
			data, _ := proto.Marshal(&user)
			_ = proto.Unmarshal(data, &user)
		}
	})
}

控制变量法,我设计了相同的结构体 proto。

protobuf 复制代码
message User {
  string Name = 1;
}
text 复制代码
Benchmark
Benchmark/gob
Benchmark/gob-8         	 1230975	      954.7 ns/op	      32 B/op	       3 allocs/op
Benchmark/json
Benchmark/json-8        	 1000000	      1130 ns/op	     256 B/op	       7 allocs/op
Benchmark/protobuf
Benchmark/protobuf-8    	 2500924	      483.2 ns/op	      16 B/op	       2 allocs/op
PASS

可能是由于我用的是简单结构体,gob 和 json 在 CPU 性能上并没有看到什么差距,但是内存分配差了蛮多,如果不考虑通用性和扩展性的话,gob 也是个不错的选择,虽然事实是这两方面不可能不考虑。而且在性能方面也远远不及代码生成派,生产实践中多多用 protobuf 才是正道。

RawMessage 的应用场景

试想这样一种情况,某个推荐业务有两层分别是 A 和 B ,通常是是 A 调用 B 的接口(RPC),然后 A 再组织数据发给前端,QA和运营需求要获取到 B 持有的信息用来 debug 和测试,这个时候因为是不关键的 debug 信息所以也就懒得定义消息结构体,而是直接在B中用 json 将数据序列化成字符串传给 A,然后 A 在外面封装一层错误码和数据传给前端,如果直接这么操作会有一个问题:

go 复制代码
type ResponseB struct {
	Name string
}

type ResponseA struct {
	Data string
}

func TestRaw(t *testing.T) {
	r := ResponseB{
		Name: "hello-world",
	}
	marshal, err := json.Marshal(r)
	assert.Nil(t, err)

	ra := &ResponseA{
		Data: string(marshal),
	}
	marshal2, err := json.Marshal(ra)
	assert.Nil(t, err)
	fmt.Println(string(marshal), string(marshal2))
}
text 复制代码
{"Name":"hello-world"} {"Data":"{\"Name\":\"hello-world\"}"}

字符串类型的字段在 json.Marshal 时,其中的双引号会被转义,甚至于三层四层来回传递后转移符号会越来越多。所以这个时候就可以使用 json.RawMessage。

go 复制代码
type ResponseB struct {
	Name string
}

type ResponseA struct {
	Data json.RawMessage
}

func TestRaw(t *testing.T) {
	r := RawStruct{
		Name: "hello-world",
	}
	marshal, err := json.Marshal(r)
	assert.Nil(t, err)

	rj := &RawJson{
		Data: json.RawMessage(marshal),
	}
	marshal3, err := json.Marshal(rj)
	assert.Nil(t, err)
	fmt.Println(string(marshal), string(marshal3))
}
text 复制代码
{"Name":"hello-world"} {"Data":{"Name":"hello-world"}}

除了编码之外,解码时的 RawMessage 也有大用处,尤其是需要二次解码的情况。比如有一个接口是聊天室发送消息,然后消息有不同的类型,每个类型的内容的结构都不一样,这时需要先解码通用结构,然后拿到消息类型,再根据消息类型解码具体消息内容。比如下面这个例子,如果不使用 RawMessage,就一定要在字符串内增加转义。

go 复制代码
type Inside struct {
	Name string
}

type Outside struct {
	Data       interface{}
	DataString string
	DataRaw    json.RawMessage
}

func TestRaw(t *testing.T) {
	data := `{"Data":"{"Name":"hello-world"}","DataString":"{"Name":"hello-world"}","DataRaw":{"Name":"hello-world"}}`
	rj := Outside{}
	err := json.Unmarshal([]byte(data), &rj)
	assert.Nil(t, err)
	fmt.Println(rj)
}
text 复制代码
Expected nil, but got: &json.SyntaxError{msg:"invalid character 'N' after object key:value pair", Offset:12}

新时代的明星 json v2

pkg.go.dev/encoding/js... 中可以看到,json 包在 go1 也就是最初的版本就已经存在了,只是当时有一些设计和特性放到当下来看是有些老旧的,由于 Go 的兼容性承诺也不便对其进行大刀阔斧的改动,正是因为如此,在最近的版本中 go 团队推出了新的 json 包也就是 json/v2 来解决 json 编解码的一些痛点问题。如果对具体内容感兴趣可以去阅读官方的文档 pkg.go.dev/encoding/js...,包括 v1 版本和 v2 版本的一些区别 pkg.go.dev/encoding/js...,以及介绍新版本 json 的博客 [[go.dev/blog/jsonv2...](https://link.juejin.cn?target=https%3A%2F%2Fgo.dev%2Fblog%2Fjsonv2-exp%255D(A "https://go.dev/blog/jsonv2-exp](A") new experimental Go API for JSON)。

会用 v2 实现 v1,只是 v1 中原本的一些特性在 v2 中会变成可选择的 Option 提供出来以保证兼容性,这些选项不乏上文提到的一些特殊性质,譬如:

  • 编解码结构体时字段大小写敏感 (case-sensitive)
  • omitempty 起作用的对象会发生变化
  • nil 的 slice 和 map 会编码成空数组和空结构体而不是 null
  • 以及其他的一些性质

当然不只是一些编解码行为发生了变化,性能方面也有了很大提高,甚至还能看到专门的文章介绍和分析当前社区流行的诸多 json 库和 json/v2 的对比,老熟人 sonic 也在其中,具体内容详见 [[github.com/go-json-exp...](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fgo-json-experiment%2Fjsonbench%255D(JSON "https://github.com/go-json-experiment/jsonbench](JSON") Benchmarks)。

欢迎访问我的个人小站 莹的网络日志 ,不定时更新文章和技术博客~

相关推荐
东百牧码人14 小时前
MySqlConnection is already in use是什么原因
后端
白衣鸽子14 小时前
【基础数据篇】数据访问守卫:Accessor模式
后端·设计模式
Cache技术分享14 小时前
213. Java 函数式编程风格 - Java 中的简单 for 循环转换:从命令式到函数式
前端·后端
知其然亦知其所以然15 小时前
面试官问:MySQL表损坏怎么修?不会这三招你就凉了!
后端·mysql·面试
北海道浪子15 小时前
多模型Codex、ChatGPT、Claude、DeepSeek等顶级AI在开发中的使用
前端·后端·程序员
ConardLi15 小时前
一个小技巧,帮你显著提高 AI 的回答质量!
前端·人工智能·后端
间彧15 小时前
Java数值类型:Long、Double、Float、Integer边界值详解与应用实践
后端
洛卡卡了15 小时前
从被动救火到主动预警,用 Prometheus + Alertmanager 跑通告警闭环
后端·架构