Go 的 “玩家” 们看到这个题目可能会很疑惑——对于 JSON 而言,Go 原生库 encoding/json 已经是提供了足够舒适的 JSON 处理工具,广受 Go 开发者的好评。它还能有什么问题?但是,实际上在业务开发过程中,我们遇到了不少原生 json 做不好甚至是做不到的问题,还真是不能完全满足我们的要求。
那么,如果不用它用什么?它又有什么问题吗?使用第三方库的原因是什么?如何选型?性能如何?
不过呢,在抛出具体问题之前,我们先来尽可能简单地了解一下 Go 目前在处理 JSON 中常用的一些库,以及对这些库的测试数据分析。如果读者觉得下面的文字太长了,也可以直接跳到结论部分。
这应该是广大 Go 程序员最熟悉的库了,使用 json.Unmarshal
和 json.Marshal
函数,可以轻松将 JSON 格式的二进制数据反序列化到指定的 Go 结构体中,以及将 Go 结构体序列化为二进制流。而对于未知结构或不确定结构的数据,则支持将二进制反序列化到 map[string]interface{}
类型中,使用 KV 的模式进行数据的存取。
这里我提两个大家可能不会留意到的额外特性:
var s string err := json.Unmarshal([]byte(`"Hello, world!"`), &s) // 注意字符串中的双引号不能缺,如果仅仅是 `Hello, world`,则这不是一个合法的 JSON 序列,会返回错误。
cert := struct { Username string `json:"username"` Password string `json:"password"` }{} err := json.Unmarshal([]byte(`{"UserName":"root","passWord":"123456"}`), &cert) if err != nil { fmt.Println("err =", err) } else { fmt.Println("username =", cert.Username) fmt.Println("password =", cert.Password) } // 实际输出: // username = root // password = 123456
打开 jsoniter 的 GitHub 主页,它一上来就宣扬两个关键词:high-performance 以及 compatible。这也是这个包最大的两个卖点。
首先说兼容:jsoniter
最大的优势就在于:能够 100% 兼容标准库,因此代码能够非常方便地进行迁移。实在是不方便的。也可以用 Go Monkey 强行换掉 json 的相关函数入口。
接着看性能:与其他吹嘘自己性能的开源库一样,它们自己的测试结论都不能无脑采信。这里我根据我个人的测试情况,先抛几个简单的结论吧:
从性能上,jsoniter
能够比众多大神联合开发的官方库性能还快的主要原因,一个是尽量减少不必要的内存复制,另一个是减少 reflect 的使用——同一类型的对象,jsoniter 只调用 reflect 解析一次之后即缓存下来。不过随着 go 版本的迭代,原生 json 库的性能也越来越高,jsonter 的性能优势也越来越窄。
此外,jsoniter 还支持 Get
函数,支持直接从一个 []byte
二进制数据中读取响应的字段,这个后文再做说明
这是 GitHub 上面的另一个 json 解析包。相比起 jsoniter 多达 9k 的 star 而言,easyjson 有 3k,也算是一个人气很高的开源项目了。
这个包最主要的卖点,依然是快。为什么 easyjson 比 jsoniter 还要快?因为 easyjson 的开发模式 protobuf 类似,在程序运行之前需要使用其代码工具,为每一个结构体专门生成序列化/反序列化的程序代码。每一个程序都有定制化的解析函数。
但也因为这种开发模式,easyjson 对业务的侵入性比较高。一方面,在 go build
之前需要先生成代码;另一方面,相关的 json 处理函数也不兼容原生 json 库。
这是我个人非常喜欢的一个 json 解析库,3.9k 的 star 数也可以看出它人气不低。它的 GitHub 主页标题就号称比官方库有高达 10x 的性能。
还是那句话:开源项目自己的测试结论都不能无脑采信。这个10x的性能我个人也测出来过,但不能代表所有的场景。
为什么 jsonparser 有那么高的性能呢?因为对于 jsonparser 本身,它只负责解构出一个二进制字节串中的一些关键边界字符,比如说:
"
,那么就找到结束的 "
,这中间就是一个字符串[
,那么就找到成对的 ]
,这中间就是一个数组{
,那么就找到成对的 }
,这中间就是一个对象然后,它将找到的数据中间的这段 []byte
数据交给调用方,由调用方进行进一步的处理。此时,对这些二进制数据的解析和合法性检查是需要调用方来负责的。
为什么看起来这么麻烦的开源库我会喜欢呢?因为开发者可以基于 jsonparser,构建特殊逻辑,甚至是构建自己的 json 解析库。我自己的开源项目 jsonvalue 在早期也基于 jsonparser 实现,尽管后来为了进一步优化性能而弃用了 jsonparser。但这不影响我对它的推崇。
这个项目是我个人的 JSON 解析库,设计之初是为了替代原生 JSON 库使用 map[string]interface{}
来处理非结构化 JSON 数据的需求。为此我有另外一篇文章叙述了这个问题:《还在用 map[string]interface{} 处理 JSON?告诉你一个更高效的方法——jsonvalue》。
我目前大致完成了对库的优化(参见 master 分支),性能已经远远高于原生 json 库,并且略微优于 jsoniter。当然,这也是特定情况下的,针对各种大相径庭的场景,各种库的性能各不相同。这也是我撰写本文的目的之一。
除了 struct 和 map 之外,还有别的?下面就我在实际业务开发中遇到的场景都列一下,以飨读者。所有测试代码均开源,读者可以查阅,也可以向我提出意见,提 issue、评论、私聊均可。
结构体解析,这是 Go 中处理 JSON 最最常规的操作了。这里我定义了这样的一个结构体:
type object struct { Int int `json:"int"` Float float64 `json:"float"` String string `json:"string"` Object *object `json:"object,omitempty"` Array []*object `json:"array,omitempty"` }
稍微使了点坏——这个结构可以疯狂自嵌套。
然后呢,我再定义了一段二进制流,用 json.cn 可以看到,这是一个有5层结构的 json 对象。
{"int":123456,"float":123.456789,"string":"Hello, world!","object":{"int":123456,"float":123.456789,"string":"Hello, world!","object":{"int":123456,"float":123.456789,"string":"Hello, world!","object":{"int":123456,"float":123.456789,"string":"Hello, world!","object":{"int":123456,"float":123.456789,"string":"Hello, world!"},"array":[{"int":123456,"float":123.456789,"string":"Hello, world!"},{"int":123456,"float":123.456789,"string":"Hello, world!"}]}}},"array":[{"int":123456,"float":123.456789,"string":"Hello, world!"},{"int":123456,"float":123.456789,"string":"Hello, world!"}]}
使用这两个结构,分别对官方 encoding/json
,jsoniter
。easyjson
三个包进行 Marshal 和 Unmarshal 的测试。首先我们看反序列化(Unmarshal)的测试结果:
包名 | 每迭代耗时 | 内存占用 | alloc 数 | 性能评价 |
---|---|---|---|---|
| 8775 ns/op | 1144 B/op | 25 allocs/op | ★★ |
| 6890 ns/op | 1720 B/op | 56 allocs/op | ★★☆ |
| 4017 ns/op | 784 B/op | 19 allocs/op | ★★★★★ |
下面是序列化的测试结果:
包名 | 每迭代耗时 | 内存占用 | alloc 数 | 性能评价 |
---|---|---|---|---|
| 6859 ns/op | 1882 B/op | 6 allocs/op | ★★ |
| 6843 ns/op | 1882 B/op | 6 allocs/op | ★★ |
| 2463 ns/op | 1240 B/op | 5 allocs/op | ★★★★★ |
纯粹从性能上来看,easyjson
不愧是专门为每一个 struct 定制化了序列化和反序列化函数,它达到了最高的性能,比另外两个库均有2.5~3倍的效率。而 jsoniter 略高于官方 json,不过相差不大。
说是 “非常规” 的原因是,在这种情况下,程序需要处理非结构化的 JSON 数据,或者是在一段函数中处理多种不同类型的数据结构,因而不能使用结构体模式来处理。官方 JSON 库的解决方案是,(对于对象类型)采用 map[string]interface{}
来保存。这个场景下,只有官方 json 和 jsoniter 支持。
测试数据如下,首先是反序列化:
包名 | 每迭代耗时 | 内存占用 | alloc 数 | 性能评价 |
---|---|---|---|---|
| 13040 ns/op | 4512 B/op | 128 allocs/op | ★★ |
| 9442 ns/op | 4521 B/op | 136 allocs/op | ★★ |
序列化情况测试数据如下:
包名 | 每迭代耗时 | 内存占用 | alloc 数 | 性能评价 |
---|---|---|---|---|
| 17140 ns/op | 5865 B/op | 121 allocs/op | ★★ |
| 17132 ns/op | 5865 B/op | 121 allocs/op | ★★ |
可见,针对这种情况,大家都是半斤八两,jsoniter 并没有什么明显优势。即便是 jsoniter 作为卖点的大数据量解析,优势也是微乎其微。在同等数据量情况下,两个库反序列化的耗时基本上是结构体情况的两倍,而序列化时间则是结构体情况的约2.5倍。
Emmm……老铁们能不用这种操作就不要用了吧,更何况程序在处理 interface{}
时还需要各种断言,这种痛苦,各位可以看我的文章感受一下。
真的涉及到无法使用 struct 的时候,就各种开源项目八仙过海各显神通了。每一个库其实都有非常详细和强大的额外功能,单单本文肯定无法说完。这里我就几个库及其代表的思路列举一下吧,后面也会附上各种情况的测试数据。
在处理非结构化 JSON 中,如果要解析一段 []byte
数据并获得其中的某个值,jsoniter 有以下相类似的方案。
第一种方案是直接解析原文并返回所需数据:
// 读取二进制数据中 response.userList 数组中的第一个元素的 name 字段 username := jsoniter.Get(data, "response", "userList", 0, "name") fmt.Println("username:", username.ToString())
也可以是直接返回一个对象,并且基于该对象可以继续操作:
obj := jsoniter.Get(data) if obj.ValueType() == jsoniter.InvalidType { // err handling } username := obj.Get("response", "userList", 0, "name") fmt.Println("username:", username.ToString())
这个函数有一个非常大的特点,那就是按需解析。比如说在这个语句 obj := jsoniter.Get(data)
中,jsoniter 就只做了最低限度的数据检查,至少先解析出了当前是一个 object 类型的 JSON,其他部分的解析均不做。
而即便是到了第二个调用 obj.Get("response", "userList", 0, "name")
中,jsoniter 也是竭尽可能减少不必要的解析,只解析需要解析的部分。
比如说,请求参数中要求解析 response.userList
的值,那么 jsoniter 在遇到诸如 response.gameList
等无关字段的时候,那么 jsoniter 就回尽量绕开而不去处理,从而尽可能地减少无关的 CPU 时间。
不过需要注意的是,返回的这个 obj
对象,从接口功能来看,可以理解为它是只读的,无法重新序列化为二进制序列。
相对于 jsoniter,要解析一段 []byte
数据并获得其中的某个值,jsonparser 的支持比较有限。
比如说,如果我们能够实现知道某一个值的类型,比如说上面的 username 字段,那么我们可以这么获取:
username, err := jsonparser.GetString(data, "response", "userList", "[0]", "name") if err != nil { // err handling } fmt.Println("username:", username)
但是 jsonparser 的 Get 系列函数只能获得除了 null 之外的基本类型,也就是 number, boolean, string 三种。
如果要操作 object 和 array,就要熟悉下面的两个函数,而这两个函数我个人觉得就是 jsonparser 的核心:
func ArrayEach( data []byte, cb func(value []byte, dataType ValueType, offset int, err error), keys ...string, ) (offset int, err error) func ObjectEach( data []byte, callback func(key []byte, value []byte, dataType ValueType, offset int) error, keys ...string, ) (err error)
这两个函数按顺序解析二进制数据,并将提取出的数据段通过回调函数返回给调用方,由调用方对数据进行操作。调用方可以组 map,可以组 slice,甚至可以做一些平常无法操作的操作(后文会做说明)
这个是我本人开发的开源 Go JSON 操作库,在 Get 类操作的 API 设计风格上与 jsoniter 的第二种风格比较类似。
比如我们同样是要获取前面所说的 username 字段,那么我们可以这么获取:
v, err := jsonvalue.Unmarshal(data) if err != nil { // err handling } username := v.GetString("response", "userList", 0, "name") fmt.Println("username:", username)
在本小节所说的 “非常规操作” 场景下,三个库中,jsoniter 和 jsonparser 在解析时都是 “按需解析”,而 jsonvalue 则是全面解析。因此在制定测试方案的时候还是有所区别。
这里我先抛出测试数据,测试评价中有两部分:
包名 | 每迭代耗时 | 内存占用 | alloc 数 | 性能评价 | 功能评价 |
---|---|---|---|---|---|
浅解析 | |||||
| 9118 ns/op | 3024 B/op | 139 allocs/op | ☆ | ★★★ |
| 7684 ns/op | 9072 B/op | 61 allocs/op | ★ | ★★★★★ |
| 853 ns/op | 0 B/op | 0 allocs/op | ★★★★★ | ★★ |
读取其中一个层级较深的数据 | |||||
| 9118 ns/op | 3024 B/op | 139 allocs/op | ☆ | ★★★★★ |
| 7928 ns/op | 9072 B/op | 61 allocs/op | ★ | ★★★★★ |
| 917 ns/op | 0 B/op | 0 allocs/op | ★★★★★ | ★★☆ |
同上,但从大量(100x)数据 | |||||
| 29967 ns/op | 4913 B/op | 469 allocs/op | ★ | ★★★★★ |
| 799450 ns/op | 917030 B/op | 6011 allocs/op | ★★★★★ | |
| 8826 ns/op | 0 B/op | 0 allocs/op | ★★★★★ | ★★☆ |
完整遍历 | |||||
| 45237 ns/op | 12659 B/op | 671 allocs/op | ☆ | ★★ |
| 7928 ns/op | 9072 B/op | 61 allocs/op | ★★★ | ★★★★★ |
| 3705 ns/op | 0 B/op | 0 allocs/op | ★★★★★ | ☆ |
| 13040 ns/op | 4512 B/op | 128 allocs/op | ★ | ★ |
| 9442 ns/op | 4521 B/op | 136 allocs/op | ★☆ | ★ |
上述测试数据中将反序列化场景中分为四种。这里我详细说明一下四种情况的应用场景,以及相应的技术选型建议:
在测试代码中,浅解析指的是针对一个较深层级的结构,仅仅将其最浅一层的 key 列表解析出来。这个场景更多的是作为参考。可以看到,jsonparser 的性能完爆其他开源库,它可以以最快的速度将第一层的 key 列表解析出来。
但是在易用性方便,jsonparser 和 jsoniter 都需要开发者对获得的数据再做进一步的处理,因此 jsoniter 和 jsonparser 的易用性在这个场景下均略低。
这个场景是这样的:JSON 数据正文中,仅有一小部分数据对当前业务有用并需要获取。这里我分了两种情况:
这里指的是在没有结构体的情况下,序列化一段数据。这种场景一般发生在以下情况中:
这个场景的第一个解决方案就是前文提到的 “常规的非常规操作”,也就是使用 map。
至于非常规操作嘛,我们首先排除 jsoniter 和 jsonparser,因为他们没有直接的构建自定义 json 结构的方法。接着排除 easyjson,因为它无法针对 map 操作。剩下的也就只有 jsonvalue 了。
比如我们返回用户的昵称,假设返回格式是: {"code":0,"message":"success","data":{"nickname":"振兴中华"}}
。
使用 map 的代码如下:
code := 0 nickname := "振兴中华" res := map[string]interface{}{ "code": code, "message": "success", "data": map[string]string{ "nickname": nickname }, } b, _ := json.Marshal(&res)
而 jsonvalue 的方法为:
res := jsonvalue.NewObject() res.SetInt(0).At("code") res.SetString("success").At("message") res.SetString(nickname).At("data", "nickname") b := res.MustMarshal()
应该说易用性上,都非常方便。我们针对官方 json、jsoniter、jsonvalue 分别进行序列化操作,测得的数据如下:
包名 | 每迭代耗时 | 内存占用 | alloc 数 | 性能评价 |
---|---|---|---|---|
| 16273 ns/op | 5865 B/op | 121 allocs/op | ★☆ |
| 16616 ns/op | 5865 B/op | 121 allocs/op | ★☆ |
| 4521 ns/op | 2224 B/op | 5 allocs/op | ★★★★★ |
结果已经非常明显了。这个原因大家也能明白,因为在处理 map 的时候,需要使用 reflect 机制去处理数据类型,这大大降低了程序的性能。
在这个场景中,我个人首推的是官方的 json 库。可能读者会比较意外。以下是我的观点:
这个场景下,我们要分高数据利用率和低数据利用率两种情况来看。所谓数据利用率,指的是 JSON 数据的正文中,如果说超过四分之一的数据都是业务需要关注和处理的,那就算是高数据利用率。
Set
方法。读者可以查阅 godoc实际操作中,超大 JSON 数据量、同时需要重新序列化的情况非常少。这种场景下往往是是代理服务器、网关、overlay中继服务等,同时又需要往原数据中注入额外信息的时候使用。换句话说,jsoniter 的适用场景比较有限。
下面是从10%到60%数据覆盖率下,不同库的操作效率对比(纵坐标单位:μs/op)
可以看到,当 jsoniter 的数据利用率达到 25% 时,相比 jsonvalue 就已经没有任何优势;而 jsonparser 则是 40% 左右。
笔者在实际应用中还遇到过一些关于 JSON 奇奇怪怪的处理场景,也趁这个机会列出来,分享一下我的解决方案。
前文说到:“json 在解析时,如果遇到大小写问题,会尽可能地进行大小写转换。即便是一个 key 与结构体中的定义不同,但如果忽略大小写后是相同的,那么依然能够为字段赋值。”
但是呢,如果你使用的是 map、jsoniter、jsonparser,这就是个大问题了。我们有两个服务,同时操作 MySQL 数据库中的同一个字段,但是两个 Go 服务所定义的结构体中,有一个字母的大小写不一致。这个问题是长期存在的,但因为官方 json 解析结构体时的上述特性,导致这个问题一直没有暴露。直到有一天,我们写了一个脚本程序洗数据的时候,采用了 map 方式来读取这个字段的时候,Bug 就曝光了。
于是我后来把大小写支持的特性加入了 jsonvalue 中,解决了这个问题:
raw := `{"user":{"nickName":"pony"}}` // 注意当中的 N v, _ := jsonvalue.UnmarshalString(raw) fmt.Println("nickname:", v.GetString("user", "nickname")) fmt.Println("nickname:", v.Caseless().GetString("user", "nickname")) // 输出 // nickname: // nickname: pony
在合作兄弟模块的接口时,对方推数据流的时候是以一个 JSON 对象的格式给到我们的业务模块中的。后来根据需求,推过来的数据要求是有序的。如果接口格式改成数组,那么就需要对双方接口的数据结构作较大的改动。此外,我们在滚动升级的时候势必遇到新旧模块同时存在的情况,所以接口需要同时兼容两套接口格式。
最后我们采用了一个很邪道的方式——数据生产方是能够按顺序将 KV 推出来的,而我们作为消费方,使用 jsonparser
的 ObjectEach
函数,就能够按顺序获得 kv 字节序列,从而也完成数据的顺序获取。
Go 算是一个很年轻的语言,在它诞生的时候,互联网上的主流字符编码已经是 unicode,编码格式则是 UTF-8 了。而其他辈分更老的语言,由于各种原因,可能采用了不一样的编码格式。
这就导致了在进行跨语言 JSON 对接时,不同团队、不同公司针对 unicode 宽字符时,采用的编码格式可能不同。如果遇到这种情况,那么解决方法就是统一采用 ASCII 编码。如果是官方 json,可以参考这个问答转义宽字符。
如果是使用 jsonvalue,则默认就是 ascii 转义,比如说:
v := jsonvalue.NewObject() v.SetString("中国").At("nation") fmt.Println(v.MustMarshalString()) // 输出 // {"nation":"\u4E2D\u56FD"}
本文章采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。
原作者: amc,欢迎转载,但请注明出处。
原文标题:《Go 语言原生的 json 包有什么问题?如何更好地处理 JSON 数据?》
发布日期:2021-05-06
3月24日,腾讯发布2020年Q4及全年财报,其中金融科技及企业服务第四季收入385亿...
云虚拟主机 可以干什么?云 虚拟主机 可以是搭 建网站 的重要产品,可用来存放网...
API风格说明 当前ECS服务对外开放两类风格的API: ECS服务自定义规范的API(以下...
客户简介 全民直播是一家涵盖游戏、娱乐、户外等多领域泛娱乐的直播平台。2015年...
排查思路 无法通过远程桌面连接裸金属服务器时,我们推荐您按照以下思路排查问题...
注册了 域名 不备案可以吗?可以的。 注册域名 并不是一定要备案的,只有搭 建网...
公司介绍 我们公司是全球法律服务整合平台,已有的4万多名律师遍布全国359个城市...
案例背景 高校健康打卡项目发起于北京大学软件与微电子学院,是该学院张齐勋老师...
客户简介 趣医网(quyiyuan.com)创立于2014年,为京颐集团重要成员企业之一,是...
??提到慕尼黑,大家第一个想到总是啤酒节,其实慕尼黑的文化同样闻名世界。慕尼...