jsonparser 爲什麼比 encoding-json 快 10 倍

概述

jsonparser 是一個開源 JSON 包,號稱比標準庫 JSON 包性能高 10 倍 (具體情況取決於具體的負載大小和數據情況),內存分配優化到 0。 項目的目標是在不犧牲開發者用戶體驗和保持包 API 簡潔的前提下,儘可能地提升 JSON 操作的性能。

基準測試

官方給出了 3 種類型 的基準測試結果,分別是 少量數據中等數據大量數據 情況下的 JSON 負載。

jsonparser 的性能在很大程度上取決於使用情況,當不需要處理完整記錄而只需要處理某幾個字段時 (尤其是訪問第三方接口時,大多數情況下我們只需要幾個字段) 性能表現非常好。

少量數據

每個測試將 190 字節的 http 日誌轉換爲 JSON。

d4P1KR

從輸出的結果中可以看到,jsonparser 要比標準庫快 ≈ 10 倍,內存分配優化到 0。

中等數據

每個測試處理一個 2.4kb 的 JSON 記錄,讀取多個嵌套字段和 1 個數組。

82U0xl

從輸出的結果中可以看到,jsonparser 要比標準庫快 ≈ 6.5 倍,內存分配優化到 0。

大量數據

每個測試處理一個 24kb 的 JSON 記錄,讀取 2 個數組,並獲取數組中每個元素的一些字段。

AgzRuw

從輸出的結果中可以看到,jsonparser 要比標準庫快 ≈ 9 倍,內存分配優化到 0。

和標準庫的差異

jsonparser 和標準庫中的 JSON 工作方式不同,不會 編碼 / 解碼 整個數據結構,而是 按需操作 (當然,這背後的代價就是開發效率會降低)。

方法對應關係

YQ0nyQ

此外,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 循環表達式有些槽點。

狀態機組成部分

狀態轉移表

guGzv4

狀態轉移表 定義完畢後,狀態機開始從初始狀態讀取參數字符,並根據 狀態轉移表 進行狀態轉換。如果在狀態轉換過程中遇到格式錯誤的字符,則會停止解析過程並返回錯誤 (-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 的操作類似,都是在方法內部維護了一套 有限狀態機,由於時間關係,這裏不再分析代碼實現了, 感興趣的讀者可以自行閱讀源代碼。

其他工具類函數

除了核心的 編碼/解碼 方法外,包還有一些內置的輔助工具函數:

wpitVm

包的整體調用關係

調用關係圖

高性能原理

小結

通過對 jsonparser 庫源代碼的分析,我們可以得出其高性能背後的實現原理: 將所有的操作迴歸到 JSON 字符串本身,通過 避免反射, []byte 參數引用, 基於狀態機編碼/解碼操作 三個核心優化,相對標準庫將性能提升了將近 10 倍。但是需要注意的是,jsonparser 庫和標準庫完全不兼容,而且從示例代碼中,可以明顯看到使用其代碼可讀性和開發效率遠不如標準庫,這也算是爲高性能付出的 應用層代價

最後順便提一句,字節跳動開源了他們基於彙編進行開發 JOSN 組件庫 sonic[1], 將 JSON 操作性能又提升到了一個新的高度。

Reference

鏈接

[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