在 JSON、YAML、CSV 和 XML 之间转换会丢掉什么

JSON、YAML、CSV、XML 和 TOML 在注释、类型、嵌套、日期和键顺序上有何不同,以及它们之间的每一种转换会悄悄丢掉其中哪些细节。

数据格式之间的每一次转换,都会丢掉源格式能表达而目标格式不能表达的东西。有些损失很显眼:YAML 变成 JSON 的那一刻,注释就消失了。多数损失是悄无声息的。一个日期变成了字符串,一个数组变成了五列,一个以 3 结尾的 ID 现在以 2 结尾。有用的习惯是:在某个细节丢掉之前,就知道它会丢。

每种格式能承载什么

注释类型嵌套日期键顺序重复键
JSON不支持string、number、boolean、null、object、array支持没有原生类型规范上无序,多数解析器会保留未定义,通常最后一个胜出
YAML支持,用 #JSON 的那些,1.1 里还加上时间戳等支持1.1 里支持,1.2 里取决于标签文件里保留,解析之后不保留不合法,但常被接受
CSV不支持没有类型,每个单元格都是文本不支持,只有扁平的行不支持由表头行固定合法但有歧义
XML支持,用 <!-- -->没有 schema 就没有类型,用 XSD 才有支持只能通过 schema元素有序,属性无序元素重复很正常,属性重复是错误
TOML支持,用 #string、integer、float、boolean、date、time、date-time、array、table支持,通过表实现支持,是一等公民无关紧要永远是错误

大多数意外来自其中两列。类型决定 007 能否存活,还是变成数字 7;嵌套决定不发明一套约定的话,能否转换成 CSV。

CSV 是扁平的,而且每个单元格都是文本

CSV 只有一种形状:一行行的字段。单元格里没办法表达一个对象,所以转换器只能发明点什么。通常它会用点号路径把结构压平,于是这个:

[{ "id": 1, "name": { "first": "Ada" }, "tags": ["admin", "ops"] }]

变成:

id,name.first,tags.0,tags.1
1,Ada,admin,ops

在两件事发生之前,这都能干净地还原回去。长度不同的数组会把表头撑到最长的那条记录,于是一条带三个标签的记录会加出一列 tags.2,让其他所有行都空着一个单元格。而一个本身就含点号的键,事后与嵌套路径无法区分,于是往回转时会重建出错误的结构。另一种做法是在单元格里放一个 JSON 字符串,按 CSV 的要求把引号写两遍,也就是 "[""admin"",""ops""]",它什么都不丢,但下游没有任何东西看得懂。

类型是问题的另一半。单元格里装的是字符,所以读取方只能猜。去猜的结果是:007 变成 7,1.01 变成同一个值,邮编丢掉前导零,一个叫 TRUE 的公司代码变成布尔值。不去猜的结果是:所有数字都变成字符串。CSV 转 JSON 或者 TSV 转 JSON 里的类型推断,是一次带着合理默认值的猜测,而不是一次恢复,因为原本的类型从来没有被写下来过。

一个空单元格可能意味着空字符串、null、零或者不适用;请你决定它是什么意思并明确说出来,因为文件本身说不了。像 CSV 转 TSV 那样换个分隔符,是这里唯一安全的转换:没有类型可以被破坏。

YAML 会替你猜类型

YAML 从不带引号的文本里推断类型,这正是它写起来舒服、转换起来危险的原因。

country: no
version: 1.20
build: 010
start: 12:30

在 YAML 1.1 的解析器下,也就是 PyYAML 和很多较老的库至今仍在实现的那一套,上面的东西是:

{ "country": false, "version": 1.2, "build": 8, "start": 750 }

四个值全都变了意思。no 就是那个“挪威问题”:YAML 1.1 把 yyesnoonoff 当作布尔值,于是国家代码变成了 false1.20 是浮点数,所以末尾的零没了。010 匹配了八进制的模式,变成了 8。12:30 匹配了六十进制的模式,变成了 750,也就是分钟数。

而一个采用 YAML 1.2 核心 schema 的解析器,比如当前的 js-yaml,会把同一个文件读成字符串 "no"、数字 10,以及字符串 "12:30"。同一个文件,两个解析器,两份不同的数据。这些规范确实彼此矛盾;1.2 是有意去掉了六十进制和布尔词那两条规则的。

防御手段只有一个字符。凡是要当作文本的东西都加引号:country: "no"version: "1.20"。YAML 与 JSON 转换器能显示某个解析器把文件读成了什么,因为 JSON 里没有地方能藏住这种歧义。

JSON 没有日期、没有注释,还有一个 53 位的天花板

JSON 的数字是 IEEE 754 双精度浮点数,所以整数只在 2^53 以内保持精确,也就是 9007199254740992。超过之后:

{ "id": 9007199254740993 }

在多数语言里解析再重新序列化,它会变成 9007199254740992。这个值根本无法被表示,所以与其说它被舍入了,不如说它压根不可用。这就是为什么 Discord、Twitter 和其他使用雪花 ID 的 API 把 ID 作为字符串发送:一个 64 位整数塞不进一个 JSON 数字。

JSON 也没有日期类型,所以日期以字符串的形式传输,按惯例是 ISO 8601,而没有任何东西标明 "2026-08-25" 是日期而不是文本。经过 TOML 或者 YAML 1.1 的解析器,它可能变成一个真正的日期,回来时格式又变了样。JSON 同样没有注释、不允许尾随逗号,所以 YAML 或 TOML 源文件里的批注是消失了,而不是被搬走了。从一份样本生成 TypeScript 接口会同时继承这两个盲点:字符串不是日期,而缺失的字段也不等于可选字段。

XML 和 JSON 互相都装不进对方

XML 区分属性和子元素,而 JSON 只有键。

<user id="7" active="true">
  <name>Ada</name>
  <tag>admin</tag>
  <tag>ops</tag>
</user>

转换器会挑一套约定,通常是给属性加 @ 前缀,并把重复的元素折叠成数组。这种有损是有特定形态的:只有一个 <tag> 时就没有数组,于是输出的形状取决于数据而不是 schema,期待一个列表的代码会在只有一个标签的那条记录上崩掉。像 <p>Hello <b>there</b> friend</p> 这样的混合内容在 JSON 里没有对应物,而命名空间、CDATA 和注释都会消失。

反方向丢掉的是另一些东西。元素名不能以数字开头,也不能包含空格,所以任意的 JSON 键必须被改写。null 按惯例变成一个空元素或者 xsi:nil。顶层数组需要一个源文件里从来没有的包裹元素。而且 JSON 里没有任何东西记录某个值曾经是不是属性,所以 JSON 转 XML 只会输出元素。CSV 转 XML 把这个选择摆到了明面上,它会问你要元素还是属性。

键顺序与重复键

键顺序在任何地方都没有保证,却几乎在所有地方都被保留着。JSON 规范说对象是无序的,然而所有主流解析器都保留插入顺序。对于要比较或者要算校验和的东西,把键排序仍然更保险,因为两份只是键挪了位置的文档,其差异是没法读的。像 JSON 比较工具那样按结构比较,是按键而不是按位置来匹配的,从而绕开了这个问题。

重复键是更锋利的那条边。JSON 什么都没说,所以各家解析器行为不一,多数会悄悄保留最后一个值。YAML 禁止重复键,而很多解析器照收不误。TOML 直接拒绝。CSV 允许两列使用同一个表头,把决定权留给读取方,这足以成为先拿 CSV 校验工具跑一遍陌生文件的理由。XML 是唯一一个重复本身有意义而非意外的格式。

有意识地转换

每一条流水线都有一个有损环节。请主动选定它是哪一个,而不是事后才发现。

  • 把最窄的格式放到最后。 XML 转 JSON 再转 CSV,第一步丢属性,第二步丢嵌套。如果目标是 CSV,就先决定哪些字段要紧,然后有意识地压平。
  • 在转换之前加引号,而不是之后。 版本号、国家代码、邮编,以及任何带前导零的东西,都应该在源文件里就带上引号。一旦 no 成了 false,原本的文本就找不回来了。
  • 把输出当作数据来检查。 格式化一遍,确认记录条数,再看一条已知很难缠的记录:名字里带逗号的那条、最长的那个数组、最大的那个 ID。
  • 对一次往返做差异比较。 转出去,再转回来,然后比较。比较报告出来的东西,就是这次转换承载不了的东西。
  • 保留原始文件。 转换后的文件是派生物,三个月后有人问某个字段当时是空的还是根本不存在时,只有源文件能回答。