原文:Why you should ditch YAML and use Jsonnet instead
作者:Joachim Bargsten · 原文发表于 2023-01-14。

译注:文中的“我”指原作者,工具和文档的相关描述保留原文发表时的语境。

我说不清自己究竟是什么时候想明白的。不过,看看 YAML 的格式和它的用法,我觉得这准是魔鬼的杰作之一。就像苍蝇被一大坨牛粪吸引,包括我在内的开发者,也被一股无形的力量推向了这种格式。

事情本来可以很简单。能收拾配置乱摊子的替代方案多的是,有类型的、无类型的都有。可不不不,我们偏要用 YAML。我大概是在第一次看到 Helm chart 时想明白这件事的。过了一阵,我才捋清这套逻辑:

  1. Helm 是 Kubernetes 的包管理器。
  2. 我把 Helm chart 写成“包”,好让别人复用。
  3. 压根没人复用这些 chart。真有人复用,就会产生极其紧密的耦合,正是每篇讲 clean code 的博客都在警告的那一种。
  4. 反复试错,直到跑通。
  5. 就快搞定了,只差把缩进调对。

或者,这就是所谓的“Arbeitsbeschaffungsmaßnahme”:人为增加工作量,好给更多开发者找点活干。

我也说不准。

YAML 的规范复杂得过了头,还会出现 Norway Problem 这样的意外解析结果,可能存在安全问题,而且经常被滥用,拿来解决本不该由 YAML 解决的问题。

还有什么替代方案?

我开始尝试 Jsonnet 和 HOCON。HOCON 在 Java/Scala 圈子之外没多少人知道,其他语言对它的支持也就……一般般。

那 Jsonnet 呢?我个人觉得,它在能力和简洁性之间拿捏得刚好。

Jsonnet 简介

Jsonnet 是什么?简单说:

面向应用和工具开发者的 data templating language。

可以把它看成增强版 JSON。从技术上说,它是 JSON 的 superset,加入了一些编程语言结构。因此,Jsonnet 需要先“编译”或转换,通常输出为 JSON。

Jsonnet = JSON + Variables, Conditionals, Arithmetic, Functions, Imports and Error Propagation

例如:

local person3 = import 'person3.libsonnet';
{
  person1: {
    name: "Alice",
    welcome: "Hello " + self.name + "!",
  },
  person2: self.person1 { name: "Bob" },
  person3: person3
}

流程很简单。从一个 Jsonnet 文件,也就是 main file 开始,在里面 import 其他 Jsonnet 文件(见图 1),再把 main file 编译成 JSON。

Jsonnet 到 JSON 的流程

图 1:Jsonnet 到 JSON 的流程。(a)Jsonnet 支持 import,可以通过 import 指令引入其他文件,这些文件通常使用 .libsonnet 扩展名,例如 local martinis = import 'martinis.libsonnet';。(b)执行 import 的配置文件,也就是 main file,把各项 imports 合并成一个配置 object;(c)将它写成 JSON 输出。

更多信息见官方文档。

能力与简洁性

能力上的空缺,总会有人来填,不管你愿不愿意。对 YAML 来说,Jinja 就是一例。但一旦能用 Jinja,开发者脑子里会发生什么?思路很简单:

  1. 我有 Jinja。
  2. 我可以写自己的 Python functions。
  3. 再把它们集成到 Jinja 里。
  4. 这样就能在 YAML template 里使用这些 functions。
  5. unit tests 也省了,反正只是配置,对吧?

或者,我可以试着把所有事情都塞进 Jinja,让 YAML 来当“胶水代码”。

结果:一堆意大利面、蛇段和筷子。

Jsonnet 的能力有个天然的上限。如果用 Jsonnet 都解决不了,问题本身可能就复杂到不该塞进配置了。最好换一门真正的编程语言,把复杂逻辑封装到独立模块里,并配上测试。这样,配置就能保持精简。我觉得,在 Jsonnet 里,“看起来干净”和“实际干净”之间的关联还挺强。你可能会问,这有什么要紧的?和代码一样,配置也是一份文档。开发者会读它,扩展和改进它,也会把它改得一团糟。

他们得维护这些配置。所以,最好让错误的代码看起来就是错的。

工具

不过,我得承认,Jsonnet 是小众配置语言,工具也有点……怎么说呢,“古怪”。这道天然的门槛,让人没法轻松上手。

先说 jsonnet.org 提供的 jsonnet package。遗憾的是,官网没给出什么像样的安装说明。github.com/google/jsonnet 倒是清楚一些:

brew install jsonnet
apt install jsonnet
<your favourite pkg manager> install jsonnet

这样应该就能装上。

另外还有 go-jsonnet,也就是官方的 Go 实现(GitHub repo)。它是 C++ 实现的 drop-in replacement,还带着 formatter(jsonnetfmt)和 linter(jsonnet-lint)。真方便!(前提是你知道有这些工具。)

brew install go-jsonnet

Go repo 里有一些安装说明。

也有 VS Code extensions,不过我没用过:

Python

这部分就长话短说,毕竟也没多少可说的:

pip install jsonnet
# or you can also use the go version
# pip install gojsonnet

这样调用:

import _jsonnet
# in case of the go-based pkg use
# import _gojsonnet
import json

res = json.loads(_jsonnet.evaluate_file("info.jsonnet"))

或者看官方示例:

import json
import _jsonnet

jsonnet_str = '''
{
  person1: {
    name: "Alice",
    welcome: "Hello " + self.name + "!",
  },
  person2: self.person1 {
    name: std.extVar("OTHER_NAME"),
  },
}
'''

json_str = _jsonnet.evaluate_snippet(
    "snippet", jsonnet_str,
    ext_vars={'OTHER_NAME': 'Bob'})

json_obj = json.loads(json_str)
for person_id, person in json_obj.items():
  print('%s is %s, greeted by "%s"' % (
      person_id,
      person['name'],
      person['welcome']))

更多文档在 bindings 页面,往下翻到“Python API”,还可以看看 Python module 的测试。从测试里能看出,_jsonnet.evaluate_* 这组 functions 还接受不少参数。再扫一眼 C 代码,能看到这些:

static PyObject* evaluate_snippet(PyObject* self, PyObject* args, PyObject *keywds)
{
  ...
  static char *kwlist[] = {
    "filename", "src", "jpathdir",
    "max_stack", "gc_min_objects", "gc_growth_trigger", "ext_vars",
    "ext_codes", "tla_vars", "tla_codes", "max_trace", "import_callback",
    "native_callbacks",
    NULL
  };
  ...
}

可惜,没有相应的文档。比较有用的,大概是 ext_vars、ext_codes、tla_vars 和 tla_codes,官方文档里也提到了它们。要弄清 import_callback 怎么用,可以看看 GitHub issue 里的讨论。

还有一点:环境变量需要通过函数参数传入。好处是没有 side effects。

语法

语法这部分我就偷个懒,直接看官方文档里的 Tutorial、Getting Started 等内容吧。文档对语法的介绍相当不错。有问题也可以去 StackOverflow。

工作流与流水线

如今,在 CI/CD 流水线里为多个环境准备配置,几乎是每个开发者的日常工作。我会尽量把 YAML 流水线描述里的代码移到 Makefile 或 bash 脚本里。这样一来,我就能在本地运行流水线的一部分,方便调试。下面这个 Makefile 可以说明我的做法:

.PHONY: build fmt clean

SHELL=bash

ENV?=acc

build:
	mkdir -p out
	# Emit multiple files
	# If you use multi-file output, the keys are the file names,
	# `app-config-file-1.json` in this case.
	jsonnet --ext-str env=$(ENV) -m out app-config-files.jsonnet
	# Emit single file to stdout
	jsonnet --ext-str env=$(ENV) app-config.jsonnet >out/app.json
	<out/app.json jq -r '.server' >out/server.txt

fmt:
	jsonnetfmt -i *.jsonnet

clean:
	rm -rf out/

(有需要时,我也会用 jq 把部分结果提取到另一个文件里。)

这个 Makefile 用到了下面这些 Jsonnet 文件:

// jsonnet/app/app-config-files.jsonnet
local env = std.extVar('env');

local config = {
  dev: import 'dev.jsonnet',
  acc: import 'acc.jsonnet',
  prd: import 'prd.jsonnet',
}[env];


{
  'app-config-file-1.json': config,
}

// jsonnet/app/app-config.jsonnet
local env = std.extVar('env');

{
  dev: import 'dev.jsonnet',
  acc: import 'acc.jsonnet',
  prd: import 'prd.jsonnet',
}[env]

// jsonnet/app/prd.jsonnet
{
  server: 'prd.bargsten.org',
}

// jsonnet/app/dev.jsonnet
{
  server: 'dev.bargsten.org',
}

// jsonnet/app/acc.jsonnet
{
  server: 'acc.bargsten.org',
}

当然,也可以把上面的做法反过来:每个环境单独用一个文件,在各自文件里 import 所需的配置:

jsonnet $ENV.jsonnet >out/app.json

结语

玩得开心!