分享

jsonparser 为什么比 encoding/json 快 10 倍 ?

 F2967527 2023-07-20 发布于北京

概述

jsonparser 是一个开源 JSON 包,号称比标准库 JSON 包性能高 10 倍 (具体情况取决于具体的负载大小和数据情况),内存分配优化到 0。 项目的目标是在不牺牲开发者用户体验和保持包 API 简洁的前提下,尽可能地提升 JSON 操作的性能。

基准测试

官方给出了 3 种类型 的基准测试结果,分别是 少量数据中等数据大量数据 情况下的 JSON 负载。

jsonparser 的性能在很大程度上取决于使用情况,当不需要处理完整记录而只需要处理某几个字段时 (尤其是访问第三方接口时,大多数情况下我们只需要几个字段) 性能表现非常好。

少量数据

每个测试将 190 字节的 http 日志转换为 JSON。

Librarytime/opbytes/opallocs/op
encoding/json struct787988018
encoding/json interface{}8946152138
buger/jsonparser136700
buger/jsonparser (EachKey API)80900

从输出的结果中可以看到,jsonparser 要比标准库快 ≈ 10 倍,内存分配优化到 0。

中等数据

每个测试处理一个 2.4kb 的 JSON 记录,读取多个嵌套字段和 1 个数组。

Librarytime/opbytes/opallocs/op
encoding/json struct57749133629
encoding/json interface{}7929710627215
buger/jsonparser1595500
buger/jsonparser (EachKey API)891600

从输出的结果中可以看到,jsonparser 要比标准库快 ≈ 6.5 倍,内存分配优化到 0。

大量数据

每个测试处理一个 24kb 的 JSON 记录,读取 2 个数组,并获取数组中每个元素的一些字段。

Librarytime/opbytes/opallocs/op
encoding/json struct7483368272307
encoding/json interface{}12242712154253395
buger/jsonparser8530800

从输出的结果中可以看到,jsonparser 要比标准库快 ≈ 9 倍,内存分配优化到 0。

和标准库的差异

jsonparser 和标准库中的 JSON 工作方式不同,不会 编码/解码 整个数据结构,而是 按需操作 (当然,这背后的代价就是开发效率会降低)。

方法对应关系


标准库jsonparser
编码MarshalSet
解码UnmarshalGet

此外,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./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./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) (intbool) {}

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.goparseInt[]byte 解析为整数,标准的 Leetcode 题解 :)
bytes_unsafe.gobytesToString[]byte 高效转换为 string, 老朋友了 :)
bytes_unsafe.goStringToBytesstring 高效转换为 []byte, 老朋友了 :)
escape.go
参照 rfc7159 文档实现的 JSON 编码/解码

包的整体调用关系

图片

调用关系图

高性能原理

  • 数据类型简化,不使用标准库 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-en.html

[4]

sonic: https://github.com/bytedance/sonic

[5]

sonic:基于 JIT 技术的开源全场景高性能 JSON 库: https://xie./article/d144592e5aec0179cafc5cf1b



    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多