Sunday, January 11, 2009

mpFx Win32 Client – Plugin Architecture

I needed a test harness for mpFx, so I built a simple WinForms application and wrote little utilities that used the primary libraries.  As the feature set grew, the test harness started to become unwieldy and frankly not very pretty.  This fact, coupled with the other requirement I had regarding finding a way to tie all my little tools and utilities into a single package, led me to throw away the test harness and start from scratch on a plugin-based architecture for what I call MpFx Client,  a Win Forms application that acts both as my test harness for MpFx and as host for all my tools.

Building a plugin-based application in .NET is easy.  I built something similar for another project.  

My thought was to implement a simple containment application, which is responsible for creating an instance of mpFx’s Project Server class.  Instantiation includes collecting login information and logging into Project Server.  Here is a screen shot of mpFx Client:

Main mpFx Client Screen

Clicking the image  button displays the Profiles Dialog


After Login  

Aside from login panel, the user interface is quite bare.  I have a Project Server instance and I have a simple user interface with a toolstrip and status bar.  All I need now is some plugins to add functionality to it.

A good description of a plugin can be found here.  The key points are you have a host application which plugins interact with to extend or add functionality, and the host provides shared services and a mechanism by which plugins can be registered.  I think most of us understand the basic points.

One of the key design considerations in building a plugin-enabled application is the plugin interface.  The interface is a specification or contract that defines how a plugin is interacted with.  In my case, in the interface is quite simple:

   1: using System;
   2: using System.Windows.Forms;
   4: namespace CodePlex.MicrosoftProject.mpFx.Client.Shared
   5: {
   6:     public interface IMpFxClientPlugin:IDisposable
   7:     {
   8:         Guid Guid { get; }
   9:         Version Version { get; }
  11:         string Name { get; }
  12:         string Description { get; }
  13:         string Author { get; }
  15:         bool IsLoaded { get;}
  19:         UserControl OptionsControl { get; }
  21:         event OnStatusChangedEventHandler OnStatusChanged;
  23:         void OnLoad(ProjectServer projectServer, Form parent, ToolStrip toolStrip);
  24:         void OnUnload();
  26:     }
  27: }

The interface is quite straightforward.  In addition to basic information which is exposed via properties,  the interface also defines a OnLoad method, an OnUnload method, and an OptionsControl property.  I will explain what this is used for later.

The OnLoad method has three parameters: The ProjectServer instance, which is created by the host application, a parent form object, and a toolstrip.  The form and the toolstrip parameters allow the plugin to add user interface elements to the host application.

Here is a short video showing the login process.  Watch the toolstrip:

MpFx Client Login

Notice that new items are being created as the plugins are loaded.   The plugin load process is implemented in the PluginHost object:


Let’s take a look at the Load method:

   1: public void Load()
   2: {
   3:     Plugins = new Dictionary<Guid, IMpFxClientPlugin>();
   5:     _StartupPath = Path.Combine(_StartupPath, Settings.Default.PluginSubDirectory);
   7:     Tools.ValidateDirectoryParam(_StartupPath,
   8:                                  string.Format(CultureInfo.CurrentCulture,
   9:                                                Resources.DirectoryNotFound,
  10:                                                _StartupPath));
  12:     string[] files = Directory.GetFiles(_StartupPath, "*.dll");
  14:     if (files.Length == 0)
  15:     {
  16:         throw new MpFxException(Resources.PluginsNotPresent);
  17:     }
  19:     AppDomain testDomain = AppDomain.CreateDomain(Resources.AppTitle);
  21:     IInterfaceInspector interfaceInspector = InterfaceInspector.Get(testDomain, Resources.Inspector);
  23:     foreach (string file in files)
  24:     {
  25:         LoadPlugin(interfaceInspector, file);
  26:     }
  28:     CreateUserInterFace();
  29: }

I maintain a dictionary of Guids and IMpFxClientPlugin objects, which are loaded from the plugins subdirectory.  The interesting part of this method is the use of the interface inspector, which provides functionality for inspecting an assembly to see if a particular interface is implemented and it does the inspection in a separate application domain.  This so because once a type is loaded into an app domain, it isn’t unloaded until the app domain is torn down at termination.  We don’t want to hold a slew of assemblies in memory just because we wanted to see if the assembly implemented a particular interface.  Let’s take a look at lines 19 through 27 and the underlying code:

   1: AppDomain testDomain = AppDomain.CreateDomain(Resources.AppTitle);
   3: IInterfaceInspector interfaceInspector = InterfaceInspector.Get(testDomain, Resources.Inspector);
   5: foreach (string file in files)
   6: {
   7:     LoadPlugin(interfaceInspector, file);
   8: }

The first thing that is need is an application domain in which the plugin candidate assemblies will be loaded.  Next, we use InterfaceInspector.Get to create an instance of the inspector in the test application domain.  InterfaceInspector implements IInterfaceInspector, an interface that specifies the contract for the object so that I can instantiate a remote instance of the object in the test application domain:

   1: using System;
   3: namespace CodePlex.MicrosoftProject.mpFx.Client.Shared
   4: {
   5:     /// <summary>
   6:     /// Specifies the interface for inspecting assemblies for interfaces
   7:     /// </summary>
   8:     public interface IInterfaceInspector
   9:     {
  10:         bool IsInterfaceImplemented(string filePath, string interfaceName);
  11:         bool TryGetTypeNameByFileAndInterface(string filePath, string interfaceName, out string typeName);
  12:         bool TryGetTypeByFileAndInterface(string filePath, string interfaceName, out Type type);
  14:         bool TryGetTypeFromPathInterfaceAndType(string filePath,
  15:                                                 string interfaceName,
  16:                                                 string typeName,
  17:                                                 out Type agentType);
  18:     }
  19: }

Let’s take a look at the Get method:

   1: /// <summary>
   2: /// Load an instance of the interface inspector in the specified application domain
   3: /// </summary>
   4: /// <returns>An instance of IInterfaceInspector</returns>
   5: public static IInterfaceInspector Get(AppDomain domain, string inspectorInterfaceName)
   6: {
   7:     if (domain == null)
   8:     {
   9:         throw new ArgumentNullException("domain");
  10:     }
  12:     if (string.IsNullOrEmpty(inspectorInterfaceName))
  13:     {
  14:         throw new ArgumentNullException(inspectorInterfaceName);
  15:     }
  17:     IInterfaceInspector interfaceInspector = domain.CreateInstanceAndUnwrap(GetExecutingAssemblyFullName(),
  18:                                                                             inspectorInterfaceName) as IInterfaceInspector;
  20:     if (interfaceInspector == null)
  21:     {
  22:         throw new NullReferenceException("IInterfaceInspector");
  23:     }
  25:     return interfaceInspector;
  26: }

Great, now I have in instance of the inspector.  I get an array of all  of the .dll assemblies in the plugin sub directory and I load the plugins:

   1: foreach (string file in files)
   2: {
   3:     LoadPlugin(interfaceInspector, file);
   4: }

Let’s take a look at LoadPlugin:

   1: public void LoadPlugin(IInterfaceInspector interfaceInspector, string file)
   2: {
   3:     string typeName;
   5:     if (interfaceInspector.TryGetTypeNameByFileAndInterface(file, Resources.PluginInterface, out typeName))
   6:     {
   7:         Assembly pluginAssembly = Assembly.LoadFile(file);
   9:         Type pluginType = pluginAssembly.GetType(typeName);
  11:         if (pluginType != null)
  12:         {                        
  13:             IMpFxClientPlugin plugin = Activator.CreateInstance(pluginType) as IMpFxClientPlugin;
  15:             if (plugin != null)
  16:             {
  17:                 Plugins.Add(plugin.Guid, plugin);
  19:                 plugin.OnStatusChanged += plugin_OnStatusChanged;
  21:                 DoOnStatusChanged(new OnStatusChangedArgs(Tools.Format(Resources.LoadingPlugin,
  22:                                                                        plugin.Name,
  23:                                                                        plugin.Version,
  24:                                                                        plugin.Author)));
  26:                 plugin.OnLoad(ProjectServer, Parent, ToolStrip);
  27:             }
  28:         }
  29:     }
  30: }

Pretty straightforward.  I use the inspector to determine if the assembly implements the IMpFxClientPlugin interface, which also returns the type name that implements the interface.  Now that we know the candidate assembly implements the plugin interface, it is okay to load assembly in the host’s application domain.   I get the type that implements the interface from the type name and the assembly and create an instance of the plugin.  After that, I hook up the OnStatusChanged event handler and load the plugin.

At the time of writing, I have created three plugins.  Let’s take a look at the plugin I wrote for managing Project Server event handlers.  Let’s start with the OnLoad method:

   1: public void OnLoad(ProjectServer projectServer, Form parent, ToolStrip toolStrip)
   2: {
   3:     ProjectServer = projectServer;
   5:     _Parent = parent;
   6:     _ToolStrip = toolStrip;
   8:     LoadUserInterface();
  10:     _IsLoaded = true;
  11: }

I store the Project Server, parent form, and toolstrip in auto properties and instance fields and then load the plugin’s user interface:

   1: private void LoadUserInterface()
   2: {
   3:     DoOnStatusChanged(new OnStatusChangedArgs("Creating menu items... "));
   5:     _EventsToolStripDropDownButton = new ToolStripDropDownButton("&Event Handlers");
   7:     _EventsToolStripDropDownButton.Click += _EventsToolStripDropDownButton_Click;
   9:     _ToolStrip.Items.Insert(2, _EventsToolStripDropDownButton);
  11:     _AddEventHandlerToolStripMenuItem = new ToolStripMenuItem("&Add...");
  13:     _AddEventHandlerToolStripMenuItem.Click += _AddEventHandlerToolStripMenuItem_Click;
  15:     _EventsToolStripDropDownButton.DropDownItems.Add(_AddEventHandlerToolStripMenuItem);
  17:     _FirstSeparator = new ToolStripSeparator();
  19:     _EventsToolStripDropDownButton.DropDownItems.Add(_FirstSeparator);
  21:     _OpenManagerToolStripMenuItem = new ToolStripMenuItem("&Open Event Handler Manager...");
  23:     _OpenManagerToolStripMenuItem.Click += _OpenManagerToolStripMenuItem_Click;
  25:     _EventsToolStripDropDownButton.DropDownItems.Add(_OpenManagerToolStripMenuItem);
  27:     DoOnStatusChanged(new OnStatusChangedArgs(string.Empty));
  29: }

This results in the following user interface attached to the host application:


Clicking on the menu item selected above displays the MDI child window that implements the bulk of the Event Handler Management Plugin:


I will blog about this particular plugin at a later date.

Notice also the Tools menu item.  Clicking it drops down to a Plugin Manager menu item, which when clicked displays the Plugin Manager dialog:


You can see the three plugins that are installed. Clicking the image  and image  buttons unload and load the selected plugin.  Here is a movie of what this looks like. Notice the toolstrip:

Clicking the Options node displays the Options Tab Control, which has a tab page for each plugin:


You might be wondering how the options user interface is loaded.   Remember the OptionsControl property of the MpFxClientPlugin interface?  That user control is hooked up to the tab page.  Another issue was how to force each OptionsControl to save options settings when the OK button is clicked. This is also achieved through the use of Interface.  Each option control must  implement the IOptionsControl interface:

   1: namespace CodePlex.MicrosoftProject.mpFx.Client.Shared
   2: {
   3:     public interface IOptionsControl
   4:     {
   5:         void SaveSettings();  
   6:     } 
   7: }

It is very simple.  In the Plugin Manager form, I have two methods:

   1: private void LoadPluginInformation()
   2: {
   3:     optionsTabControl.TabPages.Clear();
   4:     pluginsDataGridView.Rows.Clear();
   6:     foreach (KeyValuePair<Guid, IMpFxClientPlugin> plugin in _PluginHost.Plugins)
   7:     {               
   8:         Control control = plugin.Value.OptionsControl;
  10:         _OptionsControlsList.Add((IOptionsControl) control);
  12:         control.Enabled = plugin.Value.IsLoaded;
  13:         control.Dock = DockStyle.Fill;
  15:         TabPage tab = new TabPage();
  17:         tab.Text = plugin.Value.Name;
  18:         tab.Controls.Add(control);
  20:         optionsTabControl.TabPages.Add(tab);
  22:         pluginsDataGridView.Rows.Add(plugin.Value.IsLoaded ? "Loaded": "Unloaded", 
  23:                                      plugin.Value.Guid,
  24:                                      plugin.Value.Name,
  25:                                      plugin.Value.Description,
  26:                                      plugin.Value.Author,
  27:                                      plugin.Value.Version.ToString());
  28:     }
  30:     if (pluginsDataGridView.Rows.Count > 0)
  31:     {
  32:         pluginsDataGridView.Rows[0].Selected = true;
  33:         SetButtonsEnabledState();
  34:     }
  35: }

This method loads the tab pages and hooks up the OptionsControl to each page. It also populates a list of OptionsControls, which are used on the OK button click event to save settings:

   1: private void okButton_Click(object sender, EventArgs e)
   2: {
   3:     try
   4:     {
   5:         foreach (IOptionsControl optionsControl in _OptionsControlsList)
   6:         {
   7:             optionsControl.SaveSettings();
   8:         }
   9:         Close();
  10:     }
  11:     catch (Exception exception)
  12:     {
  14:         MessageBox.Show(this, exception.Message, Resources.AppTitle, MessageBoxButtons.OK, MessageBoxIcon.Error);
  15:     }
  16: }

Pretty nifty!  I hope you had a great weekend!

No comments :


Content on this site is provided "AS IS" with no warranties and confers no rights. Additionally, all content on this site is my own personal opinion and does not represent my employer's view in any way.