JSONPath 查詢語言實戰
結論:2024 年 JSONPath 終於有了正式標準 RFC 9535。這一篇講清語法、過濾器語義、函式,以及各實現之間的歷史差異。

結論:先用確定的路徑定位、再疊加過濾條件,表示式按 RFC 9535 寫;只需要取固定欄位就用屬性訪問或 JSON Pointer,需要聚合就換 jq 或程式碼。
$.store.book[?(@.price < 10)].title這種寫法你大概見過,但它成為國際標準是 2024 年的事。
一、從事實標準到正式標準
JSONPath 由 Stefan Gössner 在 2007 年提出,長期只有一篇部落格作為「規範」,各語言實現各自發揮:
- 過濾器要不要加括號(
?(@.a)vs?@.a); - 陣列下標
[-1]是否支援; - 結果返回「值」還是「值與路徑」;
- 指令碼表示式(
?(@.price < $.max))是否允許。
2024 年 2 月釋出的 RFC 9535 把這些分歧收斂成一套語法與語義,並給出了官方一致性測試套件。 新專案應當按 RFC 9535 來寫,老專案遷移時要先確認實現版本。
二、語法速查
| 語法 | 含義 | 示例 |
|---|---|---|
$ |
根節點 | $.store |
@ |
過濾器內的當前節點 | $[?@.price < 10] |
.name |
子節點(名字需匹配 [A-Za-z_][A-Za-z0-9_]*) |
$.store.book |
['name'] |
子節點(任意名字,含中文、含點) | $['store']['書'] |
[0] |
陣列下標,負數從尾部數 | $.book[0]、$.book[-1] |
[0,2] |
多個下標/多個名字 | $.book[0,2] |
[1:3] |
切片,含頭不含尾 | $.book[1:3] |
[::2] |
帶步長切片 | $.book[::2] |
[*] |
通配所有子節點 | $.book[*].title |
..name |
遞迴下降,任意深度查詢 | $..price |
..[*] |
遞迴取全部節點 | $..* |
[?<expr>] |
過濾選擇器 | $.book[?@.price < 10] |
一個必須記住的語義:JSONPath 返回的是節點集(nodelist),不是單個值。 即使只匹配一個節點,結果也是長度為一的列表。
三、用一份樣例把語法跑一遍
{
"store": {
"book": [
{ "title": "JSON 權威指南", "price": 8.95, "tags": ["json", "spec"] },
{ "title": "資料交換格式史", "price": 12.99, "tags": ["history"] },
{ "title": "解析器實現原理", "price": 8.99, "tags": ["parser", "json"] }
],
"bicycle": { "color": "red", "price": 19.95 }
},
"expensive": 10
}
| 表示式 | 結果 |
|---|---|
$.store.bicycle.color |
["red"] |
$..price |
4 個價格(含 bicycle) |
$.store.book[*].title |
3 個標題 |
$.store.book[?@.price < 10].title |
第 1、3 本的標題 |
$.store.book[?@.tags] |
所有帶 tags 的書(存在性判斷) |
$.store.book[-1].title |
最後一本的標題 |
$..book[0,1] |
前兩本 |
$.store.book[?@.price < $.expensive] |
語法上依賴實現,RFC 9535 不允許跨根引用 |
最後一行是歷史坑:很多實現支援在過濾器裡寫 $.expensive 做跨節點比較,RFC 9535 明確禁止——
過濾器只能訪問當前節點 @。需要比較全域性閾值時,把閾值作為引數傳給查詢函式。
四、過濾器能寫什麼
RFC 9535 的過濾表示式支援四類構造:
邏輯: && || ! 例:?@.price < 10 && @.tags
比較: == != < <= > >=
存在性:單獨寫一個路徑 例:?@.tags(判斷存在)
函式: length() count() match() search() value()
幾個實用例子:
// 價格在 8 到 10 之間
$.store.book[?@.price >= 8 && @.price <= 10]
// 標題包含「JSON」(正則匹配,第二個引數是 I-Regexp)
$.store.book[?match(@.title, ".*JSON.*")]
// 標籤數量大於 1
$.store.book[?length(@.tags) > 1]
// 取第一個匹配值而不是列表(value() 用於把單節點集轉成值)
$.store.book[?@.price < 9].title.value()
match() 是全串匹配、search() 是子串搜尋,底層的正則方言是 I-Regexp(RFC 9485 的子集),
不支援回溯引用與環視——這既是限制,也順手擋掉了正則 DoS。
五、實現差異:遷移前必查
| 實現 | 語言 | RFC 9535 支援 | 備註 |
|---|---|---|---|
jsonpath-plus |
JavaScript | 大部分相容 | 支援擴充套件函式與指令碼表示式,能力更強但非嚴格標準 |
jsonpath(dchester) |
JavaScript | 部分 | 老牌庫,?() 語法,結果結構不同 |
ojg / gjson |
Go | ojg 支援較好 |
gjson 是另一套路徑語法,不是 JSONPath |
jsonpath-ng |
Python | 部分 | 支援擴充套件,寫法偏寬鬆 |
Jayway JsonPath |
Java | 部分 | 生態廣,歷史行為與 RFC 有出入 |
遷移檢查三項:
- 過濾器括號是否必需;
-1負下標是否支援;- 結果是「值陣列」還是「
{path, value}物件陣列」。
第三項最容易踩:前端按 result[0] 取值,換庫後拿到的是物件,頁面直接空白。
六、效能與安全
效能
- JSONPath 本質是一次遍歷,複雜度是 O(節點數),無法利用索引;
..遞迴下降最貴,能先$.store.book定位再過濾,就不要寫$..book;- 表示式應先編譯再複用(多數庫提供
compile或快取),避免每次查詢都重新解析; - 十萬節點以上的文件,考慮先用 JSON Pointer 精確取值,或改用
jq在服務端預處理。
安全
- 使用者輸入的表示式必須限制長度與巢狀層數;
- 不要讓 JSONPath 承擔許可權控制——它只能讀,無法表達「哪些欄位不可見」;
match()用的正則來自使用者時要限制複雜度,雖比 PCRE 安全,仍建議設長度上限。
七、什麼時候不該用 JSONPath
- 只需要取一個固定欄位 → 直接
data.store.bicycle.color; - 需要在修改後寫回 → 用 JSON Pointer 或補丁,JSONPath 不回寫;
- 需要聚合計算(求和、分組)→ 用
jq、SQL 或程式碼,JSONPath 只做選擇; - 文件超大且只需要少數字段 → 用流式解析器,別先整棵讀進記憶體。
八、檢查清單
- [ ] 表示式遵循 RFC 9535,不依賴跨根引用;
- [ ] 明確了結果是節點集,取單值用
[0]或value(); - [ ] 表示式預編譯並複用;
- [ ] 使用者可控的表示式有長度與複雜度限制;
- [ ] 換實現時跑一遍官方一致性測試用例。
常見問題
如何判斷該用 JSON Pointer 還是 JSONPath?
只需要取一個固定欄位時,直接寫屬性訪問或用 JSON Pointer,不必引入表示式;需要過濾、通配或遞迴下降時再用 JSONPath。JSONPath 只負責選擇、不回寫,修改後寫回要走 JSON Pointer 或補丁。
為什麼過濾器裡不能寫 $.expensive?
RFC 9535 明確禁止跨根引用,過濾器只能訪問當前節點 @。需要比較全域性閾值時,把閾值作為引數傳給查詢函式;老實現雖然支援這種寫法,但換庫或遷移版本後會失效。
是否可以直接用 result[0] 取值?
可以,但前提是確認當前實現返回的是值陣列。部分庫返回 {path, value} 物件陣列,換庫後 result[0] 拿到的是物件,頁面會直接空白。取單值統一走 [0] 或 value(),換實現前先跑官方一致性測試用例。