发布于
JSON、YAML 和 TOML 描述的大体是同一棵由映射、列表和标量组成的树,所以它们之间的转换器好写,也因此容易被过度信任。真正有意思的是边界:YAML 会从不加引号的文本推断类型,TOML 根本没有 null,而三者中只有两种能写注释。本文讲的就是这些边界。
三种格式,三种分工
JSON 是交换格式。它是为「机器写、机器读」设计的,完整语法一页纸装得下。正是这种极简让它成为糟糕的配置语言:你没法在某个设置旁边留一句说明,而手工编辑时一个逗号放错位置就能让发布失败。
YAML 是给人写的格式,野心非常大。它支持注释、一个文件多份文档、用于复用的锚点与引用、用于内嵌文本的块标量,以及自定义标签。规范很长,而这种表达力的代价是:不加引号的纯文本会被「解释」而不是按字面接受。下一节里所有 YAML 的坑,都是这一个决定的后果。
TOML 有意坐在两者中间。它的设计目标是做一个显而易见、最小化,并且与哈希表有明确映射关系的配置格式。它有注释,有一等公民的日期时间类型,缩进不参与语义 —— 这意味着缩进写错是语法错误,而不是变成另一份配置。它的短板是深层嵌套:超过两三层之后,表头语法本身比它描述的结构还要吵。
| 能力 | JSON | YAML | TOML |
|---|---|---|---|
| 注释 | 没有 | `#` 到行尾 | `#` 到行尾 |
| 空值 | `null` | `null`、`~` 或留空 | 完全没有对应表示 |
| 日期时间类型 | 只能用字符串 | YAML 1.1 schema 中有 timestamp | 一等公民,四种变体 |
| 缩进参与语义 | 否 | 是 —— 且禁止使用制表符 | 否 |
| 单文件多文档 | 否 | 可以,用 `---` 分隔 | 否 |
| 复用 / 引用 | 无 | 锚点 `&a`、别名 `*a`、合并键 `<<` | 无 |
| 多行字符串 | 只能用 `\n` 转义 | 块标量 `|` 和 `>` | 三引号 `"""` 和 `'''` |
| 重复键 | 未定义,多数解析器取最后一个 | 规范要求报错,很多解析器取最后一个 | 明确规定为错误 |
| 不加引号的标量会被重新定型 | 不适用 | 会 —— 这是主要风险源 | 不会 |
| 数组尾随逗号 | 拒绝 | 不适用 | 数组允许,内联表不允许 |
同一份配置的三种写法
把同一份小文档并排放在一起,结构上的取舍就具体了。注意 TOML 版本里的顺序约束:属于 `[service]` 的每一个标量键都必须出现在 `[service.retry]` 表头之前,因为一个表头会终结上一张表。转换器不会搞错这一点,但手工编辑结果的人经常会。
也注意数组发生了什么。JSON 和 TOML 都写成内联形式。YAML 则同时提供块序列和内联的流式序列,多数转换器输出块形式 —— 更易读,但行数大约翻倍。两者没有谁更正确。
JSON
{
"service": {
"name": "checkout",
"port": 8080,
"debug": false,
"tags": ["eu", "beta"],
"retry": { "attempts": 3, "backoff": "250ms" }
}
}
YAML
service:
name: checkout
port: 8080
debug: false
tags:
- eu
- beta
retry:
attempts: 3
backoff: 250ms
TOML
[service]
name = "checkout"
port = 8080
debug = false
tags = ["eu", "beta"]
[service.retry]
attempts = 3
backoff = "250ms"YAML 的那些坑,以及它们为什么存在
YAML 解析一个不加引号的标量时,是拿它去匹配一组正则表达式。看起来像布尔就变成布尔,看起来像数字就变成数字,否则才是字符串。这套规则在 YAML 1.1 和 YAML 1.2 之间不一样,而用哪一套取决于你的解析器,不取决于你的文件。PyYAML 和 libyaml 实现的是 1.1;`gopkg.in/yaml.v3` 的布尔值已经转向 1.2 核心 schema,而 `yaml.v2` 没有。于是同一个文件,在两个都声称「读 YAML」的服务里可以是两种含义。
最出名的案例是「挪威问题」。在 YAML 1.1 下,`y`、`yes`、`on`、`n`、`no`、`off` 都是布尔值。一份包含挪威国家代码 `NO` 的 ISO 列表,会被解析成 `False`。知道之后修起来很简单 —— 加引号 —— 但文件里没有任何迹象提示发生过什么。
第二常见的受害者是版本号。`version: 1.10` 是一个浮点数,而浮点数 1.10 就是 1.1,于是锁定的版本悄悄退回到了更早的那个。前导零更糟:在 YAML 1.1 下 `010` 这样的值会匹配八进制模式并解析为整数 8,这已经毁过不止一个补零的账号字段。而 YAML 1.1 的六十进制整数意味着不加引号的 `12:30:00` 会变成 45000,也就是秒数。
能一次性堵住这一切的规则很机械:凡是语义上不是数字、也不是布尔的标量,一律加引号。标识符、版本字符串、国家代码、以文本形式书写的端口、时间,以及任何带前导零的值,全部加引号。成本是两个字符,换来的是整整一类事故的消失。
中间一列是 PyYAML 这类 YAML 1.1 解析器的结果。按 1.2 核心 schema 的解析器会把其中几项保留为字符串 —— 而这本身就是问题所在:你从文件上看不出自己会拿到哪一种。
| 文件里写的 | 解析结果(YAML 1.1) | 应该这样写 |
|---|---|---|
| `country: NO` | 布尔 `false` | `country: "NO"` |
| `enabled: on` | 布尔 `true` | `enabled: true` |
| `answer: y` | 布尔 `true` | `answer: "y"` |
| `version: 1.10` | 浮点 `1.1` | `version: "1.10"` |
| `account: 010` | 整数 `8`(八进制) | `account: "010"` |
| `offset: 12:30:00` | 整数 `45000`(六十进制) | `offset: "12:30:00"` |
| `value: ~` | null | 如果你要的是波浪号,写 `value: "~"` |
| `ratio: .5` | 在 1.1 下是字符串 `".5"` | `ratio: 0.5` |
| `sha: 1e10` | 浮点 `10000000000.0` | `sha: "1e10"` |
TOML 的形状,以及它别扭的地方
TOML 1.0.0 在 2021 年冻结了格式,早期使用者遇到的版本反复问题已经结束。它的类型系统是三者中最丰富的:字符串、至少保证 64 位有符号的整数、浮点数、布尔、带偏移量的日期时间、本地日期时间、本地日期、本地时间、数组、内联表,以及表数组。TOML 里的日期就是日期,而不是「大家约定用同一种方式解析」的字符串。
语法是面向行的,没有歧义。键可以是裸键、带引号的键或点分键;`a.b.c = 1` 会隐式创建嵌套表。数组可以跨行并允许尾随逗号;内联表必须写在一行内,且不允许尾随逗号。重复的 `[[products]]` 表头构成表数组,这是 TOML 对「一组对象」的回答,用起来确实舒服。
别扭出现在深度和异构嵌套上。三层结构需要 `[tool.poetry.dependencies]` 这样的表头;而「一组对象、每个对象里又是一组对象」会变得难以跟读。TOML 还没有 null:一个键要么存在要么不存在,没有第三种。这是一个干净的模型,但它意味着 JSON 里的 `{"retries": null}` 没有忠实的 TOML 形式 —— 转换器只能要么省略这个键,要么发明一个哨兵值,而这是两份不同的配置。
# 表数组 —— TOML 表达「一组对象」的地道写法
[[server]]
host = "eu-1.example.com"
port = 8443
enabled = true
[[server]]
host = "us-1.example.com"
port = 8443
enabled = false
# 点分键构造出的结构与表头写法等价
owner.name = "Ada"
owner.since = 2026-03-01 # 真正的日期,不是字符串
# 等价的 JSON
# {
# "server": [
# {"host": "eu-1.example.com", "port": 8443, "enabled": true},
# {"host": "us-1.example.com", "port": 8443, "enabled": false}
# ],
# "owner": {"name": "Ada", "since": "2026-03-01"}
# }
# 注意日期:JSON 没有日期类型,只能变成字符串。一次转换会丢掉什么
所有转换器的工作方式都是:解析成内存中的树,再重新打印。不属于这棵树的东西一律消失。注释是最大的一项:把一份写满注释的 YAML 转成 JSON 再转回来,你就失去了每一项设置存在的理由。如果这个文件是人工维护的,就把转换当成单向操作,真相始终保存在人实际编辑的那种格式里。
YAML 的锚点和别名同样会坍塌。锚点是复用机制而不是数据特性,所以 `&defaults` / `*defaults` 在输出里会展开成重复内容。结果在语义上完全相同,在结构上大得多,而且此后修改展开后的某一份副本不再会传播到其他地方。合并键(`<<: *base`)的行为一样。至于 `!Ref` 这类自定义标签 —— CloudFormation 和一些 CI 系统大量使用 —— 目标格式里根本没有对应表示,只能被丢弃或变成一个毫无用处的字符串。
- 注释:任何以 JSON 为目标的方向都会丢失。
- YAML 锚点和合并键:会被展开,永远不会被保留。
- YAML 自定义标签(`!Ref`、`!!python/object`):任何地方都没有等价物。
- TOML 的日期与时间:在 JSON 和 YAML 中都会变成字符串。
- JSON 的 `null`:TOML 没有对应表示,这个键只能被丢掉。
- 键的顺序:多数转换器会保留,但没有任何一个作出保证。
安全:解析 YAML 不是一个中立的操作
YAML 的标签系统允许文档要求构造任意对象。PyYAML 的 `yaml.load` 历史上会响应 `!!python/object/apply` 这类标签 —— 如果文档来自你无法控制的地方,这就是远程代码执行。请始终使用 `yaml.safe_load`,或 `yaml.load(..., Loader=yaml.SafeLoader)`。其他语言有类似的区分;在解析用户提供的 YAML 之前,先确认你的库默认用的是哪个 loader。
JSON 和 TOML 不存在这两个问题,它们没有任何复用或构造机制。如果配置文件来自你的信任边界之外,而格式又由你选,单凭这一点就值得优先选这两种。
要点回顾
- 凡是语义上不是数字也不是布尔的 YAML 标量都要加引号 —— 国家代码、版本字符串、补零的标识符、时间 —— 因为不加引号的文本会被重新定型,而规则在 YAML 1.1 和 1.2 的解析器之间并不一致。
- 人工维护、结构基本扁平的配置选 TOML;嵌套很深或生态已经选好的场景用 YAML;文件由程序写就用 JSON。
- 把任何以 JSON 为目标的转换当成单向操作:注释、YAML 锚点和自定义标签都不会存活,真相要留在团队实际编辑的那种格式里。
- 记住 TOML 没有 null,所以 JSON 中值为 `null` 的键只能被丢弃或换成哨兵值,而这两种结果是两份不同的配置。
- 不是自己写的 YAML 一律用 `safe_load` 或等价方式解析;转换完成后做一次往返并与原文 diff 来验证。