本文将试着解释为什么大多数配置格式用起来都不太舒服,作者建议大家尝试使用一门真正的编程语言(例如,像Python这样的通用编程语言)来编写配置,通常这是一种可行的选择,且使用过程更感愉悦。
本节,我主要针对JSON/YAML/TOML/ini文件,这是我遇到过最常见的配置格式。
我们暂将这种配置称为常见配置(如果有更好的名字,欢迎在评论中留言,谢谢)。
大家可能遇到过如下情况:
例如,虽然YAML在理论上支持重用/引用配置(他们称之为锚),但有些软件(如Github Actions)却并不支持。通常,开发者无法重用配置的一部分,必须复制粘贴。
很多人认为这是一种积极的做法,但我认为,如果不能定义临时变量、辅助函数、替换字符串或连接列表,那就有点差劲。变通方法(如果有的话)通常也不好用,因为它们额外增加了认知开销。于是,出现了一批重新发明的编程语言:
此外,他们有自己的一套函数来处理变量。你得为此学习一门从来都未曾想过要学习的新语言。
总结:我们在花时间学习没什么用处的语法,而不是在富有成效地完成工作。
当遇到这些问题时会出现什么情况呢?通常最终会使用一种“真正的”(即通用的、图灵完备的)编程语言来解决问题:
在大多数情况下,它就是类型检查的样板文件。你不仅要处理已解决的问题,而且得到的错误消息质量也不高,所有这些事情都会分散你在主要目标上的注意力。
其思想是用目标编程语言编写配置。这里我将使用Python,但是,这一思想也适用于其他语言,只要足够动态即可(比如Javascript、Ruby等等)。这样,只需import或evaluate配置文件就可完成。
一个小例子:
config.py
from typing import NamedTuple
class Person(NamedTuple):
name: str
age: int
PEOPLE = [
Person('Ann' , 22),
Person('Roger', 15),
Person('Judy' , 49),
]
使用这个配置(如果你想知道为什么我使用exec而不是import,请看看这个回复):
from pathlib import Path
config = {}
exec(Path('config.py').read_text(), config)
people = config['PEOPLE']
print(people)
[Person(name='Ann', age=22), Person(name='Roger', age=15), Person(name='Judy', age=49)]
我觉得它很简洁。让我们看看如何解决上文所述问题:
你甚至可以import 正在配置的包,可以针对配置定义一个DSL,它将在配置文件中进行导入和使用。
逻辑
你可以使用语言的语法和库。例如,单独使用像pathlib之类的可以节省大量重复配置。
当然,随意乱用可能会让人难以理解。就我个人而言,我宁愿接受语言被滥用,也不愿受限制。
校验
你可以将逻辑校验保留在配置中,以便在加载时进行检查。成熟的静态分析工具(如JS flow、eslint、pylint、mypy)对此可以有所帮助。
互操作性
如果程序是用Python编写的,那没什么问题。但如果不是,或者稍后将以另一种语言(比如C++之类的编译语言)重写它,该怎么办呢?
将来,软件是否无需解释器即可运行?现代的FFI很是繁琐,链接配置将相当棘手。
我们特别以Python为例,大多数现代OS发行版中都有它。那么,你可以按以下方式来做:
由于Python是动态的,所以无需样板文件即可执行此步骤。
通用编程语言很难推理
这多少有点主观。就我个人而言,我更有可能被一个过于冗长的普通文本配置搞得不知所措,我一直都更喜欢简洁的DSL。其中一个重要因素是代码风格:我确信你可以使配置文件在几乎任何编程语言中都具有可读性,甚至根本不熟悉该语言的人也能够看得懂,最大的问题可能是安全性和终止检查。
安全性
例如,如果配置可以执行任意代码,那么它可能会窃取密码、格式化硬盘等。
如果配置是由你不信任的第三方提供的,那么,我认为普通文本配置更安全。然而,通常并非如此,一般都是用户自己控制自己的配置。
此外,也可以通过沙箱解决这一问题,是否值得这样做取决于项目的性质,但是如果你使用像CI executor之类的东西,无论如何都需要它。
另外要注意,使用普通文本的配置格式不一定能躲过这些麻烦。参见“YAML:一般并不安全”。
终止检查
即使不关心安全性,也不希望配置会挂起程序。我个人从来没有遇到过这样的问题,但这里有一些潜在的解决方法可供参考:
有人知道在通用语言中检查终止的保守的静态分析工具的例子吗?注意,使用普通文本配置并不意味着它不会无限循环,参阅"Accidentally Turing complete".
配置会花很多时间去evaluate,虽然技术上需要在有限的时间内完成,请参阅"Why Dhall advertises the absence of Turing-completeness"。虽然Ackermann函数是一个人为设计的例子,但它表明如果你真的关心恶意输入,那么无论如何都要做沙箱处理。
我发现出于以下原因,大家都特别喜欢用Python来编写配置文件:
其实,你可以在大多数现代编程语言中获得类似的愉快体验(只要它们足够动态)。
一些项目允许用代码作为配置:
允许同时使用setup.cfg和setup.py文件。这样的话,如果你不能以普通文本配置完成你的需求,那么可以在 setup.py中进行调整,从而使你可以在声明式和灵活性之间取得平衡。
使用一个python文件配置输出。
虽然我一点也不喜欢Elisp,但它确实使Emacs非常灵活,可以实现你想要的任何配置。另一方面,如果你曾经读过其他人的Emacs设置,那么你可以发现,当你允许使用通用语言进行配置时,有些事情可能很难操控。
有些语言是专门为配置而设计的:
虽然为了确保终止检查和确定性而特意对Bazel进行了限制,但是配置Bazel比我使用过的任何其他构建系统都要愉快得多。
虽然弄一门全新的语言让人感觉有点大材小用,但是仍然好过用普通文本来进行配置。
Dhall宣称自己是“JSON +函数+类型+导入”。的确,它看起来很棒,解决了我上文列出的大部分问题。
它们之间的具体区别,请参阅其他配置语言间的比较
这种语言的缺点是还没有被广泛使用。如果你没有绑定目标语言,那么需要二次解析JSON。
但是,至少它能使你可以愉快地编写配置。
然而,如果你的程序是用Javascript编写的,并且不与其他语言交互,那么为什么不直接用Javascript编写配置呢?
在使用普通文本配置的时候,我找到了一些减少那些问题的方法:
尽量少写配置文件
这通常适用于CI流水线配置(例如Gitlab、Circle、Github Actions)或Dockerfiles。通常情况下,这样的配置使用了大量的shell命令,如果不逐行复制,就不可能在本地运行。
是的,的确也有调试的方法,但是它们的反馈周期非常慢。
这多少有点令人沮丧,因为它引入了间接而分散的代码。但是,同时它也是一个优势,你可以剥离(例如shellcheck)你的流水线脚本,使它更容易在本地运行。有时,如果你的流水线很短,你可以视情况做出自己的判断。让CI只负责为你设置VM/容器、缓存依赖项和发布构件。
生成而不是手动编写
这样做的缺点是,相比于手工编辑而言,生成的配置可能会更分散。
你可以添加警告注释,提醒该配置是自动生成的,并附上生成器的链接,同时将配置文件设置为只读,以防止有人手动编辑。
此外,如果你正在实行CI,可以将一致性检查作为流水线本身的一部分。
总体上,我同意这一观点,但是仍然有些情况是不适用于标记的。
它也容易泄露机密(密钥、令牌、密码)——无论是在你的shell历史记录中还是通过ps都可以看到。
一个有趣的方法,但不一定总是可行的,例如,你可能没有安装编译器。
我在网站上找了很久才找到一个代码例子,就在这里。
之于现在为什么YAML成为一个主流选择,我还没有答案。我相信,Ansible/CircleCI 或者Github Actions都出自于非常优秀的工程师之手,他们应该考虑过使用YAML的利弊。
欢迎大家在评论区留言,分享你在做配置时经受过的痛苦,以及是如何解决它的。
领取专属 10元无门槛券
私享最新 技术干货