WPF 和 IronPython 业务规则 - 第 2 部分
一个完整的 WPF 示例应用程序,
引言
在 第一部分 中,我们介绍了使用 IronPython 的“脚本化业务规则”的概念。该项目主要包含背景信息和一些展示概念的单元测试。
在第二部分中,我们将进行更实际的演示,展示一个完整的实现。我们的示例应用程序是一个虚构的船只销售公司。这是应用程序的用户界面:
免责声明
此示例的目的是展示如何使用 Aim.Scripting
框架将动态业务规则集成到任何应用程序中。在本篇文章中,我选择了 WPF/MVVM 作为演示概念的载体。在适当的地方,我包含了关于 Caliburn.Micro MVVM 的信息,但请记住,这既不是关于该框架的教程,也不是关于 WPF 的一般教程。所以,请记住以下几点:
- 我不是 WPF、MVVM 或
Caliburn.Micro
的专家。我对许多概念都有很好的掌握,但有些东西可能没有按照这些技术的绝对最佳实践来实现。 - 应用程序中的大部分样式和一些自定义控件来自一个名为
Aim.Wpf
的 DLL。您可以随意使用它 - 它相当稳定。我尤其喜欢我的PathButton
和FlowPanel
自定义控件! :)。但是,不要期望它在本文系列的版本之间保持不变 - 它还在开发中。 - 此示例使用的存储库存储机制并不真正适用于实际应用程序。您很可能会想使用数据库作为后端存储,但我希望减少项目的依赖项数量,因此我选择了简单的二进制文件存储库而不是 SQL 或 NoSQL。
- 项目中使用的
Aim.Xxx
DLL 尚未托管在 GitHub 等开源存储库中。我正在努力,但还没有开始。 - 我们用于示例的许多“实现”大多是占位符,其中包含一些关于真实实现可能是什么样子的注释。
EmailingProvider
、IntegrationProvider
等 - 您不应该期望开始与您的企业 SAP 实现进行实时连接!
回顾
业务规则的基础是任何程序员都能理解的两个高级概念:事件和命令。
事件
事件是响应用户交互或系统级活动的“自动连接”的 Python 代码片段。它们需要以下组件:
- 在 .NET 代码中定义的脚本化事件处理程序。
- 一个 IronPython模块(通常只是系统内的另一个数据模型,实现
IScriptDefinition
接口),带有一个 Type Key 字符串来标识要监听的类型。 - 同一个模块中的一些 IronPython 代码,带有一个与事件匹配的签名。
- 业务规则事件函数始终具有
onEventName(sender, e)
签名,其中on
(小写)是前缀,EventName
是 .NET 事件的名称,sender
是触发事件的对象,e
是事件委托合同预期的EventArgs
派生参数。
- 业务规则事件函数始终具有
快速提醒 - 脚本化事件处理程序
要“脚本化”事件处理程序,请将 Aim.Scripting
DLL 包含在您的项目中。您无需导入任何命名空间,因为 EventArgs
扩展方法定义在 System
命名空间中。
重写脚本化处理程序(在派生自定义事件的类的类中定义)
/// <summary>
/// Overridden to become a script-connected event handler.
/// </summary>
/// <param name="e"></param>
protected override void OnPropertyChanged( PropertyChangedEventArgs e )
{
e.FireWithScriptListeners( base.OnPropertyChanged, this );
}
虚拟脚本化处理程序(在与事件相同的类中定义)
/// <summary>
/// Script-connected event handler for SomeEvent event.
/// </summary>
/// <param name="e"></param>
protected virtual void OnSomeEvent( EventArgs e )
{
e.FireWithScriptListeners( () => SomeEvent, this );
}
Commands
命令要复杂得多,它们由用户交互、Python 函数、存储的记录、返回值和显示上下文组成。命令需要以下组件:
- 一个 IronPython模块,包含将被用户触发的命令函数。这是用于定义事件的相同数据模型类型(在此实现中为
BizRuleDataModel
)。唯一的区别是我们编写 Python 代码的方式。- 虽然事件具有脚本运行时理解的特定签名,但命令没有。命令函数可以接受任意数量或类型的参数,这些参数将由用户提供。
- 一个可存储的命令数据模型(在此实现中为
BizRuleCmdDataModel
),包含元信息,例如命令名称、显示上下文、有效日期等。 - 一个确定活动显示上下文、聚合适用的命令并将它们显示给用户的方法。
- 一个可选的返回值。
本文将首先介绍事件。我们还将介绍命令,但未来的文章将更深入地探讨该主题,因为命令的实现更为复杂。
自第一部分以来的重大更改
我向 Aim.Scripting
库添加了 ICommandDefinition
接口来表示一个命令。以前,从业务规则模块中提取活动命令有些麻烦,需要在 UI 和组合根目录中编写大量样板代码。我选择将这些命令正式化到一个 interface
下,允许脚本工厂处理一些查找模块活动命令的工作。
由于命令的概念在 Aim.Scripting
库中得到了更好的正式化,因此 IScriptDefinition interface
需要一种查询这些命令的方法。因此,实现起来变得稍微复杂一些,但仍然很简单。
以下是 IScriptDefinition
和 ICommandDefinition
的列表:
using System;
using System.Collections.Generic;
namespace Aim.Scripting
{
/// <summary>
/// The interface that defines an object that contains scripts. Some of
/// the functions in the scripts will be commands (user-initiated), others
/// will be events (system-initiated). Event methods have a specific
/// signature that expects a sender ("sender") and event args ("e"). Command
/// methods can accept any number or type of parameter.
/// </summary>
public interface IScriptDefinition
{
/// <summary>
/// Gets the "module name" of this script definition. This is
/// usually just the stored name or id in the data store where
/// this script definition is stored.
/// </summary>
/// <returns></returns>
string GetModuleName();
/// <summary>
/// Gets the unique type id so that the script engine or handler
/// knows whether to connect or not.
/// </summary>
/// <returns></returns>
string GetTypeKey();
/// <summary>
/// Gets the text of the script (the "program").
/// </summary>
/// <returns></returns>
string GetScripts();
/// <summary>
/// Gets a list of executable commands.
/// </summary>
/// <returns></returns>
IEnumerable<ICommandDefinition> GetCommands();
/// <summary>
/// Gets whether the definition is currently active. You should have a way
/// to mark definitions as inactive. The best way to do this is with a date
/// range, but you can also use a simple Boolean flag.
/// </summary>
/// <returns></returns>
bool IsActive();
/// <summary>
/// Gets a sequence number so that we can order events and command lists.
/// </summary>
/// <returns></returns>
int GetRunSequence();
}
/// <summary>
/// Script definitions can contain defined commands. These are a list
/// of objects that map to individual cmdXXX function within the scripts
/// of the definition.
/// </summary>
public interface ICommandDefinition
{
/// <summary>
/// Gets the full function declaration, e.g. cmdDoThis(intX, strTest).
/// </summary>
/// <returns></returns>
string GetFunction();
/// <summary>
/// Gets the human-readable command name that has been assigned.
/// </summary>
/// <returns></returns>
string GetCommandName();
/// <summary>
/// Gets the execution context that has been assigned. Commands can show
/// up in various places; this provides the ability to control where
/// and when the command is available.
/// </summary>
/// <returns></returns>
ExecutionContext GetExecutionContext();
/// <summary>
/// Gets whether the command is currently active. You should have a way
/// to mark commands as inactive. The best way to do this is with a date
/// range, but you can also use a simple Boolean flag also.
/// </summary>
/// <returns></returns>
bool IsActive();
}
}
在我们的示例项目中,这两个 interface
s 被实现为 BizRuleDataModel
和 BizRuleCmdDataModel
。您可以查看它们的代码,了解 interface
实现有多么简单——两个 interface
s 的每一个实现方法都是一行代码,本质上是将类中已定义的存储属性转发出去。
我们的第一个动态事件处理程序
打开“Read Me - Events”业务规则(单击右上角的Fx按钮 - 这将打开业务规则模块列表。双击网格中的一行以打开该记录)。
取消注释已定义的 onInform(...)
处理程序,然后保存业务规则。
现在,导航到产品列表(帆船图标),然后打开一个现有产品记录或创建一个新产品记录。按保存按钮。保存后,查看屏幕底部的状态栏。它应该会打印出由业务规则事件处理程序提供的消息。
这是怎么发生的?
- 我们创建了一个
BizRuleDataModel
,其 Type Key 告诉它监听ProductDataModel
实例的事件。 - 我们添加了一个
onInform(sender, e)
Python 方法,它将监听ProductDataModel
上的Inform
事件。 - 我们编辑了一个
ProductDataModel
实例,并且存储库引发了ProductDataModel
的Inform
事件。在这种情况下,事件参数将Info
属性设置为“Saving
”。
更有用的事件处理程序
让我们做一些更实际的事情。促销代码通常用于根据代码文本和产品类型等其他信息为客户提供折扣。
处理促销代码可能很麻烦,除非您有很多内置类型来处理它。您需要:
- 识别代码
- 有一个代码有效的时间段
- 确定何时应用代码(通常在保存记录时)
- 确定产品是否符合促销标准
- 确定客户是否符合标准 - 也许他们有一个逾期账户,并且不符合促销折扣资格
- 进行计算以确定折扣
- 如果客户有标准折扣,这可能会变得更加复杂
要查看我们“July Promotion”的代码,请打开具有该名称的业务规则模块,然后导航到Scripts选项卡。
明白了?好的,在查看完之后(您无需更改任何内容),创建一个新的 SaleDataModel
(单击主工具栏上的金钱图标以显示销售列表)。在Main选项卡上设置 SaleNumber
、Customer
和 PromoCode
属性。将 PromoCode
设置为 JULY
。
现在,导航到销售的Items选项卡并添加一些项目。对于您添加的每个项目,从 Product
组合框中选择一个产品。
现在,密切关注销售项目列表的 DiscountPct
列,按保存按钮。您应该看到数字从 0 变为 10。(如果您碰巧选择了具有超过 10% 标准折扣的 customer
,则可能会变为 10% 以上)。您还应该看到关于促销代码成功应用的系统消息。
您还应该注意“July Promotion”业务规则模块的一点:如果您查看 ActiveFrom
和 ActiveThru
属性,您会发现它仅在 2015-06-01 至 2015-08-01 期间有效。继续将“July Promotion”的 ActiveThru
属性设置为类似 2015-06-02 的值,然后保存业务规则。然后,创建一个没有标准折扣的 Customer
的新销售。您会看到 DiscountPct
属性保持在 0
。
建议您始终为模块提供活动日期范围。它应该由两个可空的 DateTime
属性组成。BizRuleDataModel
定义如下:
/// <summary>
/// The active start date for the module. Can be null.
/// </summary>
public DateTime? ActiveFrom
{
get
{
return Get( m_ActiveFrom );
}
set
{
Set( ref m_ActiveFrom, value, "ActiveFrom" );
}
}
/// <summary>
/// The active end date for the module. Can be null.
/// </summary>
public DateTime? ActiveThru
{
get
{
return Get( m_ActiveThru );
}
set
{
Set( ref m_ActiveThru, value, "ActiveThru" );
}
}
/// <summary>
/// Is the business rule module currently active?
/// </summary>
public bool IsActive
{
get
{
var now = DateTime.Now;
if( ActiveFrom.HasValue && ActiveThru.HasValue )
return ActiveFrom.Value < now && now <= ActiveThru.Value;
if( ActiveFrom.HasValue )
return ActiveFrom.Value < now;
if( ActiveThru.HasValue )
return now <= ActiveThru.Value;
return true;
}
}
其他事件
这里有几个演示其他概念的事件。
一次性启动事件
我们的示例还包含“Startup Events”模块中的一个脚本化处理程序,该处理程序在 AppBootstrapper
引发 Ready
事件后只执行一次。
这类一次性启动事件可用于记录程序使用情况、设置全局设置等。
发送电子邮件
我们还定义了一个规则,即如果销售额超过 $100,000
,则向大老板发送电子邮件(并记录系统日志消息)。请记住,我们的 EmailingProvider
实现只是一个占位符。
其他模块中定义了其他事件。所有模块在Scripts选项卡中的 Python 代码开头都有良好的注释。请花时间阅读所有注释,以获取想法、操作指南和最佳实践。
进入命令
现在我们将开始介绍命令。我们将首先查看已定义的命令的模块和输出,然后介绍操作方法。命令比事件需要更多的上下文,因此我们需要展示我们是如何使其工作的。
打开产品列表(帆船图标)。要显示Commands Panel,请单击主工具栏最右上角的列表齿轮图标。命令是上下文敏感的,基于以下内容:
- 关联业务规则模块的Type Key。
- 命令的执行上下文。
ExecutionContext
是Aim.Scripting
中定义的枚举,具有以下成员:Any
- 仅根据Type Key显示模块命令。当活动项列表或单个项(如编辑视图)处于活动状态时,这些命令将显示。Single
- 根据Type Key以及是否激活了单个记录(例如在编辑视图中)来显示模块命令。List
- 根据Type Key以及是否激活了多个记录(例如在列表视图中)来显示模块命令。
- 命令的业务规则模块的Type Key是可选的。在Type Key为空的情况下,命令将仅根据该命令定义的
ExecutionContext
显示。
仍然打开 Commands Panel,打开销售列表(金钱图标)。请注意,Commands Panel 现在显示了几个带有Run按钮的组框。
那些是从哪里来的?要了解一下,请导航到业务规则模块列表(Fx 图标)。打开名为“Read Me - Commands”的模块,然后按照Scripts选项卡中的说明进行操作。
完成之后,导航回产品列表(帆船图标)。您应该会看到类似以下内容:
继续单击我们新命令的Run按钮。您应该会看到一个状态栏消息,反映了Run按钮上方复选框控件的状态。
命令结果
执行命令可以返回结果。此结果可以是任何内容。简单的 string
消息、DataTable
、IEnumerable<T>
列表……
我们需要一个地方来显示这些结果,这就是 Command Results panel。导航回销售列表。进入后,按“Month to Date”命令下的Run按钮。
您应该会在底部看到一个新选项卡,显示执行命令的结果。在这种情况下,我们返回了一个 IEnumerable<SaleDataModel>
。
要了解我们如何到达这里,请打开“Sales Reports”业务规则模块。首先查看Scripts选项卡,阅读其中的注释和代码。
接下来,查看Commands选项卡,了解命令的定义方式。请注意,每个命令都有其 Context
属性设置为 List
- 这就是我们如何让命令在活动上下文为销售记录列表时显示出来。
定义命令
定义命令有两个部分:
- 命令函数。这是命令执行时运行的 Python 代码。
- 存储的命令,它引用此函数,但还添加了大量元数据,例如人类可读的命令名称、执行上下文、有效期等。有关示例,请参见上图或“Sales Reports”业务规则模块。
定义命令函数与定义事件处理程序函数差别不大——除了我们有更多的自由。但是,在 Python 代码中定义命令函数时,有几点需要注意:
- 命令函数名称必须以
cmd
(小写)开头。例如,cmdMyFirstCommand()
。- 这实际上是有原因的。在定义存储命令时,我们在
combobox
中显示可用的命令函数。由于一个业务规则模块可能包含任意数量的函数,其中一些是事件处理程序,一些是辅助函数等,我们需要一种方法来区分将要附加到存储命令的函数,以便我们不会用不必要的函数来使combobox
选择列表混乱——我们尤其不希望有人通过调用命令来调用事件处理程序!
- 这实际上是有原因的。在定义存储命令时,我们在
- 命令函数可以接受任意数量或类型的参数。由于 Python 是一种动态类型语言,
Aim.Scripting
使用一些命名约定技巧来尝试设置预期的类型(在动态函数构造时)并转换提供的值(在动态函数执行时)。识别的参数名称前缀包括int
、long
、num
、bool
、date
、dateTime
、time
、str
和obj
。我将留给读者自己去推断这些前缀的含义:)。- 此命名约定还具有一些有益的副作用。如果您观察敏锐,您可能会注意到 Commands Panel 中的一些控件呈现为复选框、一些是文本框、一些是日期选择器等。我们使用参数类型推断以及称为“模板选择器”的精彩 WPF 概念来实现这一点。
- 如果识别出参数前缀,则参数名称的其余部分将沿着大写字母进行分割,成为“人类可读”的参数名称。例如,
numQtyInMeters
将获得double
类型和Qty In Meters
的显示名称。
定义存储命令非常简单。一旦您拥有一个包含一些 cmdMyCommand()
类型函数的业务规则模块,您就可以导航到Commands选项卡并输入一个与command function关联的stored command。
请注意,Function
下的组合框将显示模块中所有以 cmd
开头的函数。
Command _target 变量
执行命令时,Aim.Scripting
运行时会设置一个名为 _target
的特殊变量,该变量可从正在运行的函数访问。
此 _target
变量包含命令执行的上下文——通常是在 List
上下文中对象的列表,在 Single
上下文中是单个对象。例如,这是一个“集成”命令,它使用 _target
变量作为将被“集成”的对象列表。此命令定义在“Sales Integration”业务规则模块中。
"""
Simulated integration strategies. See the AppRuleHost.cs
C# class for more details.
In particular, the host.integrate() method shows one way
that you might wire up a Task<T>, send it off to do its
thing, then report back to the UI with status updates.
While it doesn't have a whole lot to do with scripted
business rules as such, the varying ways that things must
be integrated makes a good case for using business rules
to handle some of the variability points.
Note the use of the automatic _target variable here.
Because we set the Type Key of this module to "Sale", when
we run this command from a context of the list of sales,
that list will be what the _target variable is set to.
"""
##
## Send sales information off to our Materials Requirements
## Planning software. Because our MRP system has such a simple
## API (hahHAHahAHhAHAhahhahaHah), we just coded up a quick
## PhonyIntegrator class to do the job.
##
def cmdSendToMrp():
##
## Call our phony local provider, which spins up a Task<T>
## and sends it off to do the work, reporting back on the
## status line for each item integrated. If any items fail,
## the host.integrate method will show an alert box with
## information about the number of successes and failures.
## See AppRuleHost.cs for more information.
##
host.integrate('Phony', _target)
命令参数
我们已经讨论了 Aim.Scripting
如何使用命名约定来实现类型推断,但您可以对命令参数做更多事情。例如,您可以使用命令函数上方的特殊注释来为参数提供默认值。以下是“Sales Reports”业务规则模块的 Python 代码。请特别注意 cmdForYearMonth(...)
函数上方的两条特殊注释:
"""
Sales list context reports. The output of these will
show up in a new Command Results window at the bottom
of the screen.
Here, we're simply pulling in the repository and using
that as our data source. More likely you would bring
in System.Data and maybe run a custom stored procedure
that's specific to a certain tenant, for example.
Note how we can reference specific record ids in our
code. That would be a huge no-no in your compiled,
baked-in code, but for a business rule it's exactly
what we would expect to do. We can change it in just
a few seconds if the need arises.
This module shows the concept of "parameter defaults".
If you look at the cmdForYearMonth(...) function, you'll
see that it is decorated with two special comments.
These comments can be used to set parameter defaults
when the PyFunction is constructed (and prior to its
actual runtime execution). Notice that for the "value"
part of the comment, we are calling an actual function
that's defined in this module!
There are a couple rules about using a function as the
default parameter value:
1. The function must be defined in this module, or it
must be included via host.include(...)
2. The function must take no arguments, that is it
must be in the form of myFunction(), with nothing
between the parentheses. Think of the signature for
the function as a Func<T>, that is a method that
takes no arguments and returns a value.
3. The function for the default value is run in a C#
try/catch block. If the try fails, the parameter value
will not be set.
"""
import clr
clr.AddReference('System.Data')
clr.AddReference('BizRules.Core')
clr.AddReference('BizRules.DataModels')
from System import DateTime
from System.Data import DataTable
from BizRules import RepositoryProvider
from BizRules.DataModels import SaleDataModel
prevYear = None
prevMonth = None
def allSales():
return RepositoryProvider.Current.GetRepository[SaleDataModel]().GetAll()
##
## Function for param value default
##
def getYear():
##
## If they have already run it, return the value they used before
##
if prevYear:
return prevYear
return DateTime.Now.Year
##
## Function for param value default
##
def getMonth():
##
## If they have already run it, return the value they used before
##
if prevMonth:
return prevMonth
return DateTime.Now.Month
def cmdMonthToDate():
now = DateTime.Now
return cmdForYearMonth(now.Year, now.Month)
##
## This is how we set default parameters for a command.
## Notice that we can even call a function to get the
## default value.
##
# <param name="intYear" value="getYear()" />
# <param name="intMonth" value="getMonth()" />
def cmdForYearMonth(intYear, intMonth):
global prevYear
global prevMonth
prevYear = intYear
prevMonth = intMonth
sales = allSales()
mSales = []
for sale in sales:
dos = sale.DateOfSale
if dos.Year == intYear and dos.Month == intMonth:
mSales.append(sale)
return mSales
##
## Note how we are referring to a specific record id here.
## Not a big deal at all in business rules scripts.
##
def cmdAllWidgetsInc():
sales = allSales()
mSales = []
for sale in sales:
if sale.CustomerId == '36':
mSales.append(sale)
return mSales
特殊注释的格式是:
# <param name="parameterName" value="parameterValue" />
命令参数注释还不是特别智能——例如,它没有与特定命令函数关联,因此如果您在一个模块中有两个具有相同参数名称的命令函数,您将获得第一个注释的结果。这仍在进行中——我计划使其更健壮。与此同时,如果您只是在命令函数声明中使用不同的参数名称,一切都会正常。
顺便说一句,您可能想知道“为什么不直接使用 Python 的默认参数值?”我们可以那样做,但(据我所知)您不能调用另一个 Python 函数来获取默认值。我们定义它的方式是,由于我们绕过了 Python 默认参数的正常语法,我们可以按照我们想要的方式实现它——在我们的例子中,我们添加了通过调用另一个函数来获取默认值的能力。
好吧,这已经够长了
还有很多内容需要涵盖。例如,我们几乎没有触及 AppRuleHost.cs——我们 DefaultExecuter
类的实现。请阅读 AppRuleHost
中的代码和注释,以获取有关如何通过定义 host.do_something(...)
方法来简化业务规则的想法,以便您可以用 C# 而不是 IronPython 编写参数化的样板代码。
代码中要看什么
如果您想知道Commands Panel如何响应上下文并使用可用命令和相关命令的列表进行刷新,请查看以下类:
BizRules.Client.Wpf.ViewModels.CommandModuleListViewModel
BizRules.Client.Wpf.ViewModels.CommandModuleViewModel
BizRules.Client.Wpf.ViewModels.CommandViewModel
- 请特别注意
CommandModuleListViewModel
中定义的Handle(CommandTargetActivatedMessage message)
方法中的代码,该方法是Caliburn.Micro.IHandle<T>
接口的一个实现——这是我最喜欢的接口之一。 - 请注意,这三个 VM 如何组合形成一个层次结构,显示在 Commands Panel 视图中:Modules -> Module -> Commands -> Command -> Parameters -> Parameter。
如果您想了解有关扩展 Aim.Scripting.DefaultExecuter
的更多信息,请研究 BizRules.Client.Wpf.AppRuleHost
类。该类包含许多关于以下内容的注释:
- 创建自己的
host
方法 - 在 UI 线程上写入 UI
- 启动
Task<T>
并让它自行运行,同时实时报告
如果您需要了解有关组合根以及脚本集成设置位置的更多信息,请查看以下类:
BizRules.Client.Wpf.AppBootstrapper
- 请特别注意重写的
Configure()
方法
- 请特别注意重写的
BizRules.Client.Wpf.AppScriptProvider
BizRules.Client.Wpf.ViewModels.ShellViewModel
- 在 Shell ViewModel 中查找更多
IHandle<T>
实现
- 在 Shell ViewModel 中查找更多
尝试找出“Security Events”、“Admin Customer Reports”和“Login Swap”业务规则模块中发生了什么。我将其留给读者作为练习。
历史
- 2015-06-09:首次发布