如何实现一个规则引擎?

219

我有一张数据库表存储以下内容:

RuleID  objectProperty ComparisonOperator  TargetValue
1       age            'greater_than'             15
2       username       'equal'             'some_name'
3       tags           'hasAtLeastOne'     'some_tag some_tag2'

现在假设我有一组这样的规则:

List<Rule> rules = db.GetRules();

现在我也有一个用户的实例:

User user = db.GetUser(....);

我该如何循环遍历这些规则,并应用逻辑、执行比较等操作?

if(user.age > 15)

if(user.username == "some_name")

由于对象的属性(例如年龄或用户名)与比较运算符“大于”和“等于”一起存储在表中,我该如何实现呢?

C#是一种静态类型语言,所以不确定如何继续。

12个回答

424
这段代码将规则编译成快速可执行的代码(使用表达式树),不需要任何复杂的switch语句:
(编辑:具有通用方法的完整工作示例
public Func<User, bool> CompileRule(Rule r)
{
    var paramUser = Expression.Parameter(typeof(User));
    Expression expr = BuildExpr(r, paramUser);
    // build a lambda function User->bool and compile it
    return Expression.Lambda<Func<User, bool>>(expr, paramUser).Compile();
}

您可以写成:

然后,您可以编写:

List<Rule> rules = new List<Rule> {
    new Rule ("Age", "GreaterThan", "21"),
    new Rule ( "Name", "Equal", "John"),
    new Rule ( "Tags", "Contains", "C#" )
};

// compile the rules once
var compiledRules = rules.Select(r => CompileRule(r)).ToList();

public bool MatchesAllRules(User user)
{
    return compiledRules.All(rule => rule(user));
}

下面是BuildExpr的实现:

Expression BuildExpr(Rule r, ParameterExpression param)
{
    var left = MemberExpression.Property(param, r.MemberName);
    var tProp = typeof(User).GetProperty(r.MemberName).PropertyType;
    ExpressionType tBinary;
    // is the operator a known .NET operator?
    if (ExpressionType.TryParse(r.Operator, out tBinary)) {
        var right = Expression.Constant(Convert.ChangeType(r.TargetValue, tProp));
        // use a binary operation, e.g. 'Equal' -> 'u.Age == 21'
        return Expression.MakeBinary(tBinary, left, right);
    } else {
        var method = tProp.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(left, method, right);
    }
}

请注意,我使用了“GreaterThan”而不是“greater_than”等等。这是因为“GreaterThan”是运算符的.NET名称,因此我们不需要任何额外的映射。
如果您需要自定义名称,您可以构建一个非常简单的字典,并在编译规则之前翻译所有运算符。
var nameMap = new Dictionary<string, string> {
    { "greater_than", "GreaterThan" },
    { "hasAtLeastOne", "Contains" }
};

该代码为了简便使用了类型 User。你可以将 User 替换为通用类型 T,从而拥有适用于任何对象类型的 通用规则编译器。此外,该代码应当处理错误,例如未知的运算符名称。
请注意,即使在引入表达式树 API 之前,也可以通过 Reflection.Emit 在运行时生成代码。LambdaExpression.Compile() 方法在内部使用 Reflection.Emit(您可以使用 ILSpy 查看)。

5
所有类都来自命名空间System.Linq.Expressions,并且都是使用Expression类的工厂方法创建的 - 在您的IDE中键入"type 'Expression.'"以访问它们。 在此处阅读有关表达式树的更多信息http://msdn.microsoft.com/en-us/library/bb397951.aspx - Martin Konicek
4
@Martin,我在哪里可以找到一个合格的.NET运算符名称列表? - Nahydrin
5
你可以在这里找到它们:msdn.microsoft.com/en-us/library/bb361179.aspx。其中并非所有都是布尔表达式 - 只使用布尔类型的(例如GreaterThan、NotEqual等)。 - Martin Konicek
1
@BillDaugherty 用三个属性规定一个简单的值类: MemberName,Operator,TargetValue。 例如,new Rule ("Age", "GreaterThan", "20"). - Martin Konicek
@MartinKonicek,如果我的用户模型很复杂(即具有地址列表的用户模型),您认为我能否构建一个针对地址模型中属性的规则(例如var rule = new Rule(“Addresses [0] .City”,“Equals”,“Boston”);)。 请让我知道您对此的看法以及任何有关在复杂模型上定义规则的提示。谢谢您的帮助。 - Taiseer Joudeh
显示剩余8条评论

14

这里有一段代码,可以直接编译并完成任务。基本上使用两个字典,一个包含从操作符名称到布尔函数的映射,另一个包含从User类型的属性名称到PropertyInfos的映射,用于调用属性getter(如果是公共的)。您将User实例和表中的三个值传递给静态Apply方法。

class User
{
    public int Age { get; set; }
    public string UserName { get; set; }
}

class Operator
{
    private static Dictionary<string, Func<object, object, bool>> s_operators;
    private static Dictionary<string, PropertyInfo> s_properties;
    static Operator()
    {
        s_operators = new Dictionary<string, Func<object, object, bool>>();
        s_operators["greater_than"] = new Func<object, object, bool>(s_opGreaterThan);
        s_operators["equal"] = new Func<object, object, bool>(s_opEqual);

        s_properties = typeof(User).GetProperties().ToDictionary(propInfo => propInfo.Name);
    }

    public static bool Apply(User user, string op, string prop, object target)
    {
        return s_operators[op](GetPropValue(user, prop), target);
    }

    private static object GetPropValue(User user, string prop)
    {
        PropertyInfo propInfo = s_properties[prop];
        return propInfo.GetGetMethod(false).Invoke(user, null);
    }

    #region Operators

    static bool s_opGreaterThan(object o1, object o2)
    {
        if (o1 == null || o2 == null || o1.GetType() != o2.GetType() || !(o1 is IComparable))
            return false;
        return (o1 as IComparable).CompareTo(o2) > 0;
    }

    static bool s_opEqual(object o1, object o2)
    {
        return o1 == o2;
    }

    //etc.

    #endregion

    public static void Main(string[] args)
    {
        User user = new User() { Age = 16, UserName = "John" };
        Console.WriteLine(Operator.Apply(user, "greater_than", "Age", 15));
        Console.WriteLine(Operator.Apply(user, "greater_than", "Age", 17));
        Console.WriteLine(Operator.Apply(user, "equal", "UserName", "John"));
        Console.WriteLine(Operator.Apply(user, "equal", "UserName", "Bob"));
    }
}

9
我构建了一款规则引擎,与您在问题中提出的方法不同,但我认为您会发现它比您当前的方法更加灵活。您当前的方法似乎专注于单个实体“用户”,并且您的持久化规则识别“属性名称”,“运算符”和“值”。相反,我的模式将用于谓词(Func<T, bool>)的C#代码存储在数据库中的“Expression”列中。在当前的设计中,使用代码生成,我正在从数据库查询“规则”,并编译一个带有“Rule”类型的程序集,每个类型都有一个“Test”方法。这是实现每个规则的接口的签名:
public interface IDataRule<TEntity> 
{
    /// <summary>
    /// Evaluates the validity of a rule given an instance of an entity
    /// </summary>
    /// <param name="entity">Entity to evaluate</param>
    /// <returns>result of the evaluation</returns>
    bool Test(TEntity entity);
    /// <summary>
    /// The unique indentifier for a rule.
    /// </summary>
     int RuleId { get; set; }
    /// <summary>
    /// Common name of the rule, not unique
    /// </summary>
     string RuleName { get; set; }
    /// <summary>
    /// Indicates the message used to notify the user if the rule fails
    /// </summary>
     string ValidationMessage { get; set; }   
     /// <summary>
     /// indicator of whether the rule is enabled or not
     /// </summary>
     bool IsEnabled { get; set; }
    /// <summary>
    /// Represents the order in which a rule should be executed relative to other rules
    /// </summary>
     int SortOrder { get; set; }
}

"Expression" 在应用程序首次执行时被编译为 "Test" 方法的主体。正如您所看到的,表中的其他列也作为规则的一级属性呈现,以便开发人员可以灵活地创建用户在失败或成功时接收通知的体验。
在应用程序期间生成内存中的程序集是一次性事件,并且通过不必使用反射来评估规则,可以获得性能提升。运行时将检查表达式,因为如果属性名称拼写错误等,则程序集将无法正确生成。
创建内存中程序集的机制如下:
- 从数据库加载规则 - 遍历规则,并使用 StringBuilder 和一些字符串连接来编写表示继承自 IDataRule 类的类的文本 - 使用 CodeDOM 进行编译 - 更多信息 这其实很简单,因为对于大多数人来说,这段代码只是属性实现和构造函数中的值初始化。除此之外,唯一的其他代码就是表达式。
注意:由于CodeDOM的限制,您的表达式必须是.NET 2.0(没有lambda或其他C# 3.0功能)。 这是一些示例代码。
sb.AppendLine(string.Format("\tpublic class {0} : SomeCompany.ComponentModel.IDataRule<{1}>", className, typeName));
            sb.AppendLine("\t{");
            sb.AppendLine("\t\tprivate int _ruleId = -1;");
            sb.AppendLine("\t\tprivate string _ruleName = \"\";");
            sb.AppendLine("\t\tprivate string _ruleType = \"\";");
            sb.AppendLine("\t\tprivate string _validationMessage = \"\";");
            /// ... 
            sb.AppendLine("\t\tprivate bool _isenabled= false;");
            // constructor
            sb.AppendLine(string.Format("\t\tpublic {0}()", className));
            sb.AppendLine("\t\t{");
            sb.AppendLine(string.Format("\t\t\tRuleId = {0};", ruleId));
            sb.AppendLine(string.Format("\t\t\tRuleName = \"{0}\";", ruleName.TrimEnd()));
            sb.AppendLine(string.Format("\t\t\tRuleType = \"{0}\";", ruleType.TrimEnd()));                
            sb.AppendLine(string.Format("\t\t\tValidationMessage = \"{0}\";", validationMessage.TrimEnd()));
            // ...
            sb.AppendLine(string.Format("\t\t\tSortOrder = {0};", sortOrder));                

            sb.AppendLine("\t\t}");
            // properties
            sb.AppendLine("\t\tpublic int RuleId { get { return _ruleId; } set { _ruleId = value; } }");
            sb.AppendLine("\t\tpublic string RuleName { get { return _ruleName; } set { _ruleName = value; } }");
            sb.AppendLine("\t\tpublic string RuleType { get { return _ruleType; } set { _ruleType = value; } }");

            /// ... more properties -- omitted

            sb.AppendLine(string.Format("\t\tpublic bool Test({0} entity) ", typeName));
            sb.AppendLine("\t\t{");
            // #############################################################
            // NOTE: This is where the expression from the DB Column becomes
            // the body of the Test Method, such as: return "entity.Prop1 < 5"
            // #############################################################
            sb.AppendLine(string.Format("\t\t\treturn {0};", expressionText.TrimEnd()));
            sb.AppendLine("\t\t}");  // close method
            sb.AppendLine("\t}"); // close Class

在此之外,我创建了一个名为“DataRuleCollection”的类,实现了ICollection>。这使我能够创建一个“TestAll”功能和一个按名称执行特定规则的索引器。以下是这两种方法的实现。
    /// <summary>
    /// Indexer which enables accessing rules in the collection by name
    /// </summary>
    /// <param name="ruleName">a rule name</param>
    /// <returns>an instance of a data rule or null if the rule was not found.</returns>
    public IDataRule<TEntity, bool> this[string ruleName]
    {
        get { return Contains(ruleName) ? list[ruleName] : null; }
    }
    // in this case the implementation of the Rules Collection is: 
    // DataRulesCollection<IDataRule<User>> and that generic flows through to the rule.
    // there are also some supporting concepts here not otherwise outlined, such as a "FailedRules" IList
    public bool TestAllRules(User target) 
    {
        rules.FailedRules.Clear();
        var result = true;

        foreach (var rule in rules.Where(x => x.IsEnabled)) 
        {

            result = rule.Test(target);
            if (!result)
            {

                rules.FailedRules.Add(rule);
            }
        }

        return (rules.FailedRules.Count == 0);
    }

更多代码:有人要求与代码生成相关的代码。我将功能封装在一个名为“RulesAssemblyGenerator”的类中,如下所示。

namespace Xxx.Services.Utils
    {
        public static class RulesAssemblyGenerator
        {
            static List<string> EntityTypesLoaded = new List<string>();

            public static void Execute(string typeName, string scriptCode)
            {
                if (EntityTypesLoaded.Contains(typeName)) { return; } 
                // only allow the assembly to load once per entityType per execution session
                Compile(new CSharpCodeProvider(), scriptCode);
                EntityTypesLoaded.Add(typeName);
            }
            private static void Compile(CodeDom.CodeDomProvider provider, string source)
            {
                var param = new CodeDom.CompilerParameters()
                {
                    GenerateExecutable = false,
                    IncludeDebugInformation = false,
                    GenerateInMemory = true
                };
                var path = System.Reflection.Assembly.GetExecutingAssembly().Location;
                var root_Dir = System.IO.Path.Combine(System.AppDomain.CurrentDomain.BaseDirectory, "Bin");
                param.ReferencedAssemblies.Add(path);
                // Note: This dependencies list are included as assembly reference and they should list out all dependencies
                // That you may reference in your Rules or that your entity depends on.
                // some assembly names were changed... clearly.
                var dependencies = new string[] { "yyyyyy.dll", "xxxxxx.dll", "NHibernate.dll", "ABC.Helper.Rules.dll" };
                foreach (var dependency in dependencies)
                {
                    var assemblypath = System.IO.Path.Combine(root_Dir, dependency);
                    param.ReferencedAssemblies.Add(assemblypath);
                }
                // reference .NET basics for C# 2.0 and C#3.0
                param.ReferencedAssemblies.Add(@"C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\System.dll");
                param.ReferencedAssemblies.Add(@"C:\Program Files\Reference Assemblies\Microsoft\Framework\v3.5\System.Core.dll");
                var compileResults = provider.CompileAssemblyFromSource(param, source);
                var output = compileResults.Output;
                if (compileResults.Errors.Count != 0)
                {
                    CodeDom.CompilerErrorCollection es = compileResults.Errors;
                    var edList = new List<DataRuleLoadExceptionDetails>();
                    foreach (CodeDom.CompilerError s in es)
                        edList.Add(new DataRuleLoadExceptionDetails() { Message = s.ErrorText, LineNumber = s.Line });
                    var rde = new RuleDefinitionException(source, edList.ToArray());
                    throw rde;
                }
            }
        }
    }

如果有任何其他问题、评论或请求进一步的代码示例,请告诉我。

你说得对,引擎可以更加通用化,CodeDOM API 绝对也是一个选项。也许你可以展示一下如何调用 CodeDOM,而不是使用不太清晰的 "sb.AppendLine" 代码? - Martin Konicek

8
“反射”是您最灵活的答案。您有三列数据,需要以不同的方式处理:
1. 您的字段名称。反射是从编码字段名称获取值的方法。 2. 您的比较运算符。应该有有限数量的比较运算符,因此使用case语句最容易处理它们。特别是一些比较运算符(例如“至少有一个”)稍微复杂一些。 3. 您的比较值。如果这些都是直接的数值,则很容易处理,尽管您需要将多个条目分开。但是,如果它们也是字段名称,则可以使用反射。
我会采取更类似于以下的方法:
    var value = user.GetType().GetProperty("age").GetValue(user, null);
    //Thank you Rick! Saves me remembering it;
    switch(rule.ComparisonOperator)
        case "equals":
             return EqualComparison(value, rule.CompareTo)
        case "is_one_or_more_of"
             return IsInComparison(value, rule.CompareTo)

"

等等

"
"

它为您添加更多比较选项提供了灵活性。这还意味着您可以在比较方法内部编写任何类型验证,并使其尽可能复杂。此外,在此处还有一种选项,可以将CompareTo评估为对另一行的递归回调或字段值,可以像这样完成:

"
             return IsInComparison(value, EvaluateComparison(rule.CompareTo))

这完全取决于未来的可能性...

你可以缓存反射的程序集/对象,这将使你的代码更加高效。 - Mrchief

7
如果您只有少量的属性和操作符,最简单的方法就是将所有检查都编写为特殊情况,如下所示:
public bool ApplyRules(List<Rule> rules, User user)
{
    foreach (var rule in rules)
    {
        IComparable value = null;
        object limit = null;
        if (rule.objectProperty == "age")
        {
            value = user.age;
            limit = Convert.ToInt32(rule.TargetValue);
        }
        else if (rule.objectProperty == "username")
        {
            value = user.username;
            limit = rule.TargetValue;
        }
        else
            throw new InvalidOperationException("invalid property");

        int result = value.CompareTo(limit);

        if (rule.ComparisonOperator == "equal")
        {
            if (!(result == 0)) return false;
        }
        else if (rule.ComparisonOperator == "greater_than")
        {
            if (!(result > 0)) return false;
        }
        else
            throw new InvalidOperationException("invalid operator");
    }
    return true;
}

如果您有很多属性,您可能会发现表驱动的方法更容易接受。在这种情况下,您将创建一个静态的Dictionary,将属性名称映射到匹配的委托,例如Func<User, object>
如果您不知道编译时属性的名称,或者您想避免每个属性的特殊情况并且不想使用表格方法,则可以使用反射获取属性。例如:
var value = user.GetType().GetProperty("age").GetValue(user, null);

但是由于TargetValue可能是一个string,如果需要的话,您需要小心从规则表中进行类型转换。


value.CompareTo(limit) 返回什么?-1,0还是1?我以前没见过这个! - Blankman
1
@Blankman:关闭:小于零、等于零或大于零。IComparable 用于比较事物。这里是文档:IComparable.CompareTo 方法 - Rick Sladkey
2
我不明白为什么这个答案会被点赞。它违反了许多设计原则:“告诉,不要问” => 每个规则都应该被询问以返回结果。“开放式扩展/封闭式修改” => 任何新规则都意味着ApplyRules方法需要修改。此外,代码一眼看去很难理解。 - Appetere
2
事实上,最省力的路径很少是最好的路径。请查看并点赞精彩的表达树答案。 - Rick Sladkey

6
使用面向数据类型的方法和扩展方法,你觉得怎么样?
public static class RoleExtension
{
    public static bool Match(this Role role, object obj )
    {
        var property = obj.GetType().GetProperty(role.objectProperty);
        if (property.PropertyType == typeof(int))
        {
            return ApplyIntOperation(role, (int)property.GetValue(obj, null));
        }
        if (property.PropertyType == typeof(string))
        {
            return ApplyStringOperation(role, (string)property.GetValue(obj, null));
        }
        if (property.PropertyType.GetInterface("IEnumerable<string>",false) != null)
        {
            return ApplyListOperation(role, (IEnumerable<string>)property.GetValue(obj, null));
        }
        throw new InvalidOperationException("Unknown PropertyType");
    }

    private static bool ApplyIntOperation(Role role, int value)
    {
        var targetValue = Convert.ToInt32(role.TargetValue);
        switch (role.ComparisonOperator)
        {
            case "greater_than":
                return value > targetValue;
            case "equal":
                return value == targetValue;
            //...
            default:
                throw new InvalidOperationException("Unknown ComparisonOperator");
        }
    }

    private static bool ApplyStringOperation(Role role, string value)
    {
        //...
        throw new InvalidOperationException("Unknown ComparisonOperator");
    }

    private static bool ApplyListOperation(Role role, IEnumerable<string> value)
    {
        var targetValues = role.TargetValue.Split(' ');
        switch (role.ComparisonOperator)
        {
            case "hasAtLeastOne":
                return value.Any(v => targetValues.Contains(v));
                //...
        }
        throw new InvalidOperationException("Unknown ComparisonOperator");
    }
}

你可以这样进行评估:

var myResults = users.Where(u => roles.All(r => r.Match(u)));

4
尽管回答“如何在C#中实现规则引擎?”的最明显方法是按顺序执行给定的一组规则,但这通常被认为是一种天真的实现(并不意味着它不起作用 :-))。
看起来在您的情况下“足够好”,因为您的问题似乎更多地是“如何按顺序运行一组规则”,如果您配备了最新的C#版本,lambda/表达式树(Martin的答案)无疑是这方面最优雅的方法。
然而,对于更高级的场景,这里有一个链接到Rete算法,实际上在许多商业规则引擎系统中实现,以及另一个链接到NRuler,该算法在C#中实现。

3
马丁的回答非常好。实际上,我制作了一个规则引擎,其思想与他的相同。我很惊讶它几乎一样。我已经包含了他的一些代码来改进它。尽管我已经使它处理更复杂的规则。
您可以查看Yare.NET 或在Nuget中下载。

2

我添加了规则之间的and和or实现。我添加了RuleExpression类,它表示可以是简单规则的叶子节点或者是and、or二元表达式的树的根节点。因此,它们没有规则而有表达式:

public class RuleExpression
{
    public NodeOperator NodeOperator { get; set; }
    public List<RuleExpression> Expressions { get; set; }
    public Rule Rule { get; set; }

    public RuleExpression()
    {

    }
    public RuleExpression(Rule rule)
    {
        NodeOperator = NodeOperator.Leaf;
        Rule = rule;
    }

    public RuleExpression(NodeOperator nodeOperator, List<RuleExpression> expressions, Rule rule)
    {
        this.NodeOperator = nodeOperator;
        this.Expressions = expressions;
        this.Rule = rule;
    }
}


public enum NodeOperator
{
    And,
    Or,
    Leaf
}

我有另一个类,将ruleExpression编译为一个 Func<T, bool>

 public static Func<T, bool> CompileRuleExpression<T>(RuleExpression ruleExpression)
    {
        //Input parameter
        var genericType = Expression.Parameter(typeof(T));
        var binaryExpression = RuleExpressionToOneExpression<T>(ruleExpression, genericType);
        var lambdaFunc = Expression.Lambda<Func<T, bool>>(binaryExpression, genericType);
        return lambdaFunc.Compile();
    }

    private static Expression RuleExpressionToOneExpression<T>(RuleExpression ruleExpression, ParameterExpression genericType)
    {
        if (ruleExpression == null)
        {
            throw new ArgumentNullException();
        }
        Expression finalExpression;
        //check if node is leaf
        if (ruleExpression.NodeOperator == NodeOperator.Leaf)
        {
            return RuleToExpression<T>(ruleExpression.Rule, genericType);
        }
        //check if node is NodeOperator.And
        if (ruleExpression.NodeOperator.Equals(NodeOperator.And))
        {
            finalExpression = Expression.Constant(true);
            ruleExpression.Expressions.ForEach(expression =>
            {
                finalExpression = Expression.AndAlso(finalExpression, expression.NodeOperator.Equals(NodeOperator.Leaf) ? 
                    RuleToExpression<T>(expression.Rule, genericType) :
                    RuleExpressionToOneExpression<T>(expression, genericType));
            });
            return finalExpression;
        }
        //check if node is NodeOperator.Or
        else
        {
            finalExpression = Expression.Constant(false);
            ruleExpression.Expressions.ForEach(expression =>
            {
                finalExpression = Expression.Or(finalExpression, expression.NodeOperator.Equals(NodeOperator.Leaf) ?
                    RuleToExpression<T>(expression.Rule, genericType) :
                    RuleExpressionToOneExpression<T>(expression, genericType));
            });
            return finalExpression;

        }      
    }      

    public static BinaryExpression RuleToExpression<T>(Rule rule, ParameterExpression genericType)
    {
        try
        {
            Expression value = null;
            //Get Comparison property
            var key = Expression.Property(genericType, rule.ComparisonPredicate);
            Type propertyType = typeof(T).GetProperty(rule.ComparisonPredicate).PropertyType;
            //convert case is it DateTimeOffset property
            if (propertyType == typeof(DateTimeOffset))
            {
                var converter = TypeDescriptor.GetConverter(propertyType);
                value = Expression.Constant((DateTimeOffset)converter.ConvertFromString(rule.ComparisonValue));
            }
            else
            {
                value = Expression.Constant(Convert.ChangeType(rule.ComparisonValue, propertyType));
            }
            BinaryExpression binaryExpression = Expression.MakeBinary(rule.ComparisonOperator, key, value);
            return binaryExpression;
        }
        catch (FormatException)
        {
            throw new Exception("Exception in RuleToExpression trying to convert rule Comparison Value");
        }
        catch (Exception e)
        {
            throw new Exception(e.Message);
        }

    }

2

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接