jsonparser 爲什麼比 encoding-json 快 10 倍
概述
jsonparser 是一個開源 JSON 包,號稱比標準庫 JSON 包性能高 10 倍 (具體情況取決於具體的負載大小和數據情況),內存分配優化到 0。 項目的目標是在不犧牲開發者用戶體驗和保持包 API 簡潔的前提下,儘可能地提升 JSON 操作的性能。
基準測試
官方給出了 3 種類型 的基準測試結果,分別是 少量數據、中等數據 和 大量數據 情況下的 JSON 負載。
jsonparser 的性能在很大程度上取決於使用情況,當不需要處理完整記錄而只需要處理某幾個字段時 (尤其是訪問第三方接口時,大多數情況下我們只需要幾個字段) 性能表現非常好。
少量數據
每個測試將 190 字節的 http 日誌轉換爲 JSON。
從輸出的結果中可以看到,jsonparser 要比標準庫快 ≈ 10 倍,內存分配優化到 0。
中等數據
每個測試處理一個 2.4kb 的 JSON 記錄,讀取多個嵌套字段和 1 個數組。
從輸出的結果中可以看到,jsonparser 要比標準庫快 ≈ 6.5 倍,內存分配優化到 0。
大量數據
每個測試處理一個 24kb 的 JSON 記錄,讀取 2 個數組,並獲取數組中每個元素的一些字段。
從輸出的結果中可以看到,jsonparser 要比標準庫快 ≈ 9 倍,內存分配優化到 0。
和標準庫的差異
jsonparser 和標準庫中的 JSON 工作方式不同,不會 編碼 / 解碼 整個數據結構,而是 按需操作 (當然,這背後的代價就是開發效率會降低)。
方法對應關係
此外,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等)
狀態轉移表
狀態轉移表 定義完畢後,狀態機開始從初始狀態讀取參數字符,並根據 狀態轉移表 進行狀態轉換。如果在狀態轉換過程中遇到格式錯誤的字符,則會停止解析過程並返回錯誤 (-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 的操作類似,都是在方法內部維護了一套 有限狀態機,由於時間關係,這裏不再分析代碼實現了, 感興趣的讀者可以自行閱讀源代碼。
其他工具類函數
除了核心的 編碼/解碼 方法外,包還有一些內置的輔助工具函數:
包的整體調用關係
調用關係圖
高性能原理
-
數據類型簡化,不使用標準庫
JSON和反射包,沒有interface{}類型,核心數據結構是[]byte, 核心算法實現了有限狀態機的算法機制 -
底層數據結構使用
[]byte並且利用切片的引用特性,達到無內存分配 -
不會自動進行類型轉換,默認情況都是
[]byte, 具體的轉換工作讓開發者決定 -
不會
編碼/解碼整個數據結構,而是按需操作 (犧牲了開發效率)
小結
通過對 jsonparser 庫源代碼的分析,我們可以得出其高性能背後的實現原理: 將所有的操作迴歸到 JSON 字符串本身,通過 避免反射, []byte 參數引用, 基於狀態機編碼/解碼操作 三個核心優化,相對標準庫將性能提升了將近 10 倍。但是需要注意的是,jsonparser 庫和標準庫完全不兼容,而且從示例代碼中,可以明顯看到使用其代碼可讀性和開發效率遠不如標準庫,這也算是爲高性能付出的 應用層代價。
最後順便提一句,字節跳動開源了他們基於彙編進行開發 JOSN 組件庫 sonic[1], 將 JSON 操作性能又提升到了一個新的高度。
Reference
-
jsonparser[2]
-
Introducing JSON[3]
-
sonic[4]
-
sonic:基於 JIT 技術的開源全場景高性能 JSON 庫 [5]
鏈接
[1] sonic: https://github.com/bytedance/sonic
[2] jsonparser: https://github.com/buger/jsonparser
[3] Introducing JSON: https://www.json.org/json-en.html
[4] sonic: https://github.com/bytedance/sonic
[5] sonic:基於 JIT 技術的開源全場景高性能 JSON 庫: https://xie.infoq.cn/article/d144592e5aec0179cafc5cf1b
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_9HNAqnUhi76LbBxU1n_qw