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の得意とするところといえます。
環境(使える変数や型などの情報を持つ)を構築
プログラムを環境に渡してコンパイル→型チェックが行われる。その後単独実行できるプログラムオブジェクトを作成
実際の変数値を与えて実行
package mainimport ( "fmt" "log" "github.com/google/cel-go/cel" ) func main () { 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 snapsqlgoimport ( "github.com/shopspring/decimal" ) type Decimal struct { decimal.Decimal }
CELの中で扱うデータはcel.Valueインタフェースを満たす必要があります。生成AIが出して来るコードはこの時点でだいぶ関数名や型が変わっています。あと演算子のオーバーロードはできません。やたらオーバーロードさせようとしてきて数日はまった。
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 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(): 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 { 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にまんまとやられました。
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 )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 } func (u *User) Type() ref.Type { return UserType } func (u *User) Value() interface {} { return u } var UserType = cel.ObjectType("User" )
またAdapterとProviderを実装する必要があります。Chain of Responsibility的な実装にするか複数のプロバイダを束ねるプロキシを作ってやらないと、ダメですね。FindStructFieldNames、FindStructFieldTypeあたりがポイントですね。
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())
まとめ 式言語は使い方を知っていると、複雑な利用者がカスタマイズしたいようなアプリケーションではかなり役立つと思われます。ただ、一方でどのように実装してあげればよいかの情報があまりありませんでした。これから使おうと思う方のお役に立てば、と思います。