概要
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を使って入力値を評価
このように分ける運用方法の実装例はこちらで紹介しています。
実装
次に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)) }
コメントに書いてあるように次のフローで実行します。
- enviromentで変数等を宣言
- 評価式をCompileしてASTを生成
- ASTからProgramを生成
- 評価
今回は入力値が偶数かどうか、という式を入れてみました。
具体的な実装(評価式の分離)
例えば評価式の部分を次のように外から注入できるようにしておきます。
// ローカルのテキストファイルを読み込み 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
その他
サンプルコード
サンプルコードをこちらに用意してあります。
パフォーマンスを上げたい時
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について簡単な説明をしました。
ユースケースに挙げたような要件が出たときはぜひ使ってみてください。