[C#] 규칙 엔진을 구현하는 방법?

다음을 저장하는 db 테이블이 있습니다.

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")

‘연령’또는 ‘user_name’과 같은 객체의 속성이 비교 연산자 ‘great_than’및 ‘equal’과 함께 테이블에 저장되어 있으므로 어떻게해야합니까?

C #은 정적으로 입력 된 언어이므로 어떻게 진행해야할지 잘 모르겠습니다.



답변

이 스 니펫 은 규칙을 빠른 실행 코드 ( Expression Tree 사용) 로 컴파일하며 복잡한 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", "20"),
    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 == 15'
        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);
    }
}

‘greater_than’등 대신 ‘GreaterThan’을 사용했습니다. 이는 ‘GreaterThan’이 연산자의 .NET 이름이므로 추가 매핑이 필요하지 않기 때문입니다.

사용자 지정 이름이 필요한 경우 매우 간단한 사전을 작성하고 규칙을 컴파일하기 전에 모든 연산자를 번역하면됩니다.

var nameMap = new Dictionary<string, string> {
    { "greater_than", "GreaterThan" },
    { "hasAtLeastOne", "Contains" }
};

코드는 단순성을 위해 User 유형을 사용합니다. User를 일반 유형 T로 바꾸면 모든 유형의 객체에 대한 일반 규칙 컴파일러 를 가질 수 있습니다. 또한 코드는 알 수없는 연산자 이름과 같은 오류를 처리해야합니다.

Reflection.Emit을 사용하여 Expression Tree API가 도입되기 전에도 즉시 코드를 생성 할 수있었습니다. LambdaExpression.Compile () 메서드는 커버 아래에서 Reflection.Emit을 사용합니다 ( ILSpy를 사용하여 볼 수 있음 ).


답변

다음은 그대로 컴파일하고 작업을 수행하는 코드입니다. 기본적으로 두 개의 사전을 사용하십시오. 하나는 연산자 이름에서 부울 함수로의 맵핑을 포함하고 다른 하나는 사용자 유형의 특성 이름에서 특성 게터 (공용 인 경우)를 호출하는 데 사용되는 PropertyInfo 로의 맵핑을 포함합니다. 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"));
    }
}


답변

귀하의 질문에 설명 된 것과 다른 접근 방식을 취하는 규칙 엔진을 만들었지 만 현재 접근 방식보다 훨씬 유연하다고 생각합니다.

현재 접근 방식은 단일 사용자 인 “사용자”에 중점을두고 있으며 영구 규칙은 “propertyname”, “operator”및 “value”를 식별합니다. 내 패턴은 대신 데이터베이스의 “표현식”열에 조건 자에 대한 C # 코드 (Func <T, bool>)를 저장합니다. 현재 디자인에서 코드 생성을 사용하여 데이터베이스에서 “규칙”을 쿼리하고 “규칙”유형의 어셈블리를 각각 “테스트”방법으로 컴파일합니다. 각 규칙에 구현 된 인터페이스의 서명은 다음과 같습니다.

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”메서드의 본문으로 컴파일됩니다. 보시다시피 테이블의 다른 열도 규칙에서 일류 속성으로 표시되므로 개발자는 사용자에게 실패 또는 성공을 알리는 방법에 대한 경험을 유연하게 만들 수 있습니다.

인 메모리 어셈블리 생성은 응용 프로그램에서 1 회 발생하며 규칙을 평가할 때 리플렉션을 사용하지 않아도 성능이 향상됩니다. 속성 이름의 철자가 틀린 경우 어셈블리가 올바르게 생성되지 않으므로 런타임에식이 확인됩니다.

인 메모리 어셈블리를 만드는 메커니즘은 다음과 같습니다.

  • DB에서 규칙을로드하십시오.
  • StringBuilder와 일부 문자열 연결을 사용하여 규칙과 각 규칙을 반복하고 IDataRule에서 상속되는 클래스를 나타내는 Text를 작성하십시오.
  • CodeDOM을 사용하여 컴파일- 추가 정보

대부분의 경우이 코드는 생성자의 속성 구현 및 값 초기화이므로 실제로는 매우 간단합니다. 그 외에도 다른 코드는 Expression입니다.
참고 : CodeDOM의 제한으로 인해식이 .NET 2.0이어야합니다 (람다 나 다른 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

이 외에도 ICollection>을 구현 한 “DataRuleCollection”이라는 클래스를 만들었습니다. 이를 통해 “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;
                }
            }
        }
    }

추가 코드 샘플에 대한 다른 질문이나 의견 또는 요청 이 있으면 알려주십시오.


답변

반사는 가장 다재다능한 답변입니다. 세 개의 데이터 열이 있으며 다른 방식으로 처리해야합니다.

  1. 필드 이름 리플렉션은 코드화 된 필드 이름에서 값을 얻는 방법입니다.

  2. 당신의 비교 연산자. 이것들의 수는 제한되어 있어야하므로, 사례 설명이 가장 쉽게 처리해야합니다. 특히 그들 중 일부 (하나 이상이 있음)가 약간 더 복잡합니다.

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

그것은 모두 미래의 가능성에 달려 있습니다 ….


답변

소수의 속성과 연산자 만있는 경우 저항이 가장 적은 경로는 모든 검사를 다음과 같은 특수한 경우로 코딩하는 것입니다.

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필요한 경우 규칙 테이블에서 유형 변환을 수행해야합니다.


답변

확장 방법을 사용하는 데이터 유형 지향 접근 방식은 어떻습니까?

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)));


답변

“규칙 엔진을 구현하는 방법 (C #에서)”질문에 대답하는 가장 확실한 방법은 주어진 규칙 세트를 순서대로 실행하는 것이지만 일반적으로 순진한 구현으로 간주됩니다 (작동하지 않는 것은 아닙니다) 🙂

문제가 “일련의 규칙을 순서대로 실행하는 방법”인 것처럼 보이고 람다 / 표현 트리 (마틴의 대답)가 그 문제에서 가장 우아한 방법이기 때문에 귀하의 경우에는 “충분히 좋은”것 같습니다. 최신 C # 버전이 장착되어 있습니다.

그러나 고급 시나리오의 경우 실제로 많은 상용 규칙 엔진 시스템에서 구현 되는 Rete Algorithm에 대한 링크 와 C #에서 해당 알고리즘을 구현 하는 NRuler 에 대한 다른 링크가 있습니다.