CUE:从入门到实践
本文许多内容翻译自网上官方文档或相关资料,本文仅用于学习、调研使用,不用于商业用途。
什么是 CUE?
CUE 是由 Marcel van Lohuizen 创建的强大配置语言,他共同创建了 Borg Configuration Language(BCL)------ Google 用于部署所有应用程序的语言。CUE 是 Google 多年编写配置语言的经验的结果,旨在提高开发人员的体验,同时避免一些棘手的陷阱。它是 JSON 的超集,具有额外的功能,使声明性、数据驱动的编程与常规命令式编程一样愉快和高效。
对配置语言的需求
几十年来,开发人员、工程师和系统管理员都使用某种 INI、ENV、YAML、XML 和 JSON(以及 Apache、Nginx 等自定义格式)来描述配置、资源、操作、变量、参数、状态等。虽然这些示例适用于存储数据,但它们仅仅是数据格式,而不是语言,因此它们都缺乏直接执行逻辑和操作数据的能力。
if 语句、for 循环、列表推导和字符串插值等简单而强大的功能在这些格式中是无法实现的,必须使用单独的执行过程。结果是变量或参数必须被注入,任何逻辑都必须由模板语言(如 Jinja)或由 DSL(领域特定语言)指示的单独引擎执行。通常,模板语言和 DSL 一起使用,虽然这在技术上是可行的,但结果是代码库,甚至单个文件变得过于冗长,模板语言与各种 DSL(有时是多个 DSL)交替出现(有时一个 DSL 的输出作为另一个 DSL 的输入!),没有强制执行模式(并非没有更多的努力),从而使代码难以推理,难以维护,脆弱,而且最糟糕的是容易产生副作用。
CUE 等配置语言使我们能够既指定数据,又根据实现所需输出的需要,使用任何逻辑对数据进行操作。此外,也许最重要的是,CUE 不仅允许我们将数据指定为具体值,还允许我们指定这些具体值必须具有的类型以及任何约束(例如最小值和最大值)。它使我们能够定义模式,但与使用 JSON Schema 等定义模式不同,CUE 可以定义和强制执行模式,而 JSON Schema 仅仅是一个需要其他过程来强制执行的定义。
了解 Cue
我们不会尝试重写 CUE文档 或复制一些优秀的 CUE教程,而是为您提供足够的 CUE 理解,以便满足日常开发需求。
至少就我们这里的目的而言,您需要了解几个关键概念,每个概念将在下面详细解释:
- Cue是JSON的超集
- Types是有类型的值
- 具体值
- 约束、定义和模式
- 统一
- 默认值和继承的性质
- 嵌套
- 包
虽然本地安装CUE会非常有用,但如果您愿意,也可以在 CUE playground 中尝试这些示例。
Cue是 JSON 的超集
您可以在 JSON 中表达的内容,您可以在 CUE 中表达,但并非 CUE 中的所有内容都可以在 JSON 中表达。CUE 还支持可以完全消除某些字符的 "lite" 版本的 JSON。请看以下代码:
{
"Bob": {
"Name": "Bob Smith",
"Age": 42
}
}
Bob: Name: "Bob Smith"
在这个例子中,我们看到在CUE中我们声明了顶层键Bob两次:一次是使用带有括号、引号和逗号的更详细的JSON风格,另一次是使用没有额外字符的"lite"风格。请注意,CUE支持简写:当您针对对象中的单个键时,您不需要大括号,可以将其写为冒号分隔的路径。在CUE playground中尝试一下,并注意输出(您可以选择不同的格式)。顶层Bob键被声明了两次,但只输出一次,因为CUE会自动统一这两个声明。可以多次声明相同的字段,只要我们提供相同的值。请参见下面的默认值和继承的性质。
Types是有类型的值
在前面的示例中,我们将Name
值定义为字符串字面量"Bob Smith",将Age
值定义为整数字面量42,它们都是具体值。通常,CUE的输出将被用作其他系统(无论是API、CLI工具、CICD流程等)的输入,并且这些系统可能期望数据符合模式,其中每个字段具有类型,并可能受到min、max、枚举、正则表达式等函数的约束。考虑到这一点,我们需要强制执行类型和约束,以防止将Name
值设置为整数或将Age
值设置为字符串。
Bob: {
Name: string // type as the value
Age: int
}
Bob: {
Name: "Bob Smith" // literals match the type
Age: 42
}
这里我们将Name
字段定义为string
,将Age
字段定义为int
。请注意,string
和int
不在引号内。这就是我们所说的"types are values"。对于任何编写过Go或其他强类型语言的人来说,这将非常熟悉。有了这些类型的定义,CUE将强制执行它们,因此任何提供Name的整数或Age的字符串的尝试都将导致错误。值得注意的是,这个示例的输出是隐式统一的结果;我们稍后将讨论显式统一。
数据类型
数据类型包含:
- null:空值
- string:字符串,如果你的字符串里有特殊字符,或者换行,可以使用任意数量的#包住你的字符串,这个任意数量的#会作为字符串的定界符。
- bytes:字符
- int:整数
- number:数字
- bool:布尔值
- _:任意类型,官网称这个特殊字符为"Top"
- _|_:失败,或者不存在的意思,官网称这个特殊字符为"Bottom"
单独介绍下Lists,以int为例:
- [int]:只有一个int元素的List
- 4 * [int]:有4个int元素的List
- [...int]:长度不固定的由int元素组成的List
- [...]:长度不固定的由任意类型的元素组成的List
具体值
CUE最终用于导出数据,并且当该数据已经针对明确定义的模式进行验证时,CUE最有用的。为了使CUE导出任何内容,我们必须为所有未标记为可选的已定义字段提供具体值。如果我们只是将必需字段定义为类型,而不提供具体值,CUE将返回错误。
Bob: {
Name: string
Age: int
}
Bob: {
Name: "Bob Smith"
//Age: is considered "incomplete" because no concrete value is defined
}
在CUE playground中尝试运行,发现CUE会报错值不完整。
缺省、可选值
定义一个数据类型时,往往需要定义一个字段是否必须、设置缺省值、设置字段的取值范围等。
如果一个不是必须的,可以通过?来标识,比如下面的Age:
Bob: {
Name: string
Age?: int
}
Bob: {
Name: "Bob Smith"
}
如果要设置缺省值,可以通过 * 来标识,比如下面的Age:
Bob: {
Name: string
Age: int | *10
}
Bob: {
Name: "Bob Smith"
}
如果要设置可选值,可以使用 | 来设置,比如下面的Gender:
Bob: {
Name: string
Gender: "male" | "female"
}
如果Gender赋值成了别的值,则会报错:
如果想要做更复杂的值的可选范围的校验,比如正则匹配,请参考下一章节里的内容。
Definitions
在现实世界中,我们可能需要定义不止一个人,并确保每个人都满足模式。这就是definitions
派上用场的地方。
#Person: {
Name: string
Email: string
Age?: int
}
Bob: #Person & {
Name: "Bob Smith"
Email: "bob@smith.com"
// Age is now optional
}
在这个例子中,我们声明了#Person
是一个definitions,由#符号表示。通过这样做,我们将Person对象限制为特定字段的集合,每个字段都是特定类型。默认情况下,definitions是封闭的,这意味着#Person不能包含definitions中未指定的任何字段。您还会注意到Age?
现在包含一个?
,表示此字段是可选的。
Definitions本身不会导出到最终输出。为了得到具体输出,我们声明Bob是一个#Person,并使用单个&
(与逻辑AND通过&&
!不同!)我们将#Person
与具有满足该definitions的约束的具体值的对象统一起来。
您可以将definitions视为相关约束的逻辑集,将模式视为更大的约束集体,而不需要都是definitions。
尝试在CUE playground中进行尝试,并实验通过使用?
来使字段可选,结合定义和不定义值来查看。
统一
统一是CUE的核心所在。如果值是燃料,那么统一就是引擎。正是通过统一,我们既可以定义约束,又可以计算具体值。让我们通过一些例子来看看这个想法:
import (
"strings" // import builtin package
) // more on packages later
#Person: {
// further constrain to a min and max length
Name: string & strings.MinRunes(3) & strings.MaxRunes(22)
// we don't need string because the regex handles that
Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// further constrain to realistic ages
Age?: int & >0 & <140
}
Bob: #Person & {
Name: "Bob Smith"
Email: "bob@smith.com"
Age: 42
}
// output in YAML:
Bob:
Name: Bob Smith
Email: bob@smith.com
Age: 42
这里的输出是通过将#Person
定义与包含具体值的对象统一而得到的,其中每个具体值都是通过将具体值与定义中字段声明类型和约束进行统一而得到的。请尝试在CUE playground中进行操作
默认值和继承的性质
在统一对象或结构时,会发生一种递归统一字段的合并形式,但与例如在JavaScript中合并JSON对象不同,不同的值不会覆盖,而是导致错误。这部分是由于CUE的可交换性(如果顺序不重要,您如何选择一个值而不是另一个值?),但主要是由于覆盖太容易产生难以调试的副作用。让我们看另一个例子:
import (
"strings" // a builtin package
)
#Person: {
// further constrain to a min and max length
Name: string & strings.MinRunes(3) & strings.MaxRunes(22)
// we don't need string because the regex handles that
Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// further constrain to realistic ages
Age?: int & >0 & <140
// Job is optional and a string
Job?: string
}
#Engineer: #Person & {
Job: "Engineer" // Job is further constrained to required and exactly this value
}
Bob: #Engineer & {
Name: "Bob Smith"
Email: "bob@smith.com"
Age: 42
// Job: "Carpenter" // would result in an error
}
// output in YAML:
Bob:
Name: Bob Smith
Email: bob@smith.com
Age: 42
Job: Engineer
虽然Bob对象可以从#Engineer
继承Job值,而#Engineer又从#Person
继承约束,但无法覆盖Job值。在CUE playground中尝试它,并取消注释Bob中的Job字段,以查看CUE返回错误。
如果我们希望Bob对象拥有不同的工作,它要么需要与不同的类型统一,要么#Engineer:Job:
字段需要一个较宽松的约束和默认值。尝试将Job字段更改为以下内容:
#Engineer: #Person & {
Job: string | *"Engineer" // can still be any string, but *defaults* to "Engineer" when no concrete value is explicitly defined
}
Bob继承了默认值,但现在允许指定不同的工作。
嵌套
CUE允许将一个定义嵌入到另一个定义中,类似于Golang嵌入或面向对象的组合。这避免了在定义中添加额外的深度级别。
import (
"strings" // a builtin package
)
#Person: {
// further constrain to a min and max length
Name: string & strings.MinRunes(3) & strings.MaxRunes(22)
// we don't need string because the regex handles that
Email: =~"^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$"
// further constrain to realistic ages
Age?: int & >0 & <140
// Job is optional and a string
Job?: string
}
// Engineer definition embed #Person and add Domain field
#Engineer: {
Domain: "Backend" | "Frontend" | "DevOps"
// Embed and add additional constraint on Job
#Person & {
Job: "Engineer" // Job is further constrained to required and exactly this value
}
}
Bob: #Engineer & {
Name: "Bob Smith"
Email: "bob@smith.com"
Age: 42
Domain: "Backend"
}
// output in YAML:
Bob:
Name: Bob Smith
Email: bob@smith.com
Age: 42
Job: Engineer
Domain: Backend
嵌入#Engineer
的任何定义将共享其属性,并且可以从该定义直接访问它们。请在CUE playground中尝试。
包
CUE中的包允许我们编写模块化、可重用和可组合的代码。我们可以定义导入到各个文件和项目的模式。如果您编写过Go,那么CUE应该会感到非常熟悉。不仅是因为它使用Go编写,而且其行为和语法也模仿了Go。
CUE有许多内置包,如strings、regexp、math等等。这些内置包无需下载或安装任何其他内容就可以直接使用。第三方包是位于cue.mod/pkg/
文件夹中并带有完全限定域名的包。
在前面的几个示例中,我们包含了一个import
语句来加载内置的strings
包。
实践技巧
数学运算符
CUE 包含数字的标准的数学表达式,也可以在 string
或 list
中使用乘法。在 CUE 的 math package 中还有其他的运算操作。
比较运算符
CUE 拥有比较运算符和语义,值合并时会处理相等性检查。
正则表达式
CUE 支持正则表达式,通过 =~
和 !~
进行限制。它们基于 Go 的正则表达式, CUE 也有一些额外的 regexp helpers。
数字范围
CUE里各个数字类型的范围如下:
int8 >=-128 & <=127
int16 >=-32_768 & <=32_767
int32 >=-2_147_483_648 & <=2_147_483_647
int64 >=-9_223_372_036_854_775_808 & <=9_223_372_036_854_775_807
int128 >=-170_141_183_460_469_231_731_687_303_715_884_105_728 &
<=170_141_183_460_469_231_731_687_303_715_884_105_727
uint >=0
uint8 >=0 & <=255
uint16 >=0 & <=65536
uint32 >=0 & <=4_294_967_296
uint64 >=0 & <=18_446_744_073_709_551_615
uint128 >=0 & <=340_282_366_920_938_463_463_374_607_431_768_211_455
rune >=0 & <=0x10FFFF
引用
CUE 使用 (
Field推导
CUE 也可以通过推导生成字段,比如下图里的\(app)
。
if
CUE的if与其他语言不同。它是一种理解机制,而不是分支机制。所以我们将它称为条件字段,或受保护的字段(技术上意味着保护),是另一种形式的字段推导。
相对于通常意义的 if,有一些注意事项:
- 没有 else 条件,必须有两个相反的判断条件
- 对于多个检查,不会忽略任何判断条件,所有的条件都会被计算
所以如果你想要一种类似if else的逻辑,可以选择2种方式:
- 通过将if和else两个条件都通过if写出来,分别判断和计算;
- 使用switch的写法实现;
switch
CUE里是使用 list 推导来模拟 switch 声明:
x: _
result: [
// case
if x == "x" {...},
// case
// case
// default
...,
][0] // here we select the first element
这个模板在 list 中有很多 if 条件,然后选择第 [0] 个元素。
通过这种方式,我们模拟了 switch 声明,就像其他语言一样。
需要注意的是,所有的条件声明都会被求值。
一个简单的例子
这个例子演示如何将 integer 转为描述其数字类型的 string。
顺序相关性
在下面这个例子中,我们通过分解这个模板来演示,当list的结果里有多个元素时,是。
真正有前缀的字符串确实能拿到正确的结果,但是我们也可以看到,当 a 和 b 一样的时候, HasPrefix 仍然可以匹配,所以我们会得到不那么准确的结果。
没有条件被命中
我们可以修改一下我们第一个例子来演示如果忘记 default 默认值的话会发生什么,或未覆盖所有条件的话会发生什么。
所有条件都会检查
你可能会觉得下面的例子能正常运行而不会报错。
这是所有if条件都被检查引起的,也就是说,即使前面的某个if条件命中了,后面的if条件也会继续检查,跟前面if章节一样。
List推导
CUE 可以通过 list 推导动态生成 list,可以遍历 list 或 struct 的字段。
形式是 [ for key, val in
- key 是 lists 的索引, 在 struct 中是字段的名称。
下面是一个简单的例子:
下面是一个稍复杂的例子: