Waarom een eigen CEL-engine?
De Common Expression Language (CEL) biedt een compacte, veilige manier om dynamische regels en filters te definiëren. Hoewel er Java‑ en Go‑implementaties bestaan, mist .NET een native versie. Een eigen C#‑engine geeft volledige controle, betere performance en eenvoudige integratie met bestaande .NET‑apps.
Basisprincipes van CEL
CEL is een tekstuele uitdrukkingstaal die logische, rekenkundige en string‑operaties ondersteunt. Een voorbeeld: user.age > 18 && user.country == "NL". De engine moet de string parseren, een abstracte syntaxisboom (AST) bouwen en vervolgens evalueren tegen een contextobject.
Projectstructuur
- CelParser – Leest de invoerstring en produceert tokens.
- CelAst – Representatie van de AST met knooppunten zoals
BinaryExpression,LiteralenIdentifier. - CelEvaluator – Loopt de AST af en voert de bewerkingen uit op een gegeven context.
- CelRuntime – Publieke API die een expressie compileert naar een delegate (
Func<TContext, bool>).
Stap 1: Tokenizer maken
De tokenizer scant de invoer en zet deze om in een lijst tokens (identifiers, literals, operators, haakjes). In C# gebruiken we een ReadOnlySpan<char> voor efficiënte slice‑operaties. Belangrijke punten:
- Ondersteun zowel enkele (
') als dubbele (") string‑quotes. - Herken numerieke literals (int, double).
- Detecteer logische operatoren (
&&,||,!).
public enum TokenType { Identifier, Number, String, Operator, LParen, RParen, End }
public record Token(TokenType Type, string Text);
Stap 2: Parser en AST‑opbouw
Met een recursive‑descent parser verwerken we de token‑stroom volgens de precedentie‑regels van CEL. De parser bouwt een boom van ExpressionNode‑objecten.
abstract class ExpressionNode { }
class BinaryNode : ExpressionNode { public ExpressionNode Left; public string Op; public ExpressionNode Right; }
class LiteralNode : ExpressionNode { public object Value; }
class IdentifierNode : ExpressionNode { public string Name; }
Stap 3: Evaluator implementeren
De evaluator loopt de boom recursief af. Voor IdentifierNode halen we de waarde op uit de context via reflection of een IDictionary<string,object>.
public bool Evaluate<T>(ExpressionNode node, T context)
{
switch(node)
{
case LiteralNode l: return (bool)l.Value;
case IdentifierNode i: return GetValue(context, i.Name);
case BinaryNode b:
var left = Evaluate(b.Left, context);
var right = Evaluate(b.Right, context);
return ApplyOperator(b.Op, left, right);
}
throw new NotSupportedException();
}
Stap 4: Compilatie naar delegate
Voor performance compileren we de AST naar een lambda‑expressie (System.Linq.Expressions). Zo ontstaat een Func<TContext,bool> die net zo snel is als handgeschreven code.
var param = Expression.Parameter(typeof(TContext), "ctx");
var body = BuildExpression(ast, param);
var lambda = Expression.Lambda<Func<TContext,bool>>(body, param).Compile();
Integratie in een .NET‑applicatie
De engine wordt beschikbaar gesteld via een statische helper:
public static class Cel
{
public static Func<T,bool> Compile<T>(string expr) where T : class
{
var ast = CelParser.Parse(expr);
return CelRuntime.Compile<T>(ast);
}
}
Gebruik in code:
var rule = Cel.Compile<User>("Age > 18 && Country == \"NL\"");
bool isValid = rule(user);
Tips & valkuilen
- Veiligheid: Laat geen willekeurige .NET‑methoden aanroepen. Houd de operator‑set beperkt.
- Caching: Compileer elke unieke expressie één keer en bewaar het resultaat in een
ConcurrentDictionary. - Foutmeldingen: Geef duidelijke parsing‑fouten met positie‑informatie, zodat gebruikers hun regels makkelijk kunnen debuggen.
Conclusie
Met een paar honderden regels C# kun je een volledig functionele CEL‑engine bouwen die naadloos in .NET past. Je profiteert van type‑veiligheid, hoge performance en volledige controle over de functionaliteit. Zo kun je dynamische business‑regels, feature‑flags of filter‑logica in je eigen applicaties embedden zonder externe afhankelijkheden.