JSON、YAML、TOML 配置格式到底怎么选?
一个 PR 只加了一个小小的配置文件,review 却歪成了四十条评论——用 YAML?换 TOML?干脆 JSON 算了?——最后啥也没改。这场争论卡在了一个错的问题上:哪个格式最好?根本没有最好。每种格式的最大长处,本身就带着它最大的短板。看清这一点,选择就收敛成两个简单得多的问题。
一个 PR 只加了一个小文件——新服务的一份配置——review 却歪掉了。「为什么用 YAML?那套缩进规则谁记得住。」「TOML 更清爽,用它吧。」「能不能别再引入新格式了?我们到处都是 JSON。」四十条评论下来,文件一个字没改,谁也没被说服。这场争论卡在了它每次都会卡住的那个问题上:哪个格式最好?
而这里要说的是:根本没有最好,而追问「哪个最好」恰恰是这条评论串走不动的原因。JSON、YAML、TOML 不是争同一个头衔的三名选手——它们各自为不同的活儿调过音,而且每一种的最大长处,本身就带着它最大的短板。JSON 不许写注释,正是这一条让它成为一种干净的传输格式;YAML 爱替你猜你想表达什么,正是这一条催生了它那些臭名昭著的坑;TOML 逼你把每样东西都写清楚,正是这一条让它在数据一深就变得啰嗦。一旦你看清没有哪种是白拿的,「哪个最好」就化开成两个真正有答案的问题,而格式,会从答案里自己浮出来:
- 这是机器之间交换的数据文件,还是人来维护的配置文件?
- 如果由人来维护,你希望格式替你猜多少?
先看同一份东西的三种写法
问问题之前,先把同一小份配置用三种格式各写一遍。同样的数据——一个服务名、一个端口、一个开关、一个列表:
{
"name": "billing-api",
"port": 8080,
"debug": false,
"allowedHosts": ["localhost", "127.0.0.1"]
}
name = "billing-api"
port = 8080
debug = false
allowedHosts = ["localhost", "127.0.0.1"]
name: billing-api
port: 8080
debug: false
allowedHosts:
- localhost
- 127.0.0.1
并排放着,差别看起来只是外观——花括号、key = value、还是缩进。它们并不只是外观。每一种排版都押下了一个不同的赌注:谁会往这个文件里敲字,谁又会把它读回来。要押这个注,就得靠那两个问题。
问题一:是机器间的数据,还是人维护的配置?注释就是那个信号
配置文件当然也由机器读取——分界不在读者,而在于谁来编写和维护它:数据文件由程序产生、被程序消费,在系统之间搬运信息;配置文件则由人来编写、由人来照料,用以驱动某个程序。要判断一个文件属于哪一边,最快的办法是问一件事:它允不允许写注释?
JSON 不允许。这不是疏漏——注释是被有意拿掉的,好让 JSON 成为一种纯粹的数据交换格式:语法和数据模型都足够小、足够通用,里头也没有任何东西会被读成一条指令。一段在两台服务器之间飞来飞去的数据,用不着一句 # TODO。那份严格正是它的全部意义:不能写注释、不能有尾逗号、每个键都得加引号、每样东西只有一种写法。这份刻板,恰恰是你要的——当远端那台机器每次都必须用同样的方式把它解析出来时。这也正是为什么一个多余的逗号或注释,就能让 JSON 干脆拒绝解析。
所以 JSON 是一种出色的数据格式,却是一种别扭的配置格式。当你正手动编辑一个 JSON 文件、伸手想写句注释解释「为什么 retries 是 3」的那一刻,你就发现了:这个文件根本不是在途的数据,而是一份披着数据格式外衣的配置。整个生态早就默认了这件事:tsconfig.json 并不是严格的 JSON,而是 JSONC(带注释的 JSON);一长串工具接受 JSON5。这些变体存在的唯一理由,就是把 JSON 有意拿掉的东西再加回来——因为那些文件从头到尾都是配置。
于是问题一分得很干净:
- 它是在程序之间流动的数据——一个 API 响应、一条消息队列里的消息、一个缓存项、你要存下或发出去的数据 → JSON。在这里,它不许注释、它的吹毛求疵,都是优点。伸手去拿 YAML 或 TOML,等于拿你想要的严格,去换一份没有任何机器要求过的灵活。
- 它是人来维护的配置——一份服务配置、一条 CI 流水线、某个工具的设置 → 这就是配置文件,往问题二走。
一点诚实的补充:确实有大量配置就活在 JSON 里——package.json、.eslintrc.json——而这没问题,只要那个文件主要由机器管理、人极少去碰。摩擦的大小,和人手动编辑它的频率成正比。package.json 大多是你的包管理器替你写的;tsconfig.json 却是你自己写的,这也正是工具链不得不给它把注释补回来的原因。
问题二:格式该替你猜多少?YAML 对 TOML
一旦确定是配置文件,真正的取舍就落在 YAML 和 TOML 之间,而这归结为一条:格式替你猜多少。
YAML 猜得最多。你写 port: 8080,它判定这是个数字;写 debug: false,是个布尔;写 host: localhost,是个字符串。你几乎不用敲引号或方括号。这份推断,正是 YAML 写起来那么轻盈的原因——而下一节会讲到,它也正是 YAML 那几个最出名的坑的源头。
TOML 在配置格式里猜得最少,而且是刻意的。它的立场是要明确,但也要舒服:字符串像 JSON 那样加引号,数字、布尔、日期各有毫不含糊的写法,而且——关键在这——它保留了 JSON 拒绝的那些东西:注释,还有对人友好的 [section] 分节。一个它没法归类的裸词,不会被悄悄转成别的类型,而是直接报解析错误。
所以在配置格式内部,分野在于形状和深度:
- 大体扁平的配置——一串设置项、几个分好的段落,像
[database]、[server]、[logging]→ TOML。它读起来像长大成人的 INI 文件:一目了然、好 grep、很难看走眼。这正是 Rust 的Cargo.toml、Python 的pyproject.toml,以及无数命令行工具也选择了它的原因。 - 深层嵌套的配置——由映射组成的树、对象列表、你想复用的值(Kubernetes 清单、Ansible playbook、GitHub Actions 工作流、Docker Compose)→ YAML。要铺开一棵很深的树,缩进比 TOML 那些反复出现的分节头可读得多,而且 YAML 有一套真正的机制——锚点和别名——来做复用。
给 YAML 招来骂名的那个坑:类型强制转换
正是在这里,YAML 的「猜」从便利变成了事故。最出名的一例,是所谓的挪威问题:
countries:
- NO # 是挪威……还是布尔值 false?
在 YAML 1.1 里——很多常用解析器(比如 PyYAML)至今默认都按它来——NO 不是字符串 "NO",而是布尔值 false。同一套强转也会抓住 yes、no、on、off、y、n——它们统统被读成布尔值,而不是文本。一份罗列国家代码的配置,会悄无声息地把挪威丢掉,塞给你的程序一个 false。(YAML 1.2 的 core schema 去掉了这条规则,那里 NO 保持字符串;但不少运行时默认仍是 1.1 的行为,所以别想当然以为自己没事。)
它不止于布尔,也不只是 1.1 的故事。有些陷阱在 YAML 1.2 的 core schema 下同样会触发,因为那个 token 本身就长得像个数字:
version: 1.10 # 1.1 和 1.2 core 都一样:变成数字 1.1——末尾那个零没了
build: 1e5 # 1.1 和 1.2 core 都一样:变成 100000——被当成科学计数法
time: 22:22 # 仅 YAML 1.1:变成 1342(六十进制:22×60 + 22);在 1.2 core 下是字符串
这些写法通常不会报错。某个值到底会不会被强转、强转成什么,取决于你用的解析器和它套用的 schema——22:22 的六十进制只在 YAML 1.1,而 1.10、1e5 在 1.2 的 core schema 里同样是数字。一旦发生隐式转换,错误的类型就会悄无声息地进入程序,而你要到生产环境才发现——当一处版本校验拿数字 1.1 去和字符串 "1.10" 比对、判定两者不等的时候。
补救只需养成一个习惯:凡是人会读成文本、但解析器可能读成别的东西的值,都加引号——国家代码、版本号、git SHA、带前导零的端口、时间。version: "1.10"、- "NO"。只要你把 YAML 的「猜」当成要提防的东西、而不是可以依赖的东西,它就是安全的。你只是得知道要提防。(YAML 各种坏法的完整指南——响亮的解析报错和这些静默的强制转换——另有一篇:YAML 为什么老出问题。)
再看那组让 TOML 感觉更安全的对照:
country = NO # 报错:TOML 不猜——要么写成 "NO",要么它拒绝解析
port = 0700 # 报错:不允许前导零
TOML 把 YAML 那个悄无声息的错答案,变成了一声响亮的解析报错。一个它没法归类的裸词,不会变成 false,而是在读取配置时直接抛出一个明确的解析错误。这就是它吸引人的地方,一句话:你在 TOML 里没法「不小心」把挪威问题带上线,因为那种有歧义的写法,压根就不合法。
但 TOML 并非魔法,这里有个诚实的边界:形状和数字一模一样的东西,照样会被解析成数字。
version = 1.10 # 仍然变成浮点数 1.1——要那个字符串,得写 "1.10"
差别在于可预测,而非免疫。TOML 的规则很短——长得跟数字一模一样的,就是数字,其余一切要么加引号、要么就报错;而 YAML 背着一长串出人意料的裸词(NO、on、22:22),个个都指向自己字面之外的意思。一条你能揣在脑子里的短规则,胜过一条时不时给你惊吓的长规则。
TOML 那份长处的账单:深度
TOML 的明确也有它自己的代价——结构一复杂,代价就显现。看同一串嵌套对象在两种格式里的样子:
[[server]]
name = "alpha"
[server.limits]
maxConns = 100
[[server]]
name = "beta"
[server.limits]
maxConns = 200
server:
- name: alpha
limits:
maxConns: 100
- name: beta
limits:
maxConns: 200
TOML 的 [[server]] 和 [server.limits] 这些分节头毫不含糊,但它们在重复,读的人得在脑子里把这棵树重新拼起来。而在 YAML 里,缩进本身就是那棵树。这正是数据一旦重度嵌套,大家就会越过 TOML、转投 JSON 或 YAML 的原因:扁平到浅层,TOML 是种享受;一旦深上好几层,它就开始跟你较劲,而 YAML(配上防御性的引号)读起来更顺。
这一切底下的那条原理
退一步,这三者会在同一根轴上排开——它们把多少东西交给推断——而每一条性质,好的坏的,都由一个格式落在这根轴的哪个位置决定:
| 猜多少 | 你得到 | 你付出 | |
|---|---|---|---|
| JSON | 什么都不猜;一切显式,不许注释 | 一种严格、广泛通用的传输格式 | 手动编辑难受——不能注释、不能有尾逗号 |
| TOML | 几乎不猜;有歧义就报错 | 安全、直观、好 grep 的配置 | 数据一深就变啰嗦 |
| YAML | 猜得最多;从裸词里推断类型 | 写起来最轻,能铺开很深的树 | 挪威问题,以及悄无声息的类型强转 |
没有哪一行是不付代价的。这正是那场争论没看到的:你要找的,不是一个没有短板的格式,而是要挑一个——对这个文件,你能承受它那个短板。一种手动编辑很烦的传输格式,没关系——你本来就很少手动编辑它。一种嵌套一深就啰嗦的配置格式,没关系——只要你的配置本来就扁平。一种会猜类型的配置格式,也没关系——只要你防御性地加好引号、你的 reviewer 也知道该盯哪里。
一张决策表
| 这个文件是… | 用 | 因为 |
|---|---|---|
| 机器之间交换的数据(API、消息队列、存储) | JSON | 严格和广泛互操作本身就是它要完成的任务;不需要注释 |
| 人写的配置,大体扁平分节 | TOML | 显式、无歧义——它撞不上挪威问题 |
| 人写的配置,深层嵌套或大量复用 | YAML | 缩进能铺开很深的树,锚点可复用——记得防御性加引号 |
| 人要手动编辑、却被困在 JSON 里的配置 | JSONC / JSON5 | 你已经承认它是配置了;把注释加回来 |
这里的转换器就是冲着上面这些坑做的,不只是往返转换文本。把 YAML 贴进 YAML 转换器,它的检查面板会标出 YAML 1.1 布尔陷阱(那个没加引号的 no)和即将塌成浮点的版本号;把 TOML 贴进 TOML 转换器,它会标出被降级成 ISO 字符串的日期时间、以及超过 2^53、需要转成字符串以避免丢失精度的整数——正好是你要离开的格式和要进入的格式各执一词的地方。两者都默认按 YAML 1.2 Core / 标准 TOML 解析(若文档用 %YAML 1.1 显式声明版本,则以声明为准),且只提示风险、并不拦截,所以换一个运行时到底怎么处理,仍取决于它的解析器和 schema——但把差异摆在眼前,总好过在生产环境撞上。如果你只是想清理或校验一段 JSON,JSON 格式化工具也能顺手搞定。
收尾清单
下次又有人为「用哪个格式」把评论串卡住时,别去争哪个最好。按顺序问这几句:
- 数据还是配置?机器之间交换的数据 → JSON,到此为止。它的严格是优点,不是待修的毛病。
- (如果是人维护)结构扁平还是深层嵌套?大体扁平分节 → TOML。深层嵌套或大量复用 → YAML。
- 有歧义的标量,你加引号了吗?尤其在 YAML 里——国家代码、版本号、SHA、时间、带前导零的数字。给它们加引号,否则解析器替你猜。
- 你是不是在跟格式较劲?想在 JSON 里写注释,或者被 TOML 里一堆
[[section]]头淹没,都说明这个文件已经长得超出它的格式了。那是该换的信号,不是该忍的信号。
这四步底下,是那句值得记住的话:没有最好的配置格式,只有对这个文件而言、你担得起其代价的那一个。 先分清是数据还是配置,再看结构是扁平还是深,把 YAML 会替你乱猜的地方都加上引号——选择就不再凭个人口味,而是一件你一句话就能说清楚的事。