| n .NET, reusable units of code take the form of classes hosted in assemblies, making it easier to host plug-ins than ever before thanks to some key features of the Common Language Runtime (CLR) such as class inheritance, reflection, and interfaces. In this column, I'll show how to design an MDI Windows® Forms application that is extensible with user-defined plug-ins. This simple design will allow for a high level of customization. |
A dynamic extension is a module that the application detects at startup and makes available through menus and toolbars. A plug-in module is a class that represents an optional application-related function. If the plug-in is installed and registered on the current system, then the application loads it as if it were a native component. In code terms, a .NET plug-in is a compiled assembly that exposes one or more classes that the application just retrieves and calls back. At any time, the author as well as the user can easily extend plug-in aware apps. Third parties can also get into the game once the plug-in specification is made open. Figure 1 shows how such a programming model works in various environments.
Figure 1 Plug-ins on Various Operating Systems
On startup the application looks for registered plug-ins. The search is conducted using the platform-specific configuration medium, which is INI files for 16-bit Windows and the registry for Win32® and COM applications. The configuration medium contains all the information that the application needs to set up a callback to the referenced component. If you're using C-style functions, the name of the function and the DLL that contains it is sufficient. The prototype of the callback function defines the contract between the application and external pluggable module. The application has to assume that the registered DLL function can handle the stack as prepared prior to executing the call. If you use COM, the only information you need is the progID of the external COM object. In this case, interfaces and IUnknown's QueryInterface let you better control the prototype of the code you're going to call. Figure 2 shows how all this would work in .NET.
Figure 2 Plug-ins in .NET
Setting Up an MDI Application
Let's start by creating a Windows Forms app that will provide the base on which to build a successful plug-in infrastructure. The resulting application will handle XCH documentsbasically XML files containing raw data used to create bar charts. You create XCH documents by simply typing data into an empty Windows Forms DataGrid control. When ready, each document is flushed into a DataSet object and serialized to XML. When an XCH document is opened, its data is copied into a DataGrid control, and a JPEG is created on the fly to display in a picture box.
Arranging MDI applications in .NET is considerably simpler than it was before .NET. First, you declare that your main form acts as an MDI container. In the class constructor, set the IsMdiContainer property to True, like this in Visual Basic®:
Although not mandatory in Visual Basic, you might want to define an internal member (MainForm in the sample code)
Public Sub New()
Me.IsMdiContainer = True
MainForm = Me
that mirrors the form itself and is declared as WithEvents. In C# you don't need to resort to this trick to catch any form events.
Private WithEvents MainForm As Form
Second, set the MdiParent property of each child window to the parent form. For example, if you have a child document class called XmlChartDocument, the following code ensures that the newly created window is an MDI child of the main form:
The Form class also provides a lot of free MDI-related stuff. For example, if you want to add the typical Window menu to cascade or tile children, all you need is this:
Dim doc As XmlChartDocument = New XmlChartDocument()
doc.MdiParent = Me
To tile horizontally or vertically, you only have to pick up the appropriate element from the MdiLayout enum type and pass it to the LayoutMdi method of the Form class.
Sub mnuWindowCascade_Click( _
ByVal sender As Object, _
ByVal e As EventArgs) _
Figure 3 All Children
Another nice feature is the popup menu that gathers all the child documents open at a given time (see Figure 3). Populating a menu with the list of MDI documents is as easy as adding a parent menu item ("Windows," in the figure) and setting its MdiList property to True. You can do this at design time and all the other necessary code, including the code that brings the selected window to the top, will be provided by the framework automatically for you.
Make Room For Plug-in Modules
Well-designed extensible applications allow you to add any missing functionality at any time without recompiling. To build such an infrastructure, you should follow these tips:
The programming interface of an application is a collection of different functions that can be grouped into a few categories. Each category is tied to a particular context in which the function is called to work. By default, applications provide a static set of functionality. Extensible applications are different because they allow for the dynamic insertion of new user-defined functions. A plug-in's functionality must belong to one of the application's categories of functions.
- Define the insertion points for pluggable components.
- Define the programming interface through which the application and the plug-in will interact.
- Integrate any plug-in components with the application's user interface (menu, toolbar, and so forth).
- Make the application and the plug-in components interact.
One type of functionality comprises those actions that are available at all times, whether or not a child document is open. In this category are the functions attached to the New and Open menu items. So the same plug-in can have custom functions to create or open documents and document-independent functions.
Another significant group of functionality allows you to work on the active document. These include Save, Save As, and Print. According to your needs, you might also want to consider adding the same plug-in to the document's context menu. For example, if you plan to add a "Send through e-mail" item, the File menu seems to be the right place because the function applies to the current document. By contrast, a Change Chart Color feature is a parameter specific to the document window and looks better if inserted in the context menu. Other external modules can be added to any of the basic standard menus such as Window or Help. Finally, you can give users a chance to declaratively create brand new popup menus. The options I'm going to implement are listed in Figure 4, but the types of plug-ins you support, and the type of context information you pass to them, are completely up to you.
The insertion point defines how the application should update its menu (and, possibly, its toolbars) to reflect the presence of registered plug-ins. Again, the insertion points listed are examples; they're not mandatory.
How do you register plug-ins? Any extensible application, including Windows Explorer with shell extensions and Visual Studio® with add-ins, must define a place where extension modules should be registered to be detected and become available. In Windows 3.x, INI files were used. For Win32 and COM applications it was the registry. Windows Forms applications in .NET should use the application's .CONFIG file or an XML file.
The application's .CONFIG file allows you to declare new configuration sections with custom elements. By default it supports only settings expressed in a few formats such as name/value pairs and a single tag with as many attributes as needed. This schema fits the bill in most cases, but when you have complex and structured information, as is the case of plug-ins, it soon becomes insufficient. You have two possible workarounds. You can simply avoid using the .CONFIG file and replace it with a plain XML file written according to the schema appropriate for the data. Or you can embed your XML configuration data in the standard .CONFIG file but provide a custom configuration section handler to read it. A configuration section handler is just a .NET class that implements the IConfigurationSectionHandler interface. Clearly, the attributes and child elements of a section you define depend on the section handler you use to read your settings.
For more information about configuration section handlers, refer to the MSDN® documentation. For this column, I'll go with a single XML file located in the same folder as the executable.
type="YourHandlerClass, assembly" />
How do you define the schema of the XML config file? It's up to you, but I feel comfortable with a tabular format that can be easily loaded into a collection of DataTable objects and managed through a simple, familiar syntax. Figure 5 shows a sample repository of registered plug-in extensions.
What kind of information is needed to register a plug-in? Once again, the final structure of each plug-in data row is application-specific, but some pieces of information are absolutely necessary. You should specify at least the text for the menu item and a bitmap if you also plan to make the plug-in available through the toolbar. Next, you must indicate the name of the class that implements the plug-in and, of course, the assembly that contains it. You might also want to specify the position of insertion in the menu. In this example, I assume fixed positions for all types of plug-ins except the one that creates a new popup menu.
In Figure 6 you can see the code that merges the standard menu with plug-in menu items at run time. You read the XML config file into a DataSet and have a different DataTable object for each type of plug-in. Each row in any table corresponds to a plug-in of a certain type. The data columns provide the information to handle the plug-in. Look at the PluggableMenuItem class that is instantiated in Figure 6.
The PluggableMenuItem class takes in all the standard functionality of a MenuItem class and adds an ad hoc constructor and a few new public members. The data members contain the type of the plug-in and the name of the class along with the assembly providing the expected behavior. The class is loaded at run time and one of its methods is invoked through the reflection mechanism.
Public Class PluggableMenuItem
Public AssemblyName, ClassName As String
Public PlugInType As BWSLib.PlugInType
Public Sub New(text As String, onClick As EventHandler)
Since the OnClick event is managed by the MenuItem class, the two extra data members ensure that when a plug-in menu item is clicked, the handler can easily access the assembly and class name:
Upon startup, the application looks up the XML config file, determines the registered plug-ins, and updates the menu by merging the new items with the existing base menu. The merge can also be accomplished using ad hoc methods and properties exposed by the Form class. The MergeMenu method and the MergeType, along with MergeOrder properties let you fuse two menus into one.
Private Sub StdOnClickHandler(sender As Object, e As EventArgs)
Dim mnuItem As PluggableMenuItem
mnuItem = CType(sender, PluggableMenuItem)
MessageBox.Show(mnuItem.ClassName & _
", " & mnuItem.AssemblyName)
A Base Class for Plug-ins
A plug-in is an instance of a .NET class that is dynamically loaded from the specified assembly. Such a class must have a well-known layout for the callback to work. Typically, you obtain this by making the plug-in class implement a given interface. Here I'll define an interface for each supported plug-in type. This is not always necessaryyou can have just one interface for all plug-in typesbut I feel it is the most general approach you can take. Based on the interface, you design a base class for the plug-in:
The interface and the base classes must go into a separate assembly that both the main application and any plug-in component will link to. This assembly constitutes the interaction mechanism by which an application becomes extensible. Figure 7 shows the interfaces I'm going to use in the sample application. If the interface methods can accept only base .NET types, you're fine. Otherwise, you must define any custom type you plan to use in the same interface assembly. (You could also use a second distinct assembly referenced by the interface assembly, but you can't leave custom types undefined.)
Public Class AppPlugIn
Public Overridable Sub PerformAction(...)
Let's review some practical cases. Suppose that you want to write a plug-in that takes a reference to the active document windowsay the class XmlChartDocument discussed earlier. Since this class is a custom type that plug-in interfaces reference, it must be defined in the separate interfacing assembly and not in the main application. Not very practical and elegant, but workable. But what if you want to write another type of plug-in that needs a reference to the main form of the application? This class should be moved into the assembly with interfaces too. As a result, you have a Windows Forms application made of a small EXE that calls into a big assembly. If you like such a design, then go for it.
A better and more elegant approach, though, entails using intermediate (possibly abstract) classes. In the interface assembly you define a superclass for the actual types you plan to use. The superclass will have the same layout as the actual class but an empty or minimal implementation. Thus, instead of deriving the main form from System.Windows.Forms.Form, you derive it from, say, a class called XmlChartMainForm which inherits from Form. In addition, XmlChartMainForm defines all the extra methods and properties you need to use in code. Figure 8 shows the infrastructure that allows third-party vendors to write plug-in components and applications to invoke them (Figure 9 invokes one).
Figure 8 Plug-in Infrastructure
The communication mechanism is layered in three parts: the application assembly, the interface assembly, and the plug-in, which is an assembly too. The interface assembly must be considered immutable, since it's referenced by both other parts. If you make changes to it, you risk breaking existing plug-ins.
In my sample application, I decided to implement the document class (XmlChartDocument) in the interface assembly rather than defining an abstract class for it to inherit in the application assembly. I followed this latter approach for the main form class instead. The MainApp class, which is the form providing the main frame for the application's UI, inherits from the XmlChartMainForm class. XmlChartMainForm just adds a new public method called OpenDocumentFromFile to the regular interface of a Form class. I made different implementation choices for the two custom types that plug-ins use only to show how it works in both cases. The approach based on intermediate abstract classes looks more effective and elegant from a design point of view.
In the interface assembly you also have to define the interface for each plug-in you support and a base class that implements it. You normally use the interface type to cast the instances of the dynamically created plug-in objects within the application. For example, here's how to create an instance of a class implementing a FileMenu plug-in:
I'll discuss the global Activator object in a moment.
Dim a As String = mnuItem.AssemblyName
Dim c As String = mnuItem.ClassName
Dim o As IXmlChartFileMenuPlugIn
o = CType(Activator.CreateInstance(a, c).Unwrap(), _
You normally use the class implementing the interfaces as the base class when you create a plug-in component. Consider that, in this particular case, plug-in interfaces and classes implementing them can really be used interchangeably. An interface is certainly not the same as a class, but for plug-in components the interface represents the unique and immutable way to communicate. Even though the plug-in class provides more methods and properties than the interface defines, all of them would never be called by design and by contract. That's why you can safely cast to the interface type once Activator has returned an object instance. On the other hand, when you are in the process of creating a plug-in, it can be more convenient to use a class name in such a way that would let you inherit from existing plug-ins or classes.
Creating and Invoking Plug-ins
The following code shows how to create a very minimal component that plugs into the File menu and is automatically enabled when there's at least one document open:
To create other types of plug-in components, you just replace the base class. Notice that the application itself takes care of enabling and disabling plug-ins according to the current context.
public class SendThroughEmail : XmlChartDocumentFileMenuPlugIn
public override void PerformAction(XmlChartDocument docWindow)
MessageBox.Show("Sending \"" + docWindow.FileName + "\"" _
& " through email...", "Send through email");
A plug-in component has a very simple structure. Basically, all that you have to do is override the method PerformAction for the specific base class. If the expected behavior requires you to call into other classes, create forms, or access databases, you put all the necessary references and code in the plug-in class. Then compile the plug-in into a DLL assembly and place it where the base application can locate it. Normally you deploy assemblies in either of two ways, privately or globally. Private assemblies are for application use only and subsequently go in the root folder of that application. Global, or shared, assemblies are assemblies that multiple applications can use and, as such, they raise a few more issues when it comes to deployment. For example, they must be placed in the Global Assembly Cache (GAC) and possibly have a strong name too. By design, plug-ins are app-specific and have no reason to go into the GAC. In addition, they don't require a strong name.
As you saw earlier, Figure 5 is an excerpt of the XML file that is used to collect registered plug-ins. There you need to indicate three pieces of information: the text for the menu item, the assembly name, and the actual class name.
If you want to add a separator in the specified portion of the menu, use a single dash (-) inside a text element as a separator item. Now, how do you load and invoke plug-ins?
The plug-in is a class but the application doesn't know about it until it reads the XML config file. You need to first load the assembly that contains the class and then instantiate it. You can't use the plug-in class name as any other referenced type because it is, by design, an unreferenced type.
To work around this kind of late binding, .NET provides the global Activator object, which enables you to create instances of classes when all you know is the assembly, the name, and one of the implemented interfaces. The Activator object can create a local or remote instance of the type using the class constructor that best matches the default constructor. Often used with remoting, the Activator object doesn't provide notably better or worse performance when compared to the New operator. It does, however, offer the unique capability of creating strongly typed objects from weakly typed information.
Figure 9 summarizes the code used to instantiate and invoke the right plug-in class. The StdOnClickHandler routine is attached to all pluggable menu items. From the sender class (PluggableMenuItem) it takes the information about the assembly and the class to call. Activator does the rest. Notice the use of the Unwrap method. The Activator's CreateInstance method has several overloads that can return either an Object or an ObjectHandle type. In particular, all the overloads that accept an assembly name always return an ObjectHandle type. It is a container that wraps the newly created instance of the object. The goal of ObjectHandle is to marshal object instances by value through AppDomains without copying the assembly that defines the type into the target domains. To access the physical object, simply call Unwrap.
Sample Plug-in Components
A couple of plug-ins are included with this month's download at the link at the top of this article. The NewFromQuery plug-in fires from the File menu to let you create a new document from the results of a SQL query. It displays a form that runs some SQL code against Northwind and plugs the results into a grid. When you click Save, the underlying DataSet is saved to an XCH file.
Send questions and comments for Dino to firstname.lastname@example.org.