Carpe Diem

備忘録

CEL(Common Expression Language)

概要

CEL(Common Expression Language)とは、Google によって開発された軽量で高速な式評価言語です。
そのシンプルさと効率性から、セキュリティポリシー、リソースフィルタリング、データ検証などに使われます。

例えばプロダクションでは以下のような利用例があります。

今回はCELの説明と簡単な使い方を紹介します。

環境

  • go v1.22.1
  • cel-go v0.20.1

CELとは

CELは以下の特徴を持ちます。

具体的には次のようなことができます。

// リソース名がグループ名で始まっているかどうかをチェック
resource.name.startsWith("/groups/" + auth.claims.group)
// リクエストが許可された時間内にあるかどうかをチェック
request.time - resource.age < duration("24h")
// リスト内のすべてのリソース名が指定されたフィルタに一致するかどうかをチェック
auth.claims.email_verified && resources.all(r, r.startsWith(auth.claims.email))

適したユースケース

CELに向いているユースケースとして以下があります。

  • スキーマを定義できない柔軟なConfig(評価式、ポリシー)が必要である
    • セキュリティポリシー
    • アクセス制御
    • コンテンツのフィルタ
      • 特定のジャンルや公開期間の制限を宣言的かつ柔軟に設定したい
  • 非エンジニアでも設定を記述しやすく、かつ事前に設定の正当性がvalidateしたい
  • パフォーマンスがクリティカルなパスである
  • ポリシーの変更は少ないが実行は多い

キーコンセプト

CELのキーコンセプトとしてParse, Check, Evaluateという3つのフェーズがあります。

その中で大きくParse/Check、Evaluateで実行タイミングが分かれます。

Control Plane

ref: https://codelabs.developers.google.com/codelabs/cel-go#1

  • Parse
    • ASTに変換する
  • Check
    • 式に含まれている変数や関数が宣言されているかチェックする

ポイントとしてParseやCheckは低レイテンシが求められる際はランタイム実行すべきではありません。
なので管理ツール等で事前に登録してASTとして保存しておくのがベストプラクティスです。

評価

  • Evaluate
    • 保存されたASTを使って入力値を評価

このように分ける運用方法の実装例はこちらで紹介しています。

christina04.hatenablog.com

実装

次にGoを使った具体的な実装について説明します。

用語

まず出てくる用語について説明します。

用語 役割
environment 環境。独自の変数やカスタム関数などを登録する
compile envにexpressionを渡してparseしてastを生成する
expression
ast 抽象構文木。compileして生成されるもの。
program env内でのastの評価可能なインスタンスを生成する
evaluation inputを渡してprogramから評価

Q. なぜprogramというフェーズがある?

Compile後のASTはprotobufなどにシリアライズして保存ができます(Parse&CheckとEvaluateの分離)。
実際に利用するときはその後でデシリアライズして評価するためのインスタンスを用意する必要があり、そのインスタンスがProgramとなります。

メソッド

cel-goの主なメソッドは以下です。

メソッド 役割
Compile  環境毎に登録する評価式の Parse と Check を行う
Eval  Compileされた評価式(program)と、入力値を使って評価する
Report  評価結果を詳細に表示する

具体的な実装(シンプル)

まずはシンプルな実装方法を説明します。

package main

import (
    "fmt"
    "log"

    "github.com/google/cel-go/cel"
)

func main() {
    // CEL環境の設定
    env, err := cel.NewEnv(
        // 'num' という名前の整数型の変数を宣言
        cel.Variable("num", cel.IntType),
    )
    if err != nil {
        log.Fatalf("Failed to create CEL environment: %v", err)
    }

    // 入力値が偶数かどうかをチェックするCEL式
    expr := `num % 2 == 0`

    // 式のコンパイル
    ast, issues := env.Compile(expr)
    if issues != nil && issues.Err() != nil {
        log.Fatalf("Compile error: %v", issues.Err())
    }

    // プログラムの生成
    prg, err := env.Program(ast)
    if err != nil {
        log.Fatalf("Program creation error: %v", err)
    }

    // 評価する入力値
    inputs := map[string]interface{}{
        "num": 9, // この値を変更して異なる入力で試すことができます
    }

    // 評価
    result, _, err := prg.Eval(inputs)
    if err != nil {
        log.Fatalf("Evaluation error: %v", err)
    }

    fmt.Printf("Is %v an even number? %v\n", inputs["num"], result.Value().(bool))
}

コメントに書いてあるように次のフローで実行します。

  1. enviromentで変数等を宣言
  2. 評価式をCompileしてASTを生成
  3. ASTからProgramを生成
  4. 評価

今回は入力値が偶数かどうか、という式を入れてみました。

具体的な実装(評価式の分離)

例えば評価式の部分を次のように外から注入できるようにしておきます。

   // ローカルのテキストファイルを読み込み
    b, err := os.ReadFile("./expr.txt")
    if err != nil {
        log.Fatalf("Failed to read file: %v", err)
    }

    // 外部から評価式を取得
    expr := string(b)

    // 式のコンパイル
    ast, issues := env.Compile(expr)
    if issues != nil && issues.Err() != nil {
        log.Fatalf("Compile error: %v", issues.Err())
    }

そうすることでコードを変更せずとも、評価式を柔軟に変更することができるようになります。

評価式1

num % 2 == 0 && num > 10

入力値:20
結果:true

評価式2

num % 3 == 0 || num < 5

入力値:9
結果:false

その他

サンプルコード

サンプルコードをこちらに用意してあります。

github.com

パフォーマンスを上げたい時

cel.OptOptimizeを使う

cel.OptOptimizeを使います。

prog, err := env.Program(ast, cel.EvalOptions(cel.OptOptimize))

以下ベンチマーク結果です。評価式が複雑になるほど、速度差が顕著に出ていました。

$ go test -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/jun06t/cel-sample/optimize
BenchmarkNewProgramOptimizeTrue-10       1818870               657.3 ns/op           144 B/op          9 allocs/op
BenchmarkNewProgramOptimizeFalse-10       836688              1329 ns/op             896 B/op         33 allocs/op

ref: cel-sample/optimize at main · jun06t/cel-sample · GitHub

ただしCELは軽量ではあるものの、CELを通さず生のコードで実装した方が圧倒的に速いです。
なのでユースケースに述べた柔軟性を必要とするケースでの利用が前提となります。

$ go test -bench . -benchmem
goos: darwin
goarch: arm64
pkg: github.com/jun06t/cel-sample/optimize
BenchmarkNewProgramOptimizeTrue-10       1818870               657.3 ns/op           144 B/op          9 allocs/op
BenchmarkRawCode-10                     589481318                2.036 ns/op           0 B/op          0 allocs/op

マクロをOFFにする

CELには組み込み関数の拡張としてマクロが存在します。

ref: https://codelabs.developers.google.com/codelabs/cel-go#10

カスタム関数を定義するよりも簡単に使えますが、パフォーマンスとしてはカスタム関数の方が高く、またマクロを使わない場合はcel.ClearMacros()を設定してOFFにすることが推奨されています。

    env, _ := cel.NewEnv(
      cel.ClearMacros(),
      cel.Variable("num", cel.IntType),
    )

デバッグしたい

cel.OptExhaustiveEvalを使います。 するとEvaluate()の戻り値であるEvalDetailsに詳細が渡りデバッグしやすくなります。

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

------ eval states ------
1: 9 (types.Int)
2: 1 (types.Int)
3: 2 (types.Int)
4: false (types.Bool)
5: 0 (types.Int)

チュートリアル

チュートリアルが提供されており、これを一度やってみるとイメージがつきやすくなります。

CEL-Go Codelab: Fast, safe, embedded expressions

まとめ

式評価言語であるCELについて簡単な説明をしました。

ユースケースに挙げたような要件が出たときはぜひ使ってみてください。

参考