DmRules - .NET 3.0 规则运行辅助库






4.53/5 (22投票s)
一个允许您将规则嵌入应用程序的库。使用 .NET 3.0 中的 Workflow Foundation 库编写。
引言
到目前为止,我还没有找到一个合适的规则引擎用于我的 .NET 项目。当然,像 ILog 和 BizTalk 这样的大型产品拥有功能齐全的规则引擎。但是,我需要一些简单且免费的东西,可以嵌入到我的 .NET 应用程序中。
.NET 3.0 引入了 Windows Workflow Foundation,其中包含一个基于规则的活动。当您在工作流的某个点时,您可以对对象运行一些规则,并使用它来修改对象和/或决定工作流中的下一个路径。基本上,没有某种规则,工作流就无法存在。这个规则实现最让我印象深刻的是它使用 CodeDom 来处理条件和操作。最近,我写了一个 CodeDom 表达式解析器[^],这让我的 CodeDom 编程生活变得更加轻松。那么,为什么不利用这个解析器呢?
我应该事先说明,这绝不是一个真正的规则引擎。 .NET 3.0 中的规则确实采用了前向链接,这意味着您不必做很多事情。但是,前向链接不能跨类型工作。使其正常工作是未来文章的主题。
演示
可以在以下链接找到演示此规则引擎工作方式的示例。这是一个我制作的小游戏,用于说明 DmRules 如何在应用程序中使用: 猜单词 - 一个使用 WPF 和 WWF 在 .NET 3.0 中编写的游戏[^]。
目标
我希望我的可嵌入规则引擎能够处理某些事情。澄清一下:虽然我在大学时尝试过 Prolog,但我在这里没有兴趣实现它。我更感兴趣的是获得对我来说在业务应用程序中有用的核心功能集。此外,我坚信不知道如何编码的业务分析师永远不应该尝试编写规则。所以,我不会试图让规则编写变得足够简单,让他们能够做到!以下是我希望我的规则库能够做的一些事情:
- 处理计算 - 当您在一个对象中更新一个数字时,任何在该数字的计算中使用该数字的另一个对象都应该重新评估。例如,我有一个类,用于计算我每月预算需要分配多少钱。该计算的一部分是燃气费预算。另一个对象具有当前燃气价格。当该价格发生变化时,我每月总预算的数字也应该随之改变。燃气价格对象不应该关心哪些对象依赖于它,更新应该是自动的。
- 断言/审计 - 当一个值超出可接受范围时,您可能需要对此做出响应。您可以引发某种错误标志,或将问题记录到日志中。
- 前向链接 - 如上所述,如果对象 A 中发生更改,并且对象 B 依赖于对象 A,那么对象 B 应该被重新评估。基本上,系统应该能够在没有明确编码来告知它更新什么的情况下响应更改。
- 非侵入性 - 为实现规则而对实际代码进行的更改应微乎其微。
- 简单的配置 - 我希望避免有一个很大、很难看的 XML 文件来定义规则。一个没有规则引擎培训的初学者程序员应该能够从配置 XML 中读取和理解规则。
- 适应变化 - 面对现实吧,还有其他方法可以处理计算以及进行断言和审计。诸如面向切面编程或简单的 .NET 事件之类的技术已经足够。但是,真正重要的是能够改变规则的工作方式。规则的条件、计算或执行的操作可能会发生变化。规则引擎使您能够处理这些变化。
Windows Workflow Foundation 规则入门
让我们来看一个运行规则的例子。微软提供了一个基本的规则编辑器,它允许您在 GUI 中编写条件和操作,而不是使用 CodeDom。而不是使用那个工具,我们将自己编写 CodeDom。
这是我们的示例类
public class Class1
{
private int _Foo;
private string _Bar;
public int Foo
{
get { return _Foo; }
set { _Foo = value; }
}
public string Bar
{
get { return _Bar; }
set { _Bar = value; }
}
}
现在,我们可以编写一个类来将规则应用于 Class1
。首先,我们必须包含正确的引用。将 System.Workflow.Activities
和 System.Workflow.ComponentModel
的引用添加到您的项目中。现在,我们将逐步介绍代码。我们不需要包含太多命名空间。
using System.CodeDom;
using System.Workflow.Activities.Rules;
我们正在处理的核心类称为 RuleSet
。在使用微软的工具时,这个对象被序列化到 .rules 文件中。
RuleSet rs = new RuleSet();
Rule r = new Rule("Rule A");
rs.Rules.Add(r);
WF 中的规则有一个条件,一组如果条件为真则执行的操作,以及一组如果条件为假则执行的操作。所有这些都用 CodeDom 编写。我们将要测试的条件是属性 Foo
是否等于 3。
CodeThisReferenceExpression thisRef = new CodeThisReferenceExpression();
CodePropertyReferenceExpression fooRef = new
CodePropertyReferenceExpression(thisRef, "Foo");
CodeBinaryOperatorExpression fooCond = new CodeBinaryOperatorExpression();
fooCond.Left = fooRef;
fooCond.Operator = CodeBinaryOperatorType.ValueEquality;
fooCond.Right = new CodePrimitiveExpression(3);
正如您所见,我还没有使用我的 CodeDom 表达式解析器库。它稍后会到来。总之,我们获取我们的表达式并将其作为规则条件应用。
r.Condition = new RuleExpressionCondition(fooCond);
现在,我们需要一个要执行的操作,至少要表示规则有效
CodePropertyReferenceExpression barRef = new
CodePropertyReferenceExpression(thisRef, "Bar");
CodeAssignStatement barThen = new CodeAssignStatement();
barThen.Left = barRef;
barThen.Right = new CodePrimitiveExpression("Gotcha");
每当条件为真时,这将把 Bar
的值更改为“Gotcha”。我们只需要将其添加为“then”操作。
r.ThenActions.Add(new RuleStatementAction(barThen));
现在,我们必须为我们的规则应用一个验证器。验证器基于一个 Type
。基本上,它将验证您的规则是否使用了 Class1
上的有效属性/方法。这也让我想到一个有趣的讨论点。 RuleSet
是适用于一次一个 Type
的规则集。它之所以这样设计是有原因的,但它确实对前向链接等施加了限制,这些限制必须解决才能拥有一个有用的规则引擎。
RuleValidation rv = new RuleValidation(typeof(Class1), null);
rs.Validate(rv);
现在,我们想要执行规则。为了测试规则是否正常工作,我们将进入一个循环,增加 Foo
的值直到它达到 3,然后检查 Bar
的值。
Class1 c = new Class1();
c.Bar = "Uh-uh";
for (int i = 0; i < 4; i++)
{
c.Foo = i;
RuleExecution rexec = new RuleExecution(rv, c);
rs.Execute(rexec);
Console.WriteLine("i: " + i + "\n\tFoo: " + c.Foo + "\n\tBar: " + c.Bar);
}
以及结果输出
i: 0
Foo: 0
Bar: Uh-uh
i: 1
Foo: 1
Bar: Uh-uh
i: 2
Foo: 2
Bar: Uh-uh
i: 3
Foo: 3
Bar: Gotcha
此测试包含在源文件包中,因此您可以自己查看。您绝对可以看到这个系统有一些潜力。但是,除非我们想被束缚于微软的规则编写工具,否则我们必须找到一种方法来处理 CodeDom。现在请看我的 CodeDom 表达式解析器。
DmCodeDom - CodeDom 辅助库
像我在 这里[^] 演示过的表达式解析器不足以处理规则,因为它根本不处理语句。所以,我引入了另一个小的辅助库来帮助我编写语句。这个辅助库只包含基本功能。
- 赋值
- 声明
- If/Else - 在 WF 规则中不允许
- For Loop - 在 WF 规则中也不允许
- 表达式语句
为了说明,以下简单循环包含以上所有元素。
for (int i = 0; i < 7; i++)
{
if (i % 2 == 1)
foo.Bar();
}
这是您将如何在 CodeDom 中编写它。
CodeIterationStatement cis = new CodeIterationStatement();
CodeVariableReferenceExpression refI = new
CodeVariableReferenceExpression("i");
cis.InitStatement = new CodeVariableDeclarationStatement(
typeof(int), "i", new CodePrimitiveExpression(0));
cis.IncrementStatement = new CodeAssignStatement(refI,
new CodeBinaryOperatorExpression(refI,
CodeBinaryOperatorType.Add,
new CodePrimitiveExpression(1)));
cis.TestExpression = new CodeBinaryOperatorExpression(refI,
CodeBinaryOperatorType.LessThan,
new CodePrimitiveExpression(7));
CodeConditionStatement ccs = new CodeConditionStatement();
ccs.Condition = new
CodeBinaryOperatorExpression(new
CodeBinaryOperatorExpression(refI,
CodeBinaryOperatorType.Modulus,
new CodePrimitiveExpression(2)),
CodeBinaryOperatorType.ValueEquality,
new CodePrimitiveExpression(1));
CodeMethodInvokeExpression cmie = new
CodeMethodInvokeExpression(new
CodeVariableReferenceExpression("foo"), "Bar",
new CodeExpression[0]);
ccs.TrueStatements.Add(cmie);
cis.Statements.Add(ccs);
呼!CodeDom 很快就会变得难以理解。而且,这只是一个简单的循环。我们真的必须做些什么。以下是我如何在我的辅助类中处理完全相同的循环。
ForLoop fl = new ForLoop();
fl.InitStmt = new Declaration("int", "i", "0");
fl.IncrStmt = new Assignment("i", "i + 1");
fl.Cond = "i < 7";
IfElse ie = new IfElse();
ie.Cond = "i % 2 == 1";
ie.TrueStmts.Add(new ExprStmt("foo.Bar()"));
fl.LoopStmts.Add(ie);
我选择使用 ForLoop
和 Assignment
之类的类是因为我想通过 XmlSerializer
类将它们序列化到 XML。上面循环序列化到 XML 后的样子如下。
<DmCdStmt
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xsi:type="ForLoop" cond="i < 7">
<InitStmt xsi:type="Declaration" type="int" varName="i" initExp="0" />
<IncrStmt xsi:type="Assignment" left="i"
right="i + 1" />
<LoopStmts>
<DmCdStmt xsi:type="IfElse" cond="i % 2 == 1">
<TrueStmts>
<DmCdStmt xsi:type="ExprStmt" expr="foo.Bar()" />
</TrueStmts>
<FalseStmts />
</DmCdStmt>
</LoopStmts>
</DmCdStmt>
我很高兴 .NET 2.0 最终修复了 XmlSerializer
,它允许您序列化和反序列化抽象基类而不是仅具体的类。XML 可能可以更简单一些,但是实现 IXmlSerializable
并自己实现的工作量真的不值得那些好处。
也许有一天,我会将 DmCodeDom 变成一个严肃的 CodeDom 辅助库。如果您有兴趣,请在此文章底部发表评论(或给我投 5 票 )。
Using the Code
展示此代码工作原理的最佳方法是使用示例。我将有两个简单的类:Order
和 OrderItem
。每个订单都有一个项目集合。订单有一个总计,用于汇总所有项目成本。我想要处理两种情况:
- 将一个项目添加到订单或从订单中删除,以及
- 更改项目的价格或数量。
这是 Order
类。
public class Order {
private List<OrderItem> _Items = new List<OrderItem>();
private int _NumItems = 0;
private float _Total = 0f;
public List<orderitem> Items {
get { return _Items; }
}
public int NumItems {
get { return _NumItems; }
}
public float Total {
get { return _Total; }
}
internal void RecalculateTotal() {
_Total = 0f;
foreach (OrderItem oi in _Items)
_Total += oi.Price * oi.Units;
}
public Order() {}
public OrderItem NewItem() {
return new OrderItem(this);
}
public OrderItem NewItem(float price, int units) {
return new OrderItem(price, units, this);
}
}</orderitem>
正如您所见,这是一个相当简单的类。它有一个项目列表,并报告订单的总成本。需要注意的一点是,有一个 NumItems
属性,它的返回值不是 Items.Count
。这是故意的,以便规则可以识别添加或删除了项目。当然,还有其他方法可以做到这一点,但我选择了这种方法。
您大概可以想象 OrderItem
会是什么样子。这是该类。
public class OrderItem : BaseObject {
private float _Price = 0f;
private int _Units = 0;
private Order _Order = null;
public float Price {
get { return _Price; }
set {
if (value != _Price) {
_Price = value;
MarkDirty();
}
}
}
public int Units {
get { return _Units; }
set {
if (value != _Units) {
_Units = value;
MarkDirty();
}
}
}
public Order Order {
get { return _Order; }
}
internal OrderItem(Order order) {
_Order = order;
}
internal OrderItem(float price, int units, Order order) {
_Price = price;
_Units = units;
_Order = order;
}
}
关于这个类有几点需要注意。构造函数标记为 internal
,因为我希望它们由 Order
类创建。对 Units
或 Price
的更改会将对象标记为脏。如果您以前从未见过,这基本上表示对象已更改。这足以通知规则它们必须重新评估。CSLA 使用此技术来识别需要保存到数据库的更改。脏标记包含在 BaseObject
类中,该类如下所示。
public abstract class BaseObject {
protected bool _IsDirty = false;
public bool IsDirty {
get { return _IsDirty; }
}
public void MarkDirty() {
_IsDirty = true;
}
}
创建和运行规则
我们要写的第一个规则是处理项目数量的变化。此规则应用于 Order
类。我们首先将所有内容手动写出来,然后我稍后将展示如何将规则放入应用程序配置中。首先,让我们设置规则。
DmRule dr = new DmRule("this.NumItems != this.Items.Count",
"Rule1",
new DmCdStmt[] {
new ExprStmt("this.RecalculateTotal()"),
new Assignment("this._NumItems", "this.Items.Count"),
},
new DmCdStmt[0]
);
Parser parser = new Parser();
parser.Fields.Add("_NumItems");
DmRuleSet drs = new DmRuleSet();
drs.RuleTypes.Add(new DmRuleTypeSet(typeof(Order), new DmRule[] { dr }));
drs.Eval(parser);
因此,我们创建一个 DmRule
对象来表示我们的规则。规则的条件是比较 NumItems
属性与 Items.Count
。如果不匹配,则表示添加或删除了项目。如果规则评估为 true,则会发生两件事:总计被重新计算,并且 NumItems
被更改以反映当前的项目数量。我本来希望在规则操作中重新计算总计,但规则库不允许 CodeIterationStatement
。
创建规则后,我们将其与一个 Type
匹配,然后将其添加到 DmRuleSet
,它将处理规则执行。您可能还会注意到,表达式解析器需要知道 _NumItems
是一个字段。
我们将创建一个 Order
,然后向其中添加一个项目。当我们运行规则时,它应该找出已添加了一个项目并重新计算总计。
Order o = new Order();
OrderItem oi = o.NewItem(2.3f, 2);
o.Items.Add(oi);
Console.WriteLine("Before:");
Console.WriteLine(" NumItems: " + o.NumItems);
Console.WriteLine(" Total: " + o.Total);
drs.RunRules(o);
Console.WriteLine("After:");
Console.WriteLine(" NumItems: " + o.NumItems);
Console.WriteLine(" Total: " + o.Total);
结果输出是
Before:
NumItems: 0
Total: 0
After:
NumItems: 1
Total: 4.6
使用 RuleExec 简化一切
库中包含一个名为 RuleExec
的静态类。此类会自动为您处理一些事情。
- 规则可以在应用程序配置中指定,而不是手动创建。
- 通过检查类型,字段会自动添加到表达式解析器。
- 在一行代码中就可以完成在对象上运行规则的操作。
对于这部分,我们将添加另一个规则。此规则应用于 OrderItem
类。当单价或单位数量发生变化时,项目将被标记为脏。发生这种情况时,我们希望重新计算订单总计。我们将此规则添加到 Order
类上现有的规则中,并将所有内容放入 App.config 中。
<configuration>
<configSections>
<section
name="dmRulesConfig"
type="DmRules.Configuration.DmRulesConfigHandler, DmRules"
/>
</configSections>
<dmRulesConfig>
<DmRuleSet
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<RuleTypes>
<DmRuleTypeSet type="DmRules.TestHarness.Order, DmRules.TestHarness">
<Rules>
<DmRule cond="this.NumItems != this.Items.Count" name="Rule1">
<ThenStmts>
<DmCdStmt xsi:type="ExprStmt" expr="this.RecalculateTotal()" />
<DmCdStmt xsi:type="Assignment" left="this._NumItems"
right="this.Items.Count" />
</ThenStmts>
<ElseStmts />
</DmRule>
</Rules>
</DmRuleTypeSet>
<DmRuleTypeSet type="DmRules.TestHarness.OrderItem, DmRules.TestHarness">
<Rules>
<DmRule cond="this.IsDirty" name="Rule1">
<ThenStmts>
<DmCdStmt xsi:type="ExprStmt"
expr="this.Order.RecalculateTotal()" />
<DmCdStmt xsi:type="Assignment" left="this._IsDirty"
right="false" />
</ThenStmts>
<ElseStmts />
</DmRule>
</Rules>
</DmRuleTypeSet>
</RuleTypes>
</DmRuleSet>
</dmRulesConfig>
</configuration>
从这个例子中,您应该能够看到 XML 的格式。如果您经常使用 XmlSerializer
,那么这应该看起来很熟悉。OrderItem
上的规则将检查对象是否被标记为脏。如果是,它会告诉父订单重新计算其总计,并将脏标记改回 false
。另请注意,XML 中指定了完整的类型名称。规则始终与一个类型相关联。规则名称也相同,但由于它们属于不同的类型,因此没关系。
RuleExec
使编码工作变得容易得多。我们不必担心自己运行解析器或进行前面部分中完成的任何设置工作。以下代码将创建一个 Order
,向其中添加一个 OrderItem
,并更改该项目上的单位数量。
Order o = new Order();
OrderItem oi = o.NewItem(2.3f, 2);
o.Items.Add(oi);
RuleExec.ApplyRules(o);
oi.Units = 5;
Console.WriteLine("Before:");
Console.WriteLine(" Total: " + o.Total);
RuleExec.ApplyRules(oi);
Console.WriteLine("After:");
Console.WriteLine(" Total: " + o.Total);
看起来容易多了,是吧?这是此代码的结果输出。
Before:
Total: 4.6
After:
Total: 11.5
优先级和停止
在开发 Guess Word 游戏时,我发现不能保证规则的运行顺序。它们甚至不按照 RuleSet
中输入的顺序运行。有时,有必要按特定顺序运行规则。解决方案是使用优先级。
<DmRule cond="this.Foo == 5" name="Rule1" priority="2">
<ThenStmts>
<DmCdStmt xsi:type="Assignment" left="this.Bar" right=""High"" />
</ThenStmt>
</DmRule>
<DmRule cond="this.Bar == "High"" name="Rule2" priority="1">
<ThenStmts>
<DmCdStmt xsi:type="Assignment" left="this.SomeProperty" right="true" />
</ThenStmt>
</DmRule>
此规则集中的 priority
属性表明 Rule1
应该在 Rule2
之前运行。虽然 Rule1
似乎无论如何都会在 Rule2
之前运行,但这并不保证。为了保证顺序,我们指定了优先级。在这种特定情况下,前向链接将接管并识别我们更改了 Bar
属性。如果 Rule2
在 Rule1
之前运行,最坏的情况是规则集运行两次。但是,想象一下您有多个规则,它们都更改属性。除非您使用优先级,否则规则可能比它们需要运行的次数多得多。
另一个需要的功能是停止。Workflow Foundation 中的规则允许具有停止命令。这些命令可以插入到 then 或 else 操作集中的任何位置。让我困惑的是,条件和循环不允许在 then 或 else 操作中,所以总是有顺序的流程。因此,任何在停止之后的东西都将是无法到达的。考虑到这一点,将停止作为规则的属性是安全的。
<DmRule cond="this.Foo < 10" name="Rule1" haltAfterThen="true"
haltAfterElse="true" priority="1000">
<ThenStmts>...</ThenStmts>
<ElseStmts>...</ElseStmts>
</DmRule>
显然,停止默认为 false
,不需要指定。优先级也是如此。默认情况下,所有优先级都为零,并且 Workflow Foundation 会决定它们的运行顺序。
摘要
.NET 3.0 中的新 Windows Workflow Foundation 添加了一个非常实用的规则库。有时,当您编写一个应用程序时,您会觉得需要规则,但不需要工作流。这就是我整理这个库的原因。微软包含了一个规则编写 GUI,它最终会编写一个 .rules 文件,该文件与您的原始源代码配对。这对于工作流环境来说很好,但序列化格式不可读。使用我的 CodeDom 表达式解析器,我能够将规则写入应用程序配置中。我的目的是向人们介绍这个规则库,并使他们能够将规则嵌入到他们的应用程序中。
里面有什么
这是文件中包含的文件列表:
- CodeDomExpParser - 我的 CodeDom 表达式解析器库。
- DmCodeDom - CodeDom 辅助库。
- Assignment.cs - 创建一个
CodeAssignmentStatement
。 - Declaration.cs - 创建一个
CodeVariableDeclarationStatement
。 - DmCdStmt.cs - 基类。
- ExprStmt.cs - 创建一个
CodeExpressionStatement
。 - ForLoop.cs - 创建一个
CodeIterationStatement
。 - IfElse.cs - 创建一个
CodeConditionStatement
。 - DmCodeDom.TestHarness - DmCodeDom 库的 NUnit 测试工具。
- TestDmCd.cs - 包含文章中提到的代码。
- DmRules
- Configuration\DmRulesConfigHandler.cs - 一个配置节处理程序,用于从应用程序配置读取规则。
- DmRule.cs - 表示一个规则。
- DmRuleSet.cs - 包含所有规则。
- DmRuleTypeSet.cs - 将规则与类型配对。
- RuleExec.cs - 用于运行规则的静态辅助类。
- DmRules.TestHarness - DmRules 的 NUnit 测试工具。
- BaseObject.cs - 一个具有
IsDirty
属性的基类。 - Order.cs - 上面使用的
Order
类。 - OrderItem.cs - 上面使用的
OrderItem
类。 - TestDmRules.cs - 包含文章中使用的代码。
历史
- 0.1 : 2006-06-20 : 初始版本
- 0.2 : 2006-07-10 : 添加了优先级和停止
更新了 CodeDomExpParser 库以支持 .NET 2.0。创建了 DmRules 的初始版本,能够按类型运行规则。未来库的主要考虑是跨类型的先前链接。
规则不保证按任何特定顺序运行。指定优先级可以解决此问题。有时,有必要停止处理其他规则,为此已添加了停止命令。