本章导读
第 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概念
行分帧:什么算一条记录
五条规则里最值得记住的是最后一条:一行必须恰好是一个完整值。这是"记录"语义的来源——行既是分帧边界也是校验边界,{"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/脚本/异构客户端都能逐行消费 |
| 内部日志与管道 | XLONL | bytes/time/set 原样落行,回放零转换 |
| 一份配置、整体加载 | 第 32/33 章文档 | 单文档语义(重复键策略、SAX/Writer 全路径) |
| 单条巨大(>几 MiB) | 文档 + 流式 | 逐行格式的预算按"行"收紧时会被单条顶爆 |
选型变量与第 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; }
刚才发生了什么。 ① 分帧实证:输入混合了空行与 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:
*/ #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 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 边产边消费。对照的要点:追加写与部分损坏容忍是记录流的本质优势——只要数据是"只增不改的事件序列",逐行格式就是正确形态;反过来,需要整体读取、字段间强关联的配置,仍然是文档的地盘。
常见错误
症状:写出时一批记录里混进一个 JSON 表达不了的值(比如函数句柄),期望"跳过这条继续写",结果整个 Stringify 失败。
原因:SKIP 策略只作用于单条根值内部的成员;根值本身不可表达时整条记录失败——这是"每行必须恰好一个完整值"的写出侧镜像,防止产出物理空行破坏分帧。
/* 值树里混入了不可 JSON 表达的根值,指望 SKIP 跳过整条 */ xrtJsonlStringify(pArray, &Size); /* 整体失败:根值不可表达不能跳 */
/* 写前过滤:把不可表达的记录摘出去,或按约定降级成占位对象 */ /* 对外通道全 JSON 类型;内部通道直接用 XLONL——扩展类型原生可表达 */ str Text = xrtXlonlStringify(pArray, &Size);
症状:Write 回调写到一半目标满了返回 false,调用方以为文件/流恢复到写前状态继续重试——结果日志里出现了半批记录。
原因:同步回调是流式交付——字节在回调返回 true 的那一刻就已提交给对端(文件、socket、管道),库侧没有任何"撤回"通道;这是承诺语义(契约第 6 条),不是缺陷。
/* 失败后原地重试,把已提交的半批记录又写了一遍 */ while ( !xrtJsonlWrite(pArray, &Write, sink, Ctx) ) ; /* 已提交字节无法撤回——重试 = 重复记录 */
* 恢复时从该条之后重放;或改用文件形态(原子替换,天然全有或全无) */ if ( !xrtJsonlWriteFile(Path, pArray, &Write) ) { /* 原文件未动,修数据后重来 */ }
编程练习
读一个 JSONL 文件成 Array;把含语法错误的行号、记录下标与字节偏移打印成审计表(提示:按行切文本逐行 xrtJsonValid,或用严格空行模式 + ErrorLocation)。验收:构造 10 行含 2 处错误的文件,报告恰好指向两处。
实现双向转换: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 个条目)
点击查看完整签名、参数约束、返回值与错误说明