Building a MicroRuleEngine using LinqExpressions

2020-06-29 06:05发布

问题:

So I am building a MicroRuleEngine (Would love to see this take off as an OpenSource project) and I am running into a null reference Error When executing the compiled ExpressionTree and I am not exactly sure why. Rules against the simple properties work but going against Child Properties aka Customer.Client.Address.StreetName etc. do not work.

Below is the MicroRuleEngine

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Linq.Expressions;

namespace Trial
{
    public class MicroRuleEngine
    {
        public bool PassesRules<T>(List<Rule> rules, T toInspect)
        {
            bool pass = true;
            foreach (var rule in rules)
            {
                var cr = this.CompileRule<T>(rule);
                pass = pass && cr.Invoke(toInspect);
                if (!pass)
                    return pass;
            }
            return pass;
        }
        public Func<T, bool> CompileRule<T>(Rule r)
        {
            var paramUser = Expression.Parameter(typeof(T));
            Expression expr = BuildExpr<T>(r, paramUser);
            // build a lambda function User->bool and compile it

            return Expression.Lambda<Func<T, bool>>(expr, paramUser).Compile();
        }

        Expression BuildExpr<T>(Rule r, ParameterExpression param)
        {
            Expression propExpression;
            Type propType;// typeof(T).GetProperty(r.MemberName).PropertyType;
            ExpressionType tBinary;
            if (r.MemberName.Contains('.'))
            {
                // support to be sorted on child fields.
                String[] childProperties = r.MemberName.Split('.');
                var property = typeof(T).GetProperty(childProperties[0]);
                var paramExp = Expression.Parameter(typeof(T), "SomeObject");
                propExpression = Expression.MakeMemberAccess(paramExp, property);
                for (int i = 1; i < childProperties.Length; i++)
                {
                    property = property.PropertyType.GetProperty(childProperties[i]);
                    propExpression = Expression.MakeMemberAccess(propExpression, property);
                }
                propType = propExpression.Type;
                propExpression = Expression.Block(new[] { paramExp }, new[]{ propExpression });

            }
            else
            {
                propExpression = MemberExpression.Property(param, r.MemberName);
                propType = propExpression.Type;
            }

            // is the operator a known .NET operator?
            if (ExpressionType.TryParse(r.Operator, out tBinary))
            {
                var right = Expression.Constant(Convert.ChangeType(r.TargetValue, propType));
                // use a binary operation, e.g. 'Equal' -> 'u.Age == 15'
                return Expression.MakeBinary(tBinary, propExpression, right);
            }
            else
            {
                var method = propType.GetMethod(r.Operator);
                var tParam = method.GetParameters()[0].ParameterType;
                var right = Expression.Constant(Convert.ChangeType(r.TargetValue, tParam));
                // use a method call, e.g. 'Contains' -> 'u.Tags.Contains(some_tag)'
                return Expression.Call(propExpression, method, right);
            }
        }

    }
    public class Rule
    {
        public string MemberName { get; set; }
        public string Operator { get; set; }
        public string TargetValue { get; set; }
    }
}

And This is the Test that is Failing

[TestMethod]
public void ChildPropertyRuleTest()
{
    Container container = new Container()
    {
        Repository = "TestRepo",
        Shipment = new Shipment() { OrderNumber = "555" }
    };

    MicroRuleEngine mr = new MicroRuleEngine();
    var rules = new List<Rule>() { new Rule() { MemberName = "Shipment.OrderNumber", Operator = "Contains", TargetValue = "55" } };
    var pases = mr.PassesRules<Container>(rules, container);
    Assert.IsTrue(!pases);
}

回答1:

Don't supposed you've had a look at the dynamic expression parser which was bundled as an example project for the VS2008 samples. It includes a type called an ExpressionParser which can be used to convert string expressions to Expression instances. I've used this previously to turn string expressions into compilable delegates, e.g, I could do something like:

string expression = "(1 + 2)";
var func = FunctionFactory.Create<int>(expression);

int result = func(1, 2); // Result should be 3.

...where FunctionFactory is a wrapper around the ExpressionParser type. I could also do:

expression = "(a * b)";
var func2 = FunctionFactory.Create<int, int, int>(expresion new[] { "a", "b" });

int result = func2(10, 50); // Result should be 500;

Or something a little tangible:

expression = "(Age == 5)";
var func3 = FunctionFactory.Create<Person, bool>(expression);

bool isFive = func3(new Person { Age = 5 });

Would this be of any use to you? You can read my blog article here.



回答2:

So the error I was running into was all the examples I read in trying to find out how to access sub properties were using MemberAccess Expressions to walk down the properties and I found that using PropertyExpressions worked without a problem for the simple tests I have. Below is an update that is now working

public class MicroRuleEngine
    {
        public bool PassesRules<T>(List<Rule> rules, T toInspect)
        {
            return this.CompileRules<T>(rules).Invoke(toInspect);
        }
        public Func<T, bool> CompileRule<T>(Rule r)
        {
            var paramUser = Expression.Parameter(typeof(T));
            Expression expr = BuildExpr<T>(r, paramUser);

            return Expression.Lambda<Func<T, bool>>(expr, paramUser).Compile();
        }

        public Func<T, bool> CompileRules<T>(IList<Rule> rules)
        {
            var paramUser = Expression.Parameter(typeof(T));
            List<Expression> expressions = new List<Expression>();
            foreach (var r in rules)
            {
                expressions.Add(BuildExpr<T>(r, paramUser));
            }
            var expr = AndExpressions(expressions);

            return Expression.Lambda<Func<T, bool>>(expr, paramUser).Compile();
        }

        Expression AndExpressions(IList<Expression> expressions)
        {
            if(expressions.Count == 1)
                return expressions[0];
            Expression exp = Expression.And(expressions[0], expressions[1]);
            for(int i = 2; expressions.Count > i; i++)
            {
                exp = Expression.And(exp, expressions[i]);
            }
            return exp;
        }

        Expression BuildExpr<T>(Rule r, ParameterExpression param)
        {
            Expression propExpression;
            Type propType;
            ExpressionType tBinary;
            if (r.MemberName.Contains('.'))
            {
                String[] childProperties = r.MemberName.Split('.');
                var property = typeof(T).GetProperty(childProperties[0]);
                var paramExp = Expression.Parameter(typeof(T), "SomeObject");

                propExpression = Expression.PropertyOrField(param, childProperties[0]);
                for (int i = 1; i < childProperties.Length; i++)
                {
                    property = property.PropertyType.GetProperty(childProperties[i]);
                    propExpression = Expression.PropertyOrField(propExpression, childProperties[i]);
                }
                propType = propExpression.Type;
            }
            else
            {
                propExpression = Expression.PropertyOrField(param, r.MemberName);
                propType = propExpression.Type;
            }

            // is the operator a known .NET operator?
            if (ExpressionType.TryParse(r.Operator, out tBinary))
            {
                var right = Expression.Constant(Convert.ChangeType(r.TargetValue, propType));
                // use a binary operation, e.g. 'Equal' -> 'u.Age == 15'
                return Expression.MakeBinary(tBinary, propExpression, right);
            }
            else
            {
                var method = propType.GetMethod(r.Operator);
                var tParam = method.GetParameters()[0].ParameterType;
                var right = Expression.Constant(Convert.ChangeType(r.TargetValue, tParam));
                // use a method call, e.g. 'Contains' -> 'u.Tags.Contains(some_tag)'
                return Expression.Call(propExpression, method, right);
            }
        }

    }
    public class Rule
    {
        public string MemberName { get; set; }
        public string Operator { get; set; }
        public string TargetValue { get; set; }
    }


回答3:

Is it possible in your test that the Shipment property has not been initialized in your container?

A couple other recommendations: If you must use expressions, consider caching the compiled version of the expression so that it can be reused rather than having to recompile it each time you want to use it.

Second, is there a specific reason you elected to go with expressions rather than just using Func in your rule? Typically when creating rules engines like this, my rule class is defined as somthing like:

public class Rule
{
   public string Description {get; set;}
   public Func<T, bool> RuleToApply {get; set;}
}

Given that, I instantiate my Rules collection as follows:

 var rules = new List<Rule>() { 
     new Rule { Description = "OrderNumber Contains 55", 
                RuleToApply = order => order.OrderNumber.Contains("55") } 
 }; 

And PassesRule becomes:

public bool PassesRules<T>(List<Rule> rules, T toInspect) 
{ 
    return rules.All(rule => rule(toInspect));
} 

The other advantage here is that instead of passing strings and evaulating expressions, I keep the type safety and refactoring support that I would loose by using strings and dynamically building expressions.

Another thing to keep in mind if you are building a reusable expression parser, make sure to set up tests in VB as well as C# because they don't always generate the same expression tree under the covers. In particular, add VB tests for string equality (city = "London"). I've seen countless LINQ providers which have overlooked this simple case.