深入 Go 中各个高性能 JSON 解析库
原创转载请声明出处哦~,本篇文章发布于luozhiyun的博客: https://www.luozhiyun.com/archives/535
其实本来我是没打算去看 JSON 库的性能问题的,但是最近我对我的项目做了一次 pprof,从下面的火焰图中可以发现在业务逻辑处理中,有一半多的性能消耗都是在 JSON 解析过程中,所以就有了这篇文章。
这篇文章深入源码分析一下在 Go 中标准库是如何解析 JSON 的,然后再看看有哪些比较流行的 Json 解析库,以及这些库都有什么特点,在什么场景下能更好的帮助我们进行开发。
主要介绍分析以下几个库:
|
库名 |
Star |
|---|---|
|
标准库 JSON Unmarshal |
|
|
valyala/fastjson |
1.2 k |
|
tidwall/gjson |
8.3 k |
|
buger/jsonparser |
4 k |
json-iterator
库也是一个非常有名的库,但是我测了一下性能和标准库相差很小,相比之下还是标准库更值得使用;
Jeffail/gabs
库与
bitly/go-simplejson
直接用的标准库的 Unmarshal 来进行解析,所以性能上和标准库一致,本篇文章也不会提及;
easyjson
这个库需要像 protobuf 一样为每一个结构体生成序列化的代码,具有强入侵性,我个人不是很喜欢,所以也没提及。
上面的这些库是我能搜到的 Star 数大于 1k 比较知名,并且仍然在迭代的 JSON 解析库,如果有遗漏的,可以联系我,我会补上。
标准库 JSON Unmarshal
分析
官方的 JSON 解析库需要传两个参数,一个是需要被序列化的对象,另一个是表示这个对象的类型。
在真正执行 JSON 解析之前会调用
reflect.ValueOf
来获取参数 v 的反射对象。然后会获取到传入的 data 对象的开头非空字符来界定该用哪种方式来进行解析。
如果被解析的对象是以
[
开头,那么表示这是个数组对象会进入到 scanBeginArray 分支;如果是以
{
开头,表明被解析的对象是一个结构体或 map,那么进入到 scanBeginObject 分支 等等。
以解析对象为例:
- 首先会缓存结构体对象;
- 循环遍历结构体对象;
- 找到结构体中的 key 值之后再找到结构体中同名字段类型;
- 递归调用 value 方法反射设置结构体对应的值;
-
直到遍历到 JSON 中结尾
}结束循环。
小结
通过看 Unmarshal 源码中可以看到其中使用了大量的反射来获取字段值,如果是多层嵌套的 JSON 的话,那么还需要递归进行反射获取值,可想而知性能是非常差的了。
但是如果对性能不是那么看重的话,直接使用它其实是一个非常好的选择,功能完善的同时并且官方也一直在迭代优化,说不定在以后的版本中性能也会得到质的飞跃。
fastjson
库地址: https://github.com/valyala/fastjson
这个库的特点和它的名字一样就是快,它的介绍页是这么说的:
Fast. As usual, up to 15x faster than the standard encoding/json.
它的使用也是非常的简单,如下:
使用 fastjson 首先要将被解析的 JSON 串交给 Parser 解析器进行解析,然后通过 Parse 方法返回的对象来获取。如果是嵌套对象可以直接在 Get 方法传参的时候传入相应的父子 key 即可。
分析
fastjson 在设计上和标准库 Unmarshal 不同的是,它将 JSON 解析划分为两部分:Parse、Get。
Parse 负责将 JSON 串解析成为一个结构体并返回,然后通过返回的结构体来获取数据。在 Parse 解析的过程是无锁的,所以如果想要在并发地调用 Parse 进行解析需要使用 ParserPool
fastjson 是从上往下依次遍历 JSON ,然后解析好的数据存放在 Value 结构体中:
这个结构体非常简成:
-
o Object:表示被解析的结构是一个对象; -
a []*Value:表示表示被解析的结构是个数组; -
s string:如果被解析的结构不是对象也不是数组,那么其他类型的值会以字符串的形式存放在这个字段中; -
t Type:表示这个结构的类型,有 TypeObject、TypeArray、TypeString、TypeNumber等。
这个结构存放对象的递归结构。如果把上面例子中的 JSON 串解析完毕之后就是这样一个结构:
代码
在代码实现上,由于没有了反射部分的代码,所以整个解析过程变得非常的清爽。我们直接看看主干部分的解析:
parseValue 会根据字符串的第一个非空字符来判断要解析的类型。这里用一个对象类型来做解析:
parseObject 函数也非常简单,在循环体中会获取 key 值,然后调用 parseValue 递归解析 value 值,从上往下依次解析 JSON 对象,直到最后遇到
}
退出。
小结
通过上面的分析可以知道 fastjson 在实现上比标准库简单不少,性能也高上不少。使用 Parse 解析好 JSON 树之后可以多次反复使用,避免了需要反复解析进而提升性能。
但是它的功能是非常的简陋的,没有常用的如 JSON 转 Struct 或 JSON 转 map 的操作。如果只是想简单的获取 JSON 中的值,那么使用这个库是非常方便的,但是如果想要把 JSON 值转化成一个结构体就需要自己动手一个个设值了。
GJSON
库地址: https://github.com/tidwall/gjson
GJSON 在我的测试中,虽然性能是没有 fastjson 这么极致,但是功能是非常完善,性能也是相当 OK 的,下面先简单介绍一下 GJSON 的功能。
GJSON 的使用是和 fastjson 差不多的,也是非常的简单,只要在参数中传入 json 串以及需要获取的值即可:
除了这个功能以外还可以进行简单的模糊匹配,支持在键中包含通配符
*
和
?
,
*
匹配任意多个字符,
?
匹配单个字符,如下:
-
child*.2:首先child*匹配children,.2读取第 3 个元素; -
c?ildren.0:c?ildren匹配到children,.0读取第一个元素;
除了模糊匹配以外还支持修饰符操作:
children|@reverse
先读取数组
children
,然后使用修饰符
@reverse
翻转之后返回,输出。
@flatten
将数组
nested
的内层数组平坦到外层后返回:
等等还有一些其他有意思的功能,大家可以去查阅一下官方文档。
分析
GJSON 的 Get 方法参数是由两部分组成,一个是 JSON 串,另一个叫做 Path 表示需要获取的 JSON 值的匹配路径。
在 GJSON 中因为要满足很多的定义的解析场景,所以解析是分为两部分的,需要先解析好 Path 之后才去遍历解析 JSON 串。
在解析过程中如果遇到可以匹配上的值,那么会直接返回,不需要继续往下遍历,如果是匹配多个值,那么会一直遍历完整个 JSON 串。如果遇到某个 Path 在 JSON 串中匹配不到,那么也是需要遍历完整个 JSON 串。
在解析的过程中也不会像 fastjson 一样将解析的内容保存在一个结构体中,可以反复的利用。所以当调用 GetMany 想要返回多个值的时候,其实也是需要遍历 JSON 串多次,因此效率会比较低。
除此之外,在解析 JSON 的时候并不会对它进行校验,即使这个放入的字符串不是个 JSON 也会照样解析,所以需要用户自己去确保放入的是 JSON 。
代码
Get 方法里面可以看到有很长一串的代码是用来解析各种 Path,然后一个 for 循环一直遍历 JSON 直到找到 '{' 或 '[',然后才进行相应的逻辑进行处理。