Ориентация главы
Главы 32 и 33 решили, как «один документ» входит и выходит из дерева значений; в инженерии не менее распространена вторая форма — «последовательность записей»: серверные журналы с одним событием на строку, массовый импорт по записи на строку, обмен между процессами по сообщению на строку. JSONL (JSON Lines) превращает каждую строку в одно полное значение JSON; XLONL — в одно полное значение XLON — много строк вместе образуют Array из xvalue, при этом каждая строка отдельно разбирается, локализуется и пропускается. Это не новый парсер: правила кодирования отдельных записей, строгость и домены ошибок дословно наследуются из глав 32/33; добавляются ровно три вещи — семантика кадрирования строк, накопительные бюджеты между записями и пересчёт позиции ошибки внутри записи в глобальный номер строки и индекс записи. Когда нужен «один большой документ», а когда «последовательность записей» — эта глава даёт правила выбора.
34.1引入
Три сценария говорят об одном: естественная единица данных — строка, а не документ. Серверный журнал: каждый обработанный запрос дописывает строку события — при воспроизведении и статистике разбор идёт построчно, плохие строки пропускаются с записью позиции, и лишняя запятая в строке 4096 не должна выбросить весь файл. Массовый импорт: миллионы записей, по одной на строку — DOM главы 32 превращает весь файл в одно дерево (память возрастает в разы), SAX потоковый, но не знает понятия «запись N»; построчный формат делает «разбор не удался на записи N» гражданином первого сорта. Обмен между процессами: внутренние каналы доставляют сообщения построчно — JSON-строки универсальны наружу, XLON-строки (bytes/time/set/intmap главы 33) дают полный круг типов внутри. Три хозяина используют один набор API: прочитать в один Array, записать как последовательность строк, ошибки с номерами строк — кадрирование — дело формата, семантика остаётся семантикой дерева значений.
34.2概念
Кадрирование строк: что считается записью
Правило, которое стоит запомнить, — последнее: строка обязана содержать ровно одно полное значение. Отсюда и семантика «записи»: строка — одновременно граница кадрирования и граница проверки; {"id":1} {"id":2} в одной строке — ошибка (корневых значений больше одного), {"id":\n1} на двух строках — тоже (ни одного полного корня). Экранированный \n внутри строки не участвует в кадрировании — "{\"a\":1}\n" — законная строковая запись.
Двухуровневые бюджеты: запись наследует, поток суммирует
| Уровень | Бюджет | Смысл |
|---|---|---|
| Запись (Record) | MaxInputBytes / MaxOutputBytes / глубина | Дословно как в главах 32/33; без разделителей CRLF/LF |
| Весь ввод | MaxInputBytes | Включая пропущенные пустые строки и все разделители |
| Весь вывод | MaxOutputBytes | Включая каждый дописанный LF; без завершающего NUL |
| Число записей | MaxRecords | Непустые записи = верхняя граница элементов Array |
| Синтаксические значения | MaxTotalValues | Накопительно по всем записям; включая отброшенные политикой повторных ключей; без синтезируемого Array |
| Декодированные байты (только XLONL) | MaxTotalDecodedBytes | Накопительное декодирование встроенных тегов bytes; проверка перед выделением |
Две точки, на которых чаще всего спотыкаются: бюджет всего вывода считает LF (при MaxOutputBytes=1 первая запись "1" фиксируется, а её LF уже даёт LIMIT); бюджет записи не считает разделители (строка длиной до 1 МиБ — это 1 МиБ для MaxInputBytes). Значения по умолчанию следуют обще-библиотечной конвенции: 64 МиБ целиком, 1000000 записей и синтаксических значений — для недоверенного ввода сначала ужесточьте.
Глобальная позиция ошибки: три координаты и один индекс
Ошибка внутри записи (главы 32/33) несёт строку/столбец внутри записи; построчный формат пересчитывает её в глобальные координаты — xrtJsonlErrorLocation возвращает смещение в байтах с нуля, физический номер строки с единицы, байтовый столбец с единицы и индекс записи с нуля. Ошибка пустой строки в строгом режиме указывает на индекс «следующей ожидаемой записи»; пропущенные пустые строки всё равно увеличивают физический номер строки — line=2 record=1 значит «строка 2 пустая, а должна была быть записью 1». Цепочка причин (Cause) сохраняет конкретную причину и Kind разбора записи; обёртка добавляет оболочку только в RECORD/OUTPUT/IO.
Запись: две формы — обратный вызов и файл
Сериализация пишет каждый элемент Array одной компактной строкой: форма с обратным вызовом (xrtJsonlWrite) отдаёт куски sink-у, заимствованные байты действительны только внутри вызова — для потоковой передачи прямо в сеть; файловая форма (xrtJsonlWriteFile) сначала полностью сериализует в памяти и затем атомарно заменяет целевой файл — при ошибке сериализации исходный файл остаётся нетронутым. Общая семантика обеих форм: компактно + LF (PRETTY отвергается), корень обязан быть XVALUE_ARRAY, конфигурация снимается по значению (изменение конфигурации в обратном вызове не влияет на текущий вызов).
JSONL, XLONL или без построчного формата
| Сценарий | Выбор | Причина |
|---|---|---|
| Внешние журналы / открытые данные | JSONL | Любой jq/скрипт/чужой клиент читает построчно |
| Внутренние журналы и каналы | XLONL | bytes/time/set ложатся в строки как есть; воспроизведение без конверсий |
| Одна конфигурация, целиком | Документы глав 32/33 | Документная семантика (политика повторных ключей, полные пути SAX/Writer) |
| Единичные гигантские записи (>неск. МиБ) | Документ + поток | Построчные бюджеты при ужесточении «на строку» лопаются от одной записи |
Переменная выбора изоморфна главе 33: приёмник под контролем → XLONL с полными типами; вне контроля → JSONL для совместимости; если данные — «одна конфигурация», не дробите их на строки; если «поток событий» — не запихивайте в один большой массив.
34.3示例
Полная программа: круг JSONL и позиционирование ошибок
Из примера репозитория examples/data/jsonl/main.c — разбор, валидация, чтение, запись через обратный вызов, атомарный файловый круг и локализация пустой строки за один заход:
*/ #include <stdio.h> #include <xrt.h> static bool discard(xbytesview Data, ptr pUserData) { (void)Data; (void)pUserData; return true; } int main(void) { xjsonlreadconfig Read; xjsonlwriteconfig Write; xjsonllocation Location; xvalue* pArray = NULL; xvalue* pRead = NULL; str Text = NULL; size_t Size = 0; cstr Path = ".xrt-jsonl-example.jsonl"; bool Created = false; int Result = 1; xrtJsonlReadConfigInit(&Read); xrtJsonlWriteConfigInit(&Write); pArray = xrtJsonlParse(XRT_STR_LITERAL("{\"id\":1}\n\n[2,3]\r\nnull\n")); if ( pArray == NULL ) goto done; Text = xrtJsonlStringify(pArray, &Size); if ( Text == NULL ) goto done; if ( !xrtJsonlValid((xstrview){ Text, Size }) ) goto done; pRead = xrtJsonlRead((xstrview){ Text, Size }, &Read); if ( pRead == NULL ) goto done; printf("JSONL: %zu records, %zu bytes\n", xrtValueCount(pRead), Size); xrtValueRelease(pRead); pRead = NULL; if ( !xrtJsonlWrite(pArray, &Write, discard, NULL) ) goto done; if ( !xrtJsonlStringifyFile(Path, pArray) ) goto done; Created = true; pRead = xrtJsonlParseFile(Path); if ( pRead == NULL ) goto done; xrtValueRelease(pRead); pRead = NULL; if ( !xrtJsonlWriteFile(Path, pArray, &Write) ) goto done; pRead = xrtJsonlReadFile(Path, &Read); if ( pRead == NULL ) goto done; xrtValueRelease(pRead); pRead = NULL; Read.Flags |= XJSONL_READ_REJECT_EMPTY_LINES; pRead = xrtJsonlRead(XRT_STR_LITERAL("{}\n\n"), &Read); if ( pRead != NULL ) goto done; if ( !xrtJsonlErrorLocation(xrtGetError(), &Location) ) goto done; printf("empty line: line=%zu record=%zu\n", Location.Line, Location.RecordIndex); xrtClearError(); Result = 0; done: xrtValueRelease(pRead); xrtValueRelease(pArray); xrtFree(Text); if ( Created && !xrtFileDelete(Path) ) Result = 1; return Result; }
Что только что произошло. (1) Кадрирование на практике: вход смешивает пустые строки с CRLF/LF — все 3 записи (объект, массив, null) на месте, 20 байт — фактический размер компактного вывода (с LF на запись). (2) Политика пустых строк: при пропуске по умолчанию Valid/Read зелёные; включите XJSONL_READ_REJECT_EMPTY_LINES — и "{}\n\n" сразу падает на пустой строке перед второй записью. (3) Глобальная позиция: ошибка даёт line=2 record=1 — пустая строка во второй строке, она разрезает позицию после первой записи; вызывающий получает «какую строку править», а не диапазон байтов. (4) Файловый контур: StringifyFile атомарно заменяет → ParseFile читает → WriteFile (с конфигурацией) заменяет снова → ReadFile читает ещё раз — любая неудача оставляет файл в последнем полном состоянии.
Полная программа: построчная запись и чтение XLONL
Из examples/data/xlonl/main.c — та же машина кадрирования и бюджетов, правила отдельной записи заменены на XLON:
*/ #include <stdio.h> #include <xrt.h> static bool discard(xbytesview Data, ptr pUserData) { (void)Data; (void)pUserData; return true; } int main(void) { xxlonlreadconfig Read; xxlonlwriteconfig Write; xxlonllocation Location; xvalue* pArray = NULL; xvalue* pRead = NULL; str Text = NULL; size_t Size = 0; cstr Path = ".xrt-xlonl-example.xlonl"; bool Created = false; int Result = 1; xrtXlonlReadConfigInit(&Read); xrtXlonlWriteConfigInit(&Write); pArray = xrtXlonlParse(XRT_STR_LITERAL("{\"id\":1}\n\n[2,3]\r\nnull\n")); if ( pArray == NULL ) goto done; Text = xrtXlonlStringify(pArray, &Size); if ( Text == NULL ) goto done; if ( !xrtXlonlValid((xstrview){ Text, Size }) ) goto done; pRead = xrtXlonlRead((xstrview){ Text, Size }, &Read); if ( pRead == NULL ) goto done; printf("XLONL: %zu records, %zu bytes\n", xrtValueCount(pRead), Size); xrtValueRelease(pRead); pRead = NULL; if ( !xrtXlonlWrite(pArray, &Write, discard, NULL) ) goto done; if ( !xrtXlonlStringifyFile(Path, pArray) ) goto done; Created = true; pRead = xrtXlonlParseFile(Path); if ( pRead == NULL ) goto done; xrtValueRelease(pRead); pRead = NULL; if ( !xrtXlonlWriteFile(Path, pArray, &Write) ) goto done; pRead = xrtXlonlReadFile(Path, &Read); if ( pRead == NULL ) goto done; xrtValueRelease(pRead); pRead = NULL; Read.Flags |= XXLONL_READ_REJECT_EMPTY_LINES; pRead = xrtXlonlRead(XRT_STR_LITERAL("{}\n\n"), &Read); if ( pRead != NULL ) goto done; if ( !xrtXlonlErrorLocation(xrtGetError(), &Location) ) goto done; printf("empty line: line=%zu record=%zu\n", Location.Line, Location.RecordIndex); xrtClearError(); Result = 0; done: xrtValueRelease(pRead); xrtValueRelease(pArray); xrtFree(Text); if ( Created && !xrtFileDelete(Path) ) Result = 1; return Result; }
Что только что произошло. Поток построчно изоморфен примеру jsonl — это не лень, а зримое исполнение проектного обещания: смени формат — кадрирование, структура бюджетов и локализация ошибок не меняются, формы API соответствуют один к одному. Разница — в семантике отдельной записи: каждая строка здесь — полное значение XLON, и четыре расширенных типа главы 33 входят в построчный поток напрямую —
/* Полнотипные записи в потоке XLONL: после разбора каждая занимает своё место в дереве */ xvalue* pLog = xrtXlonlParse(XRT_STR_LITERAL( "{\"key\":bytes(\"AAEC/w==\"),\"at\":time(\"2026-07-31T00:00:00Z\")}\n" "set[80, 443]\n")); /* pLog[0] — объект (поля Bytes/Time), pLog[1] — контейнер set; построчное воспроизведение без конверсий */
Когда встроенному тегу bytes нужен отдельный суммарный предел, MaxTotalDecodedBytes перехватывает до выделения буфера (по умолчанию 64 МиБ) — против дешифровальной бомбы «по чуть-чуть в каждой строке, а в сумме гора».
34.4契约
- Одно значение на строку: каждая строка — ровно одно полное корневое значение; экранированные переводы внутри строк не кадрируют; последняя запись может быть без перевода.
- Атомарность неудачи: чтение не возвращает частичный Array (при ошибке освобождаются все частичные результаты); файловая запись сначала сериализует целиком, затем атомарно заменяет — ошибка не трогает оригинал.
- Смыслы бюджетов: весь ввод/вывод включают разделители и пустые строки; отдельная запись — нет; вывод включает каждый LF — все три смысла — обещания, а не детали реализации.
- Заимствование и владение: текст ввода и конфигурация заимствуются до возврата вызова; результат Array освобождается через
xrtValueRelease, строка — черезxrtFree; запись не потребляет ссылки вызывающего (итерация по снимку backing). - Потоки: без неявных блокировок; каждый вызов владеет независимым рабочим пространством; общий изменяемый ввод синхронизирует вызывающий.
- Зафиксированные байты не откатываются: байты записей, уже отданные до неудачи синхронного обратного вызова, вернуть нельзя — см. ловушку 2.
От примеров к инженерии: три хозяина построчных данных
Серверный журнал (самое частое): писатель на каждое событие пишет строку через Stringify и дописывает её или отдаёт через Write прямо в sink; читатель через Parse разбирает в Array для офлайн-статистики, плохие строки через ErrorLocation уходят в аудит-журнал, и поток продолжается. Массовый импорт: Read отдаёт все законные записи разом (любая ошибка — неудача целиком с отчётом по строкам), либо текст режется на куски и разбирается покусочно через Parse — «плохой кусок пропусти, импорт продолжь»; обе семантики стоят на «строка — граница». Межпроцессные потоки: изоморфно правилам главы 33 — наружу JSONL, внутрь XLONL; построчные потоки естественно ложатся на текстовые конвейеры (grep, перенаправление, построчные шлюзы) — это операционное свойство, которого документные форматы не дают.
Контраст: одна и та же пачка событий — два способа на диск
Пачку журналов запросов можно положить одним большим JSON-массивом (глава 32) или JSONL. Массив: дописывание — это прочитать старое, разобрать, добавить, сериализовать, записать (или хрупкий трюк с заплаткой на хвостовом ]); одна порча — и весь файл не разбирается; потоковому потребителю нужно ждать закрытия документа. JSONL: дописывание — обычный AppendFile; испорченная строка вредит только себе; tail -f потребляет по мере поступления. Суть контраста: дописывающая запись и терпимость к частичным порчам — сущностные преимущества потоков записей — пока данные остаются «только растущей последовательностью событий», построчный формат и есть правильная форма; наоборот, конфигурации, требующие цельного чтения и сильных связей полей, остаются территорией документов.
Типичные ошибки
Симптом: при записи в пачке встречается значение, которое JSON не может выразить (например, дескриптор функции); вы ждёте «эту пропустить и продолжить», а падает весь вызов Stringify.
Причина: SKIP действует только на члены внутри одного корневого значения; непредставимый корень валит запись целиком — это зеркальная сторона «одна строка = ровно одно полное значение», защищающая от физических пустых строк, ломающих кадрирование.
/* В дереве затесалось корневое значение вне выразимости формата; надеемся на SKIP */ xrtJsonlStringify(pArray, &Size); /* неудача целиком: непредставимый корень не пропустить */
/* Фильтр до записи: убрать непредставимые записи или деградировать их в объекты-заполнители по конвенции */ /* Внешний канал — только JSON-типы; внутренний канал — сразу XLONL: расширенные типы выразимы нативно */ str Text = xrtXlonlStringify(pArray, &Size);
Симптом: обратный вызов Write на середине упирается в заполненную цель и возвращает false; вызывающий считает, что файл/поток восстановлен до состояния до записи, и повторяет на месте — в журнале появляется половина пачки.
Причина: синхронный обратный вызов — это потоковая доставка: байты фиксируются на дальней стороне (файл, сокет, пайп) в момент возврата true, и «канала отзыва» у библиотеки нет; это обещанная семантика (пункт 6 контракта), а не дефект.
/* Повтор на месте: зафиксированная половина пачки записывается ещё раз */ while ( !xrtJsonlWrite(pArray, &Write, sink, Ctx) ) ; /* зафиксированные байты не отозвать — повтор = дубли записей */
* или файловая форма (атомарная замена: всё или ничего по построению) */ if ( !xrtJsonlWriteFile(Path, pArray, &Write) ) { /* оригинал не тронут; починить данные и начать заново */ }
Практические задания
Прочитать файл JSONL в Array; вывести таблицу аудита синтаксически ошибочных строк: номер строки, индекс записи и смещение в байтах (подсказка: резать текст на строки и прогонять xrtJsonValid по каждой, либо строгий режим пустых строк плюс ErrorLocation). Приёмка: на файле из 10 строк с 2 подложенными ошибками отчёт указывает ровно на них.
Реализовать оба направления: файл JSONL → один документ-массив JSON; документ-массив → JSONL. Накопительные бюджеты обязаны ограничивать память (общий предел настраивается), ошибки конвертации сообщаются по индексу строки/элемента. Приёмка: Valid зелёный в обе стороны; круговой проход совпадает с исходным файлом запись к записи.
Собрать конвейер «плохой кусок пропусти — импорт продолжи»: разбор через Parse кусками по N строк; плохой кусок деградирует в построчный Parse с точным пропуском плохой строки; прогресс (число импортированных записей) сохраняется на диск для докачки без дублей. Приёмка: на файле 10 000 строк с 3 разбросанными ошибками один прогон импортирует 9 997 записей и рапортует 3 строки; второй прогон — ноль повторов.
Итоги
| Точка | Шпаргалка |
|---|---|
| Кадрирование | Одно значение на строку; LF/CRLF равноправны; пустые строки пропускаются по умолчанию; последняя строка может быть без перевода; одиночный CR/пробелы Юникода — не разделители |
| Бюджеты | Весь поток включает разделители и пустые строки; вывод включает LF; запись — без разделителей; по умолчанию 64 МиБ / 1 000 000 записей |
| Позиция | Смещение с нуля + строка/столбец с единицы + индекс записи с нуля; ошибка пустой строки указывает на следующую ожидаемую запись |
| Чтение | Всё или ничего (без частичного Array); Valid — без DOM, без политики повторных ключей |
| Запись | Компактно + LF (PRETTY отвергается); потоковый обратный вызов / атомарная замена файла; зафиксированные байты не откатываются |
| Выбор | Потоки событий → построчный формат (наружу JSONL / внутрь XLONL); конфигурации → документы глав 32/33 |
| Дельта XLONL | Запись = правила XLON; MaxTotalDecodedBytes ограничивает суммарное декодирование встроенных bytes |
📖 Справочники API в этой главе:jsonl (17 записей)xlonl (17 записей)
Полные сигнатуры, ограничения параметров, возвращаемые значения и ошибки