دنبالههای متن JSON (RFC 7464): JSON جریانی با جداکننده RS
جمعبندی: RFC 7464 از جداکننده 0x1E برای جریان دادن متنهای JSON استفاده میکند که همزمان میتوانید بخوانید و بنویسید. قواعد، تفاوت با NDJSON و پیادهسازی تقسیم-سپس-تجزیه را پوشش میدهد.

جمعبندی: وقتی هر دو سر را کنترل میکنید، نوع رسانه ثبتشده میخواهید و میخواهید خواننده پس از بریده شدن دوباره همتراز شود از JSON-seq استفاده کنید؛ وقتی داده به سیستمهای لاگ میرود، باید انسانخوان باشد یا باید با ابزارهای موجود کار کند از NDJSON؛ هرگز JSON-seq را به همتایی که فقط ورودی خطمحدود میفهمد تحمیل نکنید.
NDJSON رکوردها را روی خط جدید میشکند، اما خط جدید قراردادی انسانی است. RFC 7464 مرز را در خود جریان بایت مینویسد: یک
0x1Eنامرئی پیش از هر رکورد.
۱. مشکلی که یک بایت حل میکند
RFC 7464 در فوریه ۲۰۱۵ با عنوان دنبالههای متن JSON منتشر شد و نوع رسانه application/json-seq را ثبت میکند. هیچ نوع داده جدیدی اضافه نمیکند. فقط تعریف میکند که دنبالهای از متنهای JSON چگونه در یک جریان بایت چیده شود. کل قاعده در یک جمله جا میشود: پیش از هر متن JSON یک RS و پس از آن یک LF بگذارید.
- RS جداکننده رکورد ASCII است، نقطه کد
0x1E، در یونیکد با نام 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 صریحاً از رکوردهای افزودهشده به فایلهای لاگ یاد میکند که پس از crash یا kill اداری توسط فایلسیستم بریده میشوند، و میگوید پارسر نباید به همین دلیل کل خواندن را رها کند.
۲. دو مجموعه قاعده: رمزگذارها و پارسرها
RFC 7464 بهطور غیرمعمول دو گرامر ABNF ارائه میدهد، یکی که نویسندهها را محدود میکند و یکی که خوانندهها را آسان میگیرد.
| نقش | گرامر | معنا |
|---|---|---|
| رمزگذار | *(RS JSON-text LF) |
یک RS پیش از هر متن JSON، یک LF پس از آن |
| پارسر | *(1*RS possible-JSON) |
بایتهای RS متوالی مجازند و LF بین رکوردها اختیاری است |
در عمل مهندسی، پنج قاعده سخت وجود دارد:
- UTF-8 الزامی است؛ UTF-16 و UTF-32 نباید استفاده شوند؛
- پارسر «هر بایتی غیر از RS» را متن JSON احتمالی میداند و میکوشد تجزیهاش کند؛ در شکست به RS بعدی میپرد و ادامه میدهد؛
- بایتهای RS متوالی عنصر خالی را نمایش نمیدهند و نادیده گرفته میشوند؛
- نشانه پایان دنباله وجود ندارد، پس جریان صرفاً تمام میشود؛
- وقتی مقدار سطح بالا عدد،
true،falseیاnullاست، رکورد باید با یک بایت فضای سفید تمام شود، وگرنه بهعنوان احتمالاً بریدهشده کنار گذاشته میشود.
قاعده پنجم همان چیزی است که مردم فراموش میکنند. اشیا، آرایهها و رشتهها در JSON خودمرزبند هستند؛ عددها نه. <RS>123<RS> نمیتواند بگوید 123 کامل دارد یا 1234 بریدهشده. RFC بنابراین از نویسندهها میخواهد LF ضمیمه کنند و از آن بهعنوان بایت قناری برای تشخیص بریدگی استفاده میکند. به همین ترتیب <RS>true<RS> ممکن بود اصلاً trueish بوده باشد.
۳. انتخاب بین JSON-seq و NDJSON
هر دو قالب جریان میدهند، اما مشکلات متفاوتی را هدف میگیرند:
| بُعد | دنبالههای متن JSON | NDJSON / JSON Lines |
|---|---|---|
| موقعیت جداکننده | RS (0x1E) پیش از رکورد |
LF (0x0A) پس از رکورد |
| نوع رسانه | application/json-seq، ثبتشده در IANA |
ثبت رسمی ندارد، قرارداد جامعه |
| خوانایی بایتی | نویسه کنترلی نامرئی، cat چیزی نشان نمیدهد |
متن ساده، مستقیماً خوانا |
| افزودن همزمان | خواننده میتواند دوباره همتراز شود و دنباله بریده را بیندازد | افزودن هم کار میکند، خطوط بد رد میشوند |
| عددهای سطح بالا | برای اعتماد به بایت فضای سفید انتهایی نیاز دارد | خط جدید همان مرز است |
| جداکننده داخل رشتهها | RS فقط میتواند بهصورت \u001e ظاهر شود |
LF باید \n نوشته شود |
| ابزار | jq --seq و چند کتابخانه |
تقریباً هر ابزار لاگ و داده بزرگ |
| پسوند فایل | توسط RFC تعریف نشده | .jsonl / .ndjson |
تصمیم کوتاه است: وقتی هر دو سر را کنترل میکنید، نوع رسانه رسمی میخواهید و میخواهید خواننده پس از بریدگی دوباره همتراز شود از JSON-seq استفاده کنید؛ وقتی داده به سیستمهای لاگ میرود، باید انسانخوان باشد یا باید با ابزارهای موجود کار کند از JSON Lines و NDJSON استفاده کنید.
۴. یک پارسر: تقسیم روی 0x1E، سپس JSON.parse
ملموسترین مزیت RFC 7464 این است که به پارسر جریانی JSON نیاز ندارید. بر اساس بایت تقسیم کنید، سپس 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 نمیتواند نویسه چندبایتی را نصف کند. - هنگام جریان دادن، دنباله را بافر کنید. هنگام خواندن تکهتکه، بخش آخر ممکن است هنوز به RS بعدی نرسیده باشد. تا رسیدن تکه بعدی در بافر نگهش دارید؛ وگرنه همان نیمرکورد را دو بار تجزیه میکنید.
- شکستها را بشمارید نه اینکه رها کنید. RFC به پارسرها میگوید به RS بعدی بپرند و ادامه دهند، پس
badCountیک متریک هشدار است، نه استثنا. رکورد آخر بریدهشده هم در همان سبد است.
مصرف حافظه به بزرگترین رکورد واحد بهعلاوه یک بافر بستگی دارد، نه حجم کل فایل. همان ایدهای که در تجزیه و جریان دادن JSON بزرگ بحث شده است.
۵. نویسههای کنترلی داخل رشتهها
JSON الزام میکند هر نویسه کنترلی از U+0000 تا U+001F داخل رشته escape شود و RS در آن بازه است. پس یک رشته JSON معتبر فقط میتواند پنج نویسه \u001e داشته باشد، هرگز بایت خام 0x1E. به همین دلیل تقسیم روی 0x1E همیشه برای داده معتبر امن است.
خطر در سمت نویسنده است:
- ساخت دستی متن JSON با
'\x1e'و درج یک نویسه کنترلی خام در رشته؛ - ساخت JSON با template literal بهجای
JSON.stringify، و گذاشتن خطوط جدید، گیومهها و نویسههای کنترلی escapeنشده؛ - کپی متنی شامل RS از ترمینال یا ویرایشگر که نویسه کنترلی در آن بلعیده یا جایگزین میشود.
الگوی درست همیشه تولید اول متن و سپس ضمیمه کردن جداکننده است:
const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;
JSON.stringify نویسههای کنترلی را بهصورت \u00XX میدهد، پس RS خام هرگز داخل رکورد ظاهر نمیشود. وقتی این گام درست شود، منطق تقسیم فقط باید با بایتها سر و کار داشته باشد و هرگز نیازی به فهم نحو JSON ندارد.
بیشتر شکستهای این دسته بهصورت پرتاب Unexpected token توسط JSON.parse ظاهر میشوند، بهویژه حالت گیجکننده «نخستین نویسه یک بایت کنترلی نامرئی است». جدول جستوجوی پیام خطای JSON این دسته خطا را خط به خط مرور میکند.
۶. انتقال HTTP و نوع رسانه
application/json-seq نوع رسانه ثبتشدهای بدون پارامتر الزامی یا اختیاری است و مجموعه نویسه ثابت UTF-8. روی HTTP سه رویه مهم است:
Accept: application/json-seqبفرستید و باContent-Type: application/json-seqپاسخ دهید؛- برای جریان واقعی، سرور باید انتقال تکهتکه (chunked) استفاده کند و بافرینگ پاسخ را در واسطههایی مانند reverse proxy، CDN و ابزارهای تست بار غیرفعال کند؛
- کلاینتی که تکهتکه میخواند ممکن است تکهای بگیرد که وسط رکورد تمام میشود، پس پارسر باید تا RS بعدی بافر کند و بعد رکورد بدهد.
اگر طرف مقابل فقط ورودی خطمحدود میفهمد، JSON-seq را تحمیلش نکنید. استاندارد است، اما نفوذ اکوسیستمیاش بسیار کمتر از NDJSON است، پس سازگاری باید هنگام مذاکره پروتکل تصمیم گرفته شود نه بعد از شکست تجزیه.
۷. خط فرمان، لاگ و پشتیبانی ابزار
jq یکی از معدود ابزارهایی است که پشتیبانی بومی دارد؛ پرچم --seq دقیقاً برای RFC 7464 وجود دارد:
printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'
این 1 و 2 را در خطوط جداگانه چاپ میکند. برای endpoint جریانی، یادتان باشد بافرینگ curl را غیرفعال کنید:
curl -sN https://api.example.com/events | jq --seq -c '.type'
jq --seq ورودی RFC 7464 میخواند، رکوردهای بد را نادیده میگیرد و تجزیه را ادامه میدهد. این هنگام tail کردن فایل لاگی که هنوز در حال نوشتن است بسیار مفید است: رکورد آخر ممکن است نیمهنوشته باشد و خروجی شیء پایانی جعلی نشان نمیدهد.
۷.۱ پشتیبانی بومی بر اساس محیط
| محیط | پشتیبانی داخلی | یادداشت |
|---|---|---|
| jq | --seq |
آسانترین گزینه خط فرمان |
| Node.js | ندارد | Buffer.indexOf بهعلاوه JSON.parse |
| Python | ندارد | تقسیم روی b'\x1e' |
| Go | ندارد | SplitFunc سفارشی برای bufio.Scanner |
| مرورگر | ندارد | fetch بهعلاوه ReadableStream، تقسیم دستی بایتها |
۷.۲ تلههای رایج
اشتباهات در سمت خواندن جمع میشوند:
- خواندن با
readlineکه پیشوند RS نامرئی را روی خط اول باقی میگذارد وJSON.parseهمیشه شکست میخورد؛ - باز کردن فایل
.json-seqدر ویرایشگری که نویسههای کنترلی نامرئیاند، پس مثل یک سند JSON عجیب به نظر میرسد؛ - تقسیم با
split(/\n/)که خطوط جدید escapeشده داخل رکورد را با LF بین رکوردها اشتباه میگیرد؛ - برخورد با RS بهعنوان نویسه معمولی در مستندات، پس پس از کپی-چسباندن ناپدید میشود.
۸. چه زمانی ارزش استفاده دارد
تناسب خوب:
- تولیدکننده و مصرفکننده هر دو مال شماست و ابزار شخص ثالثی بینشان نیست؛
- داده جریان رویداد پیوستهافزا است و فرایند ممکن است crash کند، پس به بازیابی از بریدگی نیاز دارید؛
- پروتکل شایسته نوع رسانه رسمی است نه قراردادی مانند
.jsonl؛ - رکوردهای منفرد ممکن است عدد یا بولی سطح بالا باشند، جایی که قاعده صریح بریدگی اهمیت دارد.
تناسب ضعیف:
- لاگها باید به ELK، Loki یا سیستم دیگری بروند که فقط ورودی خطمحدود میفهمد؛
- مردم باید داده را مستقیم با
cat،grepوawkبخوانند؛ - طرف مقابل فقط میتواند
application/jsonرا تجزیه کند.
۹. چکلیست
- [ ] نویسنده رکوردها را با
JSON.stringifyتولید میکند، سپس0x1Eرا جلو و\nرا عقب میگذارد؛ - [ ] رکوردهای حاوی عدد، بولی یا
nullسطح بالا با LF تمام میشوند؛ - [ ] خواننده بر اساس بایت تقسیم میکند نه خطبهخط؛
- [ ] خواندن جریانی دنباله بستهنشده را تا رسیدن RS بعدی در بافر نگه میدارد؛
- [ ] شکستهای تجزیه شمرده و رد میشوند، هرگز کشنده دنباله نیستند؛
- [ ] رابطهای عمومی
application/json-seqرا صریح اعلام و یک جایگزین NDJSON نگه میدارند.
پرسشهای متداول
آیا باید JSON-seq را برای endpoint API عمومی بپذیرم؟
فقط وقتی هر دو سر قابل تغییرند یا قالب از پیش مذاکره شده. JSON-seq استاندارد ثبتشده با نوع رسانه application/json-seq است، اما نفوذ اکوسیستمیاش بسیار کمتر از NDJSON است، پس تحمیلش به کلاینت شخص ثالث معمولاً شکست میخورد. سازگاری را هنگام مذاکره پروتکل تصمیم بگیرید و جایگزین NDJSON نگه دارید.
آیا باید خط لوله NDJSON موجود را به JSON-seq تبدیل کنم؟
معمولاً نه. NDJSON از قبل جریان میدهد، میافزاید و خطوط بد را رد میکند، و تقریباً هر ابزار لاگ و داده بزرگ آن را میپذیرد. فقط وقتی تبدیل کنید که نوع رسانه ثبتشده در IANA یا همترازسازی مجدد ناشی از جداکننده ابتدایی را لازم دارید، و فقط وقتی تولیدکننده و مصرفکننده با هم قابل تغییرند.
کدام رایجتر است، دنبالههای متن JSON یا NDJSON؟
NDJSON. قواعدش سادهتر، پوشش ابزارش بسیار گستردهتر است و سیستمهای لاگ و خطوط داده بزرگ عموماً میپذیرندش. JSON-seq با نوع رسانه ثبتشده در IANA و همترازسازی مجددی که از گذاشتن جداکننده در ابتدا میآید برنده است، که مناسب پروتکلهای جریانی کنترلشده است نه تبادل داده عمومی.
چرا جداکننده پیش از رکورد گذاشته میشود نه پس از آن؟
چون رکوردها میتوانند بریده شوند. با جداکننده ابتدایی، خوانندهای که RS میبیند میداند رکورد جدیدی آغاز شده، پس رکورد قبلی نمیتواند بعدی را خراب کند، چه کامل باشد چه نه. با جداکننده انتهایی، نوشتن قطعشده بخش پایانیای باقی میگذارد که نه نشانه پایان دارد نه نشانه شروع بعدی، پس مرز فقط قابل حدس است.
اگر 0x1E داخل یک رکورد ظاهر شود چه میشود؟
یک رشته JSON معتبر باید RS را بهصورت \u001e escape کند، پس 0x1E خام هرگز نباید ظاهر شود. اگر ظاهر شود، متن دیگر JSON معتبر نیست: تقسیم روی 0x1E در جای اشتباه میشکند و JSON.parse شکست میخورد. اصلاح به سمت نویسنده تعلق دارد: از JSON.stringify بهجای متن دستساز استفاده کنید.
آیا مرورگر میتواند application/json-seq را مستقیم تجزیه کند؟
نه. مرورگرها پارسر JSON-seq ندارند؛ fetch فقط بایتها را تحویل میدهد. باید از response.body.getReader() استفاده کنید، تکهها را تا 0x1E بعدی جمع کنید و JSON.parse را روی هر بخش صدا بزنید. برای payloadهای کوچک، خواندن کل بدنه بهصورت رشته و سپس تقسیم هم کار میکند.
چرا عدد سطح بالا به LF انتهایی نیاز دارد؟
اشیا، آرایهها و رشتهها در JSON خودمرزبند هستند، در حالی که عددها نه: <RS>123<RS> میتواند 123 کامل باشد یا 1234 بریدهشده. RFC 7464 بنابراین یک بایت فضای سفید پس از هر رکورد الزام میکند و LF بهعنوان قناری تشخیص بریدگی عمل میکند. همان قاعده برای true، false و null سطح بالا اعمال میشود.