配置文件格式的前世今生:INI、XML、JSON、YAML 与 TOML 的不可能三角

写在前面

你每天都在写配置: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.inismb.conf 是 INI 风格,nginx.conf 是自定义的块指令 DSL(大括号 + 分号,长得像 C),redis.conf 是逐行 参数 参数 格式——同一个后缀,三种语法。讨论格式时说 .conf,等于说“姓张的都算北京人”。
  • .proto 是模式语言。它定义的是数据的形状(字段、类型),不是程序的参数。它和 XSD、JSON Schema 同族,是“格式的元层”。
  • .md 是文档标记,目标读者是人。但它有个著名交叉现象:frontmatter——本站每篇文章都是 .md 头上嵌一段 TOML 配置。md 不是配置格式,但它是配置格式最流行的宿主。

本文只讨论第一类。判断口诀:给程序喂参数的是配置格式,给数据定规矩的是模式语言,给人读的是文档,只定后缀不定语法的是惯例。


二、INI:一切的起点

上世纪 90 年代,Windows 3.x 用 WIN.INISYSTEM.INI 管理系统配置,应用程序跟着学,INI 成为事实标准。Windows 甚至提供了专门的 API(GetPrivateProfileString 一族)来读写它——INI 在当年不是“约定”,是操作系统级设施。

它的语法两句话讲完:

1
2
3
4
5
[database]                 ; section(分区)
host = db.prod.internal    ; key = value
port = 5432
pool_min = 2               ; 无嵌套,只能拍平键名
pool_max = 20

它为什么能活三十多年:核心语法 key = value 是人类和机器的共同母语,解析器一屏代码写得完。写一行配置几乎不可能犯语法错误——在语法层,INI 确实简单到不会错。

它为什么被淘汰:表达力天花板太低——

  • 没有嵌套结构(只有一层 section);
  • 没有数组(hosts = a,b,c 是字符串还是列表,全看程序自己 split);
  • 没有统一规范。INI 从未被标准化,每个工具都是方言。同样是注释,php.ini;.gitconfig#; 都认;多行值、引号、转义更是各家各造。语法简单,方言不简单——跨工具的 INI 经验不可迁移,这是它真正的坑。
1
2
; php.ini 风格:分号注释
; date.timezone = Asia/Shanghai
1
2
3
4
5
6
# .gitconfig 风格:# 和 ; 都认,还支持两级分区
[core]
    autocrlf = input

[remote "origin"]        # 子分区
    url = git@github.com:user/repo.git

顺带一段历史注脚: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 年代的企业计算,几乎构筑在尖括号之上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<configuration>
  <database>
    <host>db.prod.internal</host>
    <port>5432</port>
    <pool min="2" max="20" />
    <replicas>
      <replica>db-r1</replica>
      <replica>db-r2</replica>
    </replicas>
  </database>
</configuration>

它的王牌是“格式 + 模式”的完整体系。XSD(XML Schema Definition)可以严格声明每个元素的名字、类型、出现次数、嵌套关系——在进入应用逻辑之前,XML 可以先完成良构性检查、再做 XSD 校验。给前面那段配置配一小段 schema(示意片段,省略了 xs:schema 根元素、命名空间声明和 pool / replicas 的定义):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<xs:element name="database">
  <xs:complexType>
    <xs:sequence>
      <xs:element name="host" type="xs:string"/>
      <xs:element name="port">
        <xs:simpleType>
          <xs:restriction base="xs:int">
            <xs:minInclusive value="1"/>
            <xs:maxInclusive value="65535"/>
          </xs:restriction>
        </xs:simpleType>
      </xs:element>
    </xs:sequence>
  </xs:complexType>
</xs:element>

端口写成 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 双重标准化,本身就是它地位的注脚。

1
2
3
4
5
6
7
8
{
  "database": {
    "host": "db.prod.internal",
    "port": 5432,
    "pool": { "min": 2, "max": 20 },
    "replicas": ["db-r1", "db-r2"]
  }
}

它上位靠什么:机器亲和力碾压前辈。浏览器一行 JSON.parse 就能解析(XML 要 DOM API);结构直接映射到 JavaScript 对象、也天然映射到各语言的哈希表 / 字典;类型系统小而明确(字符串、数字、布尔、null、数组、对象)。它本是 序列化格式 出身,拿来当配置格式是“顺手”——而这一“顺手”埋下了所有问题。

致命伤:严格 JSON 没有注释。 对机器生成、机器消费的序列化格式这无可指摘;对 人手写的配置 这是灾难。生态的自救分三路:

第一路:方言。

  • JSONC:带注释和尾逗号的 JSON。VS Code 的 settings.json、TypeScript 的 tsconfig.json 走的都是这条路——很多人天天在写方言而不自知;
  • JSON5:更进一步。准确的能力清单是:注释、对象和数组的 尾逗号、单引号字符串、无引号键名(标识符)、十六进制数字、Infinity / NaN、前导或尾随小数点(.55.)。注意:成员之间的分隔逗号仍然必须有——JSON5 允许的是“多的”,不是“少的”。
1
2
3
4
5
6
7
{
  // 注释合法
  host: 'db.prod.internal',    // 无引号键 + 单引号值
  port: 0x1f90,                // 十六进制
  timeout: Infinity,
  retries: 3,                  // 尾逗号合法
}

第二路:宽容解析器。 不改格式,改解析器——不少平台在读取时直接容忍注释。最典型的就是 .NET:ASP.NET Core 的 JSON 配置提供程序从早期版本起就跳过 ///* */ 注释、允许尾逗号——appsettings.json 一直能写注释。这是个常见误解的高发区,值得单独澄清:ASP.NET Core 的 appsettings.Development.json / appsettings.Production.json 分层覆盖,解决的是 环境差异与优先级(文件 < 环境变量 < 命令行参数,后者逐层覆盖),不是 注释问题——注释人家本来就支持。

第三路:字段级 hack。 最朴素的民间智慧:

1
2
3
4
{
  "_comment": "生产库只读副本地址,改前先找 DBA",
  "host": "db-ro.prod.internal"
}

能用,但它污染数据结构、无法批量管理、删配置时注释变孤儿——是所有方案里最将就的一个。方言和宽容解析器的繁荣本身就在说明:“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})混用——写起来确实舒服:

1
2
3
4
5
6
7
database:
  host: db.prod.internal
  port: 5432
  pool:
    min: 2
    max: 20
  replicas: [db-r1, db-r2]   # 块结构与流结构混用

它赶上了云原生大潮并被加冕:Kubernetes 清单、docker-compose、GitHub Actions workflow、Ansible playbook、GitLab CI、OpenAPI、Prometheus 配置——云原生世界的官方语言

5.1 先说好东西:多行字符串

YAML 有一个 JSON 完全没有、TOML 要用三引号才有的能力——原生多行字符串

1
2
3
4
5
6
7
8
9
# | 是字面块:保留内部换行,写脚本 / SQL / 证书极方便
bootstrap_script: |
  echo "init..."
  mkdir -p /data/app

# > 是折叠块:换行折叠为空格,适合长段落
description: >
  这几行会在渲染时
  折叠成一行

注意块内 没有注释这回事——缩进进块的 # 是字符串内容而不是注释,所以说明必须写在键的上一行(上面示例就是这么做的)。尾缀还有变体:|- 去掉末尾换行、|+ 保留全部尾部空行。写过大段内嵌脚本的人都知道这个能力多救命——这也是 YAML 拥趸的真爱点之一。

但“人写舒服”这个角的权重加得太狠,“机器解析严格”就被牺牲出了著名的坑群。这一节是全文实用性最高的部分,逐个过:

5.2 坑 1:挪威问题——no 是布尔

1
2
3
4
5
6
7
# ❌ 想表达国家代码,得到布尔值
country: no        # 解析为 false(yes/on/off 同理)
enabled: no        # 你以为在写"否",其实写的是 false——碰巧对,纯靠运气

# ✅ 字符串必须显式
country: "no"
enabled: false     # 或者干脆统一只用 true/false

YAML 1.1 规范把 y / n / yes / no / on / off 全部定义为布尔值,大量主流解析器沿用至今(1.2 已收窄为 true / false,但现实见坑 6)。

5.3 坑 2:数字语义吃掉版本语义

1
2
3
4
5
# ❌ K8s 场景经典翻车
version: 1.10        # 解析为浮点数 1.1(尾零按数字处理),且不报任何错

# ✅ 凡是"看起来像数字的字符串",一律加引号
version: "1.10"

同族问题还有旧式八进制: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:重复键静默覆盖

1
2
3
replicas: 3
# ……300 行之后……
replicas: 5      # 规范说应该报错,但很多解析器静默取后者

配置写重了不吭声,前值悄悄丢失——排查半小时,原因是两个同名键隔着三百行。

5.6 坑 5:锚点、别名与合并键

1
2
3
4
5
6
7
base: &base
  timeout: 30
  retries: 3

production:
  <<: *base          # 合并锚点
  retries: 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八进制必须 0o10010 不按八进制处理
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] 表达,缩进不参与语法
重复键覆盖重复定义直接报错
锚点难读不提供引用机制(宁可不复用,不要看不懂)
1
2
3
4
5
6
7
8
[database]
host = "db.prod.internal"
port = 5432                    # 数字就是数字,字符串就是字符串

[database.pool]
min = 2
max = 20
replicas = ["db-r1", "db-r2"]  # 数组就是数组,不需要 split 约定

6.1 类型表:一切显式

类型写法
字符串"..."'...'(原样字符串)、"""..."""(多行)
整数42、十六进制 0x2A、八进制 0o52、二进制 0b101010,可加 _ 分隔
浮点3.14、科学计数 1e6infnan
布尔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 同一种嵌套,三种写法

三种写法各自是 独立文档,不能在同一份文件里混着拼:

1
2
3
4
# 写法一:表头(正式、可逐项注释)
[database.pool]
min = 2
max = 20
1
2
3
# 写法二:点号键(紧凑)
database.pool.min = 2
database.pool.max = 20
1
2
# 写法三:内联表(一行带走;定义闭合后不能再向其中追加字段)
database.pool = { min = 2, max = 20 }

为什么不能混拼?两条规则:进入 [database.pool] 表头之后,后续的键(包括点号键)都 相对当前表 解释——database.pool.max 会变成 database.pool.database.pool.max;而内联表是自包含的、封闭的,不允许事后追加。数组对象则用双方括号:

1
2
3
4
5
6
7
[[servers]]
name = "db-r1"
role = "primary"

[[servers]]
name = "db-r2"
role = "replica"

代价:表头一多就满屏方括号,深层嵌套啰嗦;“人写舒服”被做成了“人写 明确”——舒服,但繁琐。

战绩:Rust(Cargo.toml)、Python 打包(pyproject.toml,PEP 518 起)、Hugo、Netlify。注意这个名单的共性:采纳 TOML 的多是 2013 年后为新场景新定标准的生态——Hugo 2013 年发布时选了它,Python 打包则是 2016 年 PEP 518 为新打包元数据立标准时选中它。老牌领域(云原生)已归 YAML,它抢不动。


七、同一个配置写五遍

把同一组语义(注释、嵌套、数组、类型)用五种格式各写一遍,差别一目了然:

1
2
3
4
5
# INI:有注释;嵌套拍平,数组靠约定
[retry]
attempts = 3
backoff = 1.5
hosts = a,b,c
1
2
3
4
<!-- XML:有注释;一切正确,一切冗长 -->
<retry attempts="3" backoff="1.5">
  <hosts><host>a</host><host>b</host><host>c</host></hosts>
</retry>
1
2
3
4
// JSON:注释非法(这行会报错);数组最干净
{
  "retry": { "attempts": 3, "backoff": 1.5, "hosts": ["a", "b", "c"] }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# YAML:有注释、最简洁——但裸值交给了解析器
retry:
  attempts: 3
  backoff: 1.5
  hosts: [a, b, c]
  # 真正的雷区长这样(见 5.2 / 5.3 / 5.7):
  # country: no       → 1.1 下是布尔 false
  # version: 1.10     → 两版都是浮点 1.1
  # legacy: 010       → 1.1 下是八进制 8
  # schedule: 12:30   → 1.1 下是六十进制 750
1
2
3
4
5
# TOML:有注释;引号强制,类型无歧义
[retry]
attempts = 3
backoff = 1.5
hosts = ["a", "b", "c"]

汇总成一张表:

维度INIXMLJSONYAMLTOML
注释✗(JSONC 方言 / 宽容解析器补)
嵌套✓(缩进)✓(表头)
数组✗(约定)✓(冗长)
类型歧义多(全靠约定)少(皆文本)少(仍有重复键、大数精度等互操作坑)多(坑群,依解析器而异)极少(引号强制)
人的书写成本极低极高中(无注释之痛)低中
模式校验XSD(生态内标杆)JSON Schema✗(生态外挂)
机器解析极易极易中(方言多)
当下生态位.env / 老软件企业遗留、XAML前端 / .NET云原生新工具链

八、下一个二十年:配置语言化

配置格式的故事到 TOML 并没有结束,因为不可能三角的“表达能力”角又到了加码的时候——现代配置的三大新痛点:

  1. 重复:dev / staging / production 三套配置 95% 相同,复制三份必然漂移;
  2. 规模:一个中型 K8s 项目的 YAML 清单轻松上千行;
  3. 校验时机:格式解析只保证“语法对”,不保证“值合理”——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——一门能力完整的数据模板语言,重复和派生场景都能覆盖:

1
2
3
4
5
local base = { timeout: 30, retries: 3 };
{
  production: base + { retries: 5 },
  staging: base + { timeout: 10 },
}

CUE(源于 Google 内部经验)——“Configure, Unify, Execute”。核心思想是 约束与统一:类型即值、值即约束,同一份数据可以被多个来源“合并求值”,冲突当场报错。它同时吞掉了“重复”和“校验”两个问题——不仅能生成配置,还能 验证 已存在的配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#Database: {                 // 定义:字段 + 类型 + 约束
    host: string
    port: int & >=1 & <=65535
    pool: { min: int & >=0, max: int & >0 }
}

database: #Database & {      // 值与定义"统一"求值
    host: "db.prod.internal"
    port: 5432
    pool: { min: 2, max: 20 }
}
// 把 port 改成 70000,cue 求值当场报冲突——错误发生在生成之前,不是部署之后

Dhall(2016)——把编程语言理论的极端严格带进配置:total language(保证终止,配置不会算到死循环)、完备的类型系统。函数与类型齐备:

1
2
3
4
let makeDb : Text -> Natural -> { host : Text, port : Natural } =
      \(host : Text) -> \(port : Natural) -> { host = host, port = port }

in  makeDb "db.prod.internal" 5432

它支持为导入附加语义哈希来固定远程依赖的内容(dhall freeze 负责把哈希补上)——是可选的供应链安全机制,不是强制的。

Pkl(Apple,2024 开源)——配置界的“后 Java”路线:类、静态类型、约束直接写在类型上,官方提供编译输出(JSON / YAML / Plist)与多语言绑定:

1
2
3
4
5
6
7
8
9
class Database {
  host: String                  // 类型必填,缺失即错
  port: Int(this >= 1 && this <= 65535)   // 约束直接挂在类型上
}

database: Database = new {
  host = "db.prod.internal"
  port = 5432                   // 若写 70000,加载即报错
}

(还有一脉不叫配置语言但同属此列:Bazel 的 Starlark——把 Python 裁剪成可确定求值的方言,BUILD 文件即代码。)

8.3 这一波在不可能三角上怎么挪的

1
2
3
4
5
6
7
8
表达能力的阶梯:

纯数据             结构化组合              模板                    语言
JSON / TOML        YAML 锚点 / 合并        Helm(Go 模板)           Jsonnet / CUE / Dhall / Pkl
                   Kustomize(overlay)     (字符串层替换)           (变量 / 函数 / 类型 / 校验)

"机器严格"的实现位置:解析期 ──────────────────────────────▶ 求值期 / 编译期
"表达能力"的范围:    字面量 ──────────────────────────────▶ 组合 → 派生 → 逻辑

配置语言把“机器解析严格”从 解析期 推到了 求值期(类型与约束错误在生成产物之前报出,错误信息指名道姓到字段),把“表达能力”从字面量扩到逻辑,同时靠 LSP / IDE 把“人写舒服”用工具抢回来一部分。三角没有被消灭——只是被重新配平。

8.4 诚实的另一面

配置语言不是免费午餐。产物的消费方式 有两路:预编译成 YAML / JSON 提交进库,或通过语言绑定(如 pkl-go、Java 绑定)在构建期直接求值嵌入——后者省掉了产物文件,但要求构建链接受它。团队学习成本真实存在;调试“生成出来的配置”比调试手写的多一层间接。所以当下的务实答案是分层:配置量小时用格式(YAML / TOML),配置规模失控时才上语言——就像业务代码从小脚本长成服务才引入框架一样。


九、决策清单:该用什么

场景推荐一句话理由
K8s / GitHub Actions / Ansible 等既有生态YAML(别反抗)生态锁定力大于格式优劣,标新立异的成本是整个工具链
Rust / Python 打包 / Hugo 等新工具链TOML生态默认,且无歧义性经得起规模
npm / 前端工具链JSON(C)生态默认;能用 JSONC 方言或宽容解析器时把注释用起来
需要强校验的企业集成、遗留 .NETXML + XSD校验体系在其生态内最成熟,遗留系统里它是资产不是负债
本地开发的简单环境变量.env开发期事实标准;生产环境优先用平台环境变量、Secret 或密钥管理服务,不依赖入库的 .env
多环境大配置、规模失控Jsonnet / CUE / Pkl 或 Kustomize 分层重复和漂移已经不是格式能解决的了
自研工具选格式TOML 起步新项目没有历史包袱,直接站无歧义这一边

核心原则只有一条:跟随生态 > 个人偏好。格式是工具链的通用语,选“更喜欢的格式”等于主动放弃工具链。


十、设计思想:格式演进教会我们什么

  1. 不可能三角永远在,选格式就是选权衡。(再次强调:这是本文的分析模型。)没有最好的格式,只有把哪个角做到极致的格式。骂一种格式之前,先看它把哪个角做到了极致——YAML 的坑,正是它“人写舒服”的学费。
  2. 每一代格式都是上一代痛点的补丁。 INI 无结构 → XML 有结构;XML 冗长 → JSON 精简;JSON 无注释 → YAML 有注释;YAML 有坑 → TOML 无歧义。演进的动力从来不是“更好”,是“上一代哪里疼”。
  3. 配置的尽头是代码。 数据格式 → 组合 / 模板 → 语言,三十余年走出一条清晰的路。当配置需要变量、复用、校验时,它本质上已经是程序了——只是我们花了很久才承认。
  4. 生态锁定力大于格式本身。 YAML 的坑人尽皆知,K8s 依然用它——因为工具链、文档、肌肉记忆全部锁定。格式的战争从来不在语法层打,在生态层打。

结语

从 INI 到 Pkl,配置的三十余年围着同一个不可能三角打转:每一代格式都信誓旦旦要同时给三个角,然后在两三年后被下一代指出它牺牲了哪个。这不是工程师的失败,是问题的本性——“给人写的”和“给机器解析的”天然互相拉扯,而现代软件又逼着配置越来越像程序。

所以下次再被 YAML 的坑气到,或者发现 appsettings.json 里居然能写注释,你可以会心一笑:你看到的不是孤立的怪癖,是三十余年权衡艺术的一个切片。选格式时也一样——先看生态要什么,再看三角上你愿意付哪一角的利息。格式没有终局,只有下一轮重新配平。

参考资料

Licensed under CC BY-NC-SA 4.0