Description
RulesEngine is a rule engine library written in C#. Readers can learn more about it from the following sources:
Repository Address:
https://github.com/microsoft/RulesEngine
Usage Instructions:
https://microsoft.github.io/RulesEngine
Documentation Address:
https://github.com/microsoft/RulesEngine/wiki
What is a Rules Engine?
Cited from https://github.com/microsoft/RulesEngine/wiki/Introduction#what-is-the-rules-engine
In enterprise projects, the critical or core parts are always the business logic or business rules, the CRUD. These systems share a common characteristic where some or many rules or strategies within a module often change, such as customer discounts on shopping websites or freight calculations in logistics companies. These changes bring about a lot of redundant work. Without sufficient abstraction in the system, every time a new rule is added, developers have to write code to accommodate the changes in rules, regression tests, performance tests, etc.
In RulesEngine, Microsoft abstracts the rules, ensuring that the core logic remains stable and maintainable while the changes in rules can be generated simply without altering the codebase. Furthermore, the inputs to the system are essentially dynamic, meaning there’s no need to define models within the system; instead, input can be treated as an extended object or any other type of object for processing through predefined rules, resulting in an output.
It has the following features:
- Json based rules definition
- Multiple input support
- Dynamic object input support
- C# Expression support
- Extending expression via custom class/type injection
- Scoped parameters
- Post rule execution actions
In simpler terms, the output of business logic is influenced by multiple factors, but these influences follow certain patterns. Thus, it is suitable to abstract these parts out, using a rules engine to process them, such as calculating the final discount price after applying various coupons during shopping, or calculating postage for different types of packages in inter-regional transportation, etc.
The author believes that the rules engine consists of two main components:
- A rule validation system, such as validating fields according to rules, executing functions to verify current processes, and outputting execution results;
- A dynamic code engine, capable of converting strings into dynamic code, utilizing expression trees, etc.;
Of course, discussing it like this can seem quite abstract, and more coding practice is needed to truly understand what this RulesEngine is all about.
Installation
After creating a new project, you can simply search for RulesEngine
in NuGet to install it. The dependencies of RulesEngine
can be seen in the NuGet introduction:
FluentValidation is a .NET library for building strongly-typed validation rules. In ASP.NET Core projects, we often use model validation, for instance using [Required]
for required fields, [MaxLength]
for string length, etc. However, because they are annotation attributes, it is challenging to implement many validations that require dynamic checks. Using FluentValidation allows for constructing richer validation rules for model classes.
Similarly, when FluentValidation is used in RulesEngine, it serves the same purpose where RulesEngine is most often used for rule validation, checking the validation results of model classes or business logic, utilizing the rich validation rules in FluentValidation to create various convenient expression trees and build dynamic code.
How to Use
We will understand how to use RulesEngine by checking whether the fields of a model class comply with the rules.
Create a model class like this:
public class Buyer
{
public int Id { get; set; }
public int Age { get; set; }
// 是否为已认证用户
public bool Authenticated { get; set; }
}
The scenario is such that when a user places an order to purchase goods, the backend needs to determine whether the user is an adult and whether they have been authenticated.
Normally, the code should be written like this:
if(Authenticated == true && Age > 18)
But what if the age is set to 16? What if there is a recent company promotion where users can purchase goods without uploading identification?
Of course, defining variables to store in the database is also an option, but if several new conditions are added later, we would have to modify the code, which isn't ideal. Thus, we need RulesEngine.
Alright, let’s investigate this thing.
The complete validation process noted earlier, if(Authenticated == true && Age > 18)
, is referred to as a Workflow within RulesEngine, where each Workflow has multiple Rules.
if(Authenticated == true && Age > 18) => Workflow
Authenticated == true => Rule
Age > 18 => Rule
In RulesEngine, there are two ways to define these Workflows and Rules: one is by code, and the other is in JSON format. The official recommendation is to use JSON, as it can be dynamically generated, allowing for true dynamism.
Let’s take a look at how to use both JSON and code to define the verification process if(Authenticated == true && Age > 18)
.
JSON Definition:
[
{
"WorkflowName": "Test",
"Rules": [
{
"RuleName": "CheckAuthenticated",
"Expression": "Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "Age >= 18"
}
]
}
]
var rulesStr = "[{... ...}]"; // JSON
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr);
C# Code:
var workflows = new List<Workflow>();
List<Rule> rules = new List<Rule>();
Workflow exampleWorkflow = new Workflow();
exampleWorkflow.WorkflowName = "Test";
exampleWorkflow.Rules = rules;
workflows.Add(exampleWorkflow);
Rule authRule = new Rule();
authRule.RuleName = "CheckAuthenticated";
authRule.Expression = "Authenticated == true";
rules.Add(authRule);
Rule ageRule = new Rule();
ageRule.RuleName = "CheckAge";
ageRule.Expression = "Age >= 18";
rules.Add(ageRule);
Both ways are identical; each Workflow can contain multiple Rules and can define multiple Workflows.
There are two aspects we need to understand:
"RuleName": "CheckAuthenticated",
"Expression": "Authenticated == true"
RuleName
: The name of the rule;
Expression
: The actual code, which must conform to C# syntax;
After defining the Workflow and Rules, we need to create the rules engine by simply using new RulesEngine.RulesEngine()
:
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
Creating the engine takes some time.
Once the engine is created, we invoke a Workflow by name and retrieve the validation results for each Rule:
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Id = 666,
Age = 17,
Authenticated = false
});
The complete code example is as follows:
static async Task Main()
{
// Definition
var rulesStr = "... ..."; // JSON
// Generate Workflow[ Rule[] ]
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
// Call the specified Workflow and pass parameters to get each Rule's processing result
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Id = 666,
Age = 17,
Authenticated = false
});
// Print output
foreach (var item in resultList)
{
Console.WriteLine("Rule Name: {0}, Validation Result: {1}", item.Rule.RuleName, item.IsSuccess);
}
}
Multi-parameter
What if a product requires VIP membership to purchase?
Here, we define another model class to indicate whether a user is a VIP.
public class VIP
{
public int Id { get; set; }
public bool IsVIP { get; set; }
}
Now, we need to handle two model classes. To use all model classes in Rules, we need to define a RuleParameter
for each model class.
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 20,
Authenticated = true
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = false
});
This is equivalent to the expression tree:
ParameterExpression rp1 = Expression.Parameter(typeof(Buyer), "buyer"); ParameterExpression rp2 = Expression.Parameter(typeof(VIP), "vip");
You can refer to the author's series of articles on expression trees: https://ex.whuanle.cn/
Next, we redesign the JSON to add one more Rule:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAuthenticated",
"Expression": "buyer.Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "buyer.Age >= 18"
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true"
}
]
}]
Then execute this Workflow:
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", rp1, rp2);
Complete code:
static async Task Main()
{
// Definition
var rulesStr = "... ..."; // JSON
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 20,
Authenticated = true
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = false
});
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", rp1, rp2);
foreach (var item in resultList)
{
Console.WriteLine("Rule Name: {0}, Validation Result: {1}", item.Rule.RuleName, item.IsSuccess);
}
}
Global Parameters and Local Parameters
Global Parameters
Global parameters can be defined within the Workflow and take effect for all Rules within the Workflow, accessible by all Rules.
Example definition:
"WorkflowName": "Test",
"GlobalParams": [{
"Name": "age",
"Expression": "buyer.Age"
}],
The value of the parameter can be defined as a constant or derived from passed parameters.
Modify the previous example, using this global parameter in the Rule CheckAge
.
[{
"WorkflowName": "Test",
"GlobalParams": [{
"Name": "age",
"Expression": "buyer.Age"
}],
"Rules": [{
"RuleName": "CheckAuthenticated",
"Expression": "buyer.Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "age >= 18"
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true"
}
]
}]
Local Parameters
Local parameters are defined within the Rule and only take effect for the current Rule.
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAuthenticated",
"LocalParams": [{
"Name": "age",
"Expression": "buyer.Age"
}],
"Expression": "buyer.Authenticated == true"
},
{
"RuleName": "CheckAge",
"Expression": "age >= 18"
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true"
}
]
}]
When defining parameters, the values can be obtained by executing functions:
"LocalParams":[
{
"Name":"mylocal1",
"Expression":"myInput.hello.ToLower()"
}
],
LocalParams
can regenerate new variables using parameters from GlobalParams
.
"GlobalParams":[
{
"Name":"myglobal1",
"Expression":"myInput.hello"
}
],
"Rules":[
{
"RuleName": "checkGlobalAndLocalEqualsHello",
"LocalParams":[
{
"Name": "mylocal1",
"Expression": "myglobal1.ToLower()"
}
]
},
Define Success and Failure Behaviors
You can define some code to be executed upon the success and failure of each Rule.
Example format:
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "input1.TotalBilled * 0.8"
}
},
"OnFailure": {
"Name": "EvaluateRule",
"Context": {
"WorkflowName": "inputWorkflow",
"ruleName": "GiveDiscount10Percent"
}
}
}
Within OutputExpression
, defined execution code is:
"Name": "OutputExpression",
"Context": {
"Expression": "input1.TotalBilled * 0.8"
}
EvaluateRule
defines executing a Rule of another Workflow:
"Name": "EvaluateRule",
"Context": {
"WorkflowName": "inputWorkflow",
"ruleName": "GiveDiscount10Percent"
}
In OnSuccess
and OnFailure
, the internal structure is as follows:
"Name": "OutputExpression", // Name of action you want to call
"Context": { // This is passed to the action as action context
"Expression": "input1.TotalBilled * 0.8"
}
"Name": "EvaluateRule",
"Context": {
"WorkflowName": "inputWorkflow",
"ruleName": "GiveDiscount10Percent"
}
In Name:{xxx}
, {xxx}
is the name of a specific executor and not arbitrarily defined; OutputExpression
and EvaluateRule
are built-in executors. The executor is a Func<ActionBase>
, more details can be learned in the section Custom Executors.
The contents of Context
are a dictionary, and these Key/Value
pairs will be passed to the executor as parameters. Each executor requires a different Context to be set.
Additionally, each Rule can define the following three fields:
"SuccessEvent": "10",
"ErrorMessage": "One or more adjust rules failed.",
"ErrorType": "Error",
ErrorType
has two options: Warn
and Error
. If the expression of this Rule is incorrect, it determines whether to throw an exception. If set to Warn
, there is an issue with the Rule, and the validation result will be false without throwing an exception; if set to Error
, then this Rule will terminate the Workflow execution, and the program will throw an error.
SuccessEvent
and ErrorMessage
correspond to success and failure messages, respectively.
Calculating Discounts
The previous sections discussed validation rules, now we will implement rule calculations using RulesEngine.
Here, it is stipulated that the base discount is 1.0. If the user is under 18 years old, a 10% discount applies; if the user is VIP, a 10% discount also applies, with both rules being independent.
If under 18 years old, then 1.0 * 0.9
If VIP, then 1.0 * 0.9
Define a model class for passing the discount base value.
// Discount
public class Discount
{
public double Value
{
get; set;
}
}
Define three parameters:
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 16,
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = true
});
var rp3 = new RuleParameter("discount", new Discount
{
Value = 1.0
});
Define rule calculations, where each rule calculates its own discount:
[{
"WorkflowName": "Test",
"GlobalParams": [{
"Name": "value",
"Expression": "discount.Value"
}],
"Rules": [{
"RuleName": "CheckAge",
"Expression": "buyer.age < 18",
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "value * 0.9"
}
}
}
},
{
"RuleName": "CheckVIP",
"Expression": "vip.IsVIP == true",
"Actions": {
"OnSuccess": {
"Name": "OutputExpression",
"Context": {
"Expression": "value * 0.9"
}
}
}
}
]
}]
Full code:
static async Task Main()
{
// Define
var rulesStr = ... ... // JSON
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var bre = new RulesEngine.RulesEngine(workflows.ToArray());
var rp1 = new RuleParameter("buyer", new Buyer
{
Id = 666,
Age = 16,
});
var rp2 = new RuleParameter("vip", new VIP
{
Id = 666,
IsVIP = true
});
var rp3 = new RuleParameter("discount", new Discount
{
Value = 1.0
});
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", rp1, rp2, rp3);
var discount = 1.0;
foreach (var item in resultList)
{
if (item.ActionResult != null && item.ActionResult.Output != null)
{
Console.WriteLine($"{item.Rule.RuleName} Discount: {item.ActionResult.Output}");
discount = discount * (double)item.ActionResult.Output;
}
}
Console.WriteLine($"Final Discount: {discount}");
}
In this example, each rule calculates its own discount, meaning each Rule is independent, and the next Rule does not calculate based on the previous Rule's result.
< 18 : 0.9
VIP : 0.9
If the discounts can be compounded, then it would be 0.9*0.9
, resulting in a final discount of 0.81
.
If discounts cannot be compounded, only the best option should be chosen, yielding a discount of 0.9
.
Using Custom Functions
There are two types of custom functions: static functions and instance functions, and we can invoke pre-written functions in the Expression
.
Next, we will explain how to call custom functions within Rules.
Static Functions
Custom static function:
public static bool CheckAge(int age)
{
return age >= 18;
}
Registering types:
ReSettings reSettings = new ReSettings
{
CustomTypes = new[] { typeof(Program) }
};
var bre = new RulesEngine.RulesEngine(Workflows: workflows.ToArray(), reSettings: reSettings);
Using static function:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAge",
"Expression": "Program.CheckAge(buyer.Age) == true"
}]
}]
Complete code:
static async Task Main()
{
// Definition
var rulesStr = "[{\"WorkflowName\":\"Test\",\"Rules\":[{\"RuleName\":\"CheckAge\",\"Expression\":\"Program.CheckAge(buyer.Age) == true\"}]}]";
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
ReSettings reSettings = new ReSettings
{
CustomTypes = new[] { typeof(Program) }
};
var bre = new RulesEngine.RulesEngine(Workflows: workflows.ToArray(), reSettings: reSettings);
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Age = 16
});
foreach (var item in resultList)
{
Console.WriteLine("Rule name: {0}, Validation result: {1}", item.Rule.RuleName, item.IsSuccess);
}
}
public static bool CheckAge(int age)
{
return age >= 18;
}
Instance Functions
Defining an instance function:
public bool CheckAge(int age)
{
return age >= 18;
}
Passing instance through RuleParameter
:
var rp1 = new RuleParameter("p", new Program());
Calling the function by parameter name:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAge",
"Expression": "p.CheckAge(buyer.Age) == true"
}]
}]
Complete code:
static async Task Main()
{
// Definition
var rulesStr = "[{\"WorkflowName\":\"Test\",\"Rules\":[{\"RuleName\":\"CheckAge\",\"Expression\":\"p.CheckAge(buyer.Age) == true\"}]}]";
var workflows = JsonConvert.DeserializeObject<List<Workflow>>(rulesStr)!;
var rp1 = new RuleParameter("p", new Program());
var bre = new RulesEngine.RulesEngine(Workflows: workflows.ToArray());
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", new Buyer
{
Age = 16
}, rp1);
foreach (var item in resultList)
{
Console.WriteLine("Rule name: {0}, Validation result: {1}", item.Rule.RuleName, item.IsSuccess);
}
}
public bool CheckAge(int age)
{
return age >= 18;
}
Custom Executor
A custom executor is the custom execution code for the OnSuccess
and OnFailure
parts. Compared to static functions and instance functions, using a custom executor allows access to some data of the Rule.
"Actions": {
"OnSuccess": {
"Name": "MyCustomAction",
"Context": {
"customContextInput": "0.9"
}
}
}
Define a custom executor, which needs to inherit from ActionBase
.
public class MyCustomAction : ActionBase
{
public override async ValueTask<object> Run(ActionContext context, RuleParameter[] ruleParameters)
{
var customInput = context.GetContext<string>("customContextInput");
return await ValueTask.FromResult(new object());
}
}
Define ReSettings
and pass it in while building the rules engine:
var b = new Buyer
{
Age = 16
};
var reSettings = new ReSettings
{
CustomActions = new Dictionary<string, Func<ActionBase>>
{
{"MyCustomAction", () => new MyCustomAction() }
}
};
var bre = new RulesEngine.RulesEngine(workflows.ToArray(), reSettings);
List<RuleResultTree> resultList = await bre.ExecuteAllRulesAsync("Test", b);
Define JSON rule:
[{
"WorkflowName": "Test",
"Rules": [{
"RuleName": "CheckAge",
"Expression": "Age <= 18 ",
"Actions": {
"OnSuccess": {
"Name": "MyCustomAction",
"Context": {
"customContextInput": "0.9"
}
}
}
}]
}]
文章评论