JSON Text Sequences (RFC 7464): JSON แบบ Streaming ที่คั่นด้วย RS
สรุปสำคัญ: RFC 7464 ใช้ตัวคั่น 0x1E เพื่อ stream JSON text ที่อ่านและเขียนได้พร้อมกัน ครอบคลุมกฎ ความแตกต่างจาก NDJSON และ implementation ที่แยกก่อนแล้วจึง parse

สรุปสั้น: ใช้ JSON-seq เมื่อคุณควบคุมทั้งสองฝั่ง ต้องการ media type ที่ลงทะเบียนแล้ว และต้องการให้ตัวอ่านกลับมาประสานใหม่หลังการตัดทอน; ใช้ NDJSON เมื่อข้อมูลไหลเข้าสู่ระบบ log ต้องอ่านได้โดยมนุษย์ หรือต้องทำงานกับเครื่องมือที่มีอยู่ — อย่าบังคับ JSON-seq กับคู่สนทนาที่เข้าใจเพียงอินพุตคั่นด้วยบรรทัด
NDJSON แยกเรกคอร์ดด้วยอักขระขึ้นบรรทัดใหม่ แต่อักขระขึ้นบรรทัดใหม่เป็นข้อตกลงของมนุษย์ RFC 7464 เขียนขอบเขตลงใน byte stream เอง:
0x1Eที่มองไม่เห็นก่อนทุกรายการ
1. ปัญหาที่ไบต์เดียวแก้ได้
RFC 7464 เผยแพร่ในเดือนกุมภาพันธ์ 2015 ภายใต้ชื่อ JSON Text Sequences และลงทะเบียน media type application/json-seq มันไม่ได้เพิ่มชนิดข้อมูลใหม่ มันเพียงกำหนดว่าลำดับของ JSON text ถูกจัดวางใน byte stream หนึ่งอย่างไร กฎทั้งหมดพอดีในหนึ่งประโยค: ใส่ RS หนึ่งตัวก่อน JSON text แต่ละตัว และใส่ LF หนึ่งตัวหลังมัน
- RS คือ record separator ของ ASCII รหัสจุด
0x1Eรู้จักใน Unicode ว่า INFORMATION SEPARATOR TWO (U+001E); - LF คือ line feed,
0x0A
เรกคอร์ดสองรายการมีลักษณะเช่นนี้ในระดับไบต์:
1E 7B 22 69 64 22 3A 31 7D 0A ← 0x1E {"id":1} + LF
1E 7B 22 69 64 22 3A 32 7D 0A ← 0x1E {"id":2} + LF
RS อยู่ ก่อน เรกคอร์ด ซึ่งตรงข้ามกับโครงแบบ "เขียนเนื้อหา แล้วจึงเขียนตัวคั่น" ของ NDJSON ทิศทางนั้นเปลี่ยนพฤติกรรมการกู้คืน: ตัวอ่านที่เห็น 0x1E รู้ว่าเรกคอร์ดก่อนหน้าจบแล้ว แม้เรกคอร์ดที่ถูกตัดทอนกลางการเขียนก็ทำให้ขอบเขตของเรกคอร์ดถัดไปเบลอไม่ได้
นี่ไม่ใช่การออกแบบเชิงทฤษฎี RFC กล่าวถึงอย่างชัดเจนถึงเรกคอร์ดที่ต่อท้ายไฟล์ log ซึ่งถูกตัดทอนโดยระบบไฟล์หลัง crash หรือการ kill โดยผู้ดูแล และบอกว่า parser ไม่ควรยกเลิกการอ่านทั้งหมดเพราะเหตุนั้น
2. กฎสองชุด: encoder และ parser
RFC 7464 ให้ไวยากรณ์ ABNF สองชุดอย่างไม่ปกติ ชุดหนึ่งจำกัดตัวเขียนและอีกชุดผ่อนปรนตัวอ่าน
| บทบาท | ไวยากรณ์ | ความหมาย |
|---|---|---|
| Encoder | *(RS JSON-text LF) |
RS หนึ่งตัวก่อน JSON text แต่ละตัว และ LF หนึ่งตัวหลังมัน |
| Parser | *(1*RS possible-JSON) |
อนุญาตไบต์ RS ติดกัน และ LF ระหว่างเรกคอร์ดเป็นทางเลือก |
ในแง่วิศวกรรม มีกฎแข็งห้าข้อ:
- UTF-8 เป็นข้อบังคับ; ห้ามใช้ UTF-16 และ UTF-32;
- Parser ถือ "ไบต์ใดก็ตามที่ไม่ใช่ RS" เป็น JSON text ที่เป็นไปได้และพยายาม parse; เมื่อล้มเหลวจะข้ามไปยัง RS ถัดไปแล้วทำต่อ;
- ไบต์ RS ติดกันไม่ได้แทนองค์ประกอบว่างและถูกเพิกเฉย;
- ไม่มีเครื่องหมายสิ้นสุดลำดับ ลำดับจึงจบลงเฉย ๆ;
- เมื่อค่าความรู้ระดับบนสุดเป็นจำนวน,
true,falseหรือnullเรกคอร์ดต้องลงท้ายด้วยไบต์ช่องว่าง ไม่อย่างนั้นมันจะถูกทิ้งว่าเป็นไปได้ว่าถูกตัดทอน
กฎข้อห้าคือข้อที่คนลืม Object, array และ string มีการกำหนดขอบเขตในตัวเองใน JSON; จำนวนไม่มี <RS>123<RS> บอกคุณไม่ได้ว่ามันมี 123 ที่สมบูรณ์หรือ 1234 ที่ถูกตัดทอน RFC จึงกำหนดให้ตัวเขียนต่อท้ายด้วย LF โดยใช้มันเป็นไบต์ canary สำหรับตรวจจับการตัดทอน ในทำนองเดียวกัน <RS>true<RS> อาจเดิมทีเป็น trueish
3. เลือกระหว่าง JSON-seq กับ NDJSON
ทั้งสองรูปแบบ stream ได้ แต่พุ่งเป้าปัญหาต่างกัน:
| มิติ | JSON Text Sequences | NDJSON / JSON Lines |
|---|---|---|
| ตำแหน่งตัวคั่น | RS (0x1E) ก่อนเรกคอร์ด |
LF (0x0A) หลังเรกคอร์ด |
| Media type | application/json-seq ลงทะเบียนกับ IANA |
ไม่มีการลงทะเบียนทางการ เป็นข้อตกลงของชุมชน |
| การอ่านได้ในระดับไบต์ | อักขระควบคุมที่มองไม่เห็น cat ไม่แสดงอะไร |
ข้อความล้วน อ่านได้โดยตรง |
| การต่อท้ายพร้อมกัน | ตัวอ่านกลับมาประสานและทิ้งท้ายที่ถูกตัดทอนได้ | ต่อท้ายได้เช่นกัน บรรทัดเสียถูกข้าม |
| จำนวนระดับบนสุด | ต้องมีไบต์ช่องว่างต่อท้ายจึงจะเชื่อถือได้ | อักขระขึ้นบรรทัดใหม่คือขอบเขต |
| ตัวคั่นใน string | RS ปรากฏได้เฉพาะเป็น \u001e |
LF ต้องเขียนเป็น \n |
| เครื่องมือ | jq --seq และไลบรารีไม่กี่ตัว |
เครื่องมือ log และ big data เกือบทั้งหมด |
| นามสกุลไฟล์ | RFC ไม่ได้กำหนด | .jsonl / .ndjson |
การตัดสินใจสั้น: ใช้ JSON-seq เมื่อคุณควบคุมทั้งสองฝั่ง ต้องการ media type อย่างเป็นทางการ และต้องการให้ตัวอ่านกลับมาประสานใหม่หลังการตัดทอน; ใช้ JSON Lines และ NDJSON เมื่อข้อมูลไหลเข้าสู่ระบบ log ต้องอ่านได้โดยมนุษย์ หรือต้องทำงานกับเครื่องมือที่มีอยู่
4. Parser: แยกที่ 0x1E แล้วจึง JSON.parse
ประโยชน์ที่จับต้องได้ที่สุดของ RFC 7464 คือคุณไม่ต้องมี streaming JSON parser ให้แยกตามไบต์ แล้วเรียก JSON.parse กับแต่ละช่วง:
/** ASCII record separator, defined by RFC 7464 as 0x1E */
const RECORD_SEPARATOR = 0x1e;
/**
* Parses a chunk of JSON Text Sequence bytes.
* @param buffer Bytes that have been read so far
* @returns Successfully parsed records and the number of failed records
*/
function parseJsonSequence(buffer: Buffer): { records: unknown[]; badCount: number } {
const records: unknown[] = [];
let badCount = 0;
let recordStart = buffer.indexOf(RECORD_SEPARATOR);
while (recordStart !== -1) {
const nextRecordStart = buffer.indexOf(RECORD_SEPARATOR, recordStart + 1);
const recordEnd = nextRecordStart === -1 ? buffer.length : nextRecordStart;
const rawText = buffer.subarray(recordStart + 1, recordEnd).toString('utf8').trim();
if (rawText !== '') {
try {
records.push(JSON.parse(rawText));
} catch {
badCount += 1;
}
}
recordStart = nextRecordStart;
}
return { records, badCount };
}
บันทึกการนำไปใช้สามข้อ:
- การแยกในระดับไบต์ปลอดภัย ไบต์ต่อเนื่องของ UTF-8 อยู่ในช่วง
0x80–0xBFจึงไม่มีทางเท่ากับ0x1Eการแยกที่ 0x1E จึงตัดอักขระหลายไบต์ครึ่งหนึ่งไม่ได้ - บัฟเฟอร์ส่วนท้ายเมื่อ stream ขณะอ่านเป็นก้อน ช่วงสุดท้ายอาจยังไปไม่ถึง RS ถัดไป ให้เก็บไว้ในบัฟเฟอร์จนกว่า chunk ถัดไปจะมาถึง ไม่อย่างนั้นคุณจะ parse เรกคอร์ดครึ่งเดียวเดิมซ้ำสองครั้ง
- นับความล้มเหลวแทนการยกเลิก RFC บอกให้ parser ข้ามไปยัง RS ถัดไปและทำต่อ
badCountจึงเป็นเมตริกเตือน ไม่ใช่ exception เรกคอร์ดสุดท้ายที่ถูกตัดทอนก็อยู่ในกลุ่มนั้น
การใช้หน่วยความจำขึ้นกับเรกคอร์ดเดียวที่ใหญ่ที่สุดบวกบัฟเฟอร์หนึ่งตัว ไม่ขึ้นกับขนาดไฟล์ทั้งหมด นั่นคือแนวคิดเดียวกับที่กล่าวถึงใน การ parse และ streaming JSON ขนาดใหญ่
5. อักขระควบคุมภายใน string
JSON กำหนดให้อักขระควบคุมทุกตัวตั้งแต่ U+0000 ถึง U+001F ถูก escape ภายใน string และ RS อยู่ในช่วงนั้น string JSON ที่ถูกต้องจึงมีได้เพียงห้าอักขระ \u001e ไม่มีทางมีไบต์ 0x1E ดิบ นี่คือเหตุผลที่การแยกที่ 0x1E ปลอดภัยเสมอสำหรับข้อมูลที่ถูกต้อง
อันตรายอยู่ฝั่งตัวเขียน:
- สร้างข้อความ JSON ด้วยมือด้วย
'\x1e'ใส่อักขระควบคุมดิบเข้าใน string; - สร้าง JSON ด้วย template literal แทน
JSON.stringifyปล่อยอักขระขึ้นบรรทัดใหม่ เครื่องหมายคำพูด และอักขระควบคุมไว้โดยไม่ escape; - คัดลอกข้อความที่มี RS จาก terminal หรือ editor ซึ่งอักขระควบคุมถูกกลืนหรือถูกแทนที่
รูปแบบที่ถูกต้องคือสร้างข้อความก่อน แล้วจึงติดตัวคั่น:
const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;
JSON.stringify ส่งอักขระควบคุมออกมาเป็น \u00XX RS ดิบจึงปรากฏในเรกคอร์ดไม่ได้ เมื่อขั้นตอนนี้ถูกต้อง ตรรกะการแยกก็ต้องจัดการเพียงไบต์และไม่ต้องเข้าใจไวยากรณ์ JSON
ความล้มเหลวส่วนใหญ่ในหมวดนี้แสดงออกเป็น JSON.parse โยน Unexpected token โดยเฉพาะกรณีที่สับสนว่า "อักขระตัวแรกเป็นไบต์ควบคุมที่มองไม่เห็น" ตารางค้นหาข้อความ error ของ JSON อธิบาย error ประเภทนั้นทีละบรรทัด
6. การขนส่งผ่าน HTTP และ media type
application/json-seq เป็น media type ที่ลงทะเบียนแล้วโดยไม่มีพารามิเตอร์บังคับหรือทางเลือก และชุดอักขระถูกกำหนดเป็น UTF-8 บน HTTP มีแนวปฏิบัติสามข้อที่สำคัญ:
- ส่ง
Accept: application/json-seqและตอบด้วยContent-Type: application/json-seq; - เพื่อ stream จริง เซิร์ฟเวอร์ต้องใช้ chunked transfer และปิดการบัฟเฟอร์ response ในตัวกลางอย่าง reverse proxy, CDN และเครื่องมือ load test;
- ไคลเอนต์ที่อ่านทีละ chunk อาจได้รับ chunk ที่จบกลางเรกคอร์ด parser จึงต้องบัฟเฟอร์จนกว่า RS ถัดไปจะมาก่อนปล่อยเรกคอร์ด
หากอีกฝั่งเข้าใจเพียงอินพุตคั่นด้วยบรรทัด อย่าบังคับ JSON-seq กับมัน มันเป็นมาตรฐาน แต่การแทรกซึมในระบบนิเวศต่ำกว่า NDJSON มาก ความเข้ากันได้จึงควรตัดสินระหว่างการเจรจา protocol ไม่ใช่หลัง parse ล้มเหลว
7. Command line, log และการรองรับเครื่องมือ
jq เป็นหนึ่งในไม่กี่เครื่องมือที่รองรับโดยธรรมชาติ แฟล็ก --seq ของมันมีไว้เพื่อ RFC 7464 โดยเฉพาะ:
printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'
คำสั่งนั้นพิมพ์ 1 และ 2 คนละบรรทัด สำหรับ endpoint แบบ streaming อย่าลืมปิดการบัฟเฟอร์ของ curl:
curl -sN https://api.example.com/events | jq --seq -c '.type'
jq --seq อ่านอินพุต RFC 7464 เพิกเฉยเรกคอร์ดเสีย และ parse ต่อ ซึ่งมีประโยชน์เป็นพิเศษเมื่อ tail ไฟล์ log ที่ยังถูกเขียนอยู่: เรกคอร์ดสุดท้ายอาจเขียนค้างอยู่ และเอาต์พุตจะไม่แสดง object ปลอมที่ท้าย
7.1 การรองรับโดยธรรมชาติแยกตามสภาพแวดล้อม
| สภาพแวดล้อม | การรองรับในตัว | หมายเหตุ |
|---|---|---|
| jq | --seq |
ตัวเลือก command line ที่ง่ายที่สุด |
| Node.js | ไม่มี | Buffer.indexOf บวก JSON.parse |
| Python | ไม่มี | แยกที่ b'\x1e' |
| Go | ไม่มี | SplitFunc ที่เขียนเองสำหรับ bufio.Scanner |
| Browser | ไม่มี | fetch บวก ReadableStream แยกไบต์ด้วยมือ |
7.2 กับดักที่พบบ่อย
ข้อผิดพลาดกระจุกอยู่ฝั่งการอ่าน:
- อ่านด้วย
readlineซึ่งทิ้งคำนำหน้า RS ที่มองไม่เห็นไว้บนบรรทัดแรกJSON.parseจึงล้มเหลวเสมอ; - เปิดไฟล์
.json-seqใน editor ที่อักขระควบคุมมองไม่เห็น จึงดูเหมือนเอกสาร JSON แปลก ๆ หนึ่งฉบับ; - แยกด้วย
split(/\n/)ซึ่งทำให้อักขระขึ้นบรรทัดใหม่ที่ escape ภายในเรกคอร์ดปนกับ LF ระหว่างเรกคอร์ด; - ถือ RS เป็นอักขระธรรมดาในเอกสาร จึงหายไปหลังคัดลอกและวาง
8. เมื่อใดควรใช้
เหมาะดี:
- คุณเป็นเจ้าของทั้ง producer และ consumer โดยไม่มีเครื่องมือบุคคลที่สามคั่นกลาง;
- ข้อมูลเป็น stream เหตุการณ์ที่ต่อท้ายอย่างต่อเนื่องและโปรเซสอาจ crash จึงต้องกู้คืนจากการตัดทอน;
- protocol สมควรมี media type อย่างเป็นทางการมากกว่าข้อตกลงอย่าง
.jsonl; - เรกคอร์ดเดี่ยวอาจเป็นจำนวนหรือ boolean ระดับบนสุด ซึ่งกฎการตัดทอนชัดเจนสำคัญ
ไม่เหมาะ:
- Log ต้องเข้า ELK, Loki หรือระบบที่เข้าใจเพียงอินพุตคั่นด้วยบรรทัด;
- คนต้อง
cat,grepและawkข้อมูลโดยตรง; - อีกฝั่ง parse ได้เพียง
application/json
9. รายการตรวจสอบ
- [ ] ตัวเขียนสร้างเรกคอร์ดด้วย
JSON.stringifyแล้วเติม0x1Eข้างหน้าและ\nต่อท้าย; - [ ] เรกคอร์ดที่เก็บจำนวน, boolean หรือ
nullระดับบนสุดลงท้ายด้วย LF; - [ ] ตัวอ่านแยกตามไบต์แทนการอ่านทีละบรรทัด;
- [ ] การอ่านแบบ streaming เก็บท้ายที่ยังไม่ปิดไว้ในบัฟเฟอร์จนกว่า RS ถัดไปจะมาถึง;
- [ ] ความล้มเหลวในการ parse ถูกนับและข้าม ไม่ทำให้ลำดับล้มตาย;
- [ ] อินเทอร์เฟซสาธารณะประกาศ
application/json-seqอย่างชัดเจนและมี NDJSON เป็น fallback
คำถามที่พบบ่อย
ควรนำ JSON-seq ไปใช้กับ endpoint ของ API สาธารณะหรือไม่?
เฉพาะเมื่อทั้งสองฝั่งเปลี่ยนได้หรือรูปแบบถูกเจรจาล่วงหน้า JSON-seq เป็นมาตรฐานที่ลงทะเบียนแล้วด้วย media type application/json-seq แต่การแทรกซึมในระบบนิเวศต่ำกว่า NDJSON มาก การบังคับมันกับไคลเอนต์บุคคลที่สามจึงมักล้มเหลว ให้ตัดสินความเข้ากันได้ระหว่างการเจรจา protocol และเก็บ NDJSON เป็น fallback
ควรแปลง pipeline NDJSON ที่มีอยู่เป็น JSON-seq หรือไม่?
โดยทั่วไปไม่ NDJSON stream ได้ ต่อท้ายได้ และข้ามบรรทัดเสียได้อยู่แล้ว และเครื่องมือ log กับ big data เกือบทั้งหมดรับมันได้ แปลงเฉพาะเมื่อคุณต้องการ media type ที่ลงทะเบียนกับ IANA หรือการกลับมาประสานใหม่ที่ตัวคั่นนำหน้าให้ และเฉพาะเมื่อ producer กับ consumer เปลี่ยนพร้อมกันได้
JSON Text Sequences หรือ NDJSON อะไรพบบ่อยกว่า?
NDJSON กฎง่ายกว่า การครอบคลุมเครื่องมือกว้างกว่ามาก และระบบ log กับ pipeline big data โดยทั่วไปรับมันได้ JSON-seq ชนะในเรื่อง media type ที่ลงทะเบียนกับ IANA และการกลับมาประสานใหม่ที่เกิดจากการวางตัวคั่นไว้ข้างหน้า ซึ่งเหมาะกับ protocol streaming ที่ควบคุมได้มากกว่าการแลกเปลี่ยนข้อมูลทั่วไป
ทำไมตัวคั่นจึงวางก่อนเรกคอร์ดแทนที่จะวางหลัง?
เพราะเรกคอร์ดอาจถูกตัดทอน ด้วยตัวคั่นนำหน้า ตัวอ่านที่เห็น RS รู้ว่าเรกคอร์ดใหม่เริ่มแล้ว เรกคอร์ดก่อนหน้าจึงทำลายเรกคอร์ดถัดไปไม่ได้ไม่ว่าจะสมบูรณ์หรือไม่ ด้วยตัวคั่นต่อท้าย การเขียนที่ถูกขัดจังหวะทิ้งช่วงสุดท้ายที่ไม่มีทั้งเครื่องหมายสิ้นสุดและเครื่องหมายเริ่มต้นถัดไป ขอบเขตจึงเดาได้เท่านั้น
เกิดอะไรขึ้นหาก 0x1E ปรากฏภายในเรกคอร์ด?
string JSON ที่ถูกต้องต้อง escape RS เป็น \u001e ดังนั้น 0x1E ดิบไม่ควรปรากฏ หากปรากฏ ข้อความนั้นไม่ใช่ JSON ที่ถูกต้องอีกต่อไป การแยกที่ 0x1E จะแตกผิดที่และ JSON.parse จะล้มเหลว การแก้อยู่ฝั่งตัวเขียน: ใช้ JSON.stringify แทนข้อความที่สร้างด้วยมือ
Browser parse application/json-seq ได้โดยตรงหรือไม่?
ไม่ได้ Browser ไม่มี parser JSON-seq ในตัว fetch ส่งเพียงไบต์ คุณต้องใช้ response.body.getReader() สะสม chunk จนถึง 0x1E ถัดไป และเรียก JSON.parse กับแต่ละช่วง สำหรับ payload เล็ก การอ่าน body ทั้งหมดเป็น string ก่อนแล้วแยกภายหลังก็ใช้ได้
ทำไมจำนวนระดับบนสุดต้องมี LF ต่อท้าย?
Object, array และ string มีการกำหนดขอบเขตในตัวเองใน JSON ขณะที่จำนวนไม่มี: <RS>123<RS> อาจเป็น 123 ที่สมบูรณ์หรือ 1234 ที่ถูกตัดทอน RFC 7464 จึงกำหนดให้มีไบต์ช่องว่างหลังทุกรายการ และ LF ทำหน้าที่เป็น canary ตรวจจับการตัดทอน กฎเดียวกันใช้กับ true, false และ null ระดับบนสุด