使用软件,就总要和配置文件打交道。对于命令行工具,配置文件往往是调整设置的唯一途径。即使是对于有设置界面的 GUI 软件,编辑配置文件也往往是提高操作效率、解锁隐藏功能的捷径。然而,配置文件的格式繁多,语法多变,经常给新手用户造成困惑。现有文章一般关注特定工具、特定格式的配置方法,而缺少对配置文件的整体性、规律性讨论。
为此,本系列文章计划从使用者的角度出发,介绍配置文件格式、路径和管理方面的通用知识,希望能帮助读者建立起关于配置文件的整体印象和操作直觉,从而在面对陌生的工具和文件时,仍能形成入手的思路。本篇是上篇,讨论配置文件的分类及各自特点,以及什么是「好」的配置文件格式。
一一列举配置文件的格式,显然是不现实的。但按照由简到繁的顺序,大多数格式都可以划分为以下五类之一:(1) 键—值对格式、(2) 序列化格式、(3) DSL(领域特定语言)格式、(4) 代码格式,以及 (5) 二进制格式。下面分别讨论。
键—值对格式
键—值对是最简单的一类格式——至少在表面上。顾名思义,这类格式只包括一系列配置项的名称及其内容。
最典型的键—值对配置,就是许多命令行工具都支持的环境变量配置文件 .env。所谓环境变量,是指软件运行的「外部环境」,通常包括一些临时、仅限于单次运行的参数,例如搜索路径、工作路径、API 密钥、侦听端口等。
ENV 文件就是对环境变量名称和值的简单列举。这里,.env 只是一个习惯命名,主要是为了利用以 . 开头的文件在类 Unix 系统中会默认隐藏的特性。但实际上只要软件支持,可以把任何外部文件当作环境变量列表来读取。
假设你手上有一个直径 78 毫米、重 215 克的富士苹果,用 ENV 的语法来表示就是:
VARIETY=fuji
DIAMETER=78
WEIGHT=215
实践中,Docker Compose 就广泛使用 ENV 文件来为将要运行的容器设定端口、挂载路径等;近来许多命令行 AI 工具也会从 .env 中读取 OpenAI 等模型供应商的 API 密钥。如果一个命令默认读取或者支持通过参数传入 ENV 文件,其效果就如同将这些参数附加在命令名称前运行(VAR_1=val_1 [VAR_n=val_n...] command)。
ENV 文件的一大缺点在于没有通用的语法标准。按照传统的 export(1p) 命令语法,变量名称区分大小写(惯例上全大写),等号前后不得有空格,而双引号中的变量会被进一步展开为其实际值。但许多语言和工具对这个语法做了修改和扩展,例如有的会自动修剪等号前后的空格,有的允许用 # 添加行内和整行注释等。因此在编写时,应当注意查看相应的文档,以免出错。
比 .env 稍微「高级」一点的是 INI 文件。INI 是 MS-DOS 和早期 Windows 主要使用的配置文件格式,直到注册表机制逐步取代了 INI 文件在 Windows 平台上的大部分角色。即使如此,INI 仍然在许多场合中广泛使用,最典型的就是 Windows 中用来设置文件夹属性的 Desktop.ini。
除此之外,许多第三方软件使用的 .conf、.cfg 之类格式,也大体遵循 INI 的语法(例如国内用户喜闻乐见的一些网络调试软件);git config 命令写入的配置文件,以及 systemd 用来运行任务的单元文件,本质上也是 INI。
INI 的语法与 ENV 非常相似,每行为一个键值对,主要的区别在于能通过「分节」(sections)将配置划分为多个部分,以及原生支持注释。设想你现在手上有两个苹果,那么一个 .env 就不够用了,但 INI 就可以同时容纳两个苹果的信息:
[apple_1]
VARIETY=fuji
DIAMETER=78
WEIGHT=215
[apple_2]
VARIETY=gala
DIAMETER=70
WEIGHT=185
可惜的是,INI 的语法标准也非常混乱。其首倡者微软并没有提供正式文档,只在用于读取 INI 的 Windows API 函数 GetPrivateProfileString 文档中做了模糊说明。根据这个准官方规则,分节和键名都不区分大小写,分节也不支持嵌套,但很多第三方实现都不同程度地修改了这些规定。
看到这里,你可能会理解为什么前文说键—值对格式只是「表面上」简单。一方面,由于缺乏通行标准,你很难在不查阅文档的情况下,知道一个文件究竟使用的是 ENV 或 INI 的哪种变体;凭直觉编写很容易导致错误,解析器之间也难以兼容。另一方面,键—值对格式中是不存在「类型」(type)之分的。也就是说,所有的值都是字符串,例如软件读到一个 0,需要根据上下文自行判断应当将它当作数值、布尔值还是文本。这就给 bug 留下了很多空间。
当然,即使有上述缺陷,键—值对格式易读、易用的优势仍然是明显的,能够胜任许多内容简单、数量有限的配置需求,这也是它们仍然如此常见的原因。
序列化格式
键—值对格式描述的数据结构是扁平的,就像一个只有两列的表格。但很多时候,配置文件需要描述的对象是立体的,涉及到数据的层层嵌套。试想现在让你描述的不是两个苹果,而是两筐苹果;除了需要记录每个筐和苹果各自的属性,还需要记录苹果与筐的从属关系。这时,键—值对就无法胜任了。
这个需求可以通过引入序列化格式来解决。所谓序列化(serialization),是指将数据结构或对象转化为一串可存储或传输的文本。这样,软件在下次运行时,就可以读取这些文本,重新转换为所需的数据或运行状态。
目前,常用于配置文件的序列化格式主要包括两大类:一类是 XML,另一类是 JSON 及基本等效的 YAML 和 TOML。下面分别介绍。
XML 格式
先看 XML(Extensible Markup Language,可扩展标记语言)。从这个名称就可以看出,它本是一种以文档为中心的语言,最初的用途是为了创建同时易于人类阅读和机器解析的数字文档,也是网页文件格式 HTML 的近亲。可以说,充当配置文件格式只是它的一项「兼职」。
如果要用 XML 表示两筐苹果,一种可行的方法是:
<buckets>
<bucket id="1" origin="Aomori">
<apple id="1" variety="fuji" diameter="78" weight="215"></apple>
<apple id="2" variety="gala" diameter="70" weight="185"></apple>
<!-- ... -->
</bucket>
<bucket id="2" origin="Washington">
<apple id="3" variety="honeycrisp" diameter="76" weight="205"></apple>
<!-- ... -->
</bucket>
</buckets>
从这个简单的例子就可以看出,XML 其实不太适合用来表达配置文件这类非文档数据。它的语法是针对「混合内容」优化的:文本夹在语义标签之间,标签中给出文本的类型和属性。但用这种语法来表达没有多少实质文本内容的数据结构,就会显得非常啰嗦,还会带来一些编排上的困惑。
例如,之所以说上面的例子只是「一种可行的方法」,是因为这种写法将数据都放在了尖括号内的属性里。但这并不是必须的,将同样的信息写在子元素里也完全说得通:
<buckets>
<bucket>
<id>1</id>
<origin>Aomori</origin>
<apples>
<apple><id>1</id><variety>fuji</variety><diameter>78</diameter><weight>215</weight></apple>
<apple><id>2</id><variety>gala</variety><diameter>70</diameter><weight>185</weight></apple>
<!-- ... -->
</apples>
</bucket>
<bucket>
<id>2</id>
<origin>Washington</origin>
<apples>
<apple><id>3</id><variety>honeycrisp</variety><diameter>76</diameter><weight>205</weight></apple>
<!-- ... -->
</apples>
</bucket>
</buckets>
这两种写法各有利弊,但都跟简洁搭不上关系。
实际上,XML 之所以会成为一种广泛使用的配置文件格式,很大程度上可能是因为千禧年前后也没有更好的选择。在 XML 于 1998 年推出时,它是为数不多由知名标准化组织(W3C)推出、有广泛跨语言和跨平台支持、兼具良好规范性和扩展性的格式。加上获得 Java 和微软 .NET 生态的推广,XML 在本世纪早期的流行也是可以理解的。
如今,XML 仍在许多企业软件和开发环境中用作配置文件格式。例如,用于批量自动安装 Windows(unattend.xml)和 Office 的配置文件;Android 开发中指定应用程序属性(AndroidManifest.xml)和布局(res/layout/*.xml)的配置文件;等等。
此外,苹果系统中广泛使用的配置格式 .plist 也支持 XML 作为序列化形式之一。例如,macOS 和 iOS 应用中用于自我描述的 Info.plist,以及 /Library/LaunchAgents 等位置下的自启动项配置文件,打开后都是 XML 格式。
还是以上面那两筐苹果为例,如果用 plist 的语法来表示,会是这样(略去开头元信息):
<plist version="1.0">
<dict>
<key>buckets</key>
<array>
<dict>
<key>id</key><integer>1</integer>
<key>origin</key><string>Aomori</string>
<key>apples</key>
<array>
<dict>
<key>id</key><integer>1</integer>
<key>variety</key><string>fuji</string>
<key>diameter</key><integer>78</integer>
<key>weight</key><integer>215</integer>
</dict>
<dict>
<key>id</key><integer>2</integer>
<key>variety</key><string>gala</string>
<key>diameter</key><integer>70</integer>
<key>weight</key><integer>185</integer>
</dict>
<!-- ... -->
</array>
</dict>
<dict>
<key>id</key><integer>2</integer>
<key>origin</key><string>Washington</string>
<key>apples</key>
<array>
<dict>
<key>id</key><integer>3</integer>
<key>variety</key><string>honeycrisp</string>
<key>diameter</key><integer>76</integer>
<key>weight</key><integer>205</integer>
</dict>
<!-- ... -->
</array>
</dict>
</array>
</dict>
</plist>
可以看出,.plist 虽然形式上是 XML,但采用的是一种非常有限并且古怪的语法:每个 .plist 总是有一个外层的字典元素 <dict>;其中,每个键元素 <key> 的数据并不存储在其属性或子元素中,而是存储在紧邻其后的一个与数据类型相应的平行元素(<string>、<integer>、<array> 等)中,看起来尤其冗长。因此,系统中更多无需面向用户的 .plist 文件采用的是后文将介绍的二进制格式。
JSON、YAML 和 TOML
如果说 XML 格式是被「抓壮丁」征用作配置文件格式的,那么 JSON(JavaScript Object Notation,读作 Jason)就可以说是一种天生适合存储配置的格式。这个起始于 21 世纪初的格式衍生自 JavaScript 语言,从一开始就是为了满足服务器和浏览器之间数据传输的需求而制定的。因此,与 XML 相比,JSON 的语法明显简洁、易读许多,而且与现代程序语言中的常用数据类型高度匹配,不需要额外声明对应关系。
这是用 JSON 来表示上面那两筐苹果的效果:
{
"buckets": [
{
"id": 1,
"origin": "Aomori",
"apples": [
{ "id": 1, "variety": "fuji", "diameter": 78, "weight": 215 },
{ "id": 2, "variety": "gala", "diameter": 70, "weight": 185 }
// ...
]
},
{
"id": 2,
"origin": "Washington",
"apples": [
{ "id": 3, "variety": "honeycrisp", "diameter": 76, "weight": 205 }
// ...
]
}
]
}
(// 开头的注释并非有效 JSON 语法,此处仅为简洁而加入,见后文说明。)
JSON 的便利性使它成为目前最常见的配置文件格式,可能没有之一。例如,Chrome 浏览器用户文件夹中存储设置和状态的 Preferences 和 Local State 文件的内容都是 JSON 格式;Visual Studio Code 分别用 settings.json 和 keybindings.json 来存放用户设置和自定义快捷键;其他使用 Electron 封装的 Web 应用也大多使用 JSON 作配置。开发环境中,最常见的用例大概是每个 Node.js 项目都会产生的依赖项记录文件 package.json。又因为对 JSON 的支持非常广泛,即使不是基于 JavaScript 的软件也经常用它来作配置文件,例如 PyTorch、JupyterLab 这些 Python 生态的软件。
不过,JSON 也有很多限制。被抱怨最多的问题当然是不支持注释——即使作为 JSON 源头的 JavaScript 中有注释语法。这其实是其发明者在早年刻意为之的设计。在他看来,JSON 就应当是纯粹的数据,即使有注释,也应该在送进解析器之前删除。这或许有一定道理,但在配置文件的用例中就显得不太方便了,因为通过注释来提示文件结构和合法值是非常常见的需求。因此,许多 JSON 格式的配置(例如上面提到的 VS Code)实际上用的是一种称为 JSONC(JSON with Comments)的变体,其中明确允许添加语法和 JavaScript 类似的注释。
除此之外,JSON 在有些时候也不是那么易读易用。例如,如果要存储多行文本,只能通过转义字符 \n 来换行,让文件看起来非常臃肿。此外,JSON 完全依赖括号和逗号来体现数据结构,换行和缩进是可选的;一旦数据变复杂,就很难通过这些有限的视觉提示辨识层级,配平括号也会成为一个头疼问题。
正是因为这些问题,一个明显比 JSON 对人类更友好的格式——YAML 成为了很多软件青睐的配置文件格式。如果用 YAML 来表达那两筐苹果,效果会是这样:
buckets:
- id: 1
origin: Aomori
apples:
- id: 1
variety: fuji
diameter: 78
weight: 215
- id: 2
variety: gala
diameter: 70
weight: 185
# ...
- id: 2
origin: Washington
apples:
- id: 3
variety: honeycrisp
diameter: 76
weight: 205
# ...
即使你不熟悉 YAML 的语法,也能很清晰地看出这里的层级结构。与 JSON 不同,YAML 中的换行和缩进具有语法含义,每一行都是一个节点,缩进越大的节点层级越深。由于不再需要使用括号和逗号,并且可以自动识别多种数据类型,YAML 比 JSON 看起来更简洁,也非常适合「手写」。再加上 YAML 刻意保持了与 JSON 的高度兼容——任何有效的 JSON 片段都可以合乎语法地直接插入在 YAML 文件里——YAML 的定位在很大程度上已经变成了「JSON 的友好版本」,尽管它其实比 JSON 诞生更早、功能也更多。
目前,YAML 在开发和运维工具中用得非常广泛,包括持续集成中的自动化工具 GitHub Actions、容器工具 Kubernetes 和 Docker Compose,静态站点生成器 Jekyll 和 Docusaurus 等等。当然,还有一批国内用户喜闻乐见的网络调试工具。
但凡事难两全,一个语言对人类越友好,往往对机器就越不友好。正是因为 YAML 的语法省去了很多「脚手架」,在机器解析时就可能导致歧义。一个最典型的翻车案例就是所谓的「挪威问题」:由于早期版本的 YAML 将二十多种值(包括 true/false、yes/no、on/off 的各种大小写和简写)都接受为布尔值,因此如果数据中出现挪威的国家代码 NO,并且忘记了在两侧加引号来强制声明字符串类型,本来是「挪威」的值就会被当作布尔值「假」,从而引发各种 bug。虽然后来的 1.2 版通过将布尔值限制为 true 和 false 修复了这个问题,但野外的许多解析器仍然采用 1.1 版的宽松解读,导致「挪威问题」直到近年还经常被翻出来嘲笑。
除此之外,依靠缩进来表示层级,虽然写起来方便,但也更容易出错。很多时候,少按一下 Tab、多打一个连字符,出来的效果就完全南辕北辙了。更讨厌的是,这种错误很多时候是语法检查器也检测不出来的,因为弄错缩进写出来的 YAML 很可能在语法上还是有效的,只有放到软件里报错了才会发觉。
因此,如果你在用的软件选择了 YAML 作为配置格式,在修改时应该倍加小心。为此,可以使用支持语法高亮和自动缩进的编辑器来减少出错概率,还可以考虑写好以后用工具转换为 JSON(网上有很多此类工具),这样因为手滑导致的语法问题就容易发现了。
最后要介绍的 TOML 则可以看作 JSON 和 YAML 之间的一个平衡点。TOML 的意思是 Tom’s Obvious, Minimal Language(Tom 是作者的名字;他是 GitHub 的联合创始人和前 CEO),对自己的明确定位就是「简洁的配置文件格式」。
那实际上是否如此呢?不妨直接看看 TOML 语法要如何表达我们的两筐苹果:
[[buckets]]
id = 1
origin = "Aomori"
[[buckets.apples]]
id = 1
variety = "fuji"
diameter = 78
weight = 215
[[buckets.apples]]
id = 2
variety = "gala"
diameter = 70
weight = 185
# ...
[[buckets]]
id = 2
origin = "Washington"
[[buckets.apples]]
id = 3
variety = "honeycrisp"
diameter = 76
weight = 205
# ...
远看起来,TOML 确实具有与 YAML 类似的简洁外观。你可能还会注意到很明显的借鉴 INI 的痕迹;的确,TOML 在语法上可以看作是 INI 的超集。
但再仔细观察,就会注意到 TOML 要远比 YAML 严格。例如,就像在 JSON 中一样,所有的字符串都要用引号包裹,并且缩进不再具有语法含义。数据结构的表达也很繁琐:要表示 buckets 数组中的一个元素下的 apples 数组,就必须对 apples 数组的每个元素都重复一次它的完整路径 [[buckets.apples]]。
诚然,这仍然比 JSON 要易读一些,并且避免了 YAML 在类型和缩进方面的许多陷阱。但反过来说,TOML 的这种「中庸之道」也让它的地位显得很尴尬,既失去了 INI 和 YAML 的部分简洁性,又没有 JSON 的广泛支持。因此,对 TOML 的评价相当分化,虽然不少人表示欣赏它的严谨,但对于它存在必要性的批评就没有停止过。同时,身为配置格式中的「后来者」,它也很难取得和 JSON、YAML 类似的采用率。目前,TOML 的最常见应用是 Rust 项目中的清单文件 Cargo.toml 和 Python 项目中的 pyproject.toml,其他只有零星一些项目用它作配置文件,并且一般只是几种可选格式之一。
代码和 DSL 格式
到目前为止,我们介绍的配置文件格式存储的都是数据,区别只在于用于记录数据的语法和结构不同。但有些时候,我们还希望通过配置文件存储逻辑,也就是指定在某种条件下执行某些操作。这就超出了键—值对和序列化的能力范围(尽管实践中有很多存在争议的「滥用」方法,见后文);而最明显的解决方案,当然是用程序逻辑的天然载体——代码充当配置格式。