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 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 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 не може розрізати багатобайтовий символ навпіл.
  • Буферизуйте хвіст під час потокової обробки. Читаючи чанками, останній сегмент може ще не досягти наступного 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;
  • Щоб справді потоково передавати, сервер має використовувати chunked transfer і вимкнути буферизацію відповіді в посередниках, як-от зворотні проксі, 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 верхнього рівня.