Последовательности текста JSON (RFC 7464): потоковый JSON с разделителем RS
Главное: RFC 7464 использует разделитель 0x1E для потоковой передачи текстов JSON, которые можно читать и писать сразу. Рассматривает правила, отличие от NDJSON и реализацию «разделить, затем разобрать».

Главное: используйте 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 между записями необязателен |
В инженерных терминах есть пять жёстких правил:
- 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.
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 всегда безопасно для валидных данных.
Опасность живёт на стороне писателя:
- Сборка текста JSON вручную через
'\x1e', вставляя сырой управляющий символ в строку; - Сборка JSON через шаблонные литералы вместо
JSON.stringify, оставляя переводы строк, кавычки и управляющие символы неэкранированными; - Копирование текста, содержащего 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 Частые ловушки
Ошибки кластеризуются на стороне чтения:
- Чтение через
readline, которое оставляет невидимый префикс RS на первой строке, поэтомуJSON.parseвсегда падает; - Открытие файла
.json-seqв редакторе, где управляющие символы невидимы, поэтому он выглядит как один странный документ JSON; - Разделение через
split(/\n/), которое смешивает экранированные переводы строк внутри записи с LF между записями; - Трактовка 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 верхнего уровня.