คำถามที่พบบ่อยเรื่องการค้นหาและการตรวจสอบ: JSONPath และ Schema
สรุปสำคัญ: การ query ด้วย path ควรใช้ JSONPath หรือ Pointer, จะเลือก Patch กับ Merge Patch อย่างไร, Schema กันอะไรได้บ้าง และควรเลือก draft เวอร์ชันไหน — อธิบายครบในรอบเดียว

สรุปสั้น ๆ: ใช้ JSONPath เพื่อหา node ตามเงื่อนไข ใช้ JSON Pointer หรือ JSON Patch เพื่อชี้และแก้ไขตำแหน่งที่แน่นอน และใช้ Merge Patch เพื่อรวม config; Schema ใหม่ให้ปักหมุดที่ 2020-12 และเปิด allErrors เพื่อให้ข้อผิดพลาดทั้งหมดถูกส่งกลับมาในรอบเดียว
การ query คือ "หามันให้เจอ" ส่วนการ validation คือ "ยืนยันว่ามันถูก" ทั้งสองมีมาตรฐานของตัวเอง อย่าเอามาปนกัน
1. การ query ด้วย path
ควรเลือก JSONPath หรือ JSON Pointer?
ขึ้นกับว่าคุณกำลัง "หาด้วยเงื่อนไข" หรือ "ดึงด้วยตำแหน่ง"
| มิติ | JSONPath | JSON Pointer |
|---|---|---|
| มาตรฐาน | RFC 9535 (2024) | RFC 6901 (2013) |
| Expression | $.users[?(@.age > 18)].id |
/users/0/id |
| wildcard และการไล่ซ้ำ | รองรับ *, .. |
ไม่รองรับ |
| การกรองตามเงื่อนไข | รองรับ | ไม่รองรับ |
| ผลลัพธ์ | ชุดของ node (อาจหลายตัว) | ตำแหน่งเดียว |
| ความสามารถในการแสดงออก | สูง แต่มีภาระจากความต่างของ implementation ในอดีตมาก | ต่ำกว่า แต่กำหนดผลได้แน่นอนเต็มที่ |
| การใช้โดยทั่วไป | ดึงข้อมูลจาก log, debug, สำรวจข้อมูล | JSON Patch, อ้างตำแหน่งใน config, path ใน error |
กฎการเลือกตรงไปตรงมา:
- ต้อง "ดึง node ทั้งหมดที่ตรงเงื่อนไข" → JSONPath;
- ต้อง "ระบุตำแหน่งเดียวให้แน่ชัดแล้วเพิ่ม ลบ หรือแก้ไข" → JSON Pointer;
- ใช้ร่วมกัน: หา node ด้วย JSONPath แล้วอธิบายตำแหน่งของมันด้วย Pointer
อย่าใช้ JSONPath เพื่ออ้างตำแหน่งใน API contract JSONPath คืนค่าเป็นชุด และการแยกสามกรณีคือผลว่าง ผลหนึ่งตัว และหลายตัว ทำให้ protocol ซับซ้อน ส่วน Pointer หมายถึง "ชี้ไปยังตำแหน่งที่มีอยู่หรือไม่มีอยู่" ซึ่งเหมาะกับ contract มากกว่า
$.. กับ $.* ต่างกันอย่างไร?
$.. คือ recursive descent และไล่ลงไปทุกระดับ; $.* คืนเฉพาะลูกโดยตรงของระดับปัจจุบัน
{
"a": { "id": 1, "child": { "id": 2 } },
"b": { "id": 3 }
}
$.*→ object สองตัวคือ{id:1, child:{...}}และ{id:3};$..id→ สามค่า:1,2,3;$.*.id→1และ3ไม่รวม2ที่ซ้อนอยู่
recursive descent สะดวกแต่แพง: $.. ไล่ทั้ง tree ซึ่งเป็น O(n) บนเอกสารขนาดใหญ่ และมันมักกวาดฟิลด์ลึกที่คุณไม่ได้ตั้งใจมาด้วย
ถ้าใช้ระดับที่ชัดเจนได้ ก็เลี่ยง .. เถอะ
filter เปรียบเทียบ string ได้ไหม? จับคู่ด้วย regex ได้ไหม?
การเปรียบเทียบ string ทำได้ ส่วน regex ขึ้นกับ implementation
ไวยากรณ์มาตรฐาน (RFC 9535):
$.items[?(@.status == "active")]
$.items[?(@.price > 10 && @.stock > 0)]
$.items[?(@.name == $.target)] // เปรียบเทียบกับ node อีกตัว
การเปรียบเทียบ string ใช้เครื่องหมายคำพูดคู่ (RFC 9535 อนุญาตเครื่องหมายคำพูดเดี่ยวด้วย แต่ implementation เก่าบางตัวรับเฉพาะเครื่องหมายคำพูดคู่)
การเปรียบเทียบตัวเลขใช้ความหมายเชิงตัวเลข ถ้าเอา string มาปนกับ number เช่น "10" > 9 ถือเป็นพฤติกรรมที่มาตรฐานไม่ได้กำหนด จึงอย่าพึ่งมัน
สำหรับ regex นั้น RFC 9535 นิยามฟังก์ชัน match() และ search() ไว้ แต่การรองรับแตกต่างกันมากระหว่าง implementation
วิธีที่ปลอดภัย: ให้ JSONPath กรองหยาบ ๆ แล้วค่อยไปจับคู่ regex ในโค้ดหลังได้ผลลัพธ์มาแล้ว
ควรใช้ JSON Patch หรือ Merge Patch?
ทั้งสองอธิบาย "จะเปลี่ยนอะไรอย่างไร" แต่ความสามารถในการแสดงออกและความหมายต่างกันโดยสิ้นเชิง
| มิติ | JSON Patch (RFC 6902) | Merge Patch (RFC 7386) |
|---|---|---|
| รูปแบบ | array ของ operation [{"op":"add",...}] |
object บางส่วน |
| operation ที่รองรับ | add / remove / replace / move / copy / test | merge เท่านั้น (ลบด้วย null) |
| อ้างสมาชิกของ array ได้ไหม | ได้ ด้วย index | ไม่ได้ ทั้ง array ถูกแทนที่ |
| atomicity | test เปิดใช้ optimistic locking |
ไม่มี |
| ขนาดเอกสาร | ใหญ่แต่แม่นยำ | เล็กแต่ความสามารถจำกัด |
| เหมาะกับ | การแก้ไขละเอียด, การอัปเดต API บางส่วน | การรวม config, การส่งฟอร์มบางส่วน |
เกณฑ์ตัดสินใจ:
- ต้องเปลี่ยนฟิลด์ของสมาชิกตัวที่สามใน array → JSON Patch;
- ต้องรวมรายการ config หลายรายการเข้ากับ config ที่มีอยู่ → Merge Patch;
- ต้องมีการควบคุม concurrency แบบ "ถ้าค่าเดิมไม่ใช่ X ให้ล้มเหลว" →
testของ JSON Patch
ระวังกับดักในความหมายของ null ของ Merge Patch: null หมายถึง "ลบ key นี้" ไม่ใช่ "ตั้งค่าเป็น null"
การตั้งค่าเป็น null ทำไม่ได้ด้วย Merge Patch
2. การ validation ด้วย Schema
JSON Schema แก้ปัญหาอะไร?
มันเปลี่ยน "ข้อมูลควรมีหน้าตาอย่างไร" จากข้อตกลงด้วยวาจาเป็น contract ที่เครื่องตรวจได้ ข้อผิดพลาดที่มันกันได้จริง:
- ฟิลด์ที่จำเป็นหายไป (
required); - ชนิดผิด เช่นที่ควรเป็น number กลับเป็น string (
type); - ค่านอกช่วง (
minimum/maximum/minLength); - ค่านอกชุดที่อนุญาต (
enum); - ชนิดของสมาชิกใน array ไม่สม่ำเสมอหรือความยาว array ผิด (
items/minItems); - ฟิลด์ที่หายไปในโครงสร้างซ้อน (
propertiesช่วยให้ validation ไล่ลงไปได้); - ฟิลด์เกิน (
additionalProperties: false)
สิ่งที่มัน แก้ไม่ได้: กฎทางธุรกิจ ("เวลาสิ้นสุดต้องมากกว่าเวลาเริ่ม"), เงื่อนไขข้ามฟิลด์ (บางกรณีเขียนด้วย if/then ได้
แต่จะอ่านเข้าใจยากขึ้น), ความไม่ซ้ำในฐานข้อมูล และการตรวจสิทธิ์
ดังนั้น Schema คือด่านตรวจ "รูปร่างของข้อมูล" ไม่ใช่ตัวแทนของ business logic
ควรใช้ draft-07 หรือ 2020-12?
โปรเจกต์ใหม่ใช้ 2020-12; ระบบที่มีอยู่แล้วให้ดูว่า validator รองรับอะไร
| เวอร์ชัน | สถานะ | การเปลี่ยนแปลงสำคัญ |
|---|---|---|
| draft-04 | ล้าสมัย | ไลบรารีเก่าหลายตัวยังใช้เป็นค่าเริ่มต้น |
| draft-07 | มาตรฐานโดยพฤตินัย | รองรับกว้างที่สุด ระบบนิเวศสมบูรณ์ที่สุด |
| 2019-09 | ช่วงเปลี่ยนผ่าน | เปิดตัว $defs, unevaluatedProperties |
| 2020-12 | แนะนำในปัจจุบัน | แยก items เป็น prefixItems + items, รองรับ $dynamicRef |
กับดักตอนย้ายสองข้อ:
- ไวยากรณ์ tuple เปลี่ยน: ใน draft-07
itemsที่เป็น array ใช้แสดง tuple; 2020-12 ย้ายไปที่prefixItems; $refกับ keyword ข้างเคียง: ใน draft-07 keyword ข้างเคียงถูกเพิกเฉยเมื่อมี$ref; 2020-12 อนุญาตให้อยู่ร่วมกันได้
ถ้า validator รองรับแค่ draft-07 การเขียนไวยากรณ์ 2020-12 ลงไปจะถูกเพิกเฉยอย่างเงียบ ๆ — และกรณีที่อันตรายที่สุดคือ "validation ผ่าน" ทั้งที่ไม่ได้ตรวจอะไรเลย
จะสร้าง TypeScript type จาก Schema ได้อย่างไร?
สองเส้นทาง: สร้าง type จาก Schema หรือสร้าง Schema จาก type
จาก Schema ไป type (backend นิยาม contract, frontend นำไปใช้):
import type { FromSchema } from 'json-schema-to-ts';
const userSchema = {
type: 'object',
properties: {
id: { type: 'string' },
age: { type: 'integer' },
},
required: ['id'],
additionalProperties: false,
} as const;
type User = FromSchema<typeof userSchema>;
// { id: string; age?: number }
หัวใจคือ as const: ถ้าไม่มีมัน type: 'string' จะถูกอนุมานเป็น string ไม่ใช่ literal 'string' และการอนุมานจะพัง
จาก type ไป Schema (frontend มี type อยู่แล้วและต้องการ validation ตอนรันไทม์):
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const userSchema = z.object({ id: z.string(), age: z.number().int().optional() });
const jsonSchema = zodToJsonSchema(userSchema);
ข้อดีของเส้นทางนี้คือ "นิยามครั้งเดียวได้ทั้ง type และ validation ตอนรันไทม์"; ต้นทุนคือต้องเพิ่ม DSL
และ Schema ที่สร้างออกมาอาจมีรายละเอียดของ implementation ติดมา (เช่นโครงสร้าง $ref ที่ดูไม่สวย)
จะหาฟิลด์ที่ผิดเมื่อ validation ล้มเหลวได้อย่างไร?
ดูที่ instancePath (2020-12) หรือ dataPath (draft-07) ที่ validator คืนมา มันคือ JSON Pointer ไปยังตำแหน่งที่ error
[
{
"instancePath": "/items/2/price",
"schemaPath": "#/properties/items/items/properties/price/type",
"keyword": "type",
"message": "must be number"
}
]
instancePath: "/items/2/price" บอกคุณตรง ๆ ว่า: ฟิลด์ price ของสมาชิกตัวที่สามใน array items มีชนิดผิด
ข้อแนะนำในทางปฏิบัติสองข้อ:
- คืน error ทั้งหมดในครั้งเดียว แทนที่จะ throw ที่ error แรก — frontend แก้ทีเดียวจบเร็วกว่าลองไปมาหลายรอบ;
- แปลง Pointer เป็นภาษาธรรมดา ก่อนแสดงให้ผู้ใช้เห็น;
/items/2/priceมีประโยชน์กับนักพัฒนาแต่ไร้ประโยชน์กับผู้ใช้
allErrors: true คือสวิตช์ใน validator ส่วนใหญ่ (Ajv ต้องเปิดเองอย่างชัดเจน); ค่าเริ่มต้นมักเป็นโหมด fail-fast
ควรเปิด additionalProperties: false ไหม?
เปิดสำหรับ contract ภายใน; ระวังเมื่อเป็น API สาธารณะ
เมื่อเปิดแล้ว ฟิลด์ใดที่ไม่ได้ประกาศใต้ properties จะทำให้ validation ล้มเหลว ข้อดีคือจับการพิมพ์ผิด
(userNmae) และการส่งผ่านฟิลด์ที่ไม่คาดคิดได้; ข้อเสียคือการที่ upstream เพิ่มฟิลด์จะทำให้ downstream พัง และทำลายความเข้ากันได้ย้อนหลัง
ทางประนีประนอม:
| สถานการณ์ | ข้อแนะนำ |
|---|---|
| ระหว่างบริการภายใน | เปิด การเปลี่ยนฟิลด์ต้องผ่านการจัดการเวอร์ชัน |
| API สาธารณะภายนอก | ปิด ปล่อยให้ upstream เพิ่มฟิลด์ได้ |
| การใช้ข้อมูลจากบุคคลที่สาม | ปิด คุณไม่ได้ควบคุมอีกฝั่ง |
| การ validation config | เปิด การพิมพ์ผิดใน config ควรล้มเหลวทันที |
จะตรวจว่าสมาชิกใน array ไม่ซ้ำกันได้อย่างไร?
สองความหมายที่ต่างกัน — อย่าสับสน:
{ "uniqueItems": true }
อันนี้ deduplicate ทั้งสมาชิก โดยบังคับว่า array ต้องไม่มีสมาชิกสองตัวที่เท่ากันทุกชั้น
{
"type": "array",
"items": { "type": "object", "properties": { "id": { "type": "string" } } },
"uniqueItems": true
}
ตัวอย่างข้างบนรับประกันเพียงว่า object ทั้งก้อนไม่ซ้ำกัน; {"id":"a","name":"x"} กับ {"id":"a","name":"y"} ไม่นับว่าซ้ำ
ถ้าต้อง deduplicate ตามฟิลด์ใดฟิลด์หนึ่ง Schema แสดงออกไม่ได้ ต้องตรวจในชั้นธุรกิจ:
const ids = items.map((item) => item.id);
if (new Set(ids).size !== ids.length) {
throw new Error('id 重复');
}
format ตรวจ email กับวันที่จริงหรือไม่?
ไม่จำเป็น ในข้อกำหนด format เป็น "annotation" โดยค่าเริ่มต้น ไม่ใช่ "assertion" ดังนั้น validator อาจเก็บมันไว้โดยไม่ตรวจ
- Ajv ต้องติดตั้งและตั้งค่าแพ็กเกจ
ajv-formatsแยก มิฉะนั้นformat: "email"เท่ากับไม่มีผล; - validator บางตัวถือ
formatเป็น assertion ที่เปิดโดยค่าเริ่มต้น พฤติกรรมจึงไม่สม่ำเสมอ; - แม้เปิดใช้งาน regex ของ
emailก็หลวมมาก —a@bผ่านได้
สรุป: อย่าถือ format เป็นการ validation ที่เข้มแข็ง ให้ตรวจ email, URL และวันที่อย่างเข้มงวดในโค้ดธุรกิจของคุณ
การเก็บ format ไว้ใน Schema เพื่อเป็นเอกสารก็คุ้ม แต่ไม่ต้องหวังให้มันกันข้อมูลสกปรก
3. สรุปเร็ว
| คำถาม | ข้อสรุป |
|---|---|
| หา node ตามเงื่อนไข | JSONPath |
| อ้างตำแหน่งให้แม่นยำ, ใช้ patch | JSON Pointer |
| เปลี่ยนสมาชิกของ array | JSON Patch, Merge Patch ทำไม่ได้ |
| รวม config | Merge Patch |
| เวอร์ชัน Schema สำหรับโปรเจกต์ใหม่ | 2020-12 ระวัง prefixItems |
| สร้าง TS type จาก Schema | json-schema-to-ts + as const |
| หา error ของ validation | อ่าน instancePath, เปิด allErrors |
| deduplicate ตามฟิลด์ | Schema ทำไม่ได้ ใช้ Set ในชั้นธุรกิจ |
format: email |
validator ส่วนใหญ่ไม่ตรวจโดยค่าเริ่มต้น อย่าพึ่งมัน |