CEL-Go Codelab:快速、安全的嵌入式表达式

1. 简介

什么是 CEL?

CEL 是一种非图灵完备的表达式语言,旨在实现快速、可移植且安全地执行。CEL 可以单独使用,也可以嵌入到更大的产品中。

CEL 的设计初衷是作为一种可以安全执行用户代码的语言。虽然盲目调用用户 Python 代码中的 eval() 很危险,但您可以安全地执行用户的 CEL 代码。由于 CEL 可防止导致性能下降的行为,因此它可以在纳秒到微秒的时间范围内安全地进行评估;非常适合对性能要求较高的应用。

CEL 会评估表达式,这些表达式类似于单行函数或 lambda 表达式。虽然 CEL 通常用于布尔值决策,但也可用于构建更复杂的对象,例如 JSON 或 protobuf 消息。

CEL 是否适合您的项目?

由于 CEL 会在纳秒到微秒内从 AST 评估表达式,因此 CEL 的理想使用情形是具有对性能要求苛刻的路径的应用。将 CEL 代码编译为 AST 不应在关键路径中完成;理想的应用是那些经常执行配置但修改相对不频繁的应用。

例如,针对服务的每个 HTTP 请求执行安全政策是 CEL 的理想使用情形,因为安全政策很少更改,并且 CEL 对响应时间的影响微乎其微。在这种情况下,CEL 会返回一个布尔值,表示是否应允许相应请求,但它也可以返回更复杂的消息。

此 Codelab 涵盖哪些内容?

本 Codelab 的第一步将介绍使用 CEL 的动机及其核心概念。其余部分则专门用于介绍涵盖常见应用场景的编码练习。如需更深入地了解该语言、语义和功能,请参阅 GitHub 上的 CEL 语言定义CEL Go 文档

本 Codelab 旨在帮助开发者学习 CEL,以便使用已支持 CEL 的服务。此 Codelab 不会介绍如何将 CEL 集成到您自己的项目中。

学习内容

  • CEL 的核心概念
  • Hello, World:使用 CEL 评估字符串
  • 创建变量
  • 了解 CEL 在逻辑 AND/OR 运算中的短路行为
  • 如何使用 CEL 构建 JSON
  • 如何使用 CEL 构建 Protobuf
  • 创建宏
  • 调整 CEL 表达式的方法

所需条件

前提条件

此 Codelab 假定您对 Protocol BuffersGo Lang 有基本的了解。

如果您不熟悉 Protocol Buffers,第一个练习将让您了解 CEL 的运作方式,但由于更高级的示例使用 Protocol Buffers 作为 CEL 的输入,因此可能更难理解。不妨先完成其中一个教程。请注意,使用 CEL 并不需要 Protocol Buffers,但本 Codelab 中广泛使用了 Protocol Buffers。

您可以通过运行以下命令来测试 Go 是否已安装:

go --help

2. 主要概念

应用

CEL 是一种通用语言,已用于各种应用,从路由 RPC 到定义安全政策。CEL 具有可扩展性,与应用无关,并且针对“编译一次,评估多次”的工作流进行了优化。

许多服务和应用都会评估声明式配置。例如,基于角色的访问控制 (RBAC) 是一种声明性配置,可根据角色和一组用户生成访问决策。如果声明性配置能满足 80% 的使用情形,那么当用户需要更强大的表达能力时,CEL 就能派上用场,满足剩余 20% 的使用情形。

编译

表达式是针对环境编译的。编译步骤会生成 protobuf 格式的抽象语法树 (AST)。编译后的表达式通常会存储起来以供将来使用,从而尽可能加快评估速度。单个已编译的表达式可以针对许多不同的输入进行评估。

表达式

用户定义表达式;服务和应用定义表达式的运行环境。函数签名声明输入,并写在 CEL 表达式之外。可供 CEL 使用的函数库会自动导入。

在以下示例中,表达式接受一个请求对象,并且该请求包含声明令牌。该表达式会返回一个布尔值,指示声明令牌是否仍然有效。

// Check whether a JSON Web Token has expired by inspecting the 'exp' claim.
//
// Args:
//   claims - authentication claims.
//   now    - timestamp indicating the current system time.
// Returns: true if the token has expired.
//
timestamp(claims["exp"]) < now

环境

环境由服务定义。嵌入 CEL 的服务和应用会声明表达式环境。环境是指可在表达式中使用的变量和函数的集合。

基于 Proto 的声明由 CEL 类型检查器使用,以确保表达式中的所有标识符和函数引用都已正确声明和使用。

解析表达式的三个阶段

处理表达式分为三个阶段:解析、检查和评估。CEL 最常见的模式是控制平面在配置时解析并检查表达式,然后存储 AST。

c71fc08068759f81.png

在运行时,数据平面会反复检索和评估 AST。CEL 针对运行时效率进行了优化,但不应在延迟时间至关重要的代码路径中进行解析和检查。

49ab7d8517143b66.png

CEL 使用 ANTLR 词法分析器 / 解析器语法从人类可读的表达式解析为抽象语法树。解析阶段会生成基于 Proto 的抽象语法树,其中 AST 中的每个 Expr 节点都包含一个整数 ID,该 ID 用于索引解析和检查期间生成的元数据。在解析期间生成的 syntax.proto 忠实地表示了以字符串形式输入的表达式的抽象表示形式。

解析表达式后,可以针对环境检查该表达式,以确保表达式中的所有变量和函数标识符都已声明并得到正确使用。类型检查器会生成一个 checked.proto,其中包含类型、变量和函数解析元数据,可大幅提高评估效率。

CEL 评估器需要 3 个要素:

  • 任何自定义扩展程序的函数绑定
  • 变量绑定
  • 要评估的 AST

函数和变量绑定应与用于编译 AST 的绑定相匹配。这些输入中的任何一个都可以在多次评估中重复使用,例如在多组变量绑定中评估的 AST,或针对多个 AST 使用的相同变量,或在进程的整个生命周期内使用的函数绑定(常见情况)。

3. 设置

此 Codelab 的代码位于 cel-go 代码库的 codelab 文件夹中。您可以在同一代码库的 codelab/solution 文件夹中找到解决方案。

克隆并 cd 进入代码库:

git clone https://github.com/google/cel-go.git 
cd cel-go/codelab

使用 go run 运行代码:

go run .

您应该会看到以下输出内容:

=== Exercise 1: Hello World ===

=== Exercise 2: Variables ===

=== Exercise 3: Logical AND/OR ===

=== Exercise 4: Customization ===

=== Exercise 5: Building JSON ===

=== Exercise 6: Building Protos ===

=== Exercise 7: Macros ===

=== Exercise 8: Tuning ===

CEL 软件包在哪里?

在编辑器中,打开 codelab/codelab.go。您应该会看到驱动此 Codelab 中练习执行的主函数,然后是三个辅助函数块。第一组辅助函数可帮助完成 CEL 评估的各个阶段:

  • Compile 函数:根据环境解析并检查输入表达式
  • Eval 函数:针对输入评估已编译的程序
  • Report 函数:以美观的方式输出评估结果

此外,我们还提供了 requestauth 辅助函数,以帮助您构建各种练习的输入。

练习中将使用软件包的短软件包名称来指代软件包。如果您想深入了解详情,请参阅下表,其中列出了 google/cel-go 代码库中从软件包到源代码位置的映射:

软件包

来源位置

说明

cel

cel-go/cel

顶级接口

ref

cel-go/common/types/ref

参考界面

类型

cel-go/common/types

运行时类型值

4. Hello, World!

按照所有编程语言的惯例,我们将从创建和评估“Hello World!”开始。

配置环境

在编辑器中,找到 exercise1 的声明,然后填写以下内容以设置环境:

// exercise1 evaluates a simple literal expression: "Hello, World!"
//
// Compile, eval, profit!
func exercise1() {
    fmt.Println("=== Exercise 1: Hello World ===\n")
    // Create the standard environment.
    env, err := cel.NewEnv()
    if err != nil {
        glog.Exitf("env error: %v", err)
    }
    // Will add the parse and check steps here
}

CEL 应用会针对环境评估表达式。env, err := cel.NewEnv() 用于配置标准环境。

您可以通过向调用提供选项 cel.EnvOption 来自定义环境。这些选项能够停用宏、声明自定义变量和函数等。

标准 CEL 环境支持语言规范中定义的所有类型、运算符、函数和宏。

解析并检查表达式

配置环境后,即可解析和检查表达式。将以下内容添加到您的函数中:

// exercise1 evaluates a simple literal expression: "Hello, World!"
//
// Compile, eval, profit!
func exercise1() {
    fmt.Println("=== Exercise 1: Hello World ===\n")
    // Create the standard environment.
    env, err := cel.NewEnv()
    if err != nil {
        glog.Exitf("env error: %v", err)
    }
    // Check that the expression compiles and returns a String.
    ast, iss := env.Parse(`"Hello, World!"`)
    // Report syntactic errors, if present.
    if iss.Err() != nil {
        glog.Exit(iss.Err())
    }
    // Type-check the expression for correctness.
    checked, iss := env.Check(ast)
    // Report semantic errors, if present.
    if iss.Err() != nil {
        glog.Exit(iss.Err())
    }
    // Check the output type is a string.
    if !reflect.DeepEqual(checked.OutputType(), cel.StringType) {
        glog.Exitf(
            "Got %v, wanted %v output type",
            checked.OutputType(), cel.StringType,
        )
    }
    // Will add the planning step here
}

ParseCheck 调用返回的 iss 值是一个问题列表,其中可能包含错误。如果 iss.Err() 不为 nil,则表示语法或语义存在错误,程序无法继续执行。如果表达式格式正确,这些调用的结果将是可执行的 cel.Ast

评估表达式

将表达式解析并检查为 cel.Ast 后,可以将其转换为可评估的程序,该程序的功能绑定和评估模式可以通过功能选项进行自定义。请注意,您还可以使用 cel.CheckedExprToAstcel.ParsedExprToAst 函数从 proto 读取 cel.Ast

规划好 cel.Program 后,可以通过调用 Eval 根据输入对其进行评估。Eval 的结果将包含结果、评估详细信息和错误状态。

添加规划并调用 eval

// exercise1 evaluates a simple literal expression: "Hello, World!"
//
// Compile, eval, profit!
func exercise1() {
    fmt.Println("=== Exercise 1: Hello World ===\n")
    // Create the standard environment.
    env, err := cel.NewEnv()
    if err != nil {
        glog.Exitf("env error: %v", err)
    }
    // Check that the expression compiles and returns a String.
    ast, iss := env.Parse(`"Hello, World!"`)
    // Report syntactic errors, if present.
    if iss.Err() != nil {
        glog.Exit(iss.Err())
    }
    // Type-check the expression for correctness.
    checked, iss := env.Check(ast)
    // Report semantic errors, if present.
    if iss.Err() != nil {
        glog.Exit(iss.Err())
    }
    // Check the output type is a string.
    if !reflect.DeepEqual(checked.OutputType(), cel.StringType) {
        glog.Exitf(
            "Got %v, wanted %v output type",
            checked.OutputType(), cel.StringType)
    }
    // Plan the program.
    program, err := env.Program(checked)
    if err != nil {
        glog.Exitf("program error: %v", err)
    }
    // Evaluate the program without any additional arguments.
    eval(program, cel.NoVars())
    fmt.Println()
}

为简洁起见,我们将在以后的练习中省略上述错误情况。

执行代码

在命令行中,重新运行代码:

go run .

您应该会看到以下输出,以及未来练习的占位符。

=== Exercise 1: Hello World ===

------ input ------
(interpreter.emptyActivation)

------ result ------
value: Hello, World! (types.String)

5. 在函数中使用变量

大多数 CEL 应用都会声明可在表达式中引用的变量。变量声明会指定名称和类型。变量的类型可以是 CEL 内置类型、协议缓冲区知名类型,也可以是任何 protobuf 消息类型,前提是其描述符也提供给 CEL。

添加函数

在编辑器中,找到 exercise2 的声明,并添加以下内容:

// exercise2 shows how to declare and use variables in expressions.
//
// Given a request of type google.rpc.context.AttributeContext.Request
// determine whether a specific auth claim is set.
func exercise2() {
  fmt.Println("=== Exercise 2: Variables ===\n")
   env, err := cel.NewEnv(
    // Add cel.EnvOptions values here.
  )
  if err != nil {
    glog.Exit(err)
  }
  ast := compile(env, `request.auth.claims.group == 'admin'`, cel.BoolType)
  program, _ := env.Program(ast)

  // Evaluate a request object that sets the proper group claim.
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

重新运行并了解错误

重新运行该程序:

go run .

您应该会看到以下输出内容:

ERROR: <input>:1:1: undeclared reference to 'request' (in container '')
 | request.auth.claims.group == 'admin'
 | ^

类型检查器会针对请求对象生成错误,其中方便地包含发生错误的代码段。

6. 声明变量

添加EnvOptions

在编辑器中,我们通过为请求对象提供类型为 google.rpc.context.AttributeContext.Request 的消息声明来修正由此产生的错误,如下所示:

// exercise2 shows how to declare and use variables in expressions.
//
// Given a `request` of type `google.rpc.context.AttributeContext.Request`
// determine whether a specific auth claim is set.
func exercise2() {
  fmt.Println("=== Exercise 2: Variables ===\n")
  env, err := cel.NewEnv(
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
  )
  if err != nil {
    glog.Exit(err)
  }
  ast := compile(env, `request.auth.claims.group == 'admin'`, cel.BoolType)
  program, _ := env.Program(ast)

  // Evaluate a request object that sets the proper group claim.
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

重新运行并了解错误

再次运行该程序:

go run .

您应该会看到以下错误:

ERROR: <input>:1:8: [internal] unexpected failed resolution of 'google.rpc.context.AttributeContext.Request'
 | request.auth.claims.group == 'admin'
 | .......^

如需使用引用 protobuf 消息的变量,类型检查器还需要知道类型描述符。

使用 cel.Types 确定函数中请求的描述符:

// exercise2 shows how to declare and use variables in expressions.
//
// Given a `request` of type `google.rpc.context.AttributeContext.Request`
// determine whether a specific auth claim is set.
func exercise2() {
  fmt.Println("=== Exercise 2: Variables ===\n")
   env, err := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}), 
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
  )

  if err != nil {
    glog.Exit(err)
  }
  ast := compile(env, `request.auth.claims.group == 'admin'`, cel.BoolType)
  program, _ := env.Program(ast)

  // Evaluate a request object that sets the proper group claim.
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

已成功重新运行!

再次运行该程序:

go run .

您应该会看到以下内容:

=== Exercise 2: Variables ===

request.auth.claims.group == 'admin'

------ input ------
request = time: <
  seconds: 1569255569
>
auth: <
  principal: "user:me@acme.co"
  claims: <
    fields: <
      key: "group"
      value: <
        string_value: "admin"
      >
    >
  >
>

------ result ------
value: true (types.Bool)

回顾一下,我们刚刚声明了一个用于表示错误的变量,为其分配了一个类型描述符,然后在表达式求值中引用了该变量。

7. 逻辑 AND/OR

CEL 的一个更独特的特性是它使用可交换的逻辑运算符。即使遇到错误或输入不完整,条件分支的任一侧都可以使评估短路。

换句话说,CEL 会尽可能找到一种评估顺序来得出结果,而忽略其他评估顺序中可能出现的错误甚至缺失的数据。应用可以依赖此属性来最大限度地降低评估成本,在无需昂贵输入即可获得结果时,延迟收集这些输入。

我们将添加一个 AND/OR 示例,然后尝试使用不同的输入来了解 CEL 如何进行短路评估。

创建函数

在编辑器中,将以下内容添加到练习 3:

// exercise3 demonstrates how CEL's commutative logical operators work.
//
// Construct an expression which checks if the `request.auth.claims.group`
// value is equal to admin or the `request.auth.principal` is
// `user:me@acme.co`. Issue two requests, one that specifies the proper 
// user, and one that specifies an unexpected user.
func exercise3() {
  fmt.Println("=== Exercise 3: Logical AND/OR ===\n")
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
  )
}

接下来,添加此 OR 语句,如果用户是 admin 群组的成员,或者具有特定的电子邮件标识符,则该语句将返回 true:

// exercise3 demonstrates how CEL's commutative logical operators work.
//
// Construct an expression which checks if the `request.auth.claims.group`
// value is equal to admin or the `request.auth.principal` is
// `user:me@acme.co`. Issue two requests, one that specifies the proper 
// user, and one that specifies an unexpected user.
func exercise3() {
  fmt.Println("=== Exercise 3: Logical AND/OR ===\n")
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
  )

  ast := compile(env,
    `request.auth.claims.group == 'admin'
        || request.auth.principal == 'user:me@acme.co'`,
    cel.BoolType)
  program, _ := env.Program(ast)
}

最后,添加用于评估具有空声明集的用户的 eval 案例:

// exercise3 demonstrates how CEL's commutative logical operators work.
//
// Construct an expression which checks whether the request.auth.claims.group
// value is equal to admin or the request.auth.principal is
// user:me@acme.co. Issue two requests, one that specifies the proper user,
// and one that specifies an unexpected user.
func exercise3() {
  fmt.Println("=== Exercise 3: Logical AND/OR ===\n")
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
  )

  ast := compile(env,
    `request.auth.claims.group == 'admin'
        || request.auth.principal == 'user:me@acme.co'`,
    cel.BoolType)
  program, _ := env.Program(ast)

  emptyClaims := map[string]string{}
  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
}

运行代码,并设置空声明

重新运行该程序,您应该会看到以下新输出内容:

=== Exercise 3: Logical AND/OR ===

request.auth.claims.group == 'admin'
    || request.auth.principal == 'user:me@acme.co'

------ input ------
request = time: <
  seconds: 1569302377
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: true (types.Bool)

更新评估支持请求

接下来,更新评估情形,以传入具有空声明集的不同主账号:

// exercise3 demonstrates how CEL's commutative logical operators work.
//
// Construct an expression which checks whether the request.auth.claims.group
// value is equal to admin or the request.auth.principal is
// user:me@acme.co. Issue two requests, one that specifies the proper user,
// and one that specifies an unexpected user.
func exercise3() {
  fmt.Println("=== Exercise 3: Logical AND/OR ===\n")
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
  )

  ast := compile(env,
    `request.auth.claims.group == 'admin'
        || request.auth.principal == 'user:me@acme.co'`,
    cel.BoolType)
  program, _ := env.Program(ast)

  emptyClaims := map[string]string{}
  eval(program, request(auth("other:me@acme.co", emptyClaims), time.Now()))
}

按指定时间运行代码

重新运行该计划,

go run .

您应该会看到以下错误:

=== Exercise 3: Logical AND/OR ===

request.auth.claims.group == 'admin'
    || request.auth.principal == 'user:me@acme.co'

------ input ------
request = time: <
  seconds: 1588989833
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: true (types.Bool)

------ input ------
request = time: <
  seconds: 1588989833
>
auth: <
  principal: "other:me@acme.co"
  claims: <
  >
>

------ result ------
error: no such key: group

在 protobuf 中,我们知道要预期哪些字段和类型。在映射和 JSON 值中,我们不知道是否存在某个键。由于缺少键时没有安全的默认值,因此 CEL 默认为错误。

8. 自定义函数

虽然 CEL 包含许多内置函数,但在某些情况下,自定义函数会很有用。例如,自定义函数可用于在常见情况下改善用户体验或公开上下文相关状态

在本练习中,我们将探索如何公开一个函数,以将常用的检查打包在一起。

调用自定义函数

首先,创建代码以设置名为 contains 的替换项,该替换项用于确定某个键是否存在于映射中并具有特定值。为函数定义和函数绑定留下占位符:

// exercise4 demonstrates how to extend CEL with custom functions.
// Declare a `contains` member function on map types that returns a boolean
// indicating whether the map contains the key-value pair.
func exercise4() {
  fmt.Println("=== Exercise 4: Customization ===\n")
  // Determine whether an optional claim is set to the proper value. The
  // custom map.contains(key, value) function is used as an alternative to:
  //   key in map && map[key] == value
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
    // Add the custom function declaration and binding here with cel.Function()
  )
  ast := compile(env,
    `request.auth.claims.contains('group', 'admin')`,
    cel.BoolType)

  // Construct the program plan and provide the 'contains' function impl.
  // Output: false
  program, _ := env.Program(ast)
  emptyClaims := map[string]string{}
  eval(
    program,
    request(auth("user:me@acme.co", emptyClaims), time.Now()),
  )
  fmt.Println()
}  

运行代码并了解错误

重新运行代码后,您应该会看到以下错误:

ERROR: <input>:1:29: found no matching overload for 'contains' applied to 'map(string, dyn).(string, string)'
 | request.auth.claims.contains('group', 'admin')
 | ............................^

为了修正此错误,我们需要将 contains 函数添加到当前声明请求变量的声明列表中。

添加以下 3 行代码,声明一个参数化类型。(这是 CEL 中任何函数重载可能达到的最复杂程度)

// exercise4 demonstrates how to extend CEL with custom functions.
// Declare a `contains` member function on map types that returns a boolean
// indicating whether the map contains the key-value pair.
func exercise4() {
  fmt.Println("=== Exercise 4: Customization ===\n")
  // Determine whether an optional claim is set to the proper value. The custom
  // map.contains(key, value) function is used as an alternative to:
  //   key in map && map[key] == value

  // Useful components of the type-signature for 'contains'.
  typeParamA := cel.TypeParamType("A")
  typeParamB := cel.TypeParamType("B")
  mapAB := cel.MapType(typeParamA, typeParamB)

  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),
    // Add the custom function declaration and binding here with cel.Function()
  )
  ast := compile(env,
    `request.auth.claims.contains('group', 'admin')`,
    cel.BoolType)

  // Construct the program plan.
  // Output: false
  program, _ := env.Program(ast)
  emptyClaims := map[string]string{}
  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
  fmt.Println()
}

添加自定义函数

接下来,我们将添加一个使用形参化类型的新 contains 函数:

// exercise4 demonstrates how to extend CEL with custom functions.
// Declare a `contains` member function on map types that returns a boolean
// indicating whether the map contains the key-value pair.
func exercise4() {
  fmt.Println("=== Exercise 4: Customization ===\n")
  // Determine whether an optional claim is set to the proper value. The custom
  // map.contains(key, value) function is used as an alternative to:
  //   key in map && map[key] == value

  // Useful components of the type-signature for 'contains'.
  typeParamA := cel.TypeParamType("A")
  typeParamB := cel.TypeParamType("B")
  mapAB := cel.MapType(typeParamA, typeParamB)
 
  // Env declaration.
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),   
    // Declare the custom contains function and its implementation.
    cel.Function("contains",
      cel.MemberOverload(
        "map_contains_key_value",
        []*cel.Type{mapAB, typeParamA, typeParamB},
        cel.BoolType,
        // Provide the implementation using cel.FunctionBinding()
      ),
    ),
  )
  ast := compile(env,
    `request.auth.claims.contains('group', 'admin')`,
    cel.BoolType)

  // Construct the program plan and provide the 'contains' function impl.
  // Output: false
  program, _ := env.Program(ast)
  emptyClaims := map[string]string{}
  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
  fmt.Println()
}

运行程序以了解错误

运行练习。您应该会看到以下有关缺少运行时函数的错误:

------ result ------
error: no such overload: contains

使用 cel.FunctionBinding() 函数向 NewEnv 声明提供函数实现:

// exercise4 demonstrates how to extend CEL with custom functions.
//
// Declare a contains member function on map types that returns a boolean
// indicating whether the map contains the key-value pair.
func exercise4() {
  fmt.Println("=== Exercise 4: Customization ===\n")
  // Determine whether an optional claim is set to the proper value. The custom
  // map.contains(key, value) function is used as an alternative to:
  //   key in map && map[key] == value

  // Useful components of the type-signature for 'contains'.
  typeParamA := cel.TypeParamType("A")
  typeParamB := cel.TypeParamType("B")
  mapAB := cel.MapType(typeParamA, typeParamB)

  // Env declaration.
  env, _ := cel.NewEnv(
    cel.Types(&rpcpb.AttributeContext_Request{}),
    // Declare the request.
    cel.Variable("request",
      cel.ObjectType("google.rpc.context.AttributeContext.Request"),
    ),   
    // Declare the custom contains function and its implementation.
    cel.Function("contains",
      cel.MemberOverload(
        "map_contains_key_value",
        []*cel.Type{mapAB, typeParamA, typeParamB},
        cel.BoolType,
        cel.FunctionBinding(mapContainsKeyValue)),
    ),
  )
  ast := compile(env, 
    `request.auth.claims.contains('group', 'admin')`, 
    cel.BoolType)

  // Construct the program plan.
  // Output: false
  program, err := env.Program(ast)
  if err != nil {
    glog.Exit(err)
  }

  eval(program, request(auth("user:me@acme.co", emptyClaims), time.Now()))
  claims := map[string]string{"group": "admin"}
  eval(program, request(auth("user:me@acme.co", claims), time.Now()))
  fmt.Println()
}

程序现在应该能成功运行:

=== Exercise 4: Custom Functions ===

request.auth.claims.contains('group', 'admin')

------ input ------
request = time: <
  seconds: 1569302377
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: false (types.Bool)

存在版权主张时会发生什么情况?

如需获得额外学分,请尝试在输入中设置管理员声明,以验证当声明存在时,包含过载是否也会返回 true。您应该会看到以下输出内容:

=== Exercise 4: Customization ===

request.auth.claims.contains('group','admin')

------ input ------
request = time: <
  seconds: 1588991010
>
auth: <
  principal: "user:me@acme.co"
  claims: <
  >
>

------ result ------
value: false (types.Bool)

------ input ------
request = time: <
  seconds: 1588991010
>
auth: <
  principal: "user:me@acme.co"
  claims: <
    fields: <
      key: "group"
      value: <
        string_value: "admin"
      >
    >
  >
>

------ result ------
value: true (types.Bool)

在继续之前,不妨先检查一下 mapContainsKeyValue 函数本身:

// mapContainsKeyValue implements the custom function:
//   map.contains(key, value) -> bool.
func mapContainsKeyValue(args ...ref.Val) ref.Val {
  // The declaration of the function ensures that only arguments which match
  // the mapContainsKey signature will be provided to the function.
  m := args[0].(traits.Mapper)

  // CEL has many interfaces for dealing with different type abstractions.
  // The traits.Mapper interface unifies field presence testing on proto
  // messages and maps.
  key := args[1]
  v, found := m.Find(key)

  // If not found and the value was non-nil, the value is an error per the
  // `Find` contract. Propagate it accordingly. Such an error might occur with
  // a map whose key-type is listed as 'dyn'.
  if !found {
    if v != nil {
      return types.ValOrErr(v, "unsupported key type")
    }
    // Return CEL False if the key was not found.
    return types.False
  }
  // Otherwise whether the value at the key equals the value provided.
  return v.Equal(args[2])
}

为了尽可能方便地进行扩展,自定义函数的签名需要 ref.Val 类型的实参。这里的权衡是,扩展的简易性增加了实现者的负担,他们必须确保所有值类型都得到妥善处理。如果输入实参类型或数量与函数声明不匹配,应返回 no such overload 错误。

cel.FunctionBinding() 添加了运行时类型保护,以确保运行时合约与环境中的类型检查声明相匹配。

9. 构建 JSON

CEL 还可以生成非布尔值输出,例如 JSON。将以下内容添加到您的函数中:

// exercise5 covers how to build complex objects as CEL literals.
//
// Given the input now, construct a JWT with an expiry of 5 minutes.
func exercise5() {
    fmt.Println("=== Exercise 5: Building JSON ===\n")
    env, _ := cel.NewEnv(
      // Declare the 'now' variable as a Timestamp.
      // cel.Variable("now", cel.TimestampType),
    )
    // Note the quoted keys in the CEL map literal. For proto messages the
    // field names are unquoted as they represent well-defined identifiers.
    ast := compile(env, `
        {'sub': 'serviceAccount:delegate@acme.co',
         'aud': 'my-project',
         'iss': 'auth.acme.com:12350',
         'iat': now,
         'nbf': now,
         'exp': now + duration('300s'),
         'extra_claims': {
             'group': 'admin'
         }}`,
        cel.MapType(cel.StringType, cel.DynType))

    program, _ := env.Program(ast)
    out, _, _ := eval(
        program,
        map[string]interface{}{
            "now": &tpb.Timestamp{Seconds: time.Now().Unix()},
        },
    )
    fmt.Printf("------ type conversion ------\n%v\n", out)
    fmt.Println()
}

运行代码

重新运行代码后,您应该会看到以下错误:

ERROR: <input>:5:11: undeclared reference to 'now' (in container '')
 |    'iat': now,
 | ..........^
... and more ...

cel.NewEnv() 添加类型为 cel.TimestampTypenow 变量的声明,然后再次运行:

// exercise5 covers how to build complex objects as CEL literals.
//
// Given the input now, construct a JWT with an expiry of 5 minutes.
func exercise5() {
    fmt.Println("=== Exercise 5: Building JSON ===\n")
    env, _ := cel.NewEnv(
      cel.Variable("now", cel.TimestampType),
    )
    // Note the quoted keys in the CEL map literal. For proto messages the
    // field names are unquoted as they represent well-defined identifiers.
    ast := compile(env, `
        {'sub': 'serviceAccount:delegate@acme.co',
         'aud': 'my-project',
         'iss': 'auth.acme.com:12350',
         'iat': now,
         'nbf': now,
         'exp': now + duration('300s'),
         'extra_claims': {
             'group': 'admin'
         }}`,
        cel.MapType(cel.StringType, cel.DynType))

     // Hint:
     // Convert `out` to JSON using the valueToJSON() helper method.
     // The valueToJSON() function calls ConvertToNative(&structpb.Value{})
     // to adapt the CEL value to a protobuf JSON representation and then
     // uses the jsonpb.Marshaler utilities on the output to render the JSON
     // string.
    program, _ := env.Program(ast)
    out, _, _ := eval(
        program,
        map[string]interface{}{
            "now": time.Now(),
        },
    )
    fmt.Printf("------ type conversion ------\n%v\n", out)
    fmt.Println()
}

重新运行代码,应该会成功:

=== Exercise 5: Building JSON ===

  {'aud': 'my-project',
   'exp': now + duration('300s'),
   'extra_claims': {
    'group': 'admin'
   },
   'iat': now,
   'iss': 'auth.acme.com:12350',
   'nbf': now,
   'sub': 'serviceAccount:delegate@acme.co'
   }

------ input ------
now = seconds: 1569302377

------ result ------
value: &{0xc0002eaf00 map[aud:my-project exp:{0xc0000973c0} extra_claims:0xc000097400 iat:{0xc000097300} iss:auth.acme.com:12350 nbf:{0xc000097300} sub:serviceAccount:delegate@acme.co] {0x8c8f60 0xc00040a6c0 21}} (*types.baseMap)

------ type conversion ------
&{0xc000313510 map[aud:my-project exp:{0xc000442510} extra_claims:0xc0004acdc0 iat:{0xc000442450} iss:auth.acme.com:12350 nbf:{0xc000442450} sub:serviceAccount:delegate@acme.co] {0x9d0ce0 0xc0004424b0 21}}

程序会运行,但输出值 out 需要显式转换为 JSON。在这种情况下,内部 CEL 表示法是可转换为 JSON 的,因为它仅引用 JSON 可以支持的类型或具有众所周知的 Proto 到 JSON 映射的类型。

// exercise5 covers how to build complex objects as CEL literals.
//
// Given the input now, construct a JWT with an expiry of 5 minutes.
func exercise5() {
    fmt.Println("=== Exercise 5: Building JSON ===\n")
...
    fmt.Printf("------ type conversion ------\n%v\n", valueToJSON(out))
    fmt.Println()
}

使用 codelab.go 文件中的 valueToJSON 辅助函数转换类型后,您应该会看到以下额外输出:

------ type conversion ------
  {
   "aud": "my-project",
   "exp": "2019-10-13T05:54:29Z",
   "extra_claims": {
      "group": "admin"
     },
   "iat": "2019-10-13T05:49:29Z",
   "iss": "auth.acme.com:12350",
   "nbf": "2019-10-13T05:49:29Z",
   "sub": "serviceAccount:delegate@acme.co"
  }

10. 构建 Proto

CEL 可以为编译到应用中的任何消息类型构建 protobuf 消息。添加了用于从输入 jwt 构建 google.rpc.context.AttributeContext.Request 的函数

// exercise6 describes how to build proto message types within CEL.
//
// Given an input jwt and time now construct a
// google.rpc.context.AttributeContext.Request with the time and auth
// fields populated according to the go/api-attributes specification.
func exercise6() {
  fmt.Println("=== Exercise 6: Building Protos ===\n")

  // Construct an environment and indicate that the container for all references
  // within the expression is `google.rpc.context.AttributeContext`.
  requestType := &rpcpb.AttributeContext_Request{}
  env, _ := cel.NewEnv(
      // Add cel.Container() option for 'google.rpc.context.AttributeContext'
      cel.Types(requestType),
      // Add cel.Variable() option for 'jwt' as a map(string, Dyn) type
      // and for 'now' as a timestamp.
  )

  // Compile the Request message construction expression and validate that
  // the resulting expression type matches the fully qualified message name.
  //
  // Note: the field names within the proto message types are not quoted as they
  // are well-defined names composed of valid identifier characters. Also, note
  // that when building nested proto objects, the message name needs to prefix 
  // the object construction.
  ast := compile(env, `
    Request{
        auth: Auth{
            principal: jwt.iss + '/' + jwt.sub,
            audiences: [jwt.aud],
            presenter: 'azp' in jwt ? jwt.azp : "",
            claims: jwt
        },
        time: now
    }`,
    cel.ObjectType("google.rpc.context.AttributeContext.Request"),
  )
  program, _ := env.Program(ast)

  // Construct the message. The result is a ref.Val that returns a dynamic
  // proto message.
  out, _, _ := eval(
      program,
      map[string]interface{}{
          "jwt": map[string]interface{}{
              "sub": "serviceAccount:delegate@acme.co",
              "aud": "my-project",
              "iss": "auth.acme.com:12350",
              "extra_claims": map[string]string{
                  "group": "admin",
              },
          },
          "now": time.Now(),
      },
  )

  // Hint: Unwrap the CEL value to a proto. Make sure to use the
  // `ConvertToNative(reflect.TypeOf(requestType))` to convert the dynamic proto
  // message to the concrete proto message type expected.
  fmt.Printf("------ type unwrap ------\n%v\n", out)
  fmt.Println()
}

运行代码

重新运行代码后,您应该会看到以下错误:

ERROR: <input>:2:10: undeclared reference to 'Request' (in container '')
 |   Request{
 | .........^

容器基本上相当于命名空间或软件包,但甚至可以细化到 protobuf 消息名称。CEL 容器使用与 Protobuf 和 C++ 相同的命名空间解析规则来确定给定的变量、函数或类型名称的声明位置。

对于容器 google.rpc.context.AttributeContext,类型检查器和评估器将尝试以下标识符名称来查找所有变量、类型和函数:

  • google.rpc.context.AttributeContext.<id>
  • google.rpc.context.<id>
  • google.rpc.<id>
  • google.<id>
  • <id>

对于绝对名称,请在变量、类型或函数引用前面加上前导点。在此示例中,表达式 .<id> 将仅搜索顶级 <id> 标识符,而不会先在容器内进行检查。

尝试为 CEL 环境指定 cel.Container("google.rpc.context.AttributeContext") 选项,然后再次运行:

// exercise6 describes how to build proto message types within CEL.
//
// Given an input jwt and time now construct a
// google.rpc.context.AttributeContext.Request with the time and auth
// fields populated according to the go/api-attributes specification.
func exercise6() {
  fmt.Println("=== Exercise 6: Building Protos ===\n")
  // Construct an environment and indicate that the container for all references
  // within the expression is `google.rpc.context.AttributeContext`.
  requestType := &rpcpb.AttributeContext_Request{}
  env, _ := cel.NewEnv(
    // Add cel.Container() option for 'google.rpc.context.AttributeContext'
    cel.Container("google.rpc.context.AttributeContext.Request"),
    cel.Types(requestType),
    // Later, add cel.Variable() options for 'jwt' as a map(string, Dyn) type
    // and for 'now' as a timestamp.
    // cel.Variable("now", cel.TimestampType),
    // cel.Variable("jwt", cel.MapType(cel.StringType, cel.DynType)),
  )

 ...
}

输出将如下所示:

ERROR: <input>:4:16: undeclared reference to 'jwt' (in container 'google.rpc.context.AttributeContext')
 |     principal: jwt.iss + '/' + jwt.sub,
 | ...............^

…以及更多错误…

接下来,声明 jwtnow 变量,程序应按预期运行:

=== Exercise 6: Building Protos ===

  Request{
   auth: Auth{
    principal: jwt.iss + '/' + jwt.sub,
    audiences: [jwt.aud],
    presenter: 'azp' in jwt ? jwt.azp : "",
    claims: jwt
   },
   time: now
  }

------ input ------
jwt = {
  "aud": "my-project",
  "extra_claims": {
    "group": "admin"
  },
  "iss": "auth.acme.com:12350",
  "sub": "serviceAccount:delegate@acme.co"
}
now = seconds: 1588993027

------ result ------
value: &{0xc000485270 0xc000274180 {0xa6ee80 0xc000274180 22} 0xc0004be140 0xc0002bf700 false} (*types.protoObj)

----- type unwrap ----
&{0xc000485270 0xc000274180 {0xa6ee80 0xc000274180 22} 0xc0004be140 0xc0002bf700 false}

如需获得额外学分,您还应通过对消息调用 out.Value() 来解封装类型,以查看结果如何变化。

11. 宏

宏可用于在解析时操控 CEL 程序。宏匹配调用签名并操纵输入调用及其实参,以生成新的子表达式 AST。

宏可用于在无法直接用 CEL 编写的 AST 中实现复杂的逻辑。例如,has 宏可用于测试字段是否存在。存在和全部等推导宏会替换函数调用,并对输入列表或映射进行有界迭代。这两个概念在语法层面上都无法实现,但可以通过宏展开来实现。

添加并运行下一个练习:

// exercise7 introduces macros for dealing with repeated fields and maps.
//
// Determine whether the jwt.extra_claims has at least one key that starts
// with the group prefix, and ensure that all group-like keys have list
// values containing only strings that end with '@acme.co`.
func exercise7() {
    fmt.Println("=== Exercise 7: Macros ===\n")
    env, _ := cel.NewEnv(
      cel.ClearMacros(),
      cel.Variable("jwt", cel.MapType(cel.StringType, cel.DynType)),
    )
    ast := compile(env,
        `jwt.extra_claims.exists(c, c.startsWith('group'))
                && jwt.extra_claims
                      .filter(c, c.startsWith('group'))
                      .all(c, jwt.extra_claims[c]
                                 .all(g, g.endsWith('@acme.co')))`,
        cel.BoolType)
    program, _ := env.Program(ast)

    // Evaluate a complex-ish JWT with two groups that satisfy the criteria.
    // Output: true.
    eval(program,
        map[string]interface{}{
            "jwt": map[string]interface{}{
                "sub": "serviceAccount:delegate@acme.co",
                "aud": "my-project",
                "iss": "auth.acme.com:12350",
                "extra_claims": map[string][]string{
                    "group1": {"admin@acme.co", "analyst@acme.co"},
                    "labels": {"metadata", "prod", "pii"},
                    "groupN": {"forever@acme.co"},
                },
            },
        })

    fmt.Println()
}

您应该会看到以下错误:

ERROR: <input>:1:25: undeclared reference to 'c' (in container '')
 | jwt.extra_claims.exists(c, c.startsWith('group'))
 | ........................^

…以及更多功能…

出现这些错误是因为宏尚未启用。如需启用宏,请移除 cel.ClearMacros(),然后再次运行:

=== Exercise 7: Macros ===

jwt.extra_claims.exists(c, c.startsWith('group'))
    && jwt.extra_claims
       .filter(c, c.startsWith('group'))
       .all(c, jwt.extra_claims[c]
              .all(g, g.endsWith('@acme.co')))

------ input ------
jwt = {
  "aud": "my-project",
  "extra_claims": {
    "group1": [
      "admin@acme.co",
      "analyst@acme.co"
    ],
    "groupN": [
      "forever@acme.co"
    ],
    "labels": [
      "metadata",
      "prod",
      "pii"
    ]
  },
  "iss": "auth.acme.com:12350",
  "sub": "serviceAccount:delegate@acme.co"
}

------ result ------
value: true (types.Bool)

以下是目前支持的宏:

签名

说明

全部

r.all(var, cond)

测试 cond 是否对范围 r 中的所有 变量 var 的计算结果都为 true。

exists

r.exists(var, cond)

测试范围 r 中的任何 变量是否使 cond 的计算结果为 true。

exists_one

r.exists_one(var, cond)

测试范围 r 中是否仅有 一个变量的 cond 计算结果为 true。

filter

r.filter(var, cond)

对于列表,创建一个新列表,其中范围 r 中的每个元素 var 都满足条件 cond。对于映射,创建一个新列表,其中范围 r 中的每个键变量都满足条件 cond。

地图

r.map(var, expr)

创建一个新列表,其中范围 r 中的每个变量都通过表达式 expr 进行转换。

r.map(var, cond, expr)

与双实参 map 相同,但在转换值之前添加了条件 cond 过滤条件。

has(a.b)

针对值 a 上 b 的存在性测试:对于映射,JSON 测试定义。对于 proto,测试非默认原始值或已设置的消息字段。

当范围 r 实参为 map 类型时,var 将是映射键;对于 list 类型的值,var 将是列表元素值。allexistsexists_onefiltermap 宏会执行 AST 重写,从而执行受输入大小限制的 for-each 迭代。

有界限的推导可确保 CEL 程序不会是图灵完备的,但它们在相对于输入的时间内以超线性时间进行评估。请谨慎使用这些宏,或完全不使用。大量使用推导式通常表明,自定义函数可以提供更好的用户体验和性能。

12. 调优

目前,CEL-Go 独占一些功能,但这些功能预示着未来其他 CEL 实现的计划。以下练习展示了同一 AST 的不同程序计划:

// exercise8 covers features of CEL-Go which can be used to improve
// performance and debug evaluation behavior.
//
// Turn on the optimization, exhaustive eval, and state tracking
// ProgramOption flags to see the impact on evaluation behavior.
func exercise8() {
    fmt.Println("=== Exercise 8: Tuning ===\n")
    // Declare the x and 'y' variables as input into the expression.
    env, _ := cel.NewEnv(
        cel.Variable("x", cel.IntType),
        cel.Variable("y", cel.UintType),
    )
    ast := compile(env,
        `x in [1, 2, 3, 4, 5] && type(y) == uint`,
        cel.BoolType)

    // Try the different cel.EvalOptions flags when evaluating this AST for
    // the following use cases:
    // - cel.OptOptimize: optimize the expression performance.
    // - cel.OptExhaustiveEval: turn off short-circuiting.
    // - cel.OptTrackState: track state and compute a residual using the
    //   interpreter.PruneAst function.
    program, _ := env.Program(ast)
    eval(program, cel.NoVars())

    fmt.Println()
}

优化工具

开启优化标志后,CEL 会花费额外的时间提前构建列表和映射字面量,并优化某些调用(例如 in 运算符),使其成为真正的集合成员资格测试:

    // Turn on optimization.
    trueVars := map[string]interface{}{"x": int64(4), "y": uint64(2)}
    program, _ := env.Program(ast, cel.EvalOptions(cel.OptOptimize))
    // Try benchmarking with the optimization flag on and off.
    eval(program, trueVars)

如果同一程序需要针对不同的输入进行多次评估,那么优化是一个不错的选择。不过,如果程序只会被评估一次,那么优化只会增加开销。

详尽评估

详尽评估有助于调试表达式评估行为,因为它可提供表达式评估中每个步骤的观测值相关信息。

    // Turn on exhaustive eval to see what the evaluation state looks like.
    // The input is structure to show a false on the first branch, and true
    // on the second.
    falseVars := map[string]interface{}{"x": int64(6), "y": uint64(2)}
    program, _ = env.Program(ast, cel.EvalOptions(cel.OptExhaustiveEval))
    eval(program, falseVars)

您应该会看到每个表达式 ID 的表达式评估状态列表:

------ eval states ------
1: 6 (types.Int)
2: false (types.Bool)
3: &{0xc0000336b0 [1 2 3 4 5] {0x89f020 0xc000434760 151}} (*types.baseList)
4: 1 (types.Int)
5: 2 (types.Int)
6: 3 (types.Int)
7: 4 (types.Int)
8: 5 (types.Int)
9: uint (*types.Type)
10: 2 (types.Uint)
11: true (types.Bool)
12: uint (*types.Type)
13: false (types.Bool)

表达式 ID 2 对应于第一个分支中的 in 运算符的结果,表达式 ID 11 对应于第二个分支中的 == 运算符。在正常评估中,计算出 2 后,表达式就会短路。如果 y 不是 uint,则状态会显示表达式失败的两个原因,而不仅仅是一个。

13. 本实验的内容?

如果您需要表达式引擎,请考虑使用 CEL。CEL 非常适合需要执行用户配置且性能至关重要的项目。

在之前的练习中,我们希望您已经能够熟练地将数据传递到 CEL 中,并获取输出或决策结果。

我们希望您能了解可以执行的各种操作,从布尔值决策到生成 JSON 和 Protobuffer 消息,应有尽有。

希望您已经了解如何使用表达式以及它们的作用。我们了解延长保修期的常见方式。