JSON Toolkit

JSON Text Sequences (RFC 7464): JSON בזרימה עם הפרדה ב-RS

שורה תחתונה: RFC 7464 משתמש במפריד 0x1E כדי לזרום טקסטי JSON שאפשר לקרוא ולכתוב בבת אחת. מכסה את הכללים, איך הוא שונה מ-NDJSON ומימוש של פיצול ואז פרסור.

בינוניכ-9 דקות קריאהפורסם 2026-10-31עודכן 2026-10-31
תמונת כריכה של JSON Text Sequences ומפריד הרשומות RFC 7464

שורה תחתונה: השתמשו ב-JSON-seq כשאתם שולטים בשני הקצוות, צריכים סוג מדיה רשום, ורוצים שהקורא יסנכרן מחדש אחרי חיתוך; השתמשו ב-NDJSON כשהנתונים מזינים מערכות לוגים, חייבים להיות קריאים לאדם, או צריכים לעבוד עם כלים קיימים — לעולם אל תכפו JSON-seq על עמית שמבין רק קלט מופרד שורות.

NDJSON מפצל רשומות לפי שורות חדשות, אבל שורה חדשה היא מוסכמה אנושית. RFC 7464 כותב את הגבול לתוך זרם הבתים עצמו: 0x1E בלתי נראה לפני כל רשומה.

1. הבעיה שנפתרת בבית בודד

RFC 7464 פורסם בפברואר 2015 תחת הכותרת JSON Text Sequences, והוא רושם את סוג המדיה 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 Text Sequences 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 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 לא יכול לחתוך תו מרובה-בתים באמצע.
  • בפריסה בזרימה, מאגרו את הזנב. בזמן קריאה ב-chunks, הקטע האחרון אולי עוד לא הגיע ל-RS הבא. שמרו אותו ב-buffer עד שה-chunk הבא מגיע; אחרת תפרסרו את אותה חצי-רשומה פעמיים.
  • ספרו כשלים במקום לעצור. ה-RFC אומר למפרסרים לדלג ל-RS הבא ולהמשיך, ולכן badCount הוא מדד אזהרה, לא חריגה. רשומה אחרונה שנחתכה גם היא שייכת לדלי הזה.

השימוש בזיכרון תלוי ברשומה הבודדת הגדולה ביותר בתוספת buffer אחד, לא בגודל הקובץ הכולל. זהו אותו רעיון שנדון ב-פרסור וזרימה של JSON גדול.

5. תווי בקרה בתוך מחרוזות

JSON דורש שכל תו בקרה מ-U+0000 עד U+001F יהיה מהומלט בתוך מחרוזת, וה-RS נופל בטווח הזה. מחרוזת JSON תקינה יכולה לכן להכיל רק את חמשת התווים \u001e, ולעולם לא בית 0x1E גולמי. זו הסיבה שפיצול לפי 0x1E תמיד בטוח לנתונים תקינים.

הסכנה נמצאת בצד הכותב:

  1. בנייה ביד של טקסט JSON עם '\x1e', שמכניסה תו בקרה גולמי לתוך מחרוזת;
  2. בניית JSON עם template literals במקום 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;
  • כדי לזרום באמת, השרת חייב להשתמש בהעברה ב-chunks ולהשבית buffering של תגובה בתווכים כמו reverse proxies, CDNs וכלי בדיקות עומס;
  • לקוח שקורא chunk אחר chunk עלול לקבל chunk שמסתיים באמצע רשומה, ולכן המפרסר חייב לאגור עד ל-RS הבא לפני פליטת רשומה.

אם הצד השני מבין רק קלט מופרד שורות, אל תכפו עליו JSON-seq. זהו סטנדרט, אבל חדירת האקוסיסטם שלו נמוכה בהרבה מ-NDJSON, ולכן תאימות צריכה להיקבע במשא ומתן על הפרוטוקול ולא אחרי כשל פרסור.

7. שורת פקודה, לוגים ותמיכת כלים

jq הוא אחד מהכלים הבודדים עם תמיכה מקורית; הדגל --seq שלו קיים בדיוק בשביל RFC 7464:

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

זה מדפיס 1 ו-2 בשורות נפרדות. לנקודת קצה בזרימה, זכרו להשבית את ה-buffering של curl:

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

jq --seq קורא קלט RFC 7464, מתעלם מרשומות פגומות וממשיך לפרסר. זה שימושי במיוחד בזמן tail לקובץ לוג שעדיין נכתב: הרשומה האחרונה אולי חצי-כתובה, והפלט לא יציג אובייקט מזויף בסוף.

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;
  • [ ] הקורא מפצל לפי בתים במקום לקרוא שורה אחר שורה;
  • [ ] קריאות בזרימה משאירות זנב לא סגור ב-buffer עד שמגיע ה-RS הבא;
  • [ ] כשלי פרסור נספרים ומדולגים, ולעולם לא קטלניים לרצף;
  • [ ] ממשקים ציבוריים מצהירים במפורש על application/json-seq ושומרים fallback של NDJSON.

FAQ

כדאי לאמץ JSON-seq לנקודת קצה של API ציבורי?

רק כששני הקצוות ניתנים לשינוי או כשהפורמט מסוכם מראש. JSON-seq הוא סטנדרט רשום עם סוג המדיה application/json-seq, אבל חדירת האקוסיסטם שלו נמוכה בהרבה מ-NDJSON, ולכן כפייתו על לקוח צד שלישי בדרך כלל נכשלת. החליטו על תאימות במשא ומתן על הפרוטוקול ושמרו fallback של NDJSON.

כדאי להמיר צינור NDJSON קיים ל-JSON-seq?

בדרך כלל לא. NDJSON כבר זורם, מוסיף ומדלג על שורות פגומות, וכמעט כל כלי לוגים וביג-דאטה מקבל אותו. המירו רק כשצריך את סוג המדיה הרשום ב-IANA או את הסנכרון מחדש שמפריד מוביל מספק, ורק כשאפשר לשנות את המפיק ואת הצרכן יחד.

מה נפוץ יותר, JSON Text Sequences או 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(), לצבור chunks עד ל-0x1E הבא, ולקרוא ל-JSON.parse על כל קטע. למטענים קטנים, קריאת כל הגוף כמחרוזת קודם ופיצול אחר כך גם עובדת.

למה מספר ברמה העליונה צריך LF בסוף?

אובייקטים, מערכים ומחרוזות מגבילים את עצמם ב-JSON, בעוד שמספרים לא: <RS>123<RS> יכול להיות 123 שלם או 1234 שנחתך. RFC 7464 לכן דורש בית רווח אחרי כל רשומה, וה-LF משמש כברית קנרית לזיהוי חיתוך. אותו כלל חל על true, false ו-null ברמה העליונה.