Extensible Applications: New Features Without Recompiling

来源:互联网 发布:如何申请淘宝卖家账号 编辑:程序博客网 时间:2024/05/13 22:35

Joe Wirtley

In this article, Joe Wirtley describes an architecture for your applications that allows you to add new features without recompiling. Using reflection, interfaces, and dynamically loaded assemblies, you can create applications that can easily be extended with new business logic.

Imagine that you've been given the task of writing an order entry application for Widgets R Us. Widgets R Us sells its widgets to other corporations that resell those widgets. Most of its business comes from a few large customers, and the pricing rules can vary dramatically among customers. Because the rules vary by customer, the company hasn't found an off-the-shelf application to accommodate its requirements.

The two largest Widgets R Us customers are Acme and Megacorp. Acme gets a discount of 10 percent off the standard price when the current time is on an even minute and 15 percent off when it's on an odd minute. If Acme orders more than five widgets, it gets an additional 5 percent discount. For Megacorp, the price is based on the sale type. For resale, there's a 20 percent discount. For corporate sales, there's a 10 percent discount. Government sales are at a 10 percent premium, and other sales are at the list price. I'm sure you've seen business rules with similarly screwy logic in your own projects!

This description implies two requirements: First, since you can't anticipate the design of new customer pricing rules, you should place no restrictions on new pricing algorithms. Second, you may need to collect extra information to price an order. For example, to price a Megacorp order, you need to know the sale type.

Design

You need to accommodate custom pricing rules based on a customer, but you don't want to change the order entry application each time you get a new customer. So you need to apply one of the most basic software design principles: separation of concerns. Since you want the order entry application to remain the same while customer order pricing rules change, you need to separate the order entry software from the customer order pricing software.

But you can't completely separate them, for two reasons. First, the order entry application collects the information needed to price the order. Second, the order entry application must have a way of getting a price for an order, so it needs a way to interact with the customer pricing software. Since you don't want to link the order entry application directly to the customer pricing rules, you need to create a piece of software to serve as an intermediary between the two. This allows you to change either the order entry application or the customer pricing without changing the other.

Figure 1 is an abstract representation of these three pieces of software. In this figure, I use the word interface in the most abstract sense. I don't mean a C# interface. I simply mean that there's some software that's separate from both the order entry application and the customer pricing, but is relied on by both.

To support variations in customer order pricing, the pricing algorithm for each customer will be stored in a separate assembly. To load these assemblies dynamically, I've defined a dynamic package class. Each instance of the dynamic package class encapsulates a dynamically loaded assembly. Every dynamic package has a builder that's responsible for creating objects from that dynamic package.

If Figure 1 is the design view from 10,000 feet, the UML component diagram in Figure 2 shows the view from 5,000 feet. In this diagram, each component represents a separate .NET assembly and corresponds to a project in the .NET solution. The two assemblies within the box labeled Interface correspond to the Interface cloud in Figure 1. The OrderEntry component represents the order entry application. The Acme, Megacorp, and Generic components represent the oval labeled Pricing in Figure 1. In this component diagram, the dashed lines denote dependencies, with the arrow pointing in the direction of the dependency. For example, the line from the Acme component to the OrderEntryCommon component means that the Acme component depends on the OrderEntryCommon component. You'll notice in this diagram that all of the dependencies from the client pricing assemblies and the order entry application go toward the two interface components. This allows you to change the order entry application and the customer pricing assemblies independently of each other.

Interface code

This section shows the code for the interface defined in the previous section. There are two things that the interface software must provide: a means to exchange order information, and a means of interaction between order entry and pricing.

As the method to share order information, I've defined an order class. The following code is shared between the order entry application and the customer-specific dynamic assemblies. Note that for the purpose of this example, there's only one item on the order, so the color and quantity properties are on the order itself, rather than a separate line item class.

  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  }

The following code shows the interface definitions shared between the order entry application and the customer-specific dynamic assemblies. These three interfaces enable the order entry application to interact with customer-specific pricing and are defined in the CustomerPackageInterfaces assembly.

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

IOrderPricing has one method to calculate the price of an order, and receives the order as a parameter. The calculated price is written to the price property of the order passed as a parameter; therefore, there's no return value from this method.

IOrderDataControl is meant to be implemented by a control that captures customer-specific order information. It has methods to read data from an order to the control and to write data to an order from the control. It also has a method that the order entry application uses to pass a delegate to be called when a value changes in the control to capture customer-specific information.

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

The ICustomerPackageBuilder interface is for the builder class. Each dynamic package must implement a builder class that's responsible for creating objects from that package. This interface defines methods to create objects that implement the IOrderPricing and IOrderDataControl interfaces and to return a new order object. I allow each customer package to create a new order instance because there may be order properties that are specific to a customer. The GetOrderDataControl method returns a control that can be placed on a form that will gather the customer-specific order information. Lastly, the GetOrderPricing method returns an object to calculate the price for an order, which can also be specific to a customer.

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

Note that the ICustomerPackageBuilder class inherits the IPackageBuilder interface, which makes it compatible with the DynamicPackage class described later.

Order entry application

The windows in Figure 3 and Figure 4 are from the order entry application. As you can see, this is a greatly simplified prototype of an order entry system. You can enter a customer, the color, and the quantity of widgets you want to order, and the application displays the resulting price. Figure 3 shows the screen for a generic customer.

Figure 4 shows the screen for a Megacorp order. Note the addition of the Sale Type combo box, which is necessary to price a Megacorp order.

Listing 1 contains the relevant code from the order entry form. The first thing to note is that the form has a property to hold an instance of an order. It also has properties to refer to objects implementing the IOrderPricing interface and the IOrderDataControl interface, which are used in pricing the order.

Listing 1. Order entry form.
    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 );    }

The CalculatePrice method calculates the order price and is called whenever a value on the order entry form changes. The method first sets the values of the color and quantity fields on the order instance from the values on the form. The customerOrderData field is only assigned if there's customer-specific data being collected for an order. If the customerOrderData instance isn't null, then CalculatePrice assigns any data from the customer-specific control to the order by calling the WriteOrderData method. CalculatePrice then calculates the price of the order by calling the PriceOrder method and assigning the order price to a label on the form.

When the order entry user changes the customer on the order entry form, the application executes the ChangeCustomer method. The first function the ChangeCustomer method performs is to get the dynamic package for the customer name in the combo box on the order entry form. For illustration purposes, this order entry application loads an assembly with the exact name from the customer combo box. In a production application, you'd likely create a data-driven mapping between customers and assemblies, which would allow you to add new customers and assemblies without any change to the order entry application.

Using the builder from the dynamic package, ChangeCustomer creates an order instance. It then gets an IOrderPricing reference and creates a control to gather customer-specific order information. Deferring the creation of these objects to the dynamic assembly is critical in allowing additional functionality to be added by customer-specific assemblies. For example, a Megacorp order must have a sale type to be priced. Deferring creation of the order instance to the customer-specific dynamic package allows the Megacorp package to create an order instance with a sale type property.

Because there may be customer-specific order information collected, the order entry form has a panel to hold customer-specific data entry controls. The Sale Type combo box shown in Figure 4 is an example of a control using this panel. The ChangeCustomer method clears the controls from this panel every time the customer changes. If there's a control returned from the customer-specific dynamic package, that control is added to the panel. The control is also saved in the customerOrderData field, since the control implements the IOrderDataControl interface that allows data to be written to and read from the control. This interface is called before the customer-specific control is displayed to allow it to read data from the order. This is also the interface called in the CalculatePrice method to save data from the customer-specific control to the order before the price is calculated. The last function implemented for the customer-specific control is to pass an event handler to the order-specific control so that it can fire the CalculatePrice method whenever any customer-specific order data changes.

Customer pricing

In the following sections I'll show you how the pricing assemblies were created.

Generic pricing

The generic pricing for Widgets R Us orders is based on the widget color, as shown in the following code fragment:

  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 Listing 2 contains all of the relevant code for pricing Acme orders. The first class defined is the builder class that's responsible for creating other objects from the assembly. The GetOrderPricing method returns an instance of the AcmeOrderPricing class that's also defined in Listing 2. Since for Acme there are no customer-specific fields required for order entry, the GetOrderDataControl method returns null. Likewise, since there's no extra order information for an Acme order, the NewOrder method returns an instance of the Order class.

The AcmeOrderPricing class is responsible for calculating the price of Acme orders. It applies the unusual rules for Acme after calculating the generic customer price for an order by calling the GenericCustomer PriceOrder method. For a customer with completely custom pricing, you wouldn't need to call the GenericCustomer pricing.

Listing 2. Acme order pricing.
  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

Listing 3 shows the customer-specific pricing code for Megacorp. Similar to the Acme code, it has a builder class to create other objects from the assembly. Also like the Acme code, it contains the MegacorpOrderPricing class to handle the order pricing itself. Unlike the Acme code, the GetOrderDataControl method on the MegacorpBuilder class returns a new control instance, which I discuss later. This is because you need to collect the sale type to price a Megacorp order. This is also reflected in the MegacorpOrder class that's defined in this listing. The MegacorpOrder class adds a SaleType property to the base Order class. An instance of the MegacorpOrder class is returned from the NewOrder method on the builder class.

The MegacorpOrderPricing class calculates the price in a similar manner to the Acme calculation. It begins by calculating the generic price, and then applying an adjustment based on the sale type. The most interesting thing to note about the PriceOrder method is that the order passed as a parameter is typecast to the MegacorpOrder type. This typecast is necessary because you need the sale type to price an order for Megacorp. The typecast is possible because the order created by the NewOrder method on the builder is of the MegacorpOrder type.

Listing 3. Megacorp order pricing.
  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;  }

To price a Megacorp order, you must collect the sale type in addition to the base order information. In the assembly that defines the Megacorp order pricing, there's also a user control to collect the sale type. The user control has one combo box that allows the user to select the sale type as displayed in Figure 4. This control implements the IOrderDataControl interface, which allows it to interact with the order entry application. The following code segment displays the implementation of this interface:

    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;    }

The ReadOrderData method is called by the order entry application when it wants to update the user control with data from an order object. It sets the SelectedIndex property on the Sale Type combo box based on the sale type of the order passed to the method. WriteOrderData handles the situation when the order entry application requests that the data from the user control be written to the order. Lastly, the SetOrderChangeEvent method receives an EventHandler to be called whenever a value changes in the control. As you may remember, the order entry application passes in an event handler that fires the CalculatePrice method. In this control, I just set this handler to be called whenever the selected index changes on the Sale Type combo box. This will force a price calculation whenever the sale type changes.

Dynamic packages

The dynamic package class provides a convenient wrapper for dynamically loaded assemblies (see Listing 4). The dynamic package class has two public properties: name and builder. In this example, the name of the package is identical to the actual assembly name. A more advanced implementation might separate these two concepts to allow another level of redirection. The builder property represents the class responsible for creating objects from the dynamic assembly. It's possible to create objects from outside an assembly, but localizing object creation to the assembly itself simplifies coding.

Listing 4. Dynamic package class.
  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;    }  }

Note that the constructor for the DynamicPackage class is private. The only way to get an instance of the DynamicPackage class is to call the static GetDynamicPackage method. This method first looks through an ArrayList of all of the packages loaded thus far to see whether the requested package has been loaded, in which case it simply returns the loaded package. Otherwise, it creates a new dynamic package.

When creating a dynamic package, the GetAssembly method first looks through the current AppDomain to see whether the requested assembly is already in memory. If it's not loaded, it loads it from disk using Assembly.LoadFrom, which takes a path name as a parameter. Depending on your specific requirements, other static methods of the Assembly class such as Load or LoadWithPartialName could be used here.

Once the assembly is in memory, the FindBuilder method uses reflection to find a class in the loaded assembly that implements IPackageBuilder and returns an instance of that class. For each type defined in the assembly, it calls the FindInterfaces method. The FindInterfaces method takes a delegate, which it calls for each interface defined in the type. In this case, the delegate calls the InterfaceFilter method, which returns true if the interface is IPackageBuilder or a descendant of IPackageBuilder. Since the ICustomerPackageBuilder interface derives from IPackageBuilder, it will find classes that implement ICustomerPackageBuilder.

When FindBuilder finds a type that supports IPackageBuilder, it gets the ConstructorInfo for the constructor with no parameters and uses that ConstructorInfo to create an instance of the type.

Conclusion

By applying the design principle of separation of concerns and properly managing dependencies, you can create systems that easily accommodate change. This example shows an order entry application that can calculate the price for an order using a pricing method that may differ for every customer. And you can add unique pricing schemes for each customer without changing the code for the order entry application and without touching the code for any other customer's pricing. This greatly reduces the amount of time and risk required to accommodate customer-specific rules.

Download 406WIRTLEY.ZIP

Sidebar: Why Not Take a Data-Driven Approach?

You might be asking yourself, why not just create a data-driven implementation and avoid all of these classes? You could do that, but it would be much less flexible because it would force you to accommodate all future additions and changes in this data-driven implementation. It's far more important as a first step to design the appropriate interfaces than to worry about the implementation. Using the design described in this article, it would be quite straightforward to create a data-driven customer pricing assembly. In fact, it would be straightforward to create several different data-driven assemblies. Even in the circumstance where your current needs can be met through one data-driven algorithm, designing the correct interfaces allows you to add a hard-coded algorithm later if the need arises. But if you begin with one data-driven algorithm without the appropriate interfaces, it would be much more difficult to go back later and accommodate something that falls outside of the data-driven structure.

To find out more about Hardcore Visual Studio and Pinnacle Publishing, visit their Web site at http://www.pinpub.com/

Note: This is not a Microsoft Corporation Web site. Microsoft is not responsible for its content.

This article is reproduced from the June 2004 issue of Hardcore Visual Studio. Copyright 2004, by Pinnacle Publishing, Inc., unless otherwise noted. All rights are reserved. Hardcore Visual Studio is an independently produced publication of Pinnacle Publishing, Inc. No part of this article may be used or reproduced in any fashion (except in brief quotations used in critical articles and reviews) without prior consent of Pinnacle Publishing, Inc. To contact Pinnacle Publishing, Inc., please call 1-800-788-1900.

原创粉丝点击