ภาษา JSONPath ในทางปฏิบัติ
สรุปสำคัญ: ในปี 2024 JSONPath มีมาตรฐานอย่างเป็นทางการในที่สุด นั่นคือ RFC 9535 บทความนี้อธิบายไวยากรณ์ ความหมายของตัวกรอง ฟังก์ชัน และความแตกต่างเชิงประวัติศาสตร์ระหว่าง implementation

สรุปสั้น: ระบุตำแหน่งด้วยพาธแบบชัดเจนก่อน แล้วกรองบนพาธนั้น และเขียนนิพจน์ตาม RFC 9535; ใช้การเข้าถึงพร็อพเพอร์ตี้หรือ JSON Pointer สำหรับฟิลด์เดียวที่ตายตัว และใช้ jq หรือโค้ดสำหรับ aggregation
คุณคงเคยเห็นนิพจน์อย่าง
$.store.book[?(@.price < 10)].titleแต่เพิ่งกลายเป็นมาตรฐานสากลในปี 2024
1. จากมาตรฐานโดยพฤตินัยสู่มาตรฐานอย่างเป็นทางการ
JSONPath ถูกเสนอโดย Stefan Gössner ในปี 2007 และเป็นเวลานาน "ข้อกำหนด" เดียวคือบล็อกโพสต์เดียว โดยแต่ละ implementation ในแต่ละภาษาต่างทำตามใจตัวเอง:
- ตัวกรองต้องมีวงเล็บหรือไม่ (
?(@.a)กับ?@.a); - รองรับดัชนี array
[-1]หรือไม่; - ผลลัพธ์คืน "ค่า" หรือ "ค่าพร้อมพาธ";
- อนุญาต script expression (
?(@.price < $.max)) หรือไม่
RFC 9535 ที่เผยแพร่ในเดือนกุมภาพันธ์ 2024 ได้หลอมความแตกต่างเหล่านี้ให้เป็นไวยากรณ์และความหมายชุดเดียว พร้อมชุดทดสอบความสอดคล้องอย่างเป็นทางการ โปรเจกต์ใหม่ควรปฏิบัติตาม RFC 9535; เมื่อย้ายโปรเจกต์เก่า ให้ยืนยันเวอร์ชันของ implementation ก่อน
2. ตารางสรุปไวยากรณ์
| ไวยากรณ์ | ความหมาย | ตัวอย่าง |
|---|---|---|
$ |
โหนดราก | $.store |
@ |
โหนดปัจจุบันภายในตัวกรอง | $[?@.price < 10] |
.name |
โหนดลูก (ชื่อต้องตรงกับ [A-Za-z_][A-Za-z0-9_]*) |
$.store.book |
['name'] |
โหนดลูก (ชื่อใดก็ได้ รวมถึงอักขระที่ไม่ใช่ ASCII และจุด) | $['store']['书'] |
[0] |
ดัชนี array; จำนวนลบนับจากท้าย | $.book[0], $.book[-1] |
[0,2] |
หลายดัชนี / หลายชื่อ | $.book[0,2] |
[1:3] |
Slice โดยรวมจุดเริ่มและไม่รวมจุดสิ้นสุด | $.book[1:3] |
[::2] |
Slice พร้อม step | $.book[::2] |
[*] |
Wildcard สำหรับโหนดลูกทั้งหมด | $.book[*].title |
..name |
Recursive descent ค้นหาที่ความลึกใดก็ได้ | $..price |
..[*] |
ดึงโหนดทั้งหมดแบบเรียกซ้ำ | $..* |
[?<expr>] |
Filter selector | $.book[?@.price < 10] |
ความหมายหนึ่งที่ต้องจำ: JSONPath คืนค่ากลับเป็นชุดโหนด (nodelist) ไม่ใช่ค่าเดี่ยว แม้จะตรงกับโหนดเพียงหนึ่งเดียว ผลลัพธ์ก็ยังเป็นลิสต์ที่มีความยาวหนึ่ง
3. ทดลองไวยากรณ์ด้วยตัวอย่างเดียว
{
"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 ค่า (รวมราคาจักรยาน) |
$.store.book[*].title |
title 3 รายการ |
$.store.book[?@.price < 10].title |
title ของเล่มที่ 1 และ 3 |
$.store.book[?@.tags] |
หนังสือทุกเล่มที่มี tags (ทดสอบการมีอยู่) |
$.store.book[-1].title |
title ของเล่มสุดท้าย |
$..book[0,1] |
สองเล่มแรก |
$.store.book[?@.price < $.expensive] |
ขึ้นกับ implementation ตามไวยากรณ์; RFC 9535 ไม่อนุญาต การอ้างอิงข้ามราก |
แถวสุดท้ายคือกับดักทางประวัติศาสตร์: implementation จำนวนมากอนุญาต $.expensive ในตัวกรองเพื่อเปรียบเทียบข้ามโหนด ซึ่ง RFC 9535 ห้ามอย่างชัดเจน —
ตัวกรองเข้าถึงได้เฉพาะโหนดปัจจุบัน @ เมื่อต้องเปรียบเทียบกับ threshold ระดับโลก ให้ส่ง threshold เป็นอาร์กิวเมนต์ของฟังก์ชันคิวรี
4. ตัวกรองแสดงอะไรได้บ้าง
นิพจน์ตัวกรองของ RFC 9535 รองรับโครงสร้างสี่ชนิด:
ตรรกะ: && || ! e.g. ?@.price < 10 && @.tags
การเปรียบเทียบ: == != < <= > >=
การมีอยู่: พาธเปล่า e.g. ?@.tags (tests existence)
ฟังก์ชัน: length() count() match() search() value()
ตัวอย่างในทางปฏิบัติหลายข้อ:
// ราคาระหว่าง 8 ถึง 10
$.store.book[?@.price >= 8 && @.price <= 10]
// title มี "JSON" (regex match; อาร์กิวเมนต์ที่สองเป็น I-Regexp)
$.store.book[?match(@.title, ".*JSON.*")]
// มีแท็กมากกว่าหนึ่ง
$.store.book[?length(@.tags) > 1]
// ดึงค่าแรกที่ตรงแทนลิสต์ (value() เปลี่ยนชุดโหนดเดียวให้เป็นค่า)
$.store.book[?@.price < 9].title.value()
match() เป็นการจับคู่ทั้งสตริง และ search() เป็นการค้นหาสตริงย่อย; ภาษาปกติของ regex ที่ใช้คือ I-Regexp (ชุดย่อยของ RFC 9485)
ซึ่งไม่รองรับทั้ง backreference และ lookaround — นั่นเป็นทั้งข้อจำกัดและเกราะป้องกัน regex DoS ที่ดี
5. ความแตกต่างระหว่าง implementation: ตรวจสอบก่อนย้าย
| Implementation | ภาษา | การรองรับ RFC 9535 | หมายเหตุ |
|---|---|---|---|
jsonpath-plus |
JavaScript | เข้ากันได้เป็นส่วนใหญ่ | รองรับฟังก์ชันส่วนขยายและ script expression; สามารถมากกว่าแต่ไม่เข้มงวดตามมาตรฐาน |
jsonpath (dchester) |
JavaScript | บางส่วน | ไลบรารีที่อยู่มานาน มีไวยากรณ์ ?() และโครงสร้างผลลัพธ์ต่างออกไป |
ojg / gjson |
Go | ojg รองรับดี |
gjson ใช้ไวยากรณ์พาธอีกแบบ ไม่ใช่ JSONPath |
jsonpath-ng |
Python | บางส่วน | รองรับส่วนขยายและค่อนข้างผ่อนปรน |
Jayway JsonPath |
Java | บางส่วน | ระบบนิเวศกว้าง; พฤติกรรมเชิงประวัติศาสตร์ต่างจาก RFC |
การตรวจสอบสามข้อก่อนย้าย:
- วงเล็บในตัวกรองจำเป็นหรือไม่;
- รองรับดัชนีลบอย่าง
-1หรือไม่; - ผลลัพธ์เป็น "อาร์เรย์ของค่า" หรือ "อาร์เรย์ของออบเจกต์
{path, value}"
ข้อที่สามพลาดง่ายที่สุด: frontend อ่าน result[0] และหลังเปลี่ยนไลบรารีกลับได้ออบเจกต์ ทำให้หน้าว่างเปล่า
6. ประสิทธิภาพและความปลอดภัย
ประสิทธิภาพ
- JSONPath เป็น การท่องครั้งเดียว ที่มีความซับซ้อน O(จำนวนโหนด) และไม่สามารถใช้ดัชนีได้;
- Recursive descent ด้วย
..มีต้นทุนสูงที่สุด; ถ้าระบุตำแหน่งด้วย$.store.bookก่อนแล้วกรองได้ ก็อย่าเขียน$..book; - compile นิพจน์ครั้งเดียวแล้วใช้ซ้ำ (ไลบรารีส่วนใหญ่มี
compileหรือแคช) แทนการ parse ใหม่ทุกครั้งที่คิวรี; - สำหรับเอกสารที่มีโหนดเกิน 100,000 ให้พิจารณาใช้ JSON Pointer ดึงข้อมูลแบบตรงจุดก่อน หรือประมวลผลล่วงหน้าที่เซิร์ฟเวอร์ด้วย
jq
ความปลอดภัย
- นิพจน์จากอินพุตของผู้ใช้ต้องมีขีดจำกัดความยาวและความลึกของการซ้อน;
- อย่าให้ JSONPath จัดการการอนุญาต — มันอ่านได้เท่านั้น และไม่สามารถแสดง "ฟิลด์ใดมองไม่เห็น";
- เมื่อ regex ที่
match()ใช้มาจากผู้ใช้ ให้จำกัดความซับซ้อนของมัน; แม้จะปลอดภัยกว่า PCRE แต่ยังแนะนำให้จำกัดความยาว
7. เมื่อไม่ควรใช้ JSONPath
- ต้องการฟิลด์เดียวที่ตายตัว → ใช้
data.store.bicycle.colorโดยตรง; - ต้องเขียนกลับหลังแก้ไข → ใช้ JSON Pointer หรือแพตช์; JSONPath ไม่เขียนกลับ;
- ต้องทำ aggregation (รวม หาจัดกลุ่ม) → ใช้
jq, SQL หรือโค้ด; JSONPath เลือกเท่านั้น; - เอกสารใหญ่และต้องการเพียงไม่กี่ฟิลด์ → ใช้ streaming parser แทนการอ่านต้นไม้ทั้งก้อนเข้าหน่วยความจำก่อน
8. รายการตรวจสอบ
- [ ] นิพจน์เป็นไปตาม RFC 9535 และไม่พึ่งการอ้างอิงข้ามราก;
- [ ] ชัดเจนว่าผลลัพธ์เป็นชุดโหนด; ใช้
[0]หรือvalue()เพื่อให้ได้ค่าเดี่ยว; - [ ] นิพจน์ถูก compile ล่วงหน้าและใช้ซ้ำ;
- [ ] นิพจน์ที่ผู้ใช้ควบคุมมีขีดจำกัดความยาวและความซับซ้อน;
- [ ] รันกรณีทดสอบความสอดคล้องอย่างเป็นทางการเมื่อเปลี่ยน implementation
คำถามที่พบบ่อย
จะเลือก JSON Pointer กับ JSONPath อย่างไร
ใช้การเข้าถึงพร็อพเพอร์ตี้ปกติหรือ JSON Pointer เมื่อต้องการฟิลด์เดียวที่ตายตัว; เลือก JSONPath เมื่อต้องการตัวกรอง wildcard หรือ recursive descent JSONPath เลือกเท่านั้นและไม่เขียนกลับ การเขียนกลับจึงเป็นของ JSON Pointer หรือแพตช์
ทำไมตัวกรองอ้างอิง $.expensive ไม่ได้
RFC 9535 ห้ามการอ้างอิงข้ามรากอย่างชัดเจน; ตัวกรองเข้าถึงได้เฉพาะโหนดปัจจุบัน @ ให้ส่ง threshold เป็นอาร์กิวเมนต์ของฟังก์ชันคิวรีแทน implementation เก่ารับไวยากรณ์นี้ได้ แต่จะพังเมื่อเปลี่ยนหรืออัปเกรดไลบรารี
ควรพึ่ง result[0] ในการอ่านค่าหรือไม่
ใช้ได้หลังยืนยันว่า implementation ปัจจุบันคืนอาร์เรย์ของค่า ไลบรารีบางตัวคืนออบเจกต์ {path, value} ทำให้ result[0] กลายเป็นออบเจกต์และหน้าว่างเปล่า ให้อ่านค่าเดี่ยวผ่าน [0] หรือ value() และรันชุดทดสอบความสอดคล้องอย่างเป็นทางการก่อนเปลี่ยน implementation