配置格式

JSON、YAML 与 TOML:在互转中不丢语义

三种配置格式在数据模型、注释和类型系统上的差异,哪些 YAML 标量会在无提示的情况下变类型,以及一次转换究竟悄悄扔掉了什么。

JSON、YAML 和 TOML 描述的大体是同一棵由映射、列表和标量组成的树,所以它们之间的转换器好写,也因此容易被过度信任。真正有意思的是边界:YAML 会从不加引号的文本推断类型,TOML 根本没有 null,而三者中只有两种能写注释。本文讲的就是这些边界。

三种格式,三种分工

JSON 是交换格式。它是为「机器写、机器读」设计的,完整语法一页纸装得下。正是这种极简让它成为糟糕的配置语言:你没法在某个设置旁边留一句说明,而手工编辑时一个逗号放错位置就能让发布失败。

YAML 是给人写的格式,野心非常大。它支持注释、一个文件多份文档、用于复用的锚点与引用、用于内嵌文本的块标量,以及自定义标签。规范很长,而这种表达力的代价是:不加引号的纯文本会被「解释」而不是按字面接受。下一节里所有 YAML 的坑,都是这一个决定的后果。

TOML 有意坐在两者中间。它的设计目标是做一个显而易见、最小化,并且与哈希表有明确映射关系的配置格式。它有注释,有一等公民的日期时间类型,缩进不参与语义 —— 这意味着缩进写错是语法错误,而不是变成另一份配置。它的短板是深层嵌套:超过两三层之后,表头语法本身比它描述的结构还要吵。

能力JSONYAMLTOML
注释没有`#` 到行尾`#` 到行尾
空值`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 来验证。

继续了解相关检查与工具