写在前面
你每天都在写配置:K8s 的清单是 YAML,Hugo 的 hugo.toml 是 TOML,npm 的 package.json 是 JSON,老项目的 Web.config 是 XML,环境变量是 .env。但很少有人问过:为什么是它们? 一个能统一天下的配置格式明明长什么样大家都想得出来——有注释、无歧义、支持嵌套、够简洁——可三十多年过去了,我们依然在五种格式之间反复横跳,而且新一代(Pkl、CUE)还在继续造。
本文用一个贯穿全文的分析模型来解释这件事(注意:它是本文提出的解释框架,不是被证明的工程定律)——
配置格式的不可能三角:人写舒服、机器解析严格、表达能力强,三者难以兼得。 每一代新格式都不是更“好”,而是在重新分配这三个角的权重;每一个让你骂出声的坑,都是某个角被过度牺牲后收的利息。
理解了这个三角,配置格式的演进就不再是一堆名字,而是一条清晰的因果链:INI 太简单 → XML 用结构化补表达、却把“人写舒服”牺牲干净 → JSON 把“机器严格”做到极致、却砍掉了注释 → YAML 迎回“人写舒服”、代价是类型歧义群坑 → TOML 逐条清算 YAML 的坑、代价是深层嵌套啰嗦 → 配置膨胀成代码,配置语言(Pkl / CUE / Dhall)登场。本文沿这条链讲透每一种格式:它为什么上位、靠什么繁荣、死于什么。
一、先分类:什么才算“配置格式”
在比较之前先划清边界,因为日常语境里“配置文件”是个大杂烩。按“这个文件回答什么问题”分五类:
| 类别 | 回答的问题 | 例子 |
|---|---|---|
| 配置 / 数据格式 | 数据长什么样 | XML、JSON、YAML、TOML、INI |
| 模式语言(IDL) | 数据 必须 长什么样 | .proto(Protobuf)、Thrift IDL、XSD、JSON Schema |
| 文档标记 | 人类怎么读 | .md、reStructuredText、HTML |
| 专用配置 DSL | 某个工具的专属语法 | nginx.conf 的块指令、HCL、.env |
| 扩展名惯例 | 不承诺任何语法 | .conf、.cfg |
几个容易误判的例子:
.conf不是格式,是扩展名。php.ini、smb.conf是 INI 风格,nginx.conf是自定义的块指令 DSL(大括号 + 分号,长得像 C),redis.conf是逐行参数 参数格式——同一个后缀,三种语法。讨论格式时说.conf,等于说“姓张的都算北京人”。.proto是模式语言。它定义的是数据的形状(字段、类型),不是程序的参数。它和 XSD、JSON Schema 同族,是“格式的元层”。.md是文档标记,目标读者是人。但它有个著名交叉现象:frontmatter——本站每篇文章都是.md头上嵌一段 TOML 配置。md 不是配置格式,但它是配置格式最流行的宿主。
本文只讨论第一类。判断口诀:给程序喂参数的是配置格式,给数据定规矩的是模式语言,给人读的是文档,只定后缀不定语法的是惯例。
二、INI:一切的起点
上世纪 90 年代,Windows 3.x 用 WIN.INI、SYSTEM.INI 管理系统配置,应用程序跟着学,INI 成为事实标准。Windows 甚至提供了专门的 API(GetPrivateProfileString 一族)来读写它——INI 在当年不是“约定”,是操作系统级设施。
它的语法两句话讲完:
| |
它为什么能活三十多年:核心语法 key = value 是人类和机器的共同母语,解析器一屏代码写得完。写一行配置几乎不可能犯语法错误——在语法层,INI 确实简单到不会错。
它为什么被淘汰:表达力天花板太低——
- 没有嵌套结构(只有一层 section);
- 没有数组(
hosts = a,b,c是字符串还是列表,全看程序自己 split); - 没有统一规范。INI 从未被标准化,每个工具都是方言。同样是注释,
php.ini用;,.gitconfig则#和;都认;多行值、引号、转义更是各家各造。语法简单,方言不简单——跨工具的 INI 经验不可迁移,这是它真正的坑。
| |
| |
顺带一段历史注脚:Windows 95 起,微软自己用 注册表 取代 INI 管理系统配置(动机是集中管理、并发控制、性能)——但格式本身早已在 Unix 世界扎根,至今活着的主流用户包括 php.ini、MySQL 的 my.cnf、Samba 的 smb.conf,以及 git 的 .gitconfig。
关于 .env 的澄清:KEY=value 逐行的 .env 常被当成 INI 的后代——语法像,谱系不是。它的直接来源是 shell 的环境变量约定(VAR=value 本来就是 shell 语法),dotenv 类工具把它做成“开发期批量注入环境变量”的载体。它与 Twelve-Factor“配置存环境变量”的理念相衔接(部署层用真环境变量、开发层用 .env 模拟),但要注意:12-factor 原文推荐的恰恰是操作系统环境变量,并且明确批评配置文件容易误提交、散落各处——所以 .env 通常也要进 .gitignore。它是开发事实标准,不是方法论本身。
三、XML:企业时代的全能选手
1998 年 W3C 发布 XML 1.0。它是 SGML 的简化版,生逢其时:企业集成、SOAP / WebService、Java 世界(Ant、后来的 Maven pom.xml)、.NET Framework(App.config / Web.config)、Spring 的 XML 配置,还有你写 WPF 用的 XAML——2000 年代的企业计算,几乎构筑在尖括号之上。
| |
它的王牌是“格式 + 模式”的完整体系。XSD(XML Schema Definition)可以严格声明每个元素的名字、类型、出现次数、嵌套关系——在进入应用逻辑之前,XML 可以先完成良构性检查、再做 XSD 校验。给前面那段配置配一小段 schema(示意片段,省略了 xs:schema 根元素、命名空间声明和 pool / replicas 的定义):
| |
端口写成 70000?校验阶段直接报错,不用等程序跑起来。在 XML 自己的生态里,XSD 在命名空间、复杂内容模型、属性与元素约束上形成了非常成熟的一体化体系——JSON Schema 在自己的生态里仍在逐个补这些维度的课(两者面向的数据模型不同,XML 有属性、命名空间、混合内容,直接比较高下并不严谨)。
XML 的其他底牌:注释原生、命名空间原生、XPath / XSLT 一整套查询与变换工具链、混合内容(文字和标签混排)——作为 文档格式,它依然不可替代(HTML、DocBook、Office 文档的底层都是它或它的近亲)。
它死于什么:作为 配置格式,冗长是原罪。上面那段 XML 比等价 JSON 多出近一倍的字符,大部分是 标签的开销 而非信息。它还有个著名的建模纠结:值应该放属性还是子元素?<port>5432</port> 还是 port="5432"?这不是解析歧义——两种写法机器都无歧义地认得——而是 建模选择:属性适合元数据、元素适合数据,但两条路都通意味着每个设计者都要重做一遍选择,混用两种风格的配置阅读成本直接翻倍。当“配置要人来写”成为日常(而不是管理员偶尔改一次),XML 的书写成本就不可接受了。全能选手输给了专才。
四、JSON:机器之子
JSON 的历史很有喜剧色彩:Douglas Crockford 声称自己没有“发明”它,而是“发现”了它——JavaScript 的字面量语法本来就是完美的数据格式。2005 年前后 Ajax 浪潮兴起,JSON 随之出圈,2013 年成为 ECMA-404 标准,2017 年又成为 RFC 8259——一门数据格式被 ECMA 和 IETF 双重标准化,本身就是它地位的注脚。
| |
它上位靠什么:机器亲和力碾压前辈。浏览器一行 JSON.parse 就能解析(XML 要 DOM API);结构直接映射到 JavaScript 对象、也天然映射到各语言的哈希表 / 字典;类型系统小而明确(字符串、数字、布尔、null、数组、对象)。它本是 序列化格式 出身,拿来当配置格式是“顺手”——而这一“顺手”埋下了所有问题。
致命伤:严格 JSON 没有注释。 对机器生成、机器消费的序列化格式这无可指摘;对 人手写的配置 这是灾难。生态的自救分三路:
第一路:方言。
- JSONC:带注释和尾逗号的 JSON。VS Code 的
settings.json、TypeScript 的tsconfig.json走的都是这条路——很多人天天在写方言而不自知; - JSON5:更进一步。准确的能力清单是:注释、对象和数组的 尾逗号、单引号字符串、无引号键名(标识符)、十六进制数字、
Infinity/NaN、前导或尾随小数点(.5、5.)。注意:成员之间的分隔逗号仍然必须有——JSON5 允许的是“多的”,不是“少的”。
| |
第二路:宽容解析器。 不改格式,改解析器——不少平台在读取时直接容忍注释。最典型的就是 .NET:ASP.NET Core 的 JSON 配置提供程序从早期版本起就跳过 // 与 /* */ 注释、允许尾逗号——appsettings.json 一直能写注释。这是个常见误解的高发区,值得单独澄清:ASP.NET Core 的 appsettings.Development.json / appsettings.Production.json 分层覆盖,解决的是 环境差异与优先级(文件 < 环境变量 < 命令行参数,后者逐层覆盖),不是 注释问题——注释人家本来就支持。
第三路:字段级 hack。 最朴素的民间智慧:
| |
能用,但它污染数据结构、无法批量管理、删配置时注释变孤儿——是所有方案里最将就的一个。方言和宽容解析器的繁荣本身就在说明:“JSON 不能写注释”欠的债,生态已经用二十年还了。
另一类痛在 严格性对人也不友好:最后多出的一个逗号、单引号没换双引号、键名忘加引号——都是报错高频榜前几名。还有一类更隐蔽的坑在类型层:JSON 数字不区分整数和浮点、超出 IEEE 754 安全整数范围(2^53−1 以上)时不同实现可能丢失精度、对象键重复时规范只说“应当唯一”(SHOULD),接收方可能取最后一个也可能报错——RFC 8259 明确把这三点列为互操作性风险。“比 YAML 严格”是真的,“完全无歧义”是夸张的。
五、YAML:云原生的人写之王
YAML 诞生于 2001 年(“YAML Ain’t Markup Language”的递归缩写),1.2 版规范在 2009 年,当前修订是 1.2.2(2021)。它的野心写在设计目标里:对人友好。注释、不强制引号、缩进表达嵌套、块结构与流结构([a, b]、{k: v})混用——写起来确实舒服:
| |
它赶上了云原生大潮并被加冕:Kubernetes 清单、docker-compose、GitHub Actions workflow、Ansible playbook、GitLab CI、OpenAPI、Prometheus 配置——云原生世界的官方语言。
5.1 先说好东西:多行字符串
YAML 有一个 JSON 完全没有、TOML 要用三引号才有的能力——原生多行字符串:
| |
注意块内 没有注释这回事——缩进进块的 # 是字符串内容而不是注释,所以说明必须写在键的上一行(上面示例就是这么做的)。尾缀还有变体:|- 去掉末尾换行、|+ 保留全部尾部空行。写过大段内嵌脚本的人都知道这个能力多救命——这也是 YAML 拥趸的真爱点之一。
但“人写舒服”这个角的权重加得太狠,“机器解析严格”就被牺牲出了著名的坑群。这一节是全文实用性最高的部分,逐个过:
5.2 坑 1:挪威问题——no 是布尔
| |
YAML 1.1 规范把 y / n / yes / no / on / off 全部定义为布尔值,大量主流解析器沿用至今(1.2 已收窄为 true / false,但现实见坑 6)。
5.3 坑 2:数字语义吃掉版本语义
| |
同族问题还有旧式八进制:YAML 1.1 里 legacy_mode: 010 可能被解析成十进制 8;1.2 起八进制必须写 0o10(否则 010 不再按八进制处理)。规律只有一个:裸值交给解析器猜,你就已经输了;想表达字符串,就自己加引号。
(image: myapp:1.10 不在雷区——myapp:1.10 整体是字符串位置,冒号后无空格不构成键值分隔,翻车的恰恰是 version: 这种“纯数字串”字段。)
5.4 坑 3:缩进即结构,tab 禁用
YAML 用缩进表达嵌套,且 禁止 tab 缩进(只允许空格)。空格数量错了不报“语法错误”,报的是莫名其妙的“结构不匹配”;从浏览器复制一段 YAML,肉眼完全看不出哪里混了 tab。这是 YAML 的最大单一致命体验来源,没有之一。
5.5 坑 4:重复键静默覆盖
| |
配置写重了不吭声,前值悄悄丢失——排查半小时,原因是两个同名键隔着三百行。
5.6 坑 5:锚点、别名与合并键
| |
先划清规范边界:锚点(&)与别名(*)是 YAML 核心语法;<< 合并键不是——它来自 YAML 1.1 时代的独立类型草案(yaml.org/type/merge.html),已被移出 1.2 推荐体系,只是被 libyaml / PyYAML / Go YAML 等众多实现继续支持。所以更准确的说法是:这是“被广泛实现的 1.1 扩展”,而不是“YAML 的标准能力”——依赖它之前,先确认你的解析器在列。
能力本身很强:复用、覆盖、组合是纯数据格式给不了的(一次对“表达能力”角的偷袭)。但锚点与合并的套用一旦叠起来,可读性直接崩塌:写的人得意,读的人查文档。
5.7 坑 6:你用的是“解析器的 YAML”,不是“规范的 YAML”
规范在 2009 年出了 1.2、2021 年出了 1.2.2,修掉了一批坑——但主流解析器(libyaml 系、PyYAML)大量保留 1.1 语义。同一份输入,规范与现实的差异大致是:
| 裸值输入 | YAML 1.1 行为(多数解析器现实) | YAML 1.2 core 行为(规范) |
|---|---|---|
yes / no / on / off | 布尔 | 字符串(布尔仅 true / false) |
010 | 八进制,得 8 | 八进制必须 0o10,010 不按八进制处理 |
12:30 | 六十进制,得 750(12×60+30) | 字符串(1.2 废除六十进制) |
1.10 | 浮点 1.1 | 浮点 1.1(两版一致,这里没有救) |
于是 同一段 YAML 在不同语言里可能解析出不同结果。规范升级了十几年,现实还停在起点附近——这也是“给裸值加引号”能成为 YAML 头号军规的根本原因:它不是防御规范,是防御解析器。
六、TOML:一次针对性的反击
TOML(Tom’s Obvious, Minimal Language,Tom Preston-Werner 2013 年发布,1.0 规范 2021 年落地;本文以生态广泛支持的 1.0 为基线)的设计动机几乎可以逐条对应 YAML 的坑单——它是 针对 YAML 的复仇之作:
| YAML 的坑 | TOML 的回应 |
|---|---|
no 变布尔 | 字符串 必须 加引号,类型永远显式 |
1.10 变 1.1 | 同上——引号强制,没有隐式转换 |
| 缩进表达结构 | 结构用 表头 [section] 表达,缩进不参与语法 |
| 重复键覆盖 | 重复定义直接报错 |
| 锚点难读 | 不提供引用机制(宁可不复用,不要看不懂) |
| |
6.1 类型表:一切显式
| 类型 | 写法 |
|---|---|
| 字符串 | "..."、'...'(原样字符串)、"""..."""(多行) |
| 整数 | 42、十六进制 0x2A、八进制 0o52、二进制 0b101010,可加 _ 分隔 |
| 浮点 | 3.14、科学计数 1e6、inf、nan |
| 布尔 | true / false(仅此两个,没有同义词) |
| 日期时间 | 1979-05-27(日期)、07:32:00(时间)、1979-05-27T07:32:00Z(带时区)、本地日期时间——四种,全部一等类型 |
| 数组 | [1, 2, 3],可多行、可混排 |
| 表 / 内联表 | [section]、{ k = v, k2 = v2 } |
注意 日期时间是一等类型:release = 2026-07-10T10:00:00+08:00 解析器可以将其映射为语言原生的日期时间类型,应用不必再自行解析字符串——五种主流格式里独一份。以及 没有 null:要么有值、要么整个键不写。这不是缺陷是立场——它逼你直面“没有值”和“值为空”的区别,而 JSON 的 null 和“键不存在”的纠缠是另一桩著名悬案。
6.2 同一种嵌套,三种写法
三种写法各自是 独立文档,不能在同一份文件里混着拼:
| |
| |
| |
为什么不能混拼?两条规则:进入 [database.pool] 表头之后,后续的键(包括点号键)都 相对当前表 解释——database.pool.max 会变成 database.pool.database.pool.max;而内联表是自包含的、封闭的,不允许事后追加。数组对象则用双方括号:
| |
代价:表头一多就满屏方括号,深层嵌套啰嗦;“人写舒服”被做成了“人写 明确”——舒服,但繁琐。
战绩:Rust(Cargo.toml)、Python 打包(pyproject.toml,PEP 518 起)、Hugo、Netlify。注意这个名单的共性:采纳 TOML 的多是 2013 年后为新场景新定标准的生态——Hugo 2013 年发布时选了它,Python 打包则是 2016 年 PEP 518 为新打包元数据立标准时选中它。老牌领域(云原生)已归 YAML,它抢不动。
七、同一个配置写五遍
把同一组语义(注释、嵌套、数组、类型)用五种格式各写一遍,差别一目了然:
| |
| |
| |
| |
| |
汇总成一张表:
| 维度 | INI | XML | JSON | YAML | TOML |
|---|---|---|---|---|---|
| 注释 | ✓ | ✓ | ✗(JSONC 方言 / 宽容解析器补) | ✓ | ✓ |
| 嵌套 | ✗ | ✓ | ✓ | ✓(缩进) | ✓(表头) |
| 数组 | ✗(约定) | ✓(冗长) | ✓ | ✓ | ✓ |
| 类型歧义 | 多(全靠约定) | 少(皆文本) | 少(仍有重复键、大数精度等互操作坑) | 多(坑群,依解析器而异) | 极少(引号强制) |
| 人的书写成本 | 极低 | 极高 | 中(无注释之痛) | 低 | 低中 |
| 模式校验 | ✗ | XSD(生态内标杆) | JSON Schema | ✗(生态外挂) | ✗ |
| 机器解析 | 极易 | 中 | 极易 | 中(方言多) | 易 |
| 当下生态位 | .env / 老软件 | 企业遗留、XAML | 前端 / .NET | 云原生 | 新工具链 |
八、下一个二十年:配置语言化
配置格式的故事到 TOML 并没有结束,因为不可能三角的“表达能力”角又到了加码的时候——现代配置的三大新痛点:
- 重复:dev / staging / production 三套配置 95% 相同,复制三份必然漂移;
- 规模:一个中型 K8s 项目的 YAML 清单轻松上千行;
- 校验时机:格式解析只保证“语法对”,不保证“值合理”——
max = 2, min = 20这种错误要等到运行时才炸。
8.1 先来的不是语言,是组合与模板
生态的第一反应分两路。组合派 的 Kustomize:不要模板,用“基础 + overlay + patch”分层拼装结构化的 K8s 资源——同一份基础,各环境打不同的补丁。模板派 的 Helm:把 Go template 嵌进 YAML——结果得到“字符串汤”:模板、YAML、注释三层语法互相干扰,引号转义地狱,渲染结果难以预测。两条路都在各自场景活了下来,但天花板也都很明显:组合派只能拼不能算,模板派能算但不可靠。
8.2 配置即代码:四个代表
真正的回应是 配置语言——给配置加上变量、函数、类型和校验:
Jsonnet(2014,始于 Google 的 20% 项目,代码由 Google 持有但非官方产品)——JSON 的超集(任何合法 JSON 都是合法 Jsonnet),加上变量、函数、对象继承与组合、数组 / 对象推导式、条件表达式、assertion、import——一门能力完整的数据模板语言,重复和派生场景都能覆盖:
| |
CUE(源于 Google 内部经验)——“Configure, Unify, Execute”。核心思想是 约束与统一:类型即值、值即约束,同一份数据可以被多个来源“合并求值”,冲突当场报错。它同时吞掉了“重复”和“校验”两个问题——不仅能生成配置,还能 验证 已存在的配置:
| |
Dhall(2016)——把编程语言理论的极端严格带进配置:total language(保证终止,配置不会算到死循环)、完备的类型系统。函数与类型齐备:
| |
它支持为导入附加语义哈希来固定远程依赖的内容(dhall freeze 负责把哈希补上)——是可选的供应链安全机制,不是强制的。
Pkl(Apple,2024 开源)——配置界的“后 Java”路线:类、静态类型、约束直接写在类型上,官方提供编译输出(JSON / YAML / Plist)与多语言绑定:
| |
(还有一脉不叫配置语言但同属此列:Bazel 的 Starlark——把 Python 裁剪成可确定求值的方言,BUILD 文件即代码。)
8.3 这一波在不可能三角上怎么挪的
| |
配置语言把“机器解析严格”从 解析期 推到了 求值期(类型与约束错误在生成产物之前报出,错误信息指名道姓到字段),把“表达能力”从字面量扩到逻辑,同时靠 LSP / IDE 把“人写舒服”用工具抢回来一部分。三角没有被消灭——只是被重新配平。
8.4 诚实的另一面
配置语言不是免费午餐。产物的消费方式 有两路:预编译成 YAML / JSON 提交进库,或通过语言绑定(如 pkl-go、Java 绑定)在构建期直接求值嵌入——后者省掉了产物文件,但要求构建链接受它。团队学习成本真实存在;调试“生成出来的配置”比调试手写的多一层间接。所以当下的务实答案是分层:配置量小时用格式(YAML / TOML),配置规模失控时才上语言——就像业务代码从小脚本长成服务才引入框架一样。
九、决策清单:该用什么
| 场景 | 推荐 | 一句话理由 |
|---|---|---|
| K8s / GitHub Actions / Ansible 等既有生态 | YAML(别反抗) | 生态锁定力大于格式优劣,标新立异的成本是整个工具链 |
| Rust / Python 打包 / Hugo 等新工具链 | TOML | 生态默认,且无歧义性经得起规模 |
| npm / 前端工具链 | JSON(C) | 生态默认;能用 JSONC 方言或宽容解析器时把注释用起来 |
| 需要强校验的企业集成、遗留 .NET | XML + XSD | 校验体系在其生态内最成熟,遗留系统里它是资产不是负债 |
| 本地开发的简单环境变量 | .env | 开发期事实标准;生产环境优先用平台环境变量、Secret 或密钥管理服务,不依赖入库的 .env |
| 多环境大配置、规模失控 | Jsonnet / CUE / Pkl 或 Kustomize 分层 | 重复和漂移已经不是格式能解决的了 |
| 自研工具选格式 | TOML 起步 | 新项目没有历史包袱,直接站无歧义这一边 |
核心原则只有一条:跟随生态 > 个人偏好。格式是工具链的通用语,选“更喜欢的格式”等于主动放弃工具链。
十、设计思想:格式演进教会我们什么
- 不可能三角永远在,选格式就是选权衡。(再次强调:这是本文的分析模型。)没有最好的格式,只有把哪个角做到极致的格式。骂一种格式之前,先看它把哪个角做到了极致——YAML 的坑,正是它“人写舒服”的学费。
- 每一代格式都是上一代痛点的补丁。 INI 无结构 → XML 有结构;XML 冗长 → JSON 精简;JSON 无注释 → YAML 有注释;YAML 有坑 → TOML 无歧义。演进的动力从来不是“更好”,是“上一代哪里疼”。
- 配置的尽头是代码。 数据格式 → 组合 / 模板 → 语言,三十余年走出一条清晰的路。当配置需要变量、复用、校验时,它本质上已经是程序了——只是我们花了很久才承认。
- 生态锁定力大于格式本身。 YAML 的坑人尽皆知,K8s 依然用它——因为工具链、文档、肌肉记忆全部锁定。格式的战争从来不在语法层打,在生态层打。
结语
从 INI 到 Pkl,配置的三十余年围着同一个不可能三角打转:每一代格式都信誓旦旦要同时给三个角,然后在两三年后被下一代指出它牺牲了哪个。这不是工程师的失败,是问题的本性——“给人写的”和“给机器解析的”天然互相拉扯,而现代软件又逼着配置越来越像程序。
所以下次再被 YAML 的坑气到,或者发现 appsettings.json 里居然能写注释,你可以会心一笑:你看到的不是孤立的怪癖,是三十余年权衡艺术的一个切片。选格式时也一样——先看生态要什么,再看三角上你愿意付哪一角的利息。格式没有终局,只有下一轮重新配平。