Carpe Diem

備忘録

CELでカスタム関数を使う

概要

CELは標準的な演算子や関数に加え、独自のカスタム関数を定義して機能を拡張することが可能です。

今回はカスタム関数を使ってみる際に必要な前提知識を踏まえながらサンプルコードを紹介します。

環境

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

カスタム関数の作り方

Goでの実装になりますが、手順としては以下です。

  1. environmentに関数を登録
  2. bindする関数を定義(interfaceを実装する)
  3. 評価式で関数を使う

事前知識

事前に知っておくと理解が早くなるのであらかじめ説明します。

OverloadとMemberOverloadの違い

Overload

次のように通常の関数のように定義する際はOverloadを使います。

concatStr(arg1, arg2)

MemberOverload

次のようにメンバ(target)に関数を生やす際はMemberOverloadを使います。

target.join(arg1)

overload IDの命名規則

environmentで関数を登録する際に、先程のoverloadにおいてid名を登録する必要があるのですが、

func Overload(overloadID string, args []*Type, resultType *Type, opts ...OverloadOpt) FunctionOpt
func MemberOverload(overloadID string, args []*Type, resultType *Type, opts ...OverloadOpt) FunctionOpt

その命名規則は以下となっています。

Overload

Overloadはfunc_argType_argTypeという命名規則です。

例としてconcatStr("alice", 10)のような関数を定義する場合は

concatStr_string_int

命名します。

MemberOverload

MemberOverloadはtargetType_func_argType_argTypeという命名規則です。

例としてi.greet(you)のような関数を定義する場合(i, youが文字列とします)、

string_greet_string

命名します。

UnaryBinding, BinaryBinding, FunctionBindingの違い

先ほど

  1. bindする関数を定義(interfaceを実装する)

と説明した際に、bindする関数のinterfaceが引数の数によって異なるのでその違いを説明します。

UnaryBinding

引数1つ取ります。戻り値は1つです。

func UnaryBinding(binding functions.UnaryOp) OverloadOpt
type UnaryOp func(value ref.Val) ref.Val

ref: https://pkg.go.dev/github.com/google/cel-go/cel#UnaryBinding

BinaryBinding

引数2つとります。戻り値は1つです。

func BinaryBinding(binding functions.BinaryOp) OverloadOpt
type BinaryOp func(lhs ref.Val, rhs ref.Val) ref.Val

ref: https://pkg.go.dev/github.com/google/cel-go/cel#BinaryBinding

FunctionBinding

引数は可変長です。戻り値は1つです。

func FunctionBinding(binding functions.FunctionOp) OverloadOpt
type FunctionOp func(values ...ref.Val) ref.Val

ref: https://pkg.go.dev/github.com/google/cel-go/cel#FunctionBinding

まとめるとこうなります。

binding 引数 戻り値
UnaryBinding 1 1
BinaryBinding 2 1
FunctionBinding 可変長 1

戻り値はどれも1つですが、ListTypeやMapTypeを返すことで擬似的に複数返すことも可能です。

具体的な実装

それでは具体的な実装例を紹介します。

今回は2つの文字列を結合するconcatStrという関数を独自定義します。

グローバルな関数を作るケース

func global() {
    env, err := cel.NewEnv(
        cel.Function("concatStr",
            // グローバルな関数なのでOverloadを使う
            // 命名規則はfunc_argType_argType
            cel.Overload("concatStr_string_string",
                // 引数の型を指定
                []*cel.Type{cel.StringType, cel.StringType},
                // 戻り値の型を指定
                cel.StringType,
                // 引数が2つの関数を用意するのでBinaryBinding
                cel.BinaryBinding(concatFunc),
            ),
        ),
    )
    if err != nil {
        log.Fatalf("failed to create env: %s\n", err)
    }

    // 評価式でカスタム関数を呼び出す。今回は変数なし
    expr := `concatStr('Hello', 'World')`
    ast, iss := env.Compile(expr)
    if iss != nil && iss.Err() != nil {
        log.Fatalf("failed to compile expression: %v", iss.Err())
    }

    p, err := env.Program(ast)
    if err != nil {
        log.Fatalf("failed to create program: %s\n", err)
    }

    // 変数なしなので空のmapを渡す
    out, _, err := p.Eval(map[string]interface{}{})
    if err != nil {
        log.Fatalf("evaluation error: %s\n", err)
    }

    fmt.Println("Result:", out)
}

BindするconcatFuncはBinaryOpを実装する形で定義します。

func concatFunc(arg1, arg2 ref.Val) ref.Val {
    v1 := arg1.(types.String)
    v2 := arg2.(types.String)
    return types.String(v1 + v2)
}

評価式にベタ書きなので、それが結合されて返ってきます。

Result: HelloWorld

グローバルな関数に引数をもたせる

func globalWithVariableArg(a, b string) {
    env, err := cel.NewEnv(
        // 引数を変数として定義
        cel.Variable("arg1", cel.StringType),
        cel.Variable("arg2", cel.StringType),
        cel.Function("concatStr",
            // グローバルな関数なのでOverloadを使う
            cel.Overload("concatStr_string_string",
                // 引数の型を指定
                []*cel.Type{cel.StringType, cel.StringType},
                // 戻り値の型を指定
                cel.StringType,
                // 引数が2つの関数を用意するのでBinaryBinding
                cel.BinaryBinding(concatFunc),
            ),
        ),
    )
    if err != nil {
        log.Fatalf("failed to create env: %s\n", err)
    }

    // 評価式でカスタム関数を呼び出す。今回は変数あり
    expr := `concatStr(arg1, arg2)`
    ast, iss := env.Compile(expr)
    if iss != nil && iss.Err() != nil {
        log.Fatalf("failed to compile expression: %v", iss.Err())
    }

    p, err := env.Program(ast)
    if err != nil {
        log.Fatalf("failed to create program: %s\n", err)
    }

    // 入力値として変数を渡す
    out, _, err := p.Eval(map[string]interface{}{
        "arg1": a,
        "arg2": b,
    })
    if err != nil {
        log.Fatalf("evaluation error: %s\n", err)
    }

    fmt.Println("Result:", out)
}

引数を渡して呼び出すと

globalWithVariableArg("I'm ", "Alice")

次のように結合されます。

Result: I'm Alice

メンバーに関数を生やす

レシーバーにメソッドをつけるような形式でカスタム関数を定義することもできます。

func memberWithVariableArg(a, b string) {
    env, err := cel.NewEnv(
        // メンバ、引数を変数として定義
        cel.Variable("target", cel.StringType),
        cel.Variable("arg1", cel.StringType),
        cel.Function("concatStr",
            // メンバ関数の場合はMemberOverloadを使う
            // 命名規則はtargetType_func_argType_argType
            cel.MemberOverload("string_concatStr_string",
                // 引数の型を指定。第一引数がメンバの型になる
                []*cel.Type{cel.StringType, cel.StringType},
                // 戻り値の型を指定
                cel.StringType,
                // 引数が2つの関数を用意するのでBinaryBinding
                cel.BinaryBinding(concatFunc),
            ),
        ),
    )
    if err != nil {
        log.Fatalf("failed to create env: %s\n", err)
    }

    // メンバ関数の場合は、メンバに関数を生やして記述する
    expr := `target.concatStr(arg1)`
    ast, iss := env.Compile(expr)
    if iss != nil && iss.Err() != nil {
        log.Fatalf("failed to compile expression: %v", iss.Err())
    }

    p, err := env.Program(ast)
    if err != nil {
        log.Fatalf("failed to create program: %s\n", err)
    }

    out, _, err := p.Eval(map[string]interface{}{
        "target": a,
        "arg1":   b,
    })
    if err != nil {
        log.Fatalf("evaluation error: %s\n", err)
    }

    fmt.Println("Result:", out)
}

引数を渡して呼び出すと

memberWithVariableArg("I'm ", "Bob")

次のように結合されます。

Result: I'm Bob

その他

サンプルコード

今回のサンプルコードはこちら

github.com

関数内でのエラーハンドリング

Bindする関数内でのエラーハンドリングについては、関数が

の実装をしますがどれも戻り値はref.Valのみです。

なので

  • types.NewErr
  • types.ValOrErr
  • types.MaybeNoSuchOverload

のいずれかを使ってエラーを返します。
以下例です。

func stringOrError(str string, err error) ref.Val {
    if err != nil {
        return types.NewErr(err.Error())
    }
    return types.String(str)
}

cel.TypeParamTypeはどんな時に使う?

codelabではcel.TypeParamTypeを使ったサンプルが書かれています。

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

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

これはジェネリクス的な使い方で、MapTypeやListTypeをジェネリクス的に汎化したい時に使えます。

もしMapの中身(key-value)がstringに限定されるのであればcel.TypeParamTypeは使わずcel.StringTypeで問題なく動きます。

まとめ

CELでカスタム関数を使う際に自分が気になった部分中心にサンプルを交えて説明しました。

参考