jsonparser 为什么比标准库的 encoding/json 快 10 倍 ?
2023-07-17 Golang 高性能编程 Go 源码分析 读代码
概述
jsonparser 是一个开源 JSON 包,号称比标准库 JSON 包性能高 10 倍 (具体情况取决于具体的负载大小和数据情况),内存分配优化到 0。 项目的目标是在不牺牲开发者用户体验和保持包 API 简洁的前提下,尽可能地提升 JSON 操作的性能。
基准测试
官方给出了 3 种类型
的基准测试结果,分别是 少量数据
、中等数据
和 大量数据
情况下的 JSON 负载。
jsonparser
的性能在很大程度上取决于使用情况,当不需要处理完整记录而只需要处理某几个字段时 (尤其是访问第三方接口时,大多数情况下我们只需要几个字段) 性能表现非常好。
少量数据
每个测试将 190 字节的 http 日志转换为 JSON。
Library | time/op | bytes/op | allocs/op |
---|---|---|---|
encoding/json struct | 7879 | 880 | 18 |
encoding/json interface{} | 8946 | 1521 | 38 |
buger/jsonparser | 1367 | 0 | 0 |
buger/jsonparser (EachKey API) | 809 | 0 | 0 |
从输出的结果中可以看到,jsonparser
要比标准库快 ≈ 10
倍,内存分配优化到 0。
中等数据
每个测试处理一个 2.4kb 的 JSON 记录,读取多个嵌套字段和 1 个数组。
Library | time/op | bytes/op | allocs/op |
---|---|---|---|
encoding/json struct | 57749 | 1336 | 29 |
encoding/json interface{} | 79297 | 10627 | 215 |
buger/jsonparser | 15955 | 0 | 0 |
buger/jsonparser (EachKey API) | 8916 | 0 | 0 |
从输出的结果中可以看到,jsonparser
要比标准库快 ≈ 6.5
倍,内存分配优化到 0。
大量数据
每个测试处理一个 24kb 的 JSON 记录,读取 2 个数组,并获取数组中每个元素的一些字段。
Library | time/op | bytes/op | allocs/op |
---|---|---|---|
encoding/json struct | 748336 | 8272 | 307 |
encoding/json interface{} | 1224271 | 215425 | 3395 |
buger/jsonparser | 85308 | 0 | 0 |
从输出的结果中可以看到,jsonparser
要比标准库快 ≈ 9
倍,内存分配优化到 0。
和标准库的差异
jsonparser
和标准库中的 JSON
工作方式不同,不会 编码/解码 整个数据结构,而是 按需操作 (当然,这背后的代价就是开发效率会降低)。
方法对应关系
标准库 | jsonparser | |
---|---|---|
编码 | Marshal | Set |
解码 | Unmarshal | Get |
此外,jsonparser
在 Get 方法的基础上封装了很多针对单个字段的实用小方法,例如 GetInt
, GetString
等,下面是一个简单的示例。
package main
import (
"fmt"
"log"
"github.com/buger/jsonparser"
)
var (
// JSON 字符串
dataJson = []byte(`
{
"person": {
"name": {
"first": "Leonid",
"last": "Bugaev",
"fullName": "Leonid Bugaev"
},
"github": {
"handle": "buger",
"followers": 109
},
"avatars": [
{ "url": "https://avatars1.githubusercontent.com/u/14009?v=3&s=460", "type": "thumbnail" }
]
},
"company": {
"name": "Acme"
}
}
`)
)
func main() {
// 解析对象
github := struct {
Handle string `json:"handle"`
Followers int `json:"followers"`
}{}
err := jsonparser.ObjectEach(dataJson, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
switch string(key) {
case "handle":
github.Handle = string(value)
case "followers":
followers, _ := jsonparser.ParseInt(value)
github.Followers = int(followers)
}
return nil
}, "person", "github")
if err != nil {
log.Fatal(err)
}
fmt.Printf("github = %+v\n\n", github)
// 编码结构体
githubJson, err := jsonparser.Set([]byte(`{}`), []byte(fmt.Sprintf(`{"handle: %s", "followers": "%d"}`, github.Handle, github.Followers)), "github")
if err != nil {
log.Fatal(err)
}
fmt.Printf("github json = %s\n\n", githubJson)
// 解析多个 key
paths := [][]string{
{"person", "name", "fullName"},
{"person", "avatars", "[0]", "url"},
{"company", "name"},
}
jsonparser.EachKey(dataJson, func(i int, bytes []byte, valueType jsonparser.ValueType, err error) {
switch i {
case 0:
fmt.Printf("fullName = %s\n", bytes)
case 1:
fmt.Printf("avatars[0].url = %s\n", bytes)
case 2:
fmt.Printf("company.name = %s\n\n", bytes)
}
}, paths...)
// 解析整数
n, err := jsonparser.GetInt(dataJson, "person", "github", "followers")
if err != nil {
log.Fatal(err)
}
fmt.Printf("n = %d\n\n", n)
// 解析字符串
name, err := jsonparser.GetString(dataJson, "company", "name")
if err != nil {
log.Fatal(err)
}
fmt.Printf("name = %s\n", name)
}
$ go run main.go
# 输出如下
github = {Handle:buger Followers:109}
github json = {"github":{"handle: buger", "followers": "109"}}
fullName = Leonid Bugaev
avatars[0].url = https://avatars1.githubusercontent.com/u/14009?v=3&s=460
company.name = Acme
n = 109
name = Acme
从上面的示例代码可以看到,使用 jsonparser
和标准库完全不兼容,解码
相关操作 API 的使用方式尚可接收,编码
提供的 Set
方法要手动配置大量对象和字段,
开发效率相较于标准库要低很多,而且结构体对象嵌套过深的情况下,很容易写出 Bug 代码,软件工程没有银弹。
代码实现
笔者的 Go 版本为 go1.19 linux/amd64
, 选择的 jsonparser
版本为 v1.1.1
。
jsonparser
的核心代码全部放在了一个文件中 parser.go
, 我们主要关注两种操作的实现: 解码 / Get
和 编码 / Set
。
数据类型
jsonparser
将合法的 JSON 数据类型简单进行了常量映射:
// JSON 数据类型常量
type ValueType int
const (
NotExist = ValueType(iota)
String
Number
Object
Array
Boolean
Null
Unknown
)
为了规避 GC
, 用于 JSON 字符串转义分配的 []byte
切片容量上限,超过这个上限值后,转义结果会被分配到 堆上
引发 GC
,
也就是 []byte
切片的长度超过 64 之后,会被分配到 堆上
。
// 规避 GC 的切片容量上限常量
const unescapeStackBufSize = 64
辅助方法
stringEnd
尝试寻找当前字符串的结尾,也就是 "
, 支持转义的情况,例如 \"
。
func stringEnd(data []byte) (int, bool) {}
blockEnd
尝试寻找当前数组或对象的结尾,数组的开始和结尾表示为 [
和 ]
, 对象的开始和结尾表示为 {
和 }
。
func blockEnd(data []byte, openSym byte, closeSym byte) int {}
lastToken
找到最后一个不属于该集合 [' ', '\n', '\r', '\t']
内的字符。
func lastToken(data []byte) int {}
nextToken
找到下一个不属于该集合 [' ', '\n', '\r', '\t']
内的字符。
func nextToken(data []byte) int {}
tokenEnd
尝试寻找当前 token
的结束位置,只要遇到集合内字符 [' ', '\n', '\r', '\t', ',', '}', ']']
, 都被认为是当前 token
的结束位置。
func tokenEnd(data []byte) int {}
findTokenStart
尝试查找当前 token
的开始位置。
func findTokenStart(data []byte, token byte) int {}
findKeyStart
尝试查找指定 key
的开始位置。
func findKeyStart(data []byte, key string) (int, error) {}
getType
返回指定字符的数据类型,返回值就是上面提到的 JSON 数据类型常量
其中之一。
func getType(data []byte, offset int) ([]byte, ValueType, int, error) {}
searchKeys 状态机
该方法的内部代码较长,这里就不用具体的文字描述流程了,直接将重点的代码部分用注释进行标记。
func searchKeys(data []byte, keys ...string) int {
keyLevel := 0 // 当前查找 key 的 层级
level := 0 // 当前遍历层级
i := 0 // 当前遍历字符索引
ln := len(data) // 参数 data 长度
lk := len(keys) // 参数 keys 的层级,如示例代码中的 person.name.fullName
lastMatched := true
if lk == 0 {
// 如果 keys 参数的层级为 0,直接返回
return 0
}
var stackbuf [unescapeStackBufSize]byte // 长度为 64 byte 切片
for i < ln {
// 从左向右, 逐个 byte 遍历
switch data[i] {
case '"':
// 遍历到 " 字符
i++
// 将当前位置设置为 key 的开始位置
keyBegin := i
strEnd, keyEscaped := stringEnd(data[i:])
if strEnd == -1 {
// 如果没有找到对应的结尾 " 字符,直接返回
return -1
}
// 将下次遍历字符位置更新为: 结尾 " 字符的下一个字符位置
i += strEnd
keyEnd := i - 1
// 查找结尾 " 字符的下一个 token
// 正常的情况下应该是一个冒号 :
// 例如 `"fullName": "Leonid Bugaev"` 中 "fullName" 后面跟着的 : 字符
valueOffset := nextToken(data[i:])
if valueOffset == -1 {
// 如果没有直接返回
return -1
}
// 将下次遍历字符位置更新为: 下一个 token 的位置
i += valueOffset
if data[i] == ':' {
// 如果下一个 token 正好是一个冒号 :
// 说明两个 " 之间的字符串是一个 key
if level < 1 {
// 如果当前层级为 0, 说明参数 JSON 字符串格式是错的,直接返回
return -1
}
// 将字符串 key 提取出来
key := data[keyBegin:keyEnd]
// 处理 key 字符串转义的情况
var keyUnesc []byte
if !keyEscaped {
keyUnesc = key
} else if ku, err := Unescape(key, stackbuf[:]); err != nil {
return -1
} else {
keyUnesc = ku
}
if level <= len(keys) {
if equalStr(&keyUnesc, keys[level-1]) {
// 如果当前字符串和参数 keys 当前查找元素相同
// 那么标记参数 keys 当前查找元素已经找到
lastMatched = true
// 如果当前层级减 1 等于当前查找 key 层级
if keyLevel == level-1 {
// 当前查找 key 层级加 1 (说明又找到了一个 key)
keyLevel++
// 如果当前查找 key 层级等于参数 keys 的层级
// 说明参数中的所有 key 已经全部找到,直接返回即可
if keyLevel == lk {
return i + 1
}
}
} else {
// 如果当前字符串和查到的 keys 第一个元素不相同
// 那么标记参数 keys 当前查找元素未找到
// 接下来继续从参数 keys 的第一个元素开始查找
lastMatched = false
}
} else {
// 如果当前层级比参数 keys 层级还要大
// 说明没有找到对应的 keys, 直接返回
return -1
}
} else {
// 如果下一个 token 不是一个冒号 :
// 说明两个 " 之间的字符串不是一个 key
// 那么就从当前位置继续向后遍历
i--
}
case '{':
if !lastMatched {
// 如果父级 keys 未匹配,此时应该匹配 key (也就是应该以 " 开头)
// 所以直接跳过当前的对象块 {} 即可
end := blockEnd(data[i:], '{', '}')
if end == -1 {
return -1
}
i += end - 1
} else {
// 如果父级 keys 已经匹配,将当前遍历层级加 1
level++
}
case '}':
// 如果遇到 } 字符,将当前层级变量减 1 即可
// 如果当前层级 和 当前查找 key 的 层级相同,将 keyLevel 层级减 1
// 说明当前 key 的所有子 keys 不可能在这个对象中找到了
level--
if level == keyLevel {
keyLevel--
}
case '[':
// 如果当前层级 和 当前查找 key 的 层级相同
// 并且当前查找 key 的类型是数组
if keyLevel == level && keys[level][0] == '[' {
var keyLen = len(keys[level])
...
// 通过 ArrayEach 遍历数组元素
ArrayEach(data[i:], func(value []byte, dataType ValueType, offset int, err error) {
...
})
if valueFound == nil {
// 如果没有当前 key, 说明数组中不存在对应的 key 元素
// 没有必要继续查找了,直接返回
return -1
} else {
// 如果数组中存在对应的 key 元素
// 递归从该元素中查找参数 keys 剩余的部分
subIndex := searchKeys(valueFound, keys[level+1:]...)
if subIndex < 0 {
return -1
}
return i + valueOffset + subIndex
}
} else {
// 如果当前层级 和 当前查找 key 的 层级不相同
// 或者当前查找 key 的类型不是数组
// 直接跳过当前数组 [] 数据块接口
if arraySkip := blockEnd(data[i:], '[', ']'); arraySkip == -1 {
return -1
} else {
i += arraySkip - 1
}
}
case ':':
// 边界情况处理,正常的 JSON key 中不应该包含 :
// 直接返回 -1 表示没有找到对应的 keys
return -1
}
i++ // 索引 + 1, 遍历下个字符
}
return -1
}
searchKeys
方法是整个 解析操作
的核心方法,内部实现类似于 有限状态机
机制,通过将 JSON 字符串作为输入参数,并根据定义的状态转换规则逐个解析字符,
结果返回解析到的索引,或者 -1 (解析失败)。
PS: 方法实现代码中的变量命名和 。for
循环表达式有些槽点
状态机组成部分
- 输入参数 : 存储解析的 JSON 字符串
- 解析器 : 解析 JSON 字符串并返回相应的数据结构 (
searchKeys
方法返回的是[]byte
) - 状态转移表 : 定义状态转换规则,包括当前状态、下一个字符以及下一个状态 (
searchKeys
主要是通过上面提到的辅助方法来完成的) - 状态栈 : 记录状态转换过程中的状态 (
searchKeys
用了几个变量来记录状态,例如keyLevel
,level
,ln
,lk
等)
状态转移表
状态字符 | 初始 | 对象开始 | 对象结束 | key 开始 | key 结束 | 数组开始 | 数组结束 | 比较 key | 跳过当前数据块 | 停止解析 |
---|---|---|---|---|---|---|---|---|---|---|
{ | 对象开始 | 停止解析 | 停止解析 | 跳过当前数据块 | 对象开始 | |||||
} | 停止解析 | 对象结束 | 停止解析 | 对象结束 | ||||||
" | 停止解析 | key 开始 | key 结束 | 停止解析 | ||||||
: | 停止解析 | 停止解析 | 停止解析 | 停止解析 | 停止解析 | 停止解析 | 停止解析 | |||
[ | 停止解析 | 数组开始 | 比较 key | 跳过当前数据块 |
状态转移表
定义完毕后,状态机开始从初始状态读取参数字符,并根据 状态转移表
进行状态转换。如果在状态转换过程中遇到格式错误的字符,则会停止解析过程并返回错误 (-1)。
如果所有的参数都可以正常解析,状态机最终返回对应的数据结构。
Get 方法
介绍了上面的 searchKeys 状态机
和 辅助方法
之后,我们来重点分析一下 Get
方法的内部实现。
func Get(data []byte, keys ...string) (value []byte, dataType ValueType, offset int, err error) {
a, b, _, d, e := internalGet(data, keys...)
return a, b, d, e
}
Get
方法内部直接调用了 internalGet
:
func internalGet(data []byte, keys ...string) (value []byte, dataType ValueType, offset, endOffset int, err error) {
// 第一步
if len(keys) > 0 {
if offset = searchKeys(data, keys...); offset == -1 {
return nil, NotExist, -1, -1, KeyPathNotFoundError
}
}
// 第二步
nO := nextToken(data[offset:])
if nO == -1 {
return nil, NotExist, offset, -1, MalformedJsonError
}
// 第三步
offset += nO
value, dataType, endOffset, err = getType(data, offset)
if err != nil {
return value, dataType, offset, endOffset, err
}
// 第四步
if dataType == String {
value = value[1 : len(value)-1]
}
return value[:len(value):len(value)], dataType, offset, endOffset, nil
}
- 如果给定的
[]byte
参数长度大于 0,那么会先调用searchKeys
方法确认一下给定的参数keys
是否都存在,如果不存在,直接返回错误信息 - 然后会以
searchKeys
方法返回的位置为基准,查找下一个可用token
位置,如果不存在,说明该 JSON 字符串格式是错误的 - 接着会通过上面
两个位置
相加,调用getType
计算出key
对应的元素类型,返回值就是JSON 数据类型常量
其中之一,如果getType
返回了 JSON 字符串格式错误,就直接返回错误 - 最后,如果
key
对应的元素类型为字符串,那么把两边"
删除,返回结果
Set 和 Delete 方法
上面讲完了 Get
方法的操作流程,Set
和 Delete
的操作类似,都是在方法内部维护了一套 有限状态机
,由于时间关系,这里不再分析代码实现了,
感兴趣的读者可以自行阅读源代码。
其他工具类函数
除了核心的 编码/解码
方法外,包还有一些内置的辅助工具函数:
文件名 | 函数 | 描述 |
---|---|---|
bytes.go | parseInt | 将 []byte 解析为整数,标准的 Leetcode 题解 :) |
bytes_unsafe.go | bytesToString | 将 []byte 高效转换为 string , 老朋友了 :) |
bytes_unsafe.go | StringToBytes | 将 string 高效转换为 []byte , 老朋友了 :) |
escape.go | 参照 rfc7159 文档实现的 JSON 编码/解码 |
包的整体调用关系
高性能原理
- 数据类型简化,不使用标准库
JSON
和反射
包,没有interface{}
类型,核心数据结构是[]byte
, 核心算法实现了有限状态机
的算法机制 - 底层数据结构使用
[]byte
并且利用切片的引用特性,达到无内存分配 - 不会自动进行类型转换,默认情况都是
[]byte
, 具体的转换工作让开发者决定 - 不会
编码/解码
整个数据结构,而是按需操作 (牺牲了开发效率)
小结
通过对 jsonparser
库源代码的分析,我们可以得出其高性能背后的实现原理: 将所有的操作回归到 JSON 字符串本身,
通过 避免反射
, []byte 参数引用
, 基于状态机编码/解码操作
三个核心优化,相对标准库将性能提升了将近 10 倍。但是需要注意的是,
jsonparser
库和标准库完全不兼容,而且从示例代码中,可以明显看到使用其代码可读性和开发效率远不如标准库,这也算是为高性能付出的 应用层代价
。
最后顺便提一句,字节跳动开源了他们基于汇编进行开发 JOSN 组件库 sonic, 将 JSON 操作性能又提升到了一个新的高度。