简体   繁体   中英

Parsing a string formula by injecting object values (i.e. using reflection) in C#

I need to parse a formula in string format which can contain objects and properties.

Example:

"Person1.Age > 20" or "Person1.Age > RentalCar5.MinimumAge"

Where Person1 would be an object of type Person , and RentalCar5 an object of type RentalCar .

I have the objects, so can check the formula contains "Person1" and know it to be a Person object matching that specific Name property, so I need to then determine the Age property value, then replace the whole "Person1.Age" section with the age, such as 21.

Note: I'll be able to infer the type based on iterating the objects I have, so can check formulaString.Contains((obj1 as Person).Name).

Once this is done it would be easy enough to resolve the formula as "21 >20"

Any thoughts or ideas?

There are many 3rd party C# formula evaluation libraries out there, however the same thing can be achieved through dynamic scripts with the Roslyn C# Compiler.

Whilst this solution does not technically make much use of Reflection as requested and it is very fixed to the specified input types, it is only a primer to show you the way. You could use reflection to resolve or infer some aspects of this or you could generate the script really dynamically from the formula itself.

Instead of being based 100% on the formula, creating a fixed class wrapper to execute the formula and represents all the valid or possible inputs is a simpler approach. You gain direct control over the constraints but the formula can now take on any form of comparison logic or operators, as long as it conforms to the class structure and valid c# syntax.

It is also helpful to make the formulas more abstract, Person1 is really specific to a single context, you would not normally construct a separate formula for each individual person instead your formula should probably be in the form of:

"Person.Age > RentalCar.MinimumAge"

Then in the execution context you could use the formula as a single business rule to evaluate across any combination of selected Person and RentalCar .

IMO avoid using model or type names as the variable names in your formulas, give them context by using the business domain name, the name that describes them in the business model, not the data model. It can avoid conflict between what is a Type reference, and what is an Instance

"Driver.Age > Car.MinimumAge"

For a thorough background on this process, please have a read over this response to How to programmatically create a class library DLL using reflection?

Start by writing out in long form a class wrapper that has a single method with the user's formula injected within it. These are the requirements from the original post:

  1. Return a boolean value
  2. Field/Property/Parameter called Person1 that is a type of Person
  3. Field/Property/Parameter called RentalCar5 that is a type of RentalCar

the following script uses parameters to model the inputs, which will work, but I prefer to use instance Properties as I find it simplifies the interface, script processing and debugging process, especially when have a common context but multiple formulas. This example is just to get you going

namespace DynamicScriptEngine
{
    public class PersonScript
    {
        public bool Evaluate(Person Driver, RentalCar Car)
        {
            return
            #region Dynamic Script
    
            Driver.Age > Car.MinimumAge
    
            #endregion Dynamic Script
            ;
        }
    }
}

Now, we just compile that class, call the method and get the result. The following code will generate the above class with an injected formula and return the response.

Orchestration

Call this method to pass in the parameters and the formula from the context where you need the result.

public static bool EvaluateFormula(Person Driver, RentalCar Car, string formula)
{
    string nameSpace = "DynamicScriptEngine";
    string className = "PersonScript";
    string methodName = "Evaluate";

    List<Type> knownTypes = new List<Type> { typeof(Person), typeof(RentalCar) };

    // 1. Generate the code
    string script = BuildScript(nameSpace, knownTypes, className, methodName, formula);
    // 2. Compile the script
    // 3. Load the Assembly
    Assembly dynamicAssembly = CompileScript(script);
    // 4. Execute the code
    object[] arguments = new object[] { person, rentalCar };
    bool result = ExecuteScript(dynamicAssembly, nameSpace, className, methodName, arguments);

    return result;
}

Usage

This is an example of how to call the above method to evaluate the formula:

static void Main(string[] args) { // create the input conditions Person person1 = new Person { Name = "Person1", Age = 21 }; RentalCar car5 = new RentalCar { Name = "RentalCar1", MinimumAge = 20 }; RentalCar car1 = new RentalCar { Name = "RentalCar5", MinimumAge = 25 };

    // Evaluate the formulas
    Console.WriteLine("Compare Driver: {0}", person1);
    Console.WriteLine();
    string formula = "Driver.Age > 20";
    Console.WriteLine("Formula: {0} => {1}", formula, EvaluateFormula(person1, null, formula));

    Console.WriteLine();
    Console.WriteLine("Compare against Car: {0}", car5);
    formula = "Driver.Age > Car.MinimumAge";
    Console.WriteLine("Formula: {0} => {1}", formula, EvaluateFormula(person1, car5, formula));

    Console.WriteLine();
    Console.WriteLine("Compare against Car: {0}", car1);
    formula = "Driver.Age > Car.MinimumAge";
    Console.WriteLine("Formula: {0} => {1}", formula, EvaluateFormula(person1, car1, formula));
}

Output

演示输出

Code Generator

This method will generate the code wrapper around the formula so that it can be compiled into a standalone assembly for execution.

It is important that we include the namespaces and or aliases that might be required for the dynamic script to execute, you should consider including the namespaces for the input types as a bare minimum.

private static string BuildScript(string nameSpace, List<Type> knownTypes, string className, string methodName, string formula)
{
    StringBuilder code = new StringBuilder();
    code.AppendLine("using System;");
    code.AppendLine("using System.Linq;");
    // extract the usings from the list of known types
    foreach(var ns in knownTypes.Select(x => x.Namespace).Distinct())
    {
        code.AppendLine($"using {ns};");
    }

    code.AppendLine();
    code.AppendLine($"namespace {nameSpace}");
    code.AppendLine("{");
    code.AppendLine($"    public class {className}");
    code.AppendLine("    {");
    // NOTE: here you could define the inputs are properties on this class, if you wanted
    //       You might also evaluate the parameter names somehow from the formula itself
    //       But that adds another layer of complexity, KISS
    code.Append($"        public bool {methodName}(");
    code.Append("Person Driver, ");
    code.Append("RentalCar Car");
    code.AppendLine(")");
    code.AppendLine("        {");
    code.Append("        return ");

    // NOTE: Here we insert the actual formula
    code.Append(formula);

    code.AppendLine(";");
    code.AppendLine("        }");
    code.AppendLine("    }");
    code.AppendLine("}");
    return code.ToString();
}

Compilation

private static Assembly CompileScript(string script)
{
    SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(script);

    // use "mytest.dll" if you want, random works well enough
    string assemblyName = System.IO.Path.GetRandomFileName();
    List<string> dlls = new List<string> {
    typeof(object).Assembly.Location,
    typeof(Enumerable).Assembly.Location,
    // NOTE: Include the Assembly that the Input Types exist in!
    //       And any other references, I just enumerate the working folder and load them all, but it's up to you.
    typeof(Person).Assembly.Location
};
    MetadataReference[] references = dlls.Distinct().Select(x => MetadataReference.CreateFromFile(x)).ToArray();

    CSharpCompilation compilation = CSharpCompilation.Create(
        assemblyName,
        syntaxTrees: new[] { syntaxTree },
        references: references,
        options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

    // Now we actually compile the script, this includes some very crude error handling, just to show you can
    using (var ms = new MemoryStream())
    {
        EmitResult result = compilation.Emit(ms);

        if (!result.Success)
        {
            IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
                diagnostic.IsWarningAsError ||
                diagnostic.Severity == DiagnosticSeverity.Error);

            List<string> errors = new List<string>();
            foreach (Diagnostic diagnostic in failures)
            {
                //errors.AddDistinct(String.Format("{0} : {1}", diagnostic.Id, diagnostic.Location, diagnostic.GetMessage()));
                errors.Add(diagnostic.ToString());
            }

            throw new ApplicationException("Compilation Errors: " + String.Join(Environment.NewLine, errors));
        }
        else
        {
            ms.Seek(0, SeekOrigin.Begin);
            return Assembly.Load(ms.ToArray());
        }
    }
}

Execution

This is the method that actually executes the generated script, this method is very generic, the Orchestration Logic will prepare the parameters for us. If you were using Instance Properties then the code in here is a bit more complicated.

private static bool ExecuteScript(Assembly assembly, string nameSpace, string className, string methodName, object[] arguments)
{
    var appType = assembly.GetType($"{nameSpace}.{className}");
    object app = Activator.CreateInstance(appType);
    MethodInfo method = appType.GetMethod(methodName);

    object result = method.Invoke(app, arguments);
    return (bool)result;
}

This is just a basic example and there is a lot of power in a solution like this, but you need to be aware that it can introduce a lot of vulnerability into your code. There are steps you can take to mitigate security concerns, ideally you should validate and or sanitise the formula when it is stored, and execute it in a different context or container


All In one example

// Install-Package 'Microsoft.CodeAnalysis.CSharp.Scripting'
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Emit;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;

namespace ConsoleApp2
{
    public class Person
    {
        public string Name { get; set; }
        public int Age { get; set; }

        public override string ToString()
        {
            return $"Person: {Name} Age: {Age}";
        }
    }

    public class RentalCar
    {
        public string Name { get; set; }
        public int MinimumAge { get; set; }

        public override string ToString()
        {
            return $"RentalCar: {Name} MinimumAge: {MinimumAge}";
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            // create the input conditions
            Person person1 = new Person { Name = "Person1", Age = 21 };
            RentalCar car5 = new RentalCar { Name = "RentalCar1", MinimumAge = 20 };
            RentalCar car1 = new RentalCar { Name = "RentalCar5", MinimumAge = 25 };

            // Evaluate the formulas
            Console.WriteLine("Compare Driver: {0}", person1);
            Console.WriteLine();
            string formula = "Driver.Age > 20";
            Console.WriteLine("Formula: {0} => {1}", formula, EvaluateFormula(person1, null, formula));

            Console.WriteLine();
            Console.WriteLine("Compare against Car: {0}", car5);
            formula = "Driver.Age > Car.MinimumAge";
            Console.WriteLine("Formula: {0} => {1}", formula, EvaluateFormula(person1, car5, formula));

            Console.WriteLine();
            Console.WriteLine("Compare against Car: {0}", car1);
            formula = "Driver.Age > Car.MinimumAge";
            Console.WriteLine("Formula: {0} => {1}", formula, EvaluateFormula(person1, car1, formula));
        }

        public static bool EvaluateFormula(Person driver, RentalCar car, string formula)
        {
            string nameSpace = "DynamicScriptEngine";
            string className = "PersonScript";
            string methodName = "Evaluate";

            List<Type> knownTypes = new List<Type> { typeof(Person), typeof(RentalCar) };

            // 1. Generate the code
            string script = BuildScript(nameSpace, knownTypes, className, methodName, formula);
            // 2. Compile the script
            // 3. Load the Assembly
            Assembly dynamicAssembly = CompileScript(script);
            // 4. Execute the code
            object[] arguments = new object[] { driver, car };
            bool result = ExecuteScript(dynamicAssembly, nameSpace, className, methodName, arguments);

            return result;
        }

        private static string BuildScript(string nameSpace, List<Type> knownTypes, string className, string methodName, string formula)
        {
            StringBuilder code = new StringBuilder();
            code.AppendLine("using System;");
            code.AppendLine("using System.Linq;");
            // extract the usings from the list of known types
            foreach(var ns in knownTypes.Select(x => x.Namespace).Distinct())
            {
                code.AppendLine($"using {ns};");
            }

            code.AppendLine();
            code.AppendLine($"namespace {nameSpace}");
            code.AppendLine("{");
            code.AppendLine($"    public class {className}");
            code.AppendLine("    {");
            // NOTE: here you could define the inputs are properties on this class, if you wanted
            //       You might also evaluate the parameter names somehow from the formula itself
            //       But that adds another layer of complexity, KISS
            code.Append($"        public bool {methodName}(");
            code.Append("Person Driver, ");
            code.Append("RentalCar Car");
            code.AppendLine(")");
            code.AppendLine("        {");
            code.Append("        return ");

            // NOTE: Here we insert the actual formula
            code.Append(formula);

            code.AppendLine(";");
            code.AppendLine("        }");
            code.AppendLine("    }");
            code.AppendLine("}");
            return code.ToString();
        }

        private static Assembly CompileScript(string script)
        {
            SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(script);

            // use "mytest.dll" if you want, random works well enough
            string assemblyName = System.IO.Path.GetRandomFileName();
            List<string> dlls = new List<string> {
            typeof(object).Assembly.Location,
            typeof(Enumerable).Assembly.Location,
            // NOTE: Include the Assembly that the Input Types exist in!
            //       And any other references, I just enumerate the working folder and load them all, but it's up to you.
            typeof(Person).Assembly.Location
        };
            MetadataReference[] references = dlls.Distinct().Select(x => MetadataReference.CreateFromFile(x)).ToArray();

            CSharpCompilation compilation = CSharpCompilation.Create(
                assemblyName,
                syntaxTrees: new[] { syntaxTree },
                references: references,
                options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

            // Now we actually compile the script, this includes some very crude error handling, just to show you can
            using (var ms = new MemoryStream())
            {
                EmitResult result = compilation.Emit(ms);

                if (!result.Success)
                {
                    IEnumerable<Diagnostic> failures = result.Diagnostics.Where(diagnostic =>
                        diagnostic.IsWarningAsError ||
                        diagnostic.Severity == DiagnosticSeverity.Error);

                    List<string> errors = new List<string>();
                    foreach (Diagnostic diagnostic in failures)
                    {
                        //errors.AddDistinct(String.Format("{0} : {1}", diagnostic.Id, diagnostic.Location, diagnostic.GetMessage()));
                        errors.Add(diagnostic.ToString());
                    }

                    throw new ApplicationException("Compilation Errors: " + String.Join(Environment.NewLine, errors));
                }
                else
                {
                    ms.Seek(0, SeekOrigin.Begin);
                    return Assembly.Load(ms.ToArray());
                }
            }
        }

        private static bool ExecuteScript(Assembly assembly, string nameSpace, string className, string methodName, object[] arguments)
        {
            var appType = assembly.GetType($"{nameSpace}.{className}");
            object app = Activator.CreateInstance(appType);
            MethodInfo method = appType.GetMethod(methodName);

            object result = method.Invoke(app, arguments);
            return (bool)result;
        }
    }
}

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM