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 在逻辑和/或运算中的短路
  • 如何使用 CEL 构建 JSON
  • 如何使用 CEL 构建 Protobuffer
  • 创建宏
  • 调整 CEL 表达式的方法

所需条件

前提条件

此 Codelab 的基础是对 Protocol BuffersGo Lang 有基本的了解。

如果您不熟悉 Protocol Buffers,第一个练习会让您了解 CEL 的工作原理,但由于更高级的示例使用 Protocol Buffers 作为 CEL 的输入,它们可能更难理解。不妨考虑先学习这些教程之一。请注意,协议缓冲区并非使用 CEL 的必要条件,但在此 Codelab 中得到了广泛使用。

您可以通过运行以下命令来测试是否已安装 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 的服务和应用会声明表达式环境。环境是可以在表达式中使用的变量和函数的集合。

CEL 类型检查工具会使用基于 proto 的声明,以确保正确声明和使用表达式中的所有标识符和函数引用。

解析表达式的三个阶段

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

c71fc08068759f81.png

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

49ab7d8517143b66

使用 ANTLR 词法分析器 / 解析器语法将 CEL 从人类可读的表达式解析为抽象语法树。解析阶段会发出一个基于 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!”。

配置环境

在您的编辑器中,找到 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. 逻辑和/或

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()
}

添加自定义函数

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

// 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)

版权主张存在后会出现什么情况?

如需获得额外的功劳,请尝试在输入中设置 admin 声明,以验证包含过载在声明存在时也会返回 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.TimestampTypenow 变量添加到 cel.NewEnv(),然后再次运行:

// 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. 构建原型

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。

宏可用于在 AST 中实现无法直接用 CEL 编写的复杂逻辑。例如,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)

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

存在

r.exists(var, cond)

测试范围 r 中的任何 变量的 cond 求值为 true。

exists_one

r.exists_one(变量, cond)

测试范围 r 中只有一个 变量的 cond 求值为 true。

filter

r.filter(var, cond)

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

地图

r.map(var, expr)

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

r.map(var, cond, expr)

与双参数映射相同,但在转换值之前使用条件 cond 过滤器。

存在

has(a.b)

b 在值 a 上的存在性测试:对于映射,json 会测试定义。对于 proto,测试非默认基元值、a 或 set 消息字段。

当范围 r 实参是 map 类型时,var 是映射键;对于 list 类型值,var 是列表元素值。allexistsexists_onefiltermap 宏会执行 AST 重写,以便针对每次迭代执行受输入大小限制的迭代。

有界限理解可确保 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 消息,任何内容皆可。

我们希望您了解如何使用表达式以及它们的作用。我们了解扩展数据的常见方式。