JSONPath Filters เจาะลึก: Recursion, Slice และการจับคู่หลายเงื่อนไข
สรุปสำคัญ: Recursive descent, slice และตัวกรองหลายเงื่อนไขคือจุดที่ JSONPath ทั้งทรงพลังและถูกใช้ผิด ครอบคลุมความหมายของตัวกรอง รูปร่างผลลัพธ์ และความแตกต่างระหว่างไลบรารี

สรุปสั้น: ระบุตำแหน่งที่รู้ความลึกก่อน แล้วค่อยเพิ่ม slice และตัวกรอง แทนที่จะคว้า $.. มาใช้; พาธเปล่าในตัวกรองตรวจเพียงการมีอยู่ ดังนั้นหากต้องการตรวจ boolean ให้เขียน @.stock == true
ใครก็เขียน
$.store.book[0].titleได้ สิ่งที่ทำให้ JSONPath พังจริง ๆ คือการผสม$..กับ slice และหลายเงื่อนไข แล้วได้ผลลัพธ์มากเกินไป น้อยเกินไป หรือได้ลำดับที่ไม่คาดคิด
1. เอกสารตัวอย่าง
{
"store": {
"book": [
{ "title": "JSON: The Definitive Guide", "price": 8.95, "stock": true, "tags": ["json", "spec"] },
{ "title": "A History of Data Formats", "price": 12.99, "stock": false, "tags": ["history"] },
{ "title": "How Parsers Work", "price": 8.99, "stock": true, "tags": ["parser", "json"] },
{ "title": "Streaming JSON in Practice", "price": 9.5, "stock": true, "tags": [] }
],
"bicycle": { "color": "red", "price": 19.95 }
},
"updatedAt": "2026-10-31"
}
ทุกนิพจน์ด้านล่างใช้เอกสารนี้เป็นอินพุต โปรดจำหลักสองข้อ: ผลลัพธ์เป็น ชุดโหนด และลำดับของมันเป็นไปตามลำดับในเอกสาร ไม่ใช่ลำดับที่เขียนเงื่อนไข ภายในตัวกรอง @ อ้างถึงเฉพาะโหนดตัวเลือกปัจจุบัน
2. Recursive Descent $..: สะดวกแต่มีต้นทุน
.. หมายถึง "ค้นหาแบบเรียกซ้ำที่ความลึกใดก็ได้จากตำแหน่งปัจจุบัน" RFC 9535 กำหนดให้มันเป็น descendant segment และสามารถใช้ร่วมกับชื่อ wildcard ดัชนี slice และตัวกรองได้:
$..price // every member named price at any depth
$..book[?(@.price < 10)] // any book at any depth, then filter
$..[?(@.stock)] // any node at any depth where stock exists
สามสิ่งที่ต้องเข้าใจ:
$..bookค้นหาโหนดทุกตัวที่ชื่อbookโดยไม่สนใจความลึก;- การท่องเป็นแบบ depth-first และลำดับผลลัพธ์ขึ้นกับวิธีที่ implementation เยี่ยมชมลูก;
..ไม่สามารถใช้ดัชนีใด ๆ ได้ ต้นทุนจึงโตตามจำนวนโหนดทั้งหมด
ด้วยเหตุนี้ $..price กับ $.store.book[*].price อาจต่างกันเพียงราคาจักรยานในผลลัพธ์ แต่ต้นทุนต่างกันเป็นเท่าตัว เมื่อรู้ความลึก ควรเขียนพาธแบบชัดเจนเสมอ ถ้าต้องการเพียงเปรียบเทียบขอบเขตของภาษาพาธสองแบบ ดู JSONPath กับ JSON Pointer
3. Slice และ Wildcard ของ Array: กับดักสามข้อ
ไวยากรณ์ slice คือ [start:end:step] โดยรวมจุดเริ่มและไม่รวมจุดสิ้นสุด และจำนวนลบจะนับจากท้าย:
| นิพจน์ | ความหมาย | ผลลัพธ์ (มีหนังสือสี่เล่ม) |
|---|---|---|
[0:2] |
หนังสือสองเล่มแรก | เล่มที่ 1 และ 2 |
[1:] |
จากเล่มที่สองถึงท้าย | เล่มที่ 2, 3 และ 4 |
[:2] |
หนังสือสองเล่มแรก | เล่มที่ 1 และ 2 |
[-2:] |
สองเล่มสุดท้าย | เล่มที่ 3 และ 4 |
[::2] |
เว้นเล่มละหนึ่ง | เล่มที่ 1 และ 3 |
[::-1] |
ลำดับย้อนกลับ | เล่มที่ 4, 3, 2 และ 1 |
กับดักสามข้อ:
- step เป็นศูนย์ไม่ถูกต้อง RFC 9535 กำหนดให้ step ไม่เป็นศูนย์ ขณะที่ implementation บางตัวคืนชุดว่างอย่างเงียบ ๆ;
- step ติดลบไม่ได้รับการรองรับทุกที่ implementation ดั้งเดิมของ Goessner รองรับ แต่ implementation ขนาดเล็กบางตัวรับเฉพาะ step บวก;
- การกรองหลัง slice ไม่ขยายขอบเขต
[0:2][?(@.stock)]กรองเพียงสองเล่มที่ slice สร้างออกมา ไม่ได้ย้อนกลับไปทั้ง array
Wildcard [*] ใช้ร่วมกับ slice ได้ แต่ [*] ให้ "โหนดลูกทั้งหมด" ถ้าลูกเหล่านั้นเป็นออบเจกต์ การต่อ .title จะให้ทุก title ถ้าลูกตัวหนึ่งเป็น array เอง .title จะไม่ยุบอีกชั้นโดยปริยาย เมื่อต้องขยาย ให้เขียน [*] อีกครั้ง; อย่าหวังการยุบโดยปริยาย
4. ตัวกรองหลายเงื่อนไข: การเปรียบเทียบ ตรรกะ และการมีอยู่
ตัวกรอง [?(...)] บรรจุโครงสร้างได้สี่ชนิด: การเปรียบเทียบ ตัวดำเนินการทางตรรกะ การทดสอบการมีอยู่ และฟังก์ชัน คิวรีหลายเงื่อนไขที่พบบ่อยมีลักษณะดังนี้:
$..book[?(@.price < 10 && @.stock)]
มันหมายถึง "ราคาต่ำกว่า 10 และ stock เป็น truthy" ให้ใส่ใจรายละเอียดเชิงความหมายหลายประการ:
@.stockเป็นการทดสอบการมีอยู่ ไม่ใช่ "ค่าเป็น true" ถ้าฟิลด์มีอยู่แต่ค่าเป็นfalseเงื่อนไข ยังคงเป็นจริง การทดสอบค่า boolean ต้องเขียน@.stock == true;&&ผูกแน่นกว่า||ให้ใส่วงเล็บเมื่อผสมกัน แทนที่จะพึ่งความจำ;- ทั้งสองฝั่งของการเปรียบเทียบต้องมีชนิดเดียวกัน ตัวเลขกับสตริงไม่ถูกแปลงโดยปริยาย;
@.price < "10"เป็น false หรือ error ใน implementation ที่เข้มงวด; - ฟิลด์ที่หายไปทำให้การเปรียบเทียบเป็น false เสมอ
@.missing < 10ไม่ก่อ error; เพียงแต่ไม่ตรงกับอะไรเลย
นี่คือจุดที่พลาดง่ายที่สุดเมื่อเขียนตัวกรองราวกับเป็น JavaScript: if (obj.stock) ทดสอบ truthiness ใน JavaScript ขณะที่ JSONPath ทดสอบการมีอยู่ สำหรับไวยากรณ์และฟังก์ชันมาตรฐานทั้งหมด ให้กลับไปอ่าน ภาษา JSONPath ในทางปฏิบัติ
5. Script Expression: ทรงพลังขึ้นแต่พกพาได้น้อยลง
implementation ดั้งเดิมของ Goessner และ jsonpath-plus อนุญาตการเปรียบเทียบข้ามโหนดอย่าง [?(@.price < $.max)] และแม้แต่ script ตามอำเภอใจ RFC 9535 อนุญาตอย่างชัดเจนเฉพาะการเข้าถึงโหนดปัจจุบัน @ และห้าม $ ภายในตัวกรอง
// ใช้ได้ใน implementation เก่า; RFC 9535 ไม่อนุญาต
$..book[?(@.price < $.expensive)]
// แนวทางมาตรฐาน: ส่ง threshold จากผู้เรียก
query(document, '$..book[?(@.price < threshold)]', { threshold: 10 })
Script expression สร้างปัญหาสามข้อ:
- ความสามารถพกพาลดลงเหลือศูนย์ การเปลี่ยนไลบรารีหมายถึงเขียนนิพจน์ทุกตัวใหม่;
- ขอบเขตความปลอดภัยหายไป เมื่อนิพจน์มาจากผู้ใช้ การประเมิน script เท่ากับมอบการรันโค้ดให้;
- การปรับให้เหมาะสมเชิงสถิตเป็นไปไม่ได้ implementation ทำได้เพียงตีความทีละโหนด ซึ่งช้าและแคชยาก
ถ้าต้องการเปรียบเทียบข้ามโหนดจริง ๆ ให้ส่ง threshold เป็นพารามิเตอร์ของคิวรี แล้วกรองซ้ำในโค้ดแอปพลิเคชัน วิธีนี้มักเสถียรกว่าการพึ่งไวยากรณ์ส่วนขยาย
6. รูปร่างผลลัพธ์: คุณคิดว่าจะได้ค่า แต่กลับได้โหนด
นิพจน์เดียวกันอาจคืนอย่างน้อยสามรูปร่างในแต่ละไลบรารี:
["JSON: The Definitive Guide", "How Parsers Work", "Streaming JSON in Practice"] // อาร์เรย์ของค่า
[{ "path": "$['store']['book'][0]['title']", "value": "..." }] // พาธ + ค่า
[{ "pointer": "/store/book/0/title", "value": "..." }] // JSON Pointer + ค่า
สิ่งนี้กระทบสามเรื่องโดยตรง: วิธีที่ frontend อ่านผลลัพธ์ วิธีที่ผลลัพธ์ถูก deduplicate และวิธีที่ผลคิวรีกลายเป็น operation การเขียน nodelist ที่ RFC 9535 กำหนดคือชุดของ node reference แต่ implementation ของ JavaScript มักคืนเฉพาะค่า เพราะ reference ไม่สามารถข้ามขอบเขตโปรเซสได้
คำแนะนำในทางปฏิบัติ:
- อ่านค่าเดี่ยวผ่าน
[0]หรือvalue()เสมอ; อย่าสมมติว่าผลลัพธ์เป็น scalar; - เมื่อต้องเขียนกลับ ให้เลือก implementation ที่คืน JSON Pointer หรือสร้าง pointer เอง;
- ผลลัพธ์ว่างกับผลลัพธ์
[null]ต่างกัน; ตรวจ length แทนการตรวจ truthiness
7. ความแตกต่างระหว่างไลบรารี: รันชุดทดสอบความสอดคล้องก่อนย้าย
| ความสามารถ | Goessner ดั้งเดิม | jsonpath-plus | RFC 9535 เข้มงวด |
|---|---|---|---|
วงเล็บในตัวกรอง ?() |
จำเป็น | เข้ากันได้ | ไม่บังคับ |
การอ้างอิงข้ามราก $.x |
รองรับ | รองรับ | ห้าม |
| Script expression | รองรับ | รองรับ | ห้าม |
| ดัชนีและ step ติดลบ | รองรับ | รองรับ | รองรับ |
| คืนพาธ | ตั้งค่าได้ | ตั้งค่าได้ | ไม่ได้กำหนด |
ฟังก์ชันอย่าง length() |
ไม่มี | ส่วนขยาย | ฟังก์ชันมาตรฐาน |
ก่อนย้าย ให้ยืนยันอย่างน้อยสี่เรื่อง: วงเล็บจำเป็นหรือไม่, $ ปรากฏในตัวกรองได้หรือไม่, step ติดลบทำงานอย่างไร และโครงสร้างผลลัพธ์ ชุดทดสอบความสอดคล้องอย่างเป็นทางการครอบคลุมความต่างทางไวยากรณ์ส่วนใหญ่ และป้องกันความล้มเหลวเงียบ ๆ อย่าง "หลังเปลี่ยนไลบรารีแล้วสามแถวหายไป"
8. ประสิทธิภาพและรายการตรวจสอบการใช้งานผิด
ประสิทธิภาพสรุปได้หนึ่งประโยค: JSONPath คือการท่องทั้งเอกสาร และ .. คือการท่องทั้งเอกสารที่แพงที่สุด
- สำหรับเอกสารที่มีโหนดเกิน 100,000 ให้จำกัดขอบเขตด้วยพาธแบบชัดเจนก่อน แล้วค่อยกรองชุดย่อย;
- compile นิพจน์ครั้งเดียวแล้วใช้ซ้ำ; อย่า parse สตริงใหม่ในลูป;
- เปลี่ยนไปใช้
jqหรือโค้ดสำหรับ aggregation (รวม หาจัดกลุ่ม เรียงลำดับ); JSONPath เลือกเท่านั้น; - วางเงื่อนไขให้เร็วที่สุด:
$.store.book[?(@.price < 10)]สแกนโหนดที่ไม่เกี่ยวข้องน้อยกว่า$..[?(@.price < 10)]มาก
รายการตรวจสอบการใช้งานผิด:
- [ ] ถือว่าการทดสอบการมีอยู่เป็นการทดสอบค่า boolean;
- [ ] ใช้
$..แทนความลึกที่รู้อยู่แล้ว; - [ ] พึ่ง script expression เพื่อเปรียบเทียบข้ามโหนด;
- [ ] สมมติว่าผลลัพธ์เป็นอาร์เรย์ของค่าเสมอ;
- [ ] ปล่อยให้นิพจน์ถูกควบคุมโดยผู้ใช้โดยไม่มีขีดจำกัดความยาวและความซ้อน
คำถามที่พบบ่อย
ทำไม @.stock ยังตรงเงื่อนไขเมื่อฟิลด์เป็น false
เพราะพาธเปล่าในตัวกรองหมายถึง "ฟิลด์มีอยู่" ไม่ใช่ "ค่าเป็น true" การทดสอบ boolean ต้องเปรียบเทียบอย่างชัดเจนกับ @.stock == true นี่คือความต่างเชิงความหมายที่ใหญ่ที่สุดระหว่าง JSONPath กับ truthiness ของ JavaScript
ลำดับของ recursive descent เสถียรหรือไม่
ไม่ RFC 9535 กำหนดความหมายแบบชุดโหนด และลำดับการท่องขึ้นกับวิธีที่ implementation เยี่ยมชมลูก เมื่อต้องการลำดับคงที่ ให้ระบุ array แล้วอ่านตามดัชนี หรือเรียงผลลัพธ์ในโค้ดแอปพลิเคชัน
ใช้ slice และตัวกรองร่วมกันได้ไหม
ได้ แต่ต้องจำขอบเขต $.store.book[0:2][?(@.stock)] กรองเพียงสองเล่มที่ slice สร้างออกมา ถ้าต้องกรองก่อนแล้ว slice ทีหลัง ให้เขียน $.store.book[?(@.stock)][0:2]; ลำดับต่างกันให้ผลลัพธ์ต่างกัน
ทำไม jsonpath-plus รองรับ $.expensive ขณะที่มาตรฐานห้าม
เพราะตัวกรองถูกกำหนดโดยอิงเฉพาะโหนดปัจจุบัน @ การอ้างอิงข้ามรากทำให้นิพจน์ขึ้นกับบริบทของทั้งเอกสาร ซึ่งวิเคราะห์เชิงสถิตและพอร์ตข้ามภาษาได้ยาก ให้ส่ง threshold เป็นพารามิเตอร์แทนการฝังในนิพจน์
จะป้องกัน recursive descent ไม่ให้สแกนโหนดที่ไม่เกี่ยวข้องได้อย่างไร
เขียนพาธแบบชัดเจนทุกครั้งที่รู้ความลึก จำกัดขอบเขตก่อน แล้วกรองเฉพาะชุดย่อยแทนการใช้ $.. แทนระดับที่รู้อยู่แล้ว วางเงื่อนไขให้เร็วที่สุด: $.store.book[?(@.price < 10)] สแกนโหนดที่ไม่เกี่ยวข้องน้อยกว่า $..[?(@.price < 10)] มาก สำหรับเอกสารที่มีโหนดเกิน 100,000 ให้ทำเช่นนี้ก่อน