可扩展的应用程序:新增功能时无须重新编译

在本文中,Joe Wirtley 描述了一种应用程序体系结构,可让您在添加新功能时无须重新编译。使用反射、接口和动态加载的程序集,您可以创建能够用新业务逻辑轻松扩展的应用程序。

*

假设您现在有一个为 Widgets R Us 编写定单入口应用程序的任务。Widgets R Us 将其装饰品卖给其他转售这些商品的公司。其大部分业务来自几个大客户,而且定价规则在客户之间显著不同。由于规则因客户而变化,所以该公司尚未找到一个现成的应用程序来适应其需要。

Widgets R Us 最大的两个客户是 Acme 和 Megacorp。Acme 在当前时间是偶数分钟时可以获得标准价 10% 的折扣,而在奇数分钟时可以获得 15% 的折扣。如果 Acme 订购五件以上的装饰品,那么它会获得 5% 的额外折扣。对 Megacorp 来说,价格是基于销售类型的。对于转售,有 20% 的折扣。对于公司销售,有 10% 的折扣。政府销售有 10% 的额外奖励,其它销售按价目表定价。我确信,您在自己的项目中也曾经看到过具有类似怪异逻辑的业务规则。

这段描述意味着两个要求:第一,因为您无法预期设计新客户的定价规则,所以不应该对新的定价算法施加任何限制。第二,您可能需要收集额外的信息来为定单定价。例如,要为一个 Megacorp 定单定价,您需要了解销售类型。

设计

您需要根据客户调整自定义定价规则,但是您并不希望每次获得新客户时都要更改定单入口应用程序。因此您需要应用最基本的软件设计原则之一:分离考虑。由于您希望在客户定单定价规则更改时使定单入口应用程序保持不变,就需要将定单入口软件与客户定单定价软件分开。

但是您不能将它们完全分开,这有两个原因。首先,定单入口应用程序收集的信息是为定单定价时所需的。其次,定单入口应用程序必须有一种获得定单价格的方法,因此它需要一种与客户定价软件进行交互的方法。由于您不希望将定单入口应用程序直接链接到客户定价规则,所以您需要创建一部分软件来作为两者之间的中间方。这可让您只更改定单入口应用程序或客户定价,而无须更改另一方。

图 1 是这三部分软件的一个抽象表示。在此图中,我以最抽象的感觉使用了接口 一词。我说的不是一个 C# 接口。我的意思是指某个与定单入口应用程序和客户定价两者分开、但又被两者所依赖的软件。

为了支持客户定单定价的变化,每个客户的定价算法将存储在一个单独的程序集中。为了动态加载这些程序集,我定义了一个动态的包类。动态包类的每个实例都封装了一个动态加载的程序集。每个动态包都有一个负责从该动态包创建对象的生成器。

如果图 1 是从 10,000 英尺高度观看的设计视图,那么图 2 中的 UML 组件关系图显示的是从 5,000 英尺高度观看的视图。在这个关系图中,每个组件都表示一个单独的 .NET 程序集,并且对应于 .NET 解决方案中的一个项目。在标为接口 的方框中的两个程序集对应于图 1 中的接口 云图。OrderEntry 组件表示定单入口应用程序。Acme、Megacorp 和 Generic 组件表示图 1 中标为定价 的椭圆形。在这个组件关系图中,虚线表示依赖项,其箭头指向依赖的方向。例如,从 Acme 组件到 OrderEntryCommon 组件的虚线意味着 Acme 组件依赖于 OrderEntryCommon 组件。在这个关系图中您会注意到,来自客户端定价程序集和定单入口应用程序的所有依赖项都走向两个接口组件。这使您可以相互独立地更改定单入口应用程序和客户定价程序集。

接口代码

本部分展示了在上一部分中定义的接口的代码。接口软件必须提供两种方法:一种交换定单信息的方法,以及一种在定单入口和定价之间进行交互的方法。

我定义了一个定单类作为共享定单信息的方法。以下代码将在定单入口应用程序和特定客户的动态程序集之间共享。请注意,对于本示例,定单上只有一个项,因此颜色和数量属性在定单本身上,而不是单独的行项目类。

  public class Order {
public Order() {
}
public int Quantity;
public WidgetColor Color;
public double Price;
}
public enum WidgetColor {
Blue   = 1,
Red    = 2,
White  = 3,
Yellow = 4
}

以下代码显示了在定单入口应用程序和特定客户的动态程序集之间共享的接口定义。这三个接口使定单入口应用程序能够与特定客户定价进行交互,它们是在 CustomerPackageInterfaces 程序集中定义的。

  public interface IOrderPricing {
void PriceOrder( Order order );
}

IOrderPricing 有一种计算定单价格的方法,并可以将定单作为一个参数接收。计算后的价格将写入到作为参数传递的定单的价格属性中;因此,该方法没有返回值。

您应该通过一个捕获特定客户定单信息的控件来实现 IOrderDataControl。它应该具有将数据从定单读取到控件、以及将数据从控件写入到定单的方法。它还应具有以下方法:当控件中的值更改以捕获特定客户信息时,定单入口应用程序可以使用它来传递要调用的委托。

  public interface IOrderDataControl {
void ReadOrderData( Order order );
void WriteOrderData( Order order );
void SetOrderChangeEvent( EventHandler handler );
}

ICustomerPackageBuilder 接口用于生成器类。每个动态包必须实现一个负责从该包创建对象的生成器类。这个接口会定义一些方法来创建实现 IOrderPricing 和 IOrderDataControl 接口的对象,并返回一个新的定单对象。我允许每个客户包创建一个新的定单实例,这是因为其中可能包含特定于某个客户的定单属性。GetOrderDataControl 方法会返回一个可以放置在窗体上的控件,该控件将收集特定客户的定单信息。最后,GetOrderPricing 方法返回一个对象来计算定单的价格,该价格也可能是特定于某个客户的。

  public interface ICustomerPackageBuilder:
IPackageBuilder {
IOrderPricing GetOrderPricing();
Control GetOrderDataControl();
Order NewOrder();
}

请注意,ICustomerPackageBuilder 类继承了 IPackageBuilder 接口,这使得它可以与后面描述的 DynamicPackage 类兼容。

定单入口应用程序

图 3图 4 中的窗口是定单入口应用程序的窗口。如您所见,这是一个相当简化的定单入口系统原型。您可以输入一个客户、颜色和您要订购的装饰品数量,该应用程序会显示结果价格。图 3 显示了一般客户的屏幕。

图 4 显示了一个 Megacorp 定单的屏幕。请注意为 Megacorp 定单定价所需的“销售类型”组合框的添加。

清单 1 包含了定单入口窗体的相关代码。首先要注意的是,该窗体具有一个保存定单实例的属性。它还具有一些引用实现 IOrderPricing 接口和 IOrderDataControl 接口的对象的属性,它们用于为定单定价。

清单 1. 定单入口窗体。

    private Order order;
private IOrderPricing pricing;
private IOrderDataControl customerOrderData;
private void CalculatePrice(object sender,
System.EventArgs e) {
if ( order != null ) {
order.Color = ( WidgetColor )
cboColor.SelectedIndex  + 1;
order.Quantity = Convert.ToInt32(
txtQuantity.Value );
if ( customerOrderData != null ) {
// If there is a customer specific control,
// give it a chance to write its data to
// the order.
customerOrderData.WriteOrderData( order );
}
// Price the order
pricing.PriceOrder( order );
lblPrice.Text =
order.Price.ToString( "$ 0.00" );
}
}
private void ChangeCustomer(object sender,
System.EventArgs e) {
string Customer = cboCustomer.Text;
IDynamicPackage package;
ICustomerPackageBuilder builder;
Control customerSpecificControl;
// Get the dynamic package for the customer.
// This would probably be data driven
// in a production implementation.
package = DynamicPackage.GetDynamicPackage(
Customer );
builder = package.Builder as
ICustomerPackageBuilder;
order   = builder.NewOrder();
pricing = builder.GetOrderPricing();
customerSpecificControl =
builder.GetOrderDataControl();
// Take care of the customer specific control
pnlCustomerSpecific.Controls.Clear();
if ( customerSpecificControl != null ) {
customerOrderData = customerSpecificControl
as IOrderDataControl;
// Read any data in the order into the customer
// control
customerOrderData.ReadOrderData( order );
// Display the control
pnlCustomerSpecific.Controls.Add(
customerSpecificControl );
// Give the control our CalculatePrice to call
// when anything changes
customerOrderData.SetOrderChangeEvent(
new EventHandler( this.CalculatePrice ) );
} else {
customerOrderData = null;
}
// Force a price calculation
CalculatePrice( null, null );
}

CalculatePrice 方法用于计算定单价格,只要定单入口窗体上的值更改,系统就会调用它。这个方法首先用窗体上的值设置定单实例上的颜色和数量字段的值。只有在为定单收集特定客户数据时,才能指定 customerOrderData 字段。如果 customerOrderData 实例不为空,则 CalculatePrice 可通过调用 WriteOrderData 方法,将来自特定客户控件的任何数据指定给定单。然后,CalculatePrice 通过调用 PriceOrder 方法并将定单价格指定给窗体上的标签来计算定单的价格。

当定单入口用户更改定单入口窗体上的客户时,应用程序会执行 ChangeCustomer 方法。ChangeCustomer 方法执行的第一个功能是获取定单入口窗体组合框中的客户名称的动态包。出于说明目的,该定单入口应用程序加载了一个具有客户组合框名称的程序集。在一个生产应用程序中,您可能会在客户和程序集之间创建一个数据驱动的映射,这可让您添加新的客户和程序集,而无须对定单入口应用程序进行任何更改。

ChangeCustomer 使用动态包的生成器创建一个定单实例。然后,它将获得一个 IOrderPricing 引用,并创建一个控件来收集特定客户的定单信息。在允许特定客户的程序集添加额外功能方面,将这些对象的创建委托给动态程序集是至关重要的。例如,Megacorp 定单必须带有销售类型才能定价。将定单实例的创建委托给特定客户的动态包,可以使 Megacorp 包能够创建带有销售类型属性的定单实例。

因为可能需要收集特定客户的定单信息,所以定单入口窗体应提供一个面板来保存特定客户的数据输入控件。图 4 中所示的“销售类型”组合框是使用该面板的一个示例控件。每次客户变更时,ChangeCustomer 方法都会从该面板中清除控件。如果某个控件从特定客户的动态包返回,那么该控件将被添加到面板中。这个控件还会保存在 customerOrderData 字段中,这是因为它实现了 IOrderDataControl 接口,该接口允许在控件中读取和写入数据。对这个接口的调用是在显示特定客户控件之前进行的,以便允许其从定单中读取数据。这也是在 CalculatePrice 方法中调用的接口,用于在计算价格之前将数据从特定客户控件保存到定单。为特定客户控件实现的最后一个功能是,将一个事件处理程序传递给特定定单控件,以便在任何特定客户定单数据更改时激发 CalculatePrice 方法。

客户定价

在以下部分中,我将为您展示如何创建定价程序集。

一般定价

Widgets R Us 定单的一般定价是基于装饰品颜色的,如以下代码片段所示:

  public class OrderPricing: IOrderPricing {
public void PriceOrder( Order Order ) {
double priceEach = 0;
switch ( Order.Color ) {
case WidgetColor.Blue:
priceEach = 1.00;
break;
case WidgetColor.Red:
priceEach = 1.50;
break;
case WidgetColor.White:
priceEach = 2.00;
break;
case WidgetColor.Yellow:
priceEach = 2.50;
break;
}
Order.Price = priceEach * Order.Quantity;
}
}

Acme 清单 2 包含为 Acme 定单定价的所有相关代码。定义的第一个类是生成器类,它负责从程序集创建其他对象。GetOrderPricing 方法返回一个 AcmeOrderPricing 类的实例,该类也在清单 2 中定义。因为对 Acme 来说,定单入口不需要任何特定客户字段,所以 GetOrderDataControl 方法会返回空值。同样,因为 Acme 定单没有额外的定单信息,所以 NewOrder 方法会返回一个 Order 类的实例。

AcmeOrderPricing 类负责计算 Acme 定单的价格。在调用 GenericCustomer PriceOrder 方法计算定单的一般客户价格后,它将应用 Acme 的特定规则。对于完全自定义定价的客户,您不需要调用 GenericCustomer 定价。

清单 2. Acme 定单定价。

  public class AcmeBuilder: ICustomerPackageBuilder {
public IOrderPricing GetOrderPricing() {
return new AcmeOrderPricing();
}
public Control GetOrderDataControl() {
return null;
}
public Order NewOrder() {
return new Order();
}
}
public class AcmeOrderPricing: IOrderPricing {
public void PriceOrder( Order order ) {
double discount;
GenericCustomer.OrderPricing GenericPricing =
new GenericCustomer.OrderPricing();
GenericPricing.PriceOrder( order );
// Acme gets a 10% discount from standard
// on even minutes and a 15% discount on
// odd minutes.
if ( ( DateTime.Now.Minute % 2 ) == 0 ) {
discount = 0.10;
} else {
discount = 0.15;
}
// If they order more than 5, they get
// an additional 5%
if ( order.Quantity > 5 ) {
discount += 0.05;
}
order.Price = ( 1 - discount ) * order.Price;
}
}

Megacorp

清单 3 显示了 Megacorp 的特定客户定价代码。与 Acme 代码类似,它也有一个生成器类,用于从程序集创建其他对象。与 Acme 代码一样,它包含有 MegacorpOrderPricing 类,以便亲自处理定单定价。与 Acme 代码不同的是,MegacorpBuilder 类上的 GetOrderDataControl 方法会返回一个新的控件实例,我将在后面讨论它。这是因为您需要收集销售类型来为 Megacorp 定单定价。这也反映在此清单定义的 MegacorpOrder 类中。MegacorpOrder 类将一个 SaleType 属性添加到 Order 基类中。MegacorpOrder 类的一个实例是从生成器类上的 NewOrder 方法返回的。

MegacorpOrderPricing 类使用与 Acme 计算类似的方式计算价格。它首先计算一般价格,然后根据销售类型进行调整。对于 PriceOrder 方法,需要注意的最有趣的事情是,作为参数传递的定单是 MegacorpOrder 类型的定型。这个定型很有必要,因为您需要销售类型来为 Megacorp 的定单定价。因为由生成器上的 NewOrder 方法创建的定单是 MegacorpOrder 类型,因此这个定型是可能的。

清单 3. Megacorp 定单定价。

  public class MegacorpBuilder:
ICustomerPackageBuilder {
public IOrderPricing GetOrderPricing() {
return new MegacorpOrderPricing();
}
public Control GetOrderDataControl() {
return new MegacorpOrderControl();
}
public Order NewOrder() {
return new MegacorpOrder();
}
}
public class MegacorpOrderPricing: IOrderPricing {
public void PriceOrder( Order order ) {
GenericCustomer.OrderPricing GenericPricing =
new GenericCustomer.OrderPricing();
GenericPricing.PriceOrder( order );
// For Megacorp, the price is based on the
// sale type
switch ( ( (MegacorpOrder) order ).SaleType ) {
case SaleType.Government:
order.Price = 1.1 * order.Price;
break;
case SaleType.Corporate:
order.Price = 0.9 * order.Price;
break;
case SaleType.Resale:
order.Price = 0.8 * order.Price;
break;
case SaleType.Other:
break;
}
}
}
public enum SaleType {
Government = 1,
Corporate  = 2,
Resale     = 3,
Other      = 4
}
public class MegacorpOrder: Order {
public MegacorpOrder() {
SaleType = SaleType.Other;
}
public SaleType SaleType;
}

要为一个 Megacorp 定单定价,除了基本的定单信息之外,您还必须收集销售类型。在定义 Megacorp 定单定价的程序集中,也有一个用户控件来收集销售类型。该用户控件具有一个组合框,允许用户选择销售类型,如图 4 所示。这个控件实现 IOrderDataControl 接口,这将允许其与定单入口应用程序进行交互。以下代码片段显示了这个接口的实现:

    public void ReadOrderData( Order order ) {
MegacorpOrder customerOrder =
( MegacorpOrder ) order;
SaleType SaleType = customerOrder.SaleType;
cboSaleType.SelectedIndex =
( int ) SaleType - 1;
}
public void WriteOrderData( Order order ) {
MegacorpOrder customerOrder =
( MegacorpOrder ) order;
customerOrder.SaleType =
( SaleType ) cboSaleType.SelectedIndex + 1;
}
public void SetOrderChangeEvent(
EventHandler handler ) {
cboSaleType.SelectedIndexChanged += handler;
}

当定单入口应用程序需要用定单对象的数据更新用户控件时,将调用 ReadOrderData 方法。它基于传递到方法的定单的销售类型来设置“销售类型”组合框上的 SelectedIndex 属性。当定单入口应用程序请求将用户控件的数据写入定单时,WriteOrderData 将处理这个情况。最后,SetOrderChangeEvent 方法将收到一个每当控件中的值发生更改就会被调用的 EventHandler。您可能还记得,定单入口应用程序会传入一个能够激发 CalculatePrice 方法的事件处理程序。在这个控件中,我只将这个处理程序设置为每当“销售类型”组合框上的选定索引更改时就会被调用。只要销售类型更改,就会强制执行价格计算。

动态包

动态包类为动态加载的程序集提供了一个方便的包装(请参见清单 4)。动态包类有两个公共属性:name 和 builder。在本例中,包的名称与实际的程序集名称一致。更为高级的实现可能会将这两个概念分开,以允许另一个重定向级别。builder 属性表示负责从动态程序集创建对象的类。也可以从程序集外部创建对象,但是将对象创建本地化为程序集本身可简化编码。

清单 4. 动态包类。

  public class DynamicPackage: IDynamicPackage {
private string name;
private Assembly assembly;
private IPackageBuilder builder;
static ArrayList packages = new ArrayList();
public static IDynamicPackage GetDynamicPackage(
string name ) {
DynamicPackage result = null;
foreach( DynamicPackage p in
DynamicPackage.packages ) {
if ( p.Name == name ) {
result = p;
break;
}
}
if ( result == null ) {
result = new DynamicPackage( name );
packages.Add( result );
}
return result;
}
private DynamicPackage( string name )  {
this.name = name;
Load();
}
public string Name {
get { return name; }
}
public IPackageBuilder Builder {
get { return builder; }
}
private void Load() {
// Load the assembly and get the assembly object
assembly = GetAssembly( name );
// Now look for the PackageBuilder
builder = FindBuilder();
}
private IPackageBuilder FindBuilder() {
Type[] types = assembly.GetTypes();
foreach( Type t in types ) {
Type[] interfaces = t.FindInterfaces(
new TypeFilter( InterfaceFilter ), null );
if ( interfaces.Length > 0 ) {
ConstructorInfo ConstructorInfo =
t.GetConstructor( new Type[ 0 ] );
return ConstructorInfo.Invoke(
new object[ 0 ] ) as IPackageBuilder;
}
}
throw new Exception( "Cannot find builder in " +
"dynamic package " + Name );
}
private static bool InterfaceFilter( Type type,
Object criteria ) {
return ( type == typeof( IPackageBuilder ) );
}
private Assembly GetAssembly( string name ) {
Assembly assembly = null;
foreach ( Assembly a in
AppDomain.CurrentDomain.GetAssemblies() ) {
if ( a.GetName().Name == name ) {
assembly = a;
break;
}
}
if ( assembly == null ) {
try {
assembly = Assembly.LoadFrom( name +
".dll" );
} catch {
throw new Exception( "Cannot load dynamic "
+ "package " + Name );
}
}
return assembly;
}
}

请注意,DynamicPackage 类的构造函数是私有的。获得 DynamicPackage 类实例的唯一方法就是调用静态的 GetDynamicPackage 方法。这个方法将首先浏览到目前为止所有已加载包的 ArrayList,以查看所请求的包是否已经加载,在这种情况下,它只返回已加载的包。否则,它将创建一个新的动态包。

在创建动态包时,GetAssembly 方法将首先浏览当前的 AppDomain,以查看所请求的程序集是否已经在内存中。如果该程序集尚未加载,此方法就会使用 Assembly.LoadFrom 从磁盘加载它,这将把一个路径名称作为参数。根据您的特定需求,Assembly 类的其他静态方法(如 Load 或 LoadWithPartialName)也可以在这里使用。

如果程序集在内存中,FindBuilder 方法就可以使用反射在实现 IPackageBuilder 的已加载程序集中查找某个类,并返回该类的一个实例。对于程序集中定义的每个类型,它都调用 FindInterfaces 方法。FindInterfaces 方法可获得一个委托,它为类型中定义的每个接口都调用这个委托。在此例中,该委托调用 InterfaceFilter 方法,如果接口是 IPackageBuilder 或 IPackageBuilder 的子代,这个方法将返回真。由于 ICustomerPackageBuilder 接口派生自 IPackageBuilder,因此它将查找实现 ICustomerPackageBuilder 的类。

当 FindBuilder 找到支持 IPackageBuilder 的类型时,它将获取不带任何参数的构造函数的 ConstructorInfo,并使用此 ConstructorInfo 来创建该类型的一个实例。

小结

通过应用分离考虑的设计原则以及正确地管理依赖项,您可以创建轻松适应更改的系统。本示例展示了一个定单入口应用程序,它可以使用每个客户的不同定价方法来计算定单的价格。此外,您可以为每个客户添加唯一的订价方案,而无须更改定单入口应用程序的代码,并且无须触及任何其他客户定价的代码。这将大大降低适应特定客户规则所需的时间和风险。

posted @ 2008-09-18 09:54  ejiyuan  阅读(423)  评论(0编辑  收藏  举报