Categories
程式開發

為什麼配置令人抓狂?這是一篇對YAML的瘋狂吐槽


本文將試著解釋為什麼大多數配置格式用起來都令人沮喪,我建議大家嘗試使用一門真正的編程語言(例如,像Python這樣的通用編程語言)來編寫配置,通常這是一種可行的選擇,且使用過程會令你更感愉悅。

為什麼配置令人抓狂?這是一篇對YAML的瘋狂吐槽 1

大多數現代配置格式都很糟糕

本節,我主要針對的是JSON/YAML/TOML/ini文件,這是我遇到過最常見的配置格式。

我們暫將這種配置稱為常見配置(如果有更好的名字,歡迎在評論中留言,謝謝)。

大家可能遇到過如下情況:

  • JSON 沒有註釋,設計如此
  • 大量配置無法重用

例如,雖然YAML在理論上支持重用/引用配置(他們稱之為),但有些軟件(如Github Actions)卻並不支持。通常,開發者無法重用配置的一部分,必須複製粘貼。

  • .gitconfig使用一個自定義語法來合併這些配置
  • 不能包含任何邏輯

很多人認為這是一種積極的做法,但我認為,如果不能定義臨時變量、輔助函數、替換字符串或連接列表,那就有點差勁了。

變通方法(如果有的話)通常也不好用,因為它們額外增加了認知開銷。於是,出現了一批重新發明的編程語言:

此外,他們有自己的一套函數來處理變量。你得為此學習一門從來都未曾想過要學習的新語言。

  • 範圍

    • 例如,在Github操作中有幾個針對於env指令的自定義作用域。
  • 控制流

    • for循環:構建矩陣和“排除”總是讓人頭疼不已
    • if 語句:例如,CircleCI中的when
  • 無法被校驗

你可以校驗配置語法本身(例如,檢查JSON串的正確性),但無法做語義檢查。這是因為在配置文件中沒有邏輯。通常情況下,你必須編寫一個輔助程序來檢查配置,並在傳遞給程序之前調用。很少有程序會遇到這個問題,通常,使用簡單的類型系統就可以發現程序中的細小錯誤。

  • YAML的隱式轉換和可移植性問題非常突出

這一點已經飽受非議,所以在此只提供一個相關鏈接,供感興趣的讀者自行了解:“YAML:可能沒那麼好

總結:我們在花時間學習沒什麼用處的語法,而不是在富有成效地完成工作

變通方法

當遇到這些問題時會出現什麼情況呢?通常最終會使用一種“真正的”(即通用的、圖靈完備的)編程語言來解決問題:

  • 編寫一個過濾自定義註釋語法的程序;
  • 編寫一個合併配置或使用模板引擎的程序;
  • 編寫一個“evaluate”配置的程序,在此過程中,常常需要為一門簡單的函數式語言重新實現一個解釋器
  • 編寫一個校驗配置的程序。

在大多數情況下,它就是類型檢查的樣板文件。你不僅要處理已解決的問題,而且得到的錯誤消息質量也不高,所有這些事情都會分散你在主要目標上的注意力。

使用一門真正的編程語言

其思想是用目標編程語言編寫配置。這裡我將使用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

你甚至可以import 正在配置的包。因此你可以針對配置定義一個DSL,它將在配置文件中得到導入和使用。

邏輯

你可以使用語言的語法和庫。例如,單獨使用像pathlib之類的就可以節省大量的重複配置。

當然,隨意亂用可能會讓人難以理解。但就我個人而言,我寧願接受語言可能被濫用,也不願受到限制。

校驗

你可以將邏輯校驗保留在在配置中,以便在加載時進行檢查。成熟的靜態分析工具(如JS flow、eslint、pylint、mypy)對此可以有所幫助。

缺點

這種方法有什麼問題嗎?肯定也有:

互操作性

好吧,如果你的程序用Python編寫的,那麼沒什麼問題。但如果不是,或者你稍後將以另一種語言(比如C++之類的編譯語言)重寫它,該怎麼辦呢?

將來,軟件是否無需解釋器即可運行?現代的FFI很是繁瑣,鏈接配置將相當棘手。

我們特別以Python為例,大多數現代OS發行版中都有它。那麼,你可以按以下方式來做:

  1. 使你的Python配置可執行
  2. 在main() 函數中構建配置,轉換為JSON串並輸出到stdout

由於Python是動態的,所以無需樣板文件即可執行此步驟。

  1. 在你的c++代碼中,執行該Python 配置(比如,使用popen()),讀取原生的JSON串並予以處理。

是的,你仍然需要手動在c++代碼中將配置反序列化。但我認為這至少不像只使用JSON並手動編輯它那麼糟。

通用編程語言很難推理

這多少有點主觀。就我個人而言,我更有可能被一個過於冗長的普通文本配置搞得不知所措,我一直都更喜歡簡潔的DSL。

其中一個重要因素是代碼風格:我確信你可以使配置文件在幾乎任何編程語言中都具有可讀性,甚至根本不熟悉該語言的人也能夠看得懂,最大的問題可能是安全性和終止檢查。

安全性

例如,如果你的配置可以執行任意代碼,那麼它可能會竊取密碼、格式化硬盤等。

如果配置是由你不信任的第三方提供的,那麼我認為普通文本配置更安全。然而,通常並非如此,一般都是用戶自己控制自己的配置。

此外,也可以通過沙箱解決這一問題,是否值得這樣做取決於項目的性質,但是如果你使用像CI executor之類的東西,無論如何都需要它。

另外要注意,使用普通文本的配置格式不一定能躲過這些麻煩。參見“YAML:一般並不安全”。

終結檢查

即使不關心安全性,也不希望配置會掛起程序。我個人從來沒有遇到過這樣的問題,但這裡有一些潛在的解決方法可供參考:

  • 為加載配置指定顯式的超時時間
  • 有些語言能夠有所幫助,例如,Bazel Skylark

有人知道在通用語言中檢查終止的保守的靜態分析工具的例子嗎?注意,使用普通文本配置並不意味著它不會無限循環,參閱“Accidentally Turing complete”.

配置會花很多時間去evaluate,雖然技術上需要在有限的時間內完成,請參閱“Why Dhall advertises the absence of Turing-completeness”。雖然Ackermann函數是一個人為設計的例子,但它表明如果你真的關心惡意輸入,那麼無論如何都要做沙箱處理。

為什麼是Python?

我發現出於以下原因,大家都特別喜歡用Python來編寫配置文件:

  • 幾乎所有的現代操作系統中都有Python
  • 大家認為Python語法很簡單(不是件壞事),所以Python配置很有可能不會比普通配置更難理解
  • 數據類、函數和生成器構成了精簡的DSL的基礎
  • 類型標註同時用作文檔和校驗

其實,你可以在大多數現代編程語言中獲得類似的愉快體驗(只要它們足夠動態)。

還有誰在做這件事?

一些項目允許用代碼作為配置:

  • Webpack,Web模塊打包器,使用Javascript作為配置
  • setuptools,安裝Python包的標準方法

允許同時使用setup.cfg和setup.py文件。這樣的話,如果你不能以普通文本配置完成你的需求,那麼可以在 setup.py中進行調整,從而使你可以在聲明式和靈活性之間取得平衡。

使用一個python文件配置輸出。

  • Emacs:大家都知道使用Elisp進行它的配置

雖然我一點也不喜歡Elisp,但它確實使Emacs非常靈活,可以實現你想要的任何配置。另一方面,如果你曾經讀過其他人的Emacs設置,那麼你可以發現,當你允許使用通用語言進行配置時,有些事情可能很難操控。

有些語言是專門為配置而設計的:

雖然為了確保終止檢查和確定性而特意對Bazel進行了限制,但是配置Bazel比我使用過的任何其他構建系統都要愉快得多。

  • Meson構建系統:借鑒Python的語法
  • Nix:專門為Nix包管理器設計的語言

雖然弄一門全新的語言讓人感覺有點大材小用,但是仍然好過用普通文本來進行配置。

  • Dhall:專門為配置文件設計的語言

Dhall宣稱自己是“JSON +函數+類型+導入”。的確,它看起來很棒,解決了我上文列出的大部分問題。

它們之間的具體區別,請參閱其他配置語言間的比較

這種語言的缺點是還沒有被廣泛使用。如果你沒有綁定目標語言,那麼需要二次解析JSON。

但是,至少它能使你可以愉快地編寫配置。

然而,如果你的程序是用Javascript編寫的,並且不與其他語言交互,那麼為什麼不直接用Javascript編寫配置呢?

如果一個也不選要怎麼辦?

在使用普通文本配置的時候,我找到了一些減少那些問題的方法:

盡量少寫配置文件

這通常適用於CI流水線配置(例如Gitlab、Circle、Github Actions)或Dockerfiles。通常情況下,這樣的配置使用了大量的shell命令,如果不逐行複制,就不可能在本地運行。

是的,的確也有調試的方法,但是它們的反饋週期非常慢。

  • 使用更適合設置本地虛擬環境的工具,如tox-dev/tox
  • 更多地採用helper shell腳本,並從你的流水線中調用它們

這多少有點令人沮喪,因為它引入了間接而分散的代碼。但是,同時它也是一個優勢,你可以剝離(例如shellcheck)你的流水線腳本,使它更容易在本地運行。有時,如果你的流水線很短,你可以視情況做出自己的判斷。讓CI只負責為你設置VM/容器、緩存依賴項和發布構件。

生成而不是手動編寫

這樣做的缺點是,相比於手工編輯而言,生成的配置可能會更分散。

你可以添加警告註釋,提醒該配置是自動生成的,並附上生成器的鏈接,同時將配置文件設置為只讀,以防止有人手動編輯。

此外,如果你正在實行CI,可以將一致性檢查作為流水線本身的一部分。

參考資料

總體上,我同意這一觀點,但是仍然有些情況是不適用於標記的。

它也容易洩露機密(密鑰、令牌、密碼)——無論是在你的shell歷史記錄中還是通過ps都可以看到。

  • Xmonad:配置文件可執行文件

一個有趣的方法,但不一定總是可行的,例如,你可能沒有安裝編譯器。

  • Mage:以Go編寫用於makefile的工具
  • Dhall wiki:可編程的配置文件
  • 擴展語言的演變:Lua的歷史——顯然Lua已經開始成為配置語言
  • Cue:定義、生成和驗證數據的語言

我在網站上找了很久才找到一個代碼例子,就在這裡

最後的問題

之於現在為什麼YAML成為一個主流選擇,我還沒有答案。我相信,Ansible/CircleCI 或者Github Actions都出自於非常優秀的工程師之手,他們應該考慮過使用YAML的利弊。

歡迎大家在評論區留言,分享你在做配置時經受過的痛苦,以及是如何解決它的。

原文鏈接:Your configs suck? Try a real programming language.