JSON Toolkit

تسلسلات JSON النصية (RFC 7464): JSON متدفق مفصول بـ RS

الخلاصة: يستخدم RFC 7464 فاصل 0x1E لبث نصوص JSON يمكن قراءتها وكتابتها في الوقت نفسه. يغطي القواعد واختلافه عن NDJSON وتنفيذًا يقوم على التقسيم ثم التحليل.

متوسطقراءة نحو 9 دقيقةنُشر في 2026-10-31حُدِّث في 2026-10-31
غلاف تسلسلات JSON النصية وفاصل السجل في RFC 7464

الخلاصة: استخدم JSON-seq عندما تتحكم في الطرفين معًا، وتحتاج إلى نوع وسائط مسجل، وتريد للقارئ أن يعيد المزامنة بعد الاقتطاع؛ واستخدم NDJSON عندما تغذي البيانات أنظمة سجلات، أو يجب أن تكون مقروءة للبشر، أو يجب أن تعمل مع الأدوات الموجودة — ولا تفرض JSON-seq أبدًا على طرف لا يفهم سوى المدخلات محددة الأسطر.

يفصل NDJSON السجلات بالأسطر الجديدة، لكن السطر الجديد عرف بشري. يكتب RFC 7464 الحدود في تدفق البايتات نفسه: بايت 0x1E غير مرئي قبل كل سجل.

1. المشكلة التي يحلها بايت واحد

نُشر RFC 7464 في فبراير 2015 بعنوان تسلسلات JSON النصية، ويسجل نوع الوسائط application/json-seq. لا يضيف أنواع بيانات جديدة. إنه يحدد فقط كيف تُرتَّب سلسلة من نصوص JSON داخل تدفق بايتات واحد. القاعدة كلها تتسع لجملة: ضع RS واحدًا قبل كل نص JSON وLF واحدًا بعده.

  • RS هو فاصل السجل في ASCII، نقطة الترميز 0x1E، ويُعرف في Unicode باسم INFORMATION SEPARATOR TWO (U+001E)؛
  • LF هو تغذية السطر، 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 صراحةً السجلات المضافة إلى ملفات السجل التي يقتطعها نظام الملفات بعد انهيار أو إيقاف إداري، ويقول إن على المحلل ألا يُجهض القراءة كلها بسبب ذلك.

2. مجموعتا قواعد: المرمّزات والمحللات

يقدم RFC 7464 على غير المعتاد قاعدتين ABNF، واحدة تقيّد الكاتبين وواحدة تخفف القارئين.

الدور القاعدة المعنى
المرمّز *(RS JSON-text LF) RS واحد قبل كل نص JSON، وLF واحد بعده
المحلل *(1*RS possible-JSON) بايتات RS المتتالية مسموحة، وLF بين السجلات اختياري

بمصطلحات هندسية، هناك خمس قواعد صارمة:

  1. UTF-8 إلزامي؛ ويحظر استخدام UTF-16 وUTF-32؛
  2. يعامل المحلل "أي بايت غير RS" كنص JSON محتمل ويحاول تحليله؛ وعند الفشل يتخطى إلى RS التالي ويكمل؛
  3. بايتات RS المتتالية لا تمثل عناصر فارغة وتُتجاهل؛
  4. لا يوجد علامة نهاية للتسلسل، فينتهي التدفق ببساطة؛
  5. عندما تكون القيمة في المستوى الأعلى عددًا أو true أو false أو null، يجب أن ينتهي السجل ببايت مسافة بيضاء، وإلا فأُسقط كاحتمال اقتطاع.

القاعدة الخامسة هي التي ينساها الناس. الكائنات والمصفوفات والنصوص محددة ذاتيًا في JSON؛ أما الأعداد فلا. <RS>123<RS> لا تستطيع أن تخبرك هل تحمل 123 كاملًا أم 1234 مقتطعًا. لذا يشترط RFC أن يضيف الكاتبون LF، باستخدامه بايت إنذار لكشف الاقتطاع. وبالمثل <RS>true<RS> ربما كانت أصلًا trueish.

3. الاختيار بين 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 عندما تغذي البيانات أنظمة سجلات، أو تحتاج إلى أن يقرأها البشر، أو يجب أن تعمل مع الأدوات الموجودة.

4. محلل: قسّم على 0x1E ثم JSON.parse

أكثر فائدة ملموسة في RFC 7464 هي أنك لا تحتاج إلى محلل JSON متدفق. قسّم بالبايت، ثم استدعِ JSON.parse على كل قطعة:

/** فاصل السجل في ASCII، معرف في RFC 7464 بالقيمة 0x1E */
const RECORD_SEPARATOR = 0x1e;

/**
 * يحلل جزءًا من بايتات تسلسل JSON النصي.
 * @param buffer البايتات المقروءة حتى الآن
 * @returns السجلات المحللة بنجاح وعدد السجلات الفاشلة
 */
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 الكبير وبثه.

5. محارف التحكم داخل النصوص

يشترط JSON تهريب كل محرف تحكم من U+0000 إلى U+001F داخل نص، وRS يقع ضمن ذلك النطاق. لذا لا يمكن أن يحتوي نص JSON صالح إلا على الأحرف الخمسة \u001e، لا على بايت 0x1E خام أبدًا. ولهذا يكون التقسيم على 0x1E آمنًا دائمًا للبيانات الصالحة.

يكمن الخطر على جانب الكاتب:

  1. بناء نص JSON يدويًا بـ '\x1e'، فيُدخل محرف تحكم خامًا في نص؛
  2. بناء JSON بقوالب نصية بدلًا من JSON.stringify، فتترك الأسطر الجديدة وعلامات الاقتباس ومحارف التحكم دون تهريب؛
  3. نسخ نص يحتوي RS من طرفية أو محرر، فيُبتلع محرف التحكم أو يُستبدل.

النمط الصحيح دائمًا هو توليد النص أولًا ثم إلحاق الفاصل:

const recordText = JSON.stringify(record);
const line = `${String.fromCharCode(RECORD_SEPARATOR)}${recordText}\n`;

يصدر JSON.stringify محارف التحكم كـ \u00XX، فلا يمكن أن يظهر RS خام داخل السجل أبدًا. وبمجرد أن تصح هذه الخطوة، لا يبقى على منطق التقسيم سوى التعامل مع البايتات، ولا يحتاج إلى فهم صياغة JSON إطلاقًا.

تظهر معظم الإخفاقات في هذه الفئة كـ JSON.parse يرمي Unexpected token، خصوصًا الحالة المحيرة "المحرف الأول بايت تحكم غير مرئي". ويتناول جدول البحث عن رسائل خطأ JSON تلك الفئة من الأخطاء سطرًا بسطر.

6. نقل HTTP ونوع الوسائط

application/json-seq نوع وسائط مسجل دون وسائط مطلوبة أو اختيارية، ومجموعة محارفه ثابتة على UTF-8. في HTTP، تهم ثلاث ممارسات:

  • أرسل Accept: application/json-seq واستجب بـ Content-Type: application/json-seq؛
  • للبث حقًا، يجب أن يستخدم الخادم نقلًا مجزأً وأن يعطل تخزين استجابة في الوسطاء مثل الوكلاء العكسيين وشبكات CDN وأدوات اختبار الحمل؛
  • قد يتلقى العميل الذي يقرأ جزءًا بجزء جزءًا ينتهي في منتصف سجل، فيجب أن يخزن المحلل حتى RS التالي قبل إصدار سجل.

إن كان الطرف الآخر لا يفهم سوى مدخلات محددة الأسطر، فلا تفرض عليه JSON-seq. إنه معيار، لكن تغلغله في المنظومة أدنى بكثير من NDJSON، فينبغي أن يُحسم التوافق أثناء التفاوض على البروتوكول لا بعد فشل التحليل.

7. سطر الأوامر والسجلات ودعم الأدوات

jq واحد من الأدوات القليلة ذات الدعم الأصلي؛ وُجد علم --seq فيه خصيصًا من أجل RFC 7464:

printf '\x1e{"id":1}\n\x1e{"id":2}\n' | jq --seq -c '.id'

يطبع ذلك 1 و2 على سطرين منفصلين. لنقطة نهاية متدفقة، تذكر تعطيل تخزين curl:

curl -sN https://api.example.com/events | jq --seq -c '.type'

يقرأ jq --seq مدخلات RFC 7464، ويتجاهل السجلات السيئة، ويكمل التحليل. وهذا مفيد خصوصًا عند متابعة ملف سجل ما زال يُكتب: قد يكون السجل الأخير نصف مكتوب، ولن يُظهر الخرج كائنًا ختاميًا زائفًا.

7.1 الدعم الأصلي بحسب البيئة

البيئة الدعم المدمج ملاحظات
jq --seq خيار سطر الأوامر الأسهل
Node.js لا شيء Buffer.indexOf مع JSON.parse
Python لا شيء التقسيم على b'\x1e'
Go لا شيء SplitFunc مخصص لـ bufio.Scanner
المتصفح لا شيء fetch مع ReadableStream، وتقسيم البايتات يدويًا

7.2 المصائد الشائعة

تتكدس الأخطاء على جانب القراءة:

  1. القراءة بـ readline، التي تترك بادئة RS غير المرئية على السطر الأول فيفشل JSON.parse دائمًا؛
  2. فتح ملف .json-seq في محرر تكون فيه محارف التحكم غير مرئية، فيبدو كمستند JSON غريب واحد؛
  3. التقسيم بـ split(/\n/)، الذي يخلط بين الأسطر الجديدة المهروبة داخل السجل وLF بين السجلات؛
  4. معاملة RS كمحرف عادي في التوثيق، فيختفي بعد النسخ واللصق.

8. متى يستحق الاستخدام

يناسب جيدًا:

  • تملك المنتج والمستهلك معًا، دون أدوات طرف ثالث في الوسط؛
  • البيانات تدفق أحداث يُضاف إليه باستمرار وقد تنهار العملية، فتحتاج إلى استعادة من الاقتطاع؛
  • يستحق البروتوكول نوع وسائط رسميًا لا عرفًا مثل .jsonl؛
  • قد تكون السجلات الفردية أعدادًا أو قيمًا منطقية في المستوى الأعلى، حيث تهم قاعدة اقتطاع صريحة.

لا يناسب:

  • يجب أن تذهب السجلات إلى ELK أو Loki أو نظام آخر لا يفهم سوى مدخلات محددة الأسطر؛
  • يحتاج البشر إلى cat وgrep وawk للبيانات مباشرة؛
  • الطرف الآخر لا يستطيع تحليل سوى application/json.

9. قائمة الفحص

  • [ ] يولّد الكاتب السجلات بـ JSON.stringify، ثم يسبقها بـ 0x1E ويلحقها بـ \n؛
  • [ ] السجلات التي تحمل أعدادًا أو قيمًا منطقية أو null في المستوى الأعلى تنتهي بـ LF؛
  • [ ] يقسّم القارئ على البايتات بدلًا من القراءة سطرًا بسطر؛
  • [ ] تحتفظ القراءات المتدفقة بذيل غير مغلق في المخزن حتى وصول RS التالي؛
  • [ ] تُعد إخفاقات التحليل وتُتخطى، ولا تكون قاتلة للتسلسل أبدًا؛
  • [ ] تعلن الواجهات العامة application/json-seq صراحة وتحتفظ ببديل NDJSON.

الأسئلة الشائعة

هل أتبنى JSON-seq لنقطة نهاية API عامة؟

فقط عندما يمكن تغيير الطرفين أو يتم التفاوض على الصيغة مسبقًا. JSON-seq معيار مسجل بنوع الوسائط application/json-seq، لكن تغلغله في المنظومة أدنى بكثير من NDJSON، ففرضه على عميل طرف ثالث يفشل عادة. احسم التوافق أثناء التفاوض على البروتوكول واحتفظ ببديل NDJSON.

هل أحول خط أنابيب NDJSON موجودًا إلى JSON-seq؟

لا عادة. NDJSON يتدفق ويضيف ويتخطى الأسطر السيئة أصلًا، ويكاد كل أداة سجلات وبيانات ضخمة يقبله. حوّل فقط عندما تحتاج إلى نوع الوسائط المسجل لدى IANA أو إلى إعادة المزامنة التي يوفرها الفاصل البادئ، وفقط عندما يمكن تغيير المنتج والمستهلك معًا.

أيهما أكثر شيوعًا، تسلسلات JSON النصية أم NDJSON؟

NDJSON. قواعده أبسط، وتغطية الأدوات له أوسع بكثير، وتقبله أنظمة السجلات وخطوط أنابيب البيانات الضخمة عمومًا. ويفوز JSON-seq بنوع الوسائط المسجل لدى IANA وبإعادة المزامنة الناتجة عن وضع الفاصل أولًا، وهو ما يناسب بروتوكولات بث خاضعة للتحكم لا تبادل البيانات العام.

لماذا يوضع الفاصل قبل السجل لا بعده؟

لأن السجلات قابلة للاقتطاع. مع فاصل بادئ، يعرف القارئ الذي يرى RS أن سجلًا جديدًا بدأ، فلا يمكن للسجل السابق أن يفسد التالي سواء كان كاملًا أو لا. أما مع فاصل ختامي، فترك الكتابة المتقطعة مقطعًا نهائيًا ليس له علامة نهاية ولا علامة بداية تالية، فلا يمكن إلا تخمين الحد.

ماذا يحدث إذا ظهر 0x1E داخل سجل؟

يجب أن يهرّب نص JSON الصالح RS كـ \u001e، فلا ينبغي أن يظهر 0x1E خام أبدًا. وإن ظهر، فالنص لم يعد JSON صالحًا: سينكسر التقسيم على 0x1E في الموضع الخطأ ويفشل JSON.parse. الإصلاح على جانب الكاتب: استخدم JSON.stringify بدلًا من نص مبني يدويًا.

هل يستطيع المتصفح تحليل application/json-seq مباشرة؟

لا. لا تشحن المتصفحات محلل JSON-seq؛ وfetch يسلّم البايتات فقط. تحتاج إلى استخدام response.body.getReader()، وتجميع الأجزاء حتى 0x1E التالي، واستدعاء JSON.parse على كل مقطع. للحمولات الصغيرة، قراءة الجسم كله كنص أولًا ثم التقسيم تعمل أيضًا.

لماذا يحتاج العدد في المستوى الأعلى إلى LF ختامي؟

الكائنات والمصفوفات والنصوص محددة ذاتيًا في JSON، أما الأعداد فلا: <RS>123<RS> قد تكون 123 كاملة أو 1234 مقتطعًا. لذا يشترط RFC 7464 بايت مسافة بيضاء بعد كل سجل، ويعمل LF كإنذار لكشف الاقتطاع. وتنطبق القاعدة نفسها على true وfalse وnull في المستوى الأعلى.