フューチャー技術ブログ

式言語のCELに独自の型を追加してみる

CELという言語をご存知でしょうか?Google製の組み込み言語で、さまざまな製品に組み込まれています。

  • Google Cloud
    • IAM(Identity and Access Management): ポリシー条件の記述にCELが使用されている
    • Cloud Logging: ログフィルタリングや条件式の記述にCELが使用されている
    • Certificate Authority Service: 証明書のチェックなどにCELが利用可能
  • Envoy Proxy
    Envoyの拡張機能であるCELフィルタを使用して、リクエストやレスポンスの条件式を評価可能
  • Istio
    IstioのポリシーエンジンでCELが使用されており、トラフィックルールやセキュリティポリシーの条件式で利用
  • Open Policy Agent (OPA)
    OPAの一部の拡張機能やポリシー評価でCELが利用
  • Kubernetes
    Kubernetesの一部のカスタムリソースやポリシーエンジンで、条件式の記述にCELが利用可能

アリババクラウドとかでも使っているようです。いつの間にか我々の周りでかなり使われている言語です。

Go製のライブラリの実装もあり、ちょっと動かすのは簡単です。チュートリアルもあります。公式では他にJava、C++、JavaScriptがあります。サードパーティだとRust, Python, Rubyなどもあるようです。

言語としてはifやforなどの構造化構文は持っていないけど、関数呼び出し、名前空間探索、演算子などが揃っています。JSONのようなデータ構造もJavaScriptで扱うようにさわれます。自分で関数とか追加できます。変数だけではなく関数とかも追加できたり、ProtoBufで作ったオブジェクトを簡単に登録したり渡せるようになっていたりして、組み込み言語として使いやすくなっています。

Go版以外は見ていないですが基本的に次のような流れです。たいていのインタプリタはプログラムの評価と実行が同時なのですが、CELはフェーズを分けられます。プログラムだけを先にビルドしておいて、ビルドしたプログラムに大量にデータを流し込んでフィルタさせる、というクラウドサービスの使い方はまさにCELの得意とするところといえます。

  1. 環境(使える変数や型などの情報を持つ)を構築
  2. プログラムを環境に渡してコンパイル→型チェックが行われる。その後単独実行できるプログラムオブジェクトを作成
  3. 実際の変数値を与えて実行
package main

import (
"fmt"
"log"

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

func main() {
// CEL環境を作成
env, err := cel.NewEnv(
cel.Variable("a", cel.IntType),
cel.Variable("b", cel.IntType),
)
// エラーチェック省略
// 基本的な数値の足し算
ast, issues := env.Compile("10 + 20")
// エラーチェック省略
prg, err := env.Program(ast)
// エラーチェック省略
vars := map[string]any{
"a": 15,
"b": 25,
}
result, _, err := prg1.Eval(var)
// エラーチェック省略
fmt.Printf("15 + 25 = %v\n", result)
}

一方で、ちょっと凝った使い方をしようとすると途端に情報がなくなりますし、コードを読んでもanyばっかりで型情報から得られるものがない。生成AIに聞いてもまずまともに答えが返ってこないです。関数の追加はチュートリアルにありますが、型の追加はまったくない。なぞの今はない(元々ないのか過去にはあったのかわかりませんが)APIを自信満々に回答してきます。Google製のGeminiすらも・・・ということで現行バージョンの0.26のドキュメントにもAIにもない使い方を試行錯誤しながら調べたのでまとめました。

新しいプリミティブ型を追加

中で評価とかはしないでJSONの整形のような感じで利用だけできれば良い、という感じでデータを追加します。github.com/shopspring/decimalのDecimal型のサポートをCELに追加しようというものです。

まあ他の型に変換したり演算させるようなメソッド追加も、まずはここが出発点になるでしょう。まずは既存のGo型をラップする型を作って必要なメソッドを足していきます。実際には type MyDecimal decimal.Decimal みたいに定義型だけでも良いかもしれませんが、メソッド名の衝突とかあると面倒なのでラップしてます。

package snapsqlgo

import (
"github.com/shopspring/decimal"
)

type Decimal struct {
decimal.Decimal
}

CELの中で扱うデータはcel.Valueインタフェースを満たす必要があります。生成AIが出して来るコードはこの時点でだいぶ関数名や型が変わっています。あと演算子のオーバーロードはできません。やたらオーバーロードさせようとしてきて数日はまった。

// CELの中ではこの*types.Typeで型の識別が行われる
var DecimalType = types.NewObjectType("Decimal")

var _ ref.Val = (*Decimal)(nil)


func (d *Decimal) Type() ref.Type {
return DecimalType
}

func (d *Decimal) Value() interface{} {
return d.Decimal
}

func (d *Decimal) ConvertToNative(typeDesc reflect.Type) (any, error) {
if typeDesc == reflect.TypeOf(decimal.Decimal{}) {
return d.Decimal, nil
}
// If the target type is *decimal.Decimal, return a pointer
if typeDesc == reflect.TypeOf(&decimal.Decimal{}) {
return &d.Decimal, nil
}
if typeDesc == reflect.TypeOf(float64(0)) {
f, _ := d.Decimal.Float64()
return f, nil
}
if typeDesc == reflect.TypeOf("") {
return d.Decimal.String(), nil
}
return nil, fmt.Errorf("unsupported native conversion to %v for Decimal", typeDesc)
}

func (d *Decimal) ConvertToType(typeVal ref.Type) ref.Val {
switch typeVal {
case types.DoubleType:
f, _ := d.Float64()
return types.Double(f)
case types.StringType:
return types.String(d.String())
case d.Type(): // Self conversion
return d
}
return types.NewErr("type conversion error from Decimal to %s", typeVal)
}

func (d *Decimal) Equal(other ref.Val) ref.Val {
o, ok := other.(*Decimal)
if !ok {
// Try to convert other to Decimal if possible
converted, err := other.ConvertToNative(reflect.TypeOf(d.Decimal))
if err == nil {
o, ok = converted.(*Decimal)
}
if !ok {
return types.NewErr("type conversion error during comparison")
}
}
return types.Bool(d.Decimal.Equal(o.Decimal))
}

オブジェクト型だけ作ればおしまい、というわけではありません。Go値→CEL値変換のアダプタと、型名→型情報解決のプロバイダを実装します。なお、アダプタとプロバイダは1つしか環境ごとにセットできません。デフォルトの型アダプタにフォールバックしないと基本の数値や文字列が使えなくなります。ここも生成AIにまんまとやられました。

// Goの値をCELに変換
type customDecimalTypeAdapter struct{}

func (customDecimalTypeAdapter) NativeToValue(value any) ref.Val {
switch v := value.(type) {
case *Decimal:
return v
case decimal.Decimal:
return &Decimal{v}
default: // ここ忘れないように!
return types.DefaultTypeAdapter.NativeToValue(value)
}
}

var _ types.Adapter = (*customDecimalTypeAdapter)(nil)

// CustomTypeProvider: 型名→型情報解決
type customDecimalTypeProvider struct {
}

func (p *customDecimalTypeProvider) EnumValue(enumName string) ref.Val {
return types.NewErr("not found enum: %s", enumName)
}

func (p *customDecimalTypeProvider) FindIdent(identName string) (ref.Val, bool) {
return nil, false
}

func (p *customDecimalTypeProvider) FindStructFieldNames(structType string) ([]string, bool) {
return nil, false
}

func (p *customDecimalTypeProvider) FindStructFieldType(structType string, fieldName string) (*types.FieldType, bool) {
return nil, false
}

func (p *customDecimalTypeProvider) FindStructType(structType string) (*types.Type, bool) {
return nil, false
}

func (p *customDecimalTypeProvider) NewValue(structType string, fields map[string]ref.Val) ref.Val {
return types.NewErr("not value: %s", structType)
}

func (p *customDecimalTypeProvider) FindType(typeName string) (*cel.Type, bool) {
if typeName == DecimalTypeName {
return DecimalType, true
}
return nil, false
}

var _ types.Provider = (*customDecimalTypeProvider)(nil)

あとはいつものNewEnvでこのアダプタとプロバイダを追加してあげればOKです。

current, err := cel.NewEnv(
cel.HomogeneousAggregateLiterals(),
cel.EagerlyValidateDeclarations(true),
cel.CustomTypeAdapter(&customDecimalTypeAdapter{}),
cel.CustomTypeProvider(&customDecimalTypeProvider{}),
ここに変数を追加,
)

構造体の追加

Decimalは実際に演算とかはしないプリミティブでした。テンプレートエンジンとかで3番目のDecimalを取り出すよ、的な使い方の想定。今度は複合オブジェクトとして、Goの構造体、TypeScriptのオブジェクト的なものを作ります。CELで中のフィールドも探索させます。先程はref.Valを満たし他実装にしていましたが、今度はtraits.Indexerです。これも生成AIがまったく教えてくれなくてコードを探してようやく見つけたものです。このパッケージを見ると使い勝手の良いオブジェクトが登録できそうです。ただし演算子オーバーロードはプリミティブ型しかできない?今回はラップせずに直接実装しています。

type User struct {
ID int64
Name string
}

var _ ref.Val = (*User)(nil)
var _ traits.Indexer = (*User)(nil)

// 今回大事なのがこれ
func (u *User) Get(index ref.Val) ref.Val {
v, err := index.ConvertToNative(reflect.TypeOf(""))
if s, ok := index.Value().(string); ok {
log.Println(s, ok)
switch s {
case "id":
return types.Int(u.ID)
case "name":
return types.String(u.Name)
default:
return types.NewErr("unknown field: %s", s)
}
}
return types.NewErr("index must be a string, got: %s", index.Type())
}

func (u *User) ConvertToNative(typeDesc reflect.Type) (interface{}, error) {
if typeDesc == reflect.TypeOf(u) {
return u, nil
}
return nil, fmt.Errorf("unsupported type conversion: %v", typeDesc)
}

func (u *User) ConvertToType(typeVal ref.Type) ref.Val {
if typeVal == types.TypeType {
return UserType
}
return types.NewErr("type conversion not supported")
}

func (u *User) Equal(other ref.Val) ref.Val {
if otherUser, ok := other.(*User); ok {
return types.Bool(u.ID == otherUser.ID && u.Name == otherUser.Name)
}
return types.False
}

// Type: CELでの型情報を返す
func (u *User) Type() ref.Type {
return UserType
}

// Value: CELでの値を返す
func (u *User) Value() interface{} {
return u
}

var UserType = cel.ObjectType("User")

またAdapterとProviderを実装する必要があります。Chain of Responsibility的な実装にするか複数のプロバイダを束ねるプロキシを作ってやらないと、ダメですね。FindStructFieldNames、FindStructFieldTypeあたりがポイントですね。

// CustomTypeAdapterの実装
type UserAdapter struct{}

func (a *UserAdapter) NativeToValue(value interface{}) ref.Val {
if user, ok := value.(*User); ok {
return user
}
return types.DefaultTypeAdapter.NativeToValue(value)
}

var _ types.Adapter = (*UserAdapter)(nil)

type UserProvider struct{}

func (p *UserProvider) EnumValue(enumName string) ref.Val {
return types.NewErr("EnumValue is not supported for %s", enumName)
}

func (p *UserProvider) FindIdent(identName string) (ref.Val, bool) {
return nil, false
}

func (p *UserProvider) FindStructFieldNames(structType string) ([]string, bool) {
if structType == "User" {
return []string{"id", "name"}, true
}
return nil, false
}

func (p *UserProvider) FindStructFieldType(structType string, fieldName string) (*types.FieldType, bool) {
if structType == "User" {
switch fieldName {
case "id":
return &types.FieldType{
Type: types.IntType,
}, true
case "name":
return &types.FieldType{
Type: types.StringType,
}, true
}
}
return nil, false
}

func (p *UserProvider) FindStructType(structType string) (*types.Type, bool) {
if structType == "User" {
return UserType, true
}
return nil, false
}

func (p *UserProvider) NewValue(typeName string, fields map[string]ref.Val) ref.Val {
if typeName == "User" {
id := fields["id"].Value().(int64)
name := fields["name"].Value().(string)
return &User{ID: id, Name: name}
}
return types.NewErr("unsupported type: %s", typeName)
}

var _ types.Provider = (*UserProvider)(nil)

ここまでやるとようやくuser.nameが実現できるように・・・

env, err := cel.NewEnv(
cel.CustomTypeAdapter(&UserAdapter{}),
cel.CustomTypeProvider(&UserProvider{}),
cel.Variable("user", UserType),
)
// エラー処理省略
ast, issues := env.Compile("user.name")
// エラー処理省略
prg, err := env.Program(ast)
// エラー処理省略
out, _, err := prg.Eval(map[string]any{
"user": &User{
ID: 1,
Name: "Alice",
},
})
// エラー処理省略
log.Println("Output:", out.Value()) // -> Alice

まとめ

式言語は使い方を知っていると、複雑な利用者がカスタマイズしたいようなアプリケーションではかなり役立つと思われます。ただ、一方でどのように実装してあげればよいかの情報があまりありませんでした。これから使おうと思う方のお役に立てば、と思います。