อ่านขอบเขตไวยากรณ์ JSON จาก RFC 8259
สรุปสำคัญ: แปลกฎการผลิตทางไวยากรณ์ของ RFC 8259 เป็นภาษาธรรมดา: อักขระใดปรากฏได้ตรง ๆ อักขระใดต้อง escape และจุดใดที่มาตรฐานจงใจเปิดช่องไว้

สรุปสั้น: เมื่อเกิด JSON syntax error ให้หาอักขระผิดกฎตัวแรกแล้วจำแนกว่ามันคือเครื่องหมายคำพูดเดี่ยว เครื่องหมายจุลภาคท้ายรายการ คอมเมนต์ หรืออักขระควบคุม อย่าอ่านเอกสารใหม่ทั้งหมด; ฝั่งการเข้ารหัสให้ยืนยันว่าเป็น UTF-8 ที่ไม่มี BOM และส่งจำนวนเต็มที่เกิน 2^53 - 1 เป็นสตริง
เนื้อหามาตรฐานมีเพียงสิบกว่าหน้า แต่เบื้องหลังทุกคำว่า "MUST", "SHOULD" และ "MAY" ซ่อนเหตุการณ์ที่เคยทำให้ทั้งทีมนอนไม่หลับไว้หนึ่งครั้ง
1. เอกสารสามฉบับสัมพันธ์กันอย่างไร
การเรียนรู้ JSON มักสับสนเพราะมีเลขสามตัว: RFC 4627, RFC 7159, RFC 8259 บวกกับ ECMA-404 ความสัมพันธ์จริง ๆ ชัดเจนทีเดียว:
| เอกสาร | วันที่ | สถานะ | ความต่างสำคัญ |
|---|---|---|---|
| RFC 4627 | 2006 | ถูกแทนที่แล้ว | ระดับบนสุดต้องเป็น object หรือ array |
| RFC 7159 | 2014 | ถูกแทนที่แล้ว | ผ่อนปรนระดับบนสุดให้เป็นค่า JSON ใดก็ได้ |
| RFC 8259 | 2017.12 | มาตรฐานปัจจุบัน | ระบุ UTF-8 ชัดเจน เพิ่มคำแนะนำด้านการทำงานร่วมกัน |
| ECMA-404 | 2013 ฉบับพิมพ์ครั้งที่สอง 2017.12 | ข้อกำหนดไวยากรณ์ | กำหนดเพียงไวยากรณ์ ไม่ครอบคลุมการเข้ารหัสหรือการส่งข้อมูล |
ควรอ่านฉบับไหน? อ่าน RFC 8259 ฉบับเดียวก็พอ ECMA-404 ส่วนไวยากรณ์เทียบเท่ากัน; ต่างกันที่ RFC 8259 ยังระบุ "ส่งข้อมูลเป็น UTF-8" และ "ประเด็นการทำงานร่วมกันที่ผู้พัฒนาควรระวัง" ด้วย
2. ค่าหกประเภท
กฎการผลิตทางไวยากรณ์ของมาตรฐานย่อเป็นแผนภาพเดียวได้:
JSON-text = ws value ws
value = object / array / number / string / "true" / "false" / "null"
object = "{" [ member *( "," member ) ] "}"
member = string ws ":" ws value
array = "[" [ value *( "," value ) ] "]"
มีจุดสำคัญเพียงหกข้อ:
- Object ใช้ปีกกา สมาชิกคั่นด้วยเครื่องหมายจุลภาค และเครื่องหมายจุลภาคหลังสมาชิกตัวสุดท้าย ไม่อนุญาต;
- Array ใช้วงเล็บเหลี่ยม องค์ประกอบคั่นด้วยเครื่องหมายจุลภาค และเครื่องหมายจุลภาคท้ายรายการก็ไม่อนุญาตเช่นกัน;
- ตัวเลข ห้ามมี
+นำหน้า ห้ามมีศูนย์นำหน้า (01ผิด) ห้ามมีจุดทศนิยมลอย (1.ผิด) และห้ามมีNaNหรือInfinity; - สตริง ต้องใช้เครื่องหมายคำพูดคู่ เครื่องหมายคำพูดเดี่ยวผิด;
- บูลีนและ null เป็นค่าคงที่ตัวพิมพ์เล็ก;
TrueและNULLผิดทั้งคู่; - ระหว่างองค์ประกอบเชิงโครงสร้าง ปรากฏได้เฉพาะอักขระช่องว่าง
0x20(ช่องว่าง),0x09(แท็บ),0x0A(line feed) และ0x0D(carriage return).
ตัวอย่างด้านล่างเป็น JSON ที่ถูกต้อง ถึงแม้หลายคนคิดว่าผิดตอนเห็นครั้งแรก — เพราะมันเป็นสตริงระดับบนสุดเปล่า ๆ:
"just a string"
ตั้งแต่ RFC 7159 ระดับบนสุดเป็นค่าใดก็ได้จริง ใน API อย่างไรก็ตามยังแนะนำให้ใช้เฉพาะ object หรือ array; เหตุผลอยู่ในหัวข้อ 6
3. อะไรปรากฏได้ตรง ๆ ภายในสตริง
นี่คือส่วนที่ถูก "เครื่องผมรันได้ปกติดี" หลอกได้ง่ายที่สุด กฎของมาตรฐานสำหรับสตริงคือ:
- อักขระที่ปรากฏได้ตรง ๆ:
%x20-21,%x23-5B,%x5D-10FFFF; - พูดอีกอย่าง เครื่องหมายคำพูดคู่
"(0x22) และแบ็กสแลช\(0x5C) ต้องถูก escape; - อักขระควบคุมทั้งหมดที่ต่ำกว่า 0x20 ต้องถูก escape รวมถึง line feed แท็บ และ carriage return;
- ลำดับ escape ที่ใช้ได้มีเพียง
\",\\,\/,\b,\f,\n,\r,\tและ\uXXXX.
{
"ok": "Chinese text, emoji 🎉 and full-width symbols can be written directly",
"escaped": "A line break goes here\nand a tab\tfinishes it",
"control": "\u0007 is the bell character and must be escaped",
"quote": "He said: \"don't use single quotes\""
}
\/ เป็น escape แบบเก่า (ใช้ป้องกัน </script> ปิดก่อนเวลา) ยุคปัจจุบันแทบไม่ต้องใช้ แต่ตัวแยกวิเคราะห์ต้องรองรับ
4. การเข้ารหัส: UTF-8 ไม่มี BOM
หมวด 8.1 ของ RFC 8259 กล่าวสามอย่าง:
- ข้อความ JSON ที่ใช้แลกเปลี่ยนข้อมูล ต้อง เข้ารหัสเป็น UTF-8;
- การนำไปใช้งาน ต้องไม่ เพิ่ม BOM (
EF BB BF) ที่ต้นข้อความ; - ตัวแยกวิเคราะห์ อาจ เพิกเฉยต่อ BOM เพื่อความทนทาน
ความล้มเหลวสองแบบที่พบจริง:
- Notepad ของ Windows บันทึกเป็น "UTF-8 with BOM" ไฟล์ถูก
JSON.parseปฏิเสธด้วยUnexpected token; - ไฟล์ JSON ภาษาจีนที่บันทึกเป็น GBK กลายเป็นตัวขยะบนไปป์ไลน์ที่ถอดรหัสเฉพาะ UTF-8 แต่ไวยากรณ์ยังบังเอิญผ่าน error จึงถูกเลื่อนไปที่ชั้นธุรกิจ
การจัดการตรงไปตรงมา: ตัด BOM ตอนอ่านไฟล์ แล้วถอดรหัสเป็น UTF-8 อย่างชัดเจน; ตอนเขียนให้แน่ใจว่าไม่มีการเพิ่ม BOM
/**
* Removes the UTF-8 BOM at the start of a string.
* @param text The original text
* @returns The text without a BOM
*/
function stripBom(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
}
หมายเหตุเรื่อง media type ระหว่างนี้ด้วย: application/json ไม่รับพารามิเตอร์ charset แล้ว การเขียนเข้าไปไม่ทำให้เกิด error แต่เป็นแนวปฏิบัติที่ล้าสมัย
5. สามจุดที่มาตรฐานจงใจเปิดช่องไว้
จุดที่มาตรฐานไม่ยอมกำหนดให้แน่ชัดคือที่มาของความต่างระหว่างการนำไปใช้งานนั่นเอง
1. ช่วงค่าและความแม่นยำของตัวเลข
RFC 8259 บอกเพียงว่า "ข้อกำหนดนี้ไม่จำกัดช่วงค่าหรือความแม่นยำของตัวเลข" พร้อมหมายเหตุว่าการนำไปใช้งานส่วนใหญ่ใช้ IEEE 754 ความแม่นยำสองเท่า
นี่หมายความว่า 9223372036854775807 ค่าเดียวกันกลายเป็น 9223372036854776000 ใน JavaScript แต่ยังแม่นยำใน long ของ Java
ข้อสรุป: จำนวนเต็มที่เกิน 2^53 - 1 และจำนวนเงินที่ต้องใช้ทศนิยมหลายตำแหน่ง ควรส่งเป็นสตริงเสมอ พร้อมเขียน pattern ไว้ใน schema อย่างชัดเจน
2. คีย์ซ้ำ
ถ้อยคำในมาตรฐานคือ "ชื่อภายใน object ควร (SHOULD) ไม่ซ้ำกัน"; ไม่ได้บอกว่าถ้าซ้ำแล้วต้องทำอย่างไร
JSON.parseเก็บตัวสุดท้าย;- ตัวแยกวิเคราะห์บางตัวเก็บตัวแรก;
- ไลบรารีบางตัวแค่ throw error
ข้อสรุป: อย่าเขียนคีย์ซ้ำ และอย่าพึ่งพฤติกรรมใด ๆ เป็นพิเศษในตัวตรวจสอบของคุณ การเพิ่มขั้นตอน "ตรวจจับคีย์ซ้ำ" ตอนบิลด์ถูกกว่าการสืบสวนทีหลังมาก
3. ข้อจำกัดความลึกและความยาว
มาตรฐานไม่ได้กำหนดขีดจำกัดความลึกของการซ้อน แต่การนำไปใช้งานทุกตัวมีขีดจำกัด V8 throw RangeError: Maximum call stack size exceeded ที่การซ้อนระดับหลายหมื่นชั้น
เซิร์ฟเวอร์ต้องกำหนดขีดจำกัดของตนเอง นี่เป็นข้อกำหนดด้านความปลอดภัย ไม่ใช่ข้อกำหนดของมาตรฐาน
6. ทดสอบความเข้าใจด้วยตัวอย่างที่ผิด
ทุกบรรทัดด้านล่างเป็น JSON ที่ไม่ถูกต้อง ป้อนให้ตัวแยกวิเคราะห์แล้วดูว่าข้อความ error มีประโยชน์หรือไม่:
{ 'name': 'json' } // single quotes
{ name: "json" } // key name not quoted
{"a": 1,} // trailing comma
[1, 2, 3,] // trailing comma in an array
{"a": 01} // leading zero
{"a": +1} // leading plus sign
{"a": .5} // missing integer part
{"a": NaN} // non-standard literal
{"a": undefined} // not a JSON value
{"a": 1} // comment // comment
กรณีขอบที่น่าสนใจ: {"a": 1} ตามด้วยคอมเมนต์ใช้ได้ใน JSON5 ใช้ได้ใน JSONC และผิดใน JSON มาตรฐาน
ดังนั้น "ไฟล์คอนฟิกรันได้ปกติแต่ API ปฏิเสธ" แทบทุกครั้งคือปัญหาภาษาถิ่น
7. รายการตรวจสอบสำหรับโปรดักชัน
- [ ] JSON ทุกชิ้นที่ส่งออกภายนอกใช้
application/jsonโดยไม่มีcharset; - [ ] ไปป์ไลน์อ่าน/เขียนไฟล์ตัด BOM และใช้ UTF-8 อย่างชัดเจน;
- [ ] จำนวนเต็มที่เกิน
2^53ส่งเป็นสตริงและระบุหมายเหตุใน schema; - [ ] ตรวจจับคีย์ซ้ำตอนบิลด์;
- [ ] เซิร์ฟเวอร์จำกัดขนาด request body และความลึกของการซ้อน;
- [ ] ค่าระดับบนสุดของ API สาธารณะเป็น object หรือ array ไม่ใช่ค่าสเกลาร์เปล่า ๆ
คำถามที่พบบ่อย
จะรู้ได้เร็วอย่างไรว่าข้อความหนึ่งเป็น JSON ที่ถูกต้องหรือไม่
ส่งข้อความให้ตัวแยกวิเคราะห์หรือเครื่องมือแยกวิเคราะห์ออนไลน์ อ่านตำแหน่งของอักขระผิดกฎตัวแรก แล้วเทียบกับค่าหกประเภทและกฎช่องว่างของ RFC 8259 ระดับบนสุดเป็นค่า JSON ใดก็ได้ แต่ระหว่างองค์ประกอบเชิงโครงสร้างอนุญาตเฉพาะช่องว่าง แท็บ line feed และ carriage return เครื่องหมายคำพูดเดี่ยว คีย์ที่ไม่ครอบเครื่องหมายคำพูด เครื่องหมายจุลภาคท้ายรายการ หรือคอมเมนต์ แสดงว่าไม่ใช่ JSON มาตรฐาน
ทำไม JSON ต้องใช้ UTF-8 และไม่มี BOM
หมวด 8.1 ของ RFC 8259 กำหนดให้ข้อความ JSON ที่ใช้แลกเปลี่ยนข้อมูลเข้ารหัสเป็น UTF-8 บอกว่าการนำไปใช้งานต้องไม่เพิ่ม BOM ที่ต้นข้อความ และให้ตัวแยกวิเคราะห์เพิกเฉยต่อ BOM เพื่อความทนทาน ไฟล์ที่มี BOM จะถูก JSON.parse ปฏิเสธด้วย Unexpected token แม้สายตาจะไม่เห็นอะไรผิด ดังนั้นให้ตัด BOM ตอนอ่านและถอดรหัสเป็น UTF-8 อย่างชัดเจน
ควรใช้ JSON5 หรือ JSONC เพื่อหลีกเลี่ยงข้อจำกัดไวยากรณ์เหล่านี้หรือไม่
ได้ แต่เฉพาะการอ่านในเครื่องอย่างไฟล์คอนฟิกเท่านั้น; ข้อมูลที่แลกเปลี่ยนระหว่างระบบยังควรเป็น JSON มาตรฐาน ตัวอย่างที่ผิดแสดงว่า {"a": 1} ตามด้วยคอมเมนต์ใช้ได้ใน JSON5 และ JSONC แต่ผิดใน JSON มาตรฐาน ซึ่งเป็นเหตุให้ "ไฟล์คอนฟิกรันได้แต่ API ปฏิเสธ" แทบทุกครั้งเป็นปัญหาภาษาถิ่น เมื่อแปลง ให้ลบคอมเมนต์ เครื่องหมายคำพูดเดี่ยว และเครื่องหมายจุลภาคท้ายรายการก่อน แล้วส่งผลลัพธ์ให้ตัวแยกวิเคราะห์มาตรฐาน