本章导读

第 32/33 章解决的是"一个文档怎么进出值树";工程里还有第二种同样常见的形态——"一串记录":服务日志一行一条事件、批量导入一行一条数据、进程间交换一行一条消息。JSONL(JSON Lines)把每行变成一个完整 JSON 值,XLONL 把每行变成一个完整 XLON 值——多行合起来是一个 xvalue Array,每行单独可解析、可定位、可跳过。它不是新解析器:单条记录的编解码规则、严格性与错误域原样复用第 32/33 章,新增的只有三件事——行分帧语义、跨记录的累计预算、把记录内错误位置换算成全局行号与记录下标。什么时候用"一个大文档"、什么时候用"一串记录",本章给出选型规则。

34.1引入

三个场景都在说同一件事:数据的自然单位是行,不是文档。服务日志:每个请求处理完追加一行事件——回放统计时逐行解析,坏行跳过并记下位置,绝不能因为第 4096 行多了个逗号丢掉整个文件。批量导入:千万条记录一行一条——第 32 章的 DOM 把整个文件变一棵树(内存翻几倍),SAX 能流式但没有"第几条记录"的概念;逐行格式让"解析到第 N 条失败"成为一等公民。跨进程交换:内部管道按行投递消息——JSON 行对外通用,XLON 行(第 33 章的 bytes/time/set/intmap)对内全类型往返。三种宿主共用同一个 API 面:读成一个 Array、写成一串行、错误带行号——分帧是格式的事,语义还是值树的语义。

34.2概念

行分帧:什么算一条记录

1分隔LF 一条;CRLF 计一条(CR 属于行尾不属记录)
2空白行空行 + 仅含 ASCII 空格/Tab/CR 的行 → 默认跳过(可改为报错)
3尾行最后一条完整记录允许没有换行
4不是分隔单独 CR / Unicode 空白(NBSP 不构成空行)
5一行 = 恰好一个完整根值跨行值、一行多根、BOM、残缺尾记录 → 失败

五条规则里最值得记住的是最后一条:一行必须恰好是一个完整值。这是"记录"语义的来源——行既是分帧边界也是校验边界,{"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 MiB 的配置对 MaxInputBytes 而言就是 1 MiB)。默认值沿用全库惯例:整体 64 MiB、记录数与语法值数 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/脚本/异构客户端都能逐行消费
内部日志与管道XLONLbytes/time/set 原样落行,回放零转换
一份配置、整体加载第 32/33 章文档单文档语义(重复键策略、SAX/Writer 全路径)
单条巨大(>几 MiB)文档 + 流式逐行格式的预算按"行"收紧时会被单条顶爆

选型变量与第 33 章同构:接收方可控 → XLONL 全类型;不可控 → JSONL 互操作;数据本来是"一个配置"就别拆成行,本来是"一串事件"就别硬塞进一个大数组。

34.3示例

完整程序:JSONL 往返与错误定位

来自仓库范例 examples/data/jsonl/main.c——解析、校验、读回、回调写出、文件原子读写与空行定位一次走完:

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;
}

$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/data/jsonl/main.c -lws2_32 -liphlpapiJSONL: 3 records, 20 bytesempty line: line=2 record=1

刚才发生了什么。 ① 分帧实证:输入混合了空行与 CRLF/LF——3 条记录(对象、数组、null)一条不少,20 字节是紧凑输出的实际大小(每条含 LF)。② 空行策略:默认跳过空行后 Valid/Read 全绿;把 XJSONL_READ_REJECT_EMPTY_LINES 打开,"{}\n\n" 立刻在第二条记录前的空行处失败。③ 全局定位:错误位置给出 line=2 record=1——空行在第 2 行、它截断的是第 1 条记录之后的位置;调用方拿到的是"改哪一行"而不是一段字节区间。④ 文件闭环:StringifyFile 原子替换 → ParseFile 读回 → WriteFile(带配置)再替换 → ReadFile 再读回——失败路径任一步出错原文件都保持上次完整状态。

完整程序:XLONL 逐行读写

来自 examples/data/xlonl/main.c——同一套分帧与预算机器,单条规则换成 XLON:

examples/data/xlonl/main.c源码 ↗











 */
#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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/data/xlonl/main.c -lws2_32 -liphlpapiXLONL: 3 records, 20 bytesempty line: line=2 record=1

刚才发生了什么。 流程与 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 MiB)——防"每行一小段、累计一大坨"的解码炸弹。

34.4契约

  • 一行一值:每行恰好一个完整根值;字符串内转义换行不分帧;最后一条可无换行。
  • 失败原子性:读侧不返回部分 Array(失败释放全部部分结果);文件写先序列化后原子替换,失败不动原文件。
  • 预算口径:整体输入/输出含分隔符与空白行;单条不含;输出含每条 LF——三处口径都是承诺不是实现细节。
  • 借用与所有权:输入文本与配置借用到调用返回;结果 Array 用 xrtValueRelease 释放,字符串用 xrtFree 释放;写出不消费调用方的引用(backing 快照迭代)。
  • 线程:API 无隐式锁,每次调用独立工作区;同一可变输入由调用方同步。
  • 已提交字节不撤回:同步回调写出在失败前已交付的记录字节无法回收——见坑 2。

从示例到工程:逐行数据的三个宿主

服务日志(最常见):写侧每个事件 Stringify 一行追加或 Write 直连 sink;读侧 Parse 成 Array 做离线统计,坏行用 ErrorLocation 记进审计日志继续跑。批量导入:Read 一次拿到全部合法记录(失败即整体失败、报告到行),或按块切文本逐块 Parse 实现"跳过坏块继续导入"——两种语义都建立在"行即边界"上。进程间行流:与第 33 章选型规则同构——对外 JSONL、对内 XLONL;行流天然匹配文本管道(grep/重定向/逐行网关),这是文档格式给不了的运维性质。

一个对照:同一批事件的两种落盘

一批请求日志可以落成一个大 JSON 数组(第 32 章)或 JSONL。数组形态:追加要读旧文件—解析—append—序列化—写回(或尾部补 ] 的脆弱技巧);坏了任何一处,整个文件不可解析;流式消费必须等文档闭合。JSONL 形态:追加是纯 AppendFile;单行损坏只污染自己;tail -f 边产边消费。对照的要点:追加写与部分损坏容忍是记录流的本质优势——只要数据是"只增不改的事件序列",逐行格式就是正确形态;反过来,需要整体读取、字段间强关联的配置,仍然是文档的地盘。

常见错误

坑 1:以为 SKIP 策略能跳过"编不出去"的记录

症状:写出时一批记录里混进一个 JSON 表达不了的值(比如函数句柄),期望"跳过这条继续写",结果整个 Stringify 失败。

原因:SKIP 策略只作用于单条根值内部的成员;根值本身不可表达时整条记录失败——这是"每行必须恰好一个完整值"的写出侧镜像,防止产出物理空行破坏分帧。

错误写法
/* 值树里混入了不可 JSON 表达的根值,指望 SKIP 跳过整条 */
xrtJsonlStringify(pArray, &Size);   /* 整体失败:根值不可表达不能跳 */
正确写法
/* 写前过滤:把不可表达的记录摘出去,或按约定降级成占位对象 */
/* 对外通道全 JSON 类型;内部通道直接用 XLONL——扩展类型原生可表达 */
str Text = xrtXlonlStringify(pArray, &Size);
坑 2:以为回调写出的失败能"回滚"已写内容

症状:Write 回调写到一半目标满了返回 false,调用方以为文件/流恢复到写前状态继续重试——结果日志里出现了半批记录。

原因:同步回调是流式交付——字节在回调返回 true 的那一刻就已提交给对端(文件、socket、管道),库侧没有任何"撤回"通道;这是承诺语义(契约第 6 条),不是缺陷。

错误写法
/* 失败后原地重试,把已提交的半批记录又写了一遍 */
while ( !xrtJsonlWrite(pArray, &Write, sink, Ctx) )
    ;   /* 已提交字节无法撤回——重试 = 重复记录 */
正确写法
 * 恢复时从该条之后重放;或改用文件形态(原子替换,天然全有或全无) */
if ( !xrtJsonlWriteFile(Path, pArray, &Write) ) { /* 原文件未动,修数据后重来 */ }

编程练习

基础:坏行审计器

读一个 JSONL 文件成 Array;把含语法错误的行号、记录下标与字节偏移打印成审计表(提示:按行切文本逐行 xrtJsonValid,或用严格空行模式 + ErrorLocation)。验收:构造 10 行含 2 处错误的文件,报告恰好指向两处。

进阶:JSONL ⇄ 大数组转换器

实现双向转换:JSONL 文件 → 单个 JSON 数组文档;JSON 数组文档 → JSONL。要求用累计预算控制内存(整体上限可配置)、转换错误报告到行/元素下标。验收:Valid 两方向都通过;往返(转过去再转回来)与原文件按记录逐条相等。

挑战:断点续传的流式导入器

实现"跳过坏块继续导入"的管道:按 N 行切块逐块 Parse,坏块降级为逐行 Parse 精确定位坏行并跳过;进度(已导入记录数)落盘支持断点续传,重跑从断点继续。验收:手工构造含 3 处分散错误的 10000 行文件,一次跑完导入 9997 条、报告 3 行错误,二次重跑零重复。

小结

知识点速查
分帧一行一值;LF/CRLF 皆可;空行默认跳过;尾行可无换行;孤 CR/Unicode 空白不是分隔
预算整体含分隔符与空白行、输出含 LF;单条不含分隔符;默认 64 MiB / 1000000 条
定位零基偏移 + 一基行列 + 零基记录下标;空行错误指向下一待接收记录
读全成或全败(不返回部分 Array);Valid 无 DOM 校验不参与重复键策略
写紧凑 + LF(PRETTY 拒绝);回调流式 / 文件原子替换;已提交字节不可撤回
选型事件流→逐行(外 JSONL / 内 XLONL);配置→第 32/33 章文档
XLONL 差量单条=XLON 规则;MaxTotalDecodedBytes 管内建 bytes 解码总量

📖 本章涉及的 API 参考手册:jsonl(17 个条目)xlonl(17 个条目)

点击查看完整签名、参数约束、返回值与错误说明