Micah Kepe 在博客上写了一篇题为"jsongrep is faster than {jq, jmespath, jsonpath-rust, jql}"的文章,在 Hacker News 上获得 376 分和 237 条评论。

他说:"这篇文章既是介绍我一直在工作的一个叫做 jsongrep 的工具,也是对其使用的内部搜索引擎的技术解释。我还讨论了用于比较 jsongrep 与其他 JSON 路径类查询工具和实现的性能的基准测试策略。"

"这篇文章很大程度上受到 Andrew Gallant 的 amazing ripgrep 工具及其相关博客的启发。"

什么是 jsongrep

jsongrep 是一个 JSON 查询工具,用 Rust 编写,跨平台。

你可以从 crates.io 安装:

cargo install jsongrep

或者通过 Homebrew:

brew install jsongrep

jsongrep(jg 二进制文件)接受一个查询和一个 JSON 输入,并打印每个路径通过文档匹配查询的值。

查询语言

让我们用这个示例文档逐步构建查询语言:

{
  "name": "Micah",
  "favorite_drinks": ["coffee", "Dr. Pepper", "Monster Energy"],
  "roommates": [
    {
      "name": "Alice",
      "favorite_food": "pizza"
    }
  ]
}

点路径通过名称选择嵌套字段。点(.)在字段名称之间表示连接:

$ cat sample.json | jg 'roommates[0].name'
roommates.[0].name:
"Alice"

通配符匹配任何单个键()或任何数组索引([]):

$ cat sample.json | jg 'favorite_drinks[*]'
favorite_drinks.[0]:
"coffee"
favorite_drinks.[1]:
"Dr. Pepper"
favorite_drinks.[2]:
"Monster Energy"

**交替(|)**匹配任一支路,像正则表达式交替:

$ cat sample.json | jg 'name | roommates'
name:
"Micah"
roommates:
[
  {
    "name": "Alice",
    "favorite_food": "pizza"
  }
]

递归下降在 Kleene 星中使用 * 和 [*] 任意深度地遍历树。例如,找到任意深度的每个 name 字段:

$ cat sample.json | jg '(* | [*])*.name'
name:
"Micah"
roommates.[0].name:
"Alice"

模式 (* | [*])* 意思是"跟随任何键或任何索引,零次或多次",例如,通过每个可能的路径下降。尾随的 .name 然后过滤只那些以叫做 name 的字段结束的路径。

等价地,jg 暴露 -F("固定字符串")标志作为这些递归下降查询的简写:

$ cat sample.json | jg -F name
name:
"Micah"
roommates.[0].name:
"Alice"

**可选(?)**匹配零次或一次:

$ cat sample.json | jg 'roommates[0].favorite_food?'
roommates.[0]:
{
  "name": "Alice",
  "favorite_food": "pizza"
}
roommates.[0].favorite_food:
"pizza"

注意内部字符串"pizza"与 ? 匹配,除了父零次出现情况。

为什么快

JSON 文档是树:对象和数组分支,标量是叶子,键和索引标记边。查询 JSON 文档真的是关于描述通过这个树的路径。

jsongrep 字面上采用这个观察:它的查询语言是键和索引字母表上的正则语言。想正则表达式,但不是匹配字符串中的字符,你匹配树中的边。

为什么"正则"重要?因为正则语言有一个众所周知的强大属性:它们可以被编译成确定性有限自动机(DFA)。DFA 在单次传递中处理输入,每个输入符号做 O(1) 工作——没有回溯,没有递归栈,没有指数爆炸在病态查询上。查询在编译时支付一次,然后搜索本质上免费。

这是与 jq、jmespath 或 jsonpath-rust 等工具的关键区别。那些工具解释路径表达式:在 JSON 树的每个节点,它们评估查询,检查谓词,并递归下降到匹配分支。如果查询涉及递归下降(.. 或 $..),这些工具可能重访子树或维护工作列表。

jsongrep 做一些根本不同的事情——它在查看 JSON 之前把查询编译成 DFA,然后正好一次遍历文档树,在每个边上做单个 O(1) 状态转换。没有解释,没有回溯,一次传递。

搜索引擎内部

搜索引擎的核心是一个五阶段管道:

  1. 通过 serde_json_borrow(零拷贝)解析 JSON 文档成树
  2. 解析用户的查询成 Query AST
  3. 通过 Glushkov 构造算法从查询构造 NFA
  4. 通过子集构造把 NFA 确定化成 DFA
  5. 遍历 JSON 树,在每个边上做 DFA 转换并收集匹配

基准测试

端到端搜索性能比较在 xlarge(~190 MB)数据集上。

jsongrep 比 jq 快得多。

反推销

再次借用 ripgrep 博客,这里是 jsongrep 的"反推销":

  • jsongrep 还不像 jq 那样普遍。jq 是 JSON 查询、过滤和转换的首选工具。
  • 查询语言故意不如 jq 表达。jsongrep 是一个搜索工具,不是一个转换工具——它找到值但不计算新值。没有过滤器,没有算术,没有字符串插值。
  • jsongrep 是新的,还没有在野外经过战斗测试。

实际应用

这个工具对开发者有几个实际应用:

快速 JSON 查询

如果你需要快速查询 JSON,jsongrep 是一个选择。

大数据集

如果你处理大数据集,jsongrep 的性能可能重要。

管道工具

jsongrep 可以嵌入管道。像其他 CLI 工具一样。

Rust 库

jsongrep 还暴露它的基于 DFA 的查询引擎作为库 crate,所以你可以直接在你的 Rust 项目中嵌入快速 JSON 搜索。

结语

Micah 说:"这篇文章既介绍工具,也解释内部。"

jsongrep 是一个有趣的工具。它快。它用 Rust 编写。它有基于 DFA 的查询引擎。

但更重要的是,它展示了工具可以是快的。性能不是魔法。它是设计。

在这个时代,我们需要更多关于设计,少关于魔法。