蛮荆

jsonparser 为什么比标准库的 encoding/json 快 10 倍 ?

2023-07-17

概述

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
}
  1. 如果给定的 []byte 参数长度大于 0,那么会先调用 searchKeys 方法确认一下给定的参数 keys 是否都存在,如果不存在,直接返回错误信息
  2. 然后会以 searchKeys 方法返回的位置为基准,查找下一个可用 token 位置,如果不存在,说明该 JSON 字符串格式是错误的
  3. 接着会通过上面 两个位置 相加,调用 getType 计算出 key 对应的元素类型,返回值就是 JSON 数据类型常量 其中之一,如果 getType 返回了 JSON 字符串格式错误,就直接返回错误
  4. 最后,如果 key 对应的元素类型为字符串,那么把两边 " 删除,返回结果

Set 和 Delete 方法

上面讲完了 Get 方法的操作流程,SetDelete 的操作类似,都是在方法内部维护了一套 有限状态机,由于时间关系,这里不再分析代码实现了, 感兴趣的读者可以自行阅读源代码。

其他工具类函数

除了核心的 编码/解码 方法外,包还有一些内置的辅助工具函数:

文件名 函数 描述
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 操作性能又提升到了一个新的高度。

Reference

转载申请

本作品采用 知识共享署名 4.0 国际许可协议 进行许可,转载时请注明原文链接,图片在使用时请保留全部内容,商业转载请联系作者获得授权。