Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

Creating a Flexible Dynamic Plugin Architecture under .NET

0.00/5 (No votes)
11 Nov 2003 1  
This article demonstrates how to create a simple class which can be extended to assist with plugin creation and management.

Introduction

Plugins are a powerful tool in a developers arsenal for a number of reasons which I won't go into here. I mean, if you're reading this, odds are you already know you want to use them.

The Problem

The pitfalls in a plugin based environment are typically imposed by the system using them. For example, with distributed applications, plugins are nice because they allow easy code addition. They're also a fairly tolerant system. Usually upgrades are done while the application is shut down and file locking issues are not usually a problem.

Web applications, on the other hand, tend to be more finicky. If you load a library which is used by frequent visitors to your site, you're going to be hard pressed to find a time when that file is not locked. The only solution in that case is to shut IIS down, update the file and bring it back up. In our case this happens on a weekly (and sometimes semi-weekly) basis, and is not acceptable.

The Solution

The answer is to make a system which can be upgraded on the fly with minimal potential for error. This requires a couple things:

  • Libraries can not be directly used
  • The folder needs to be scanned for updates
  • Unused files need to be deleted
  • Libraries need to be indexed

Each of these points needs to addressed. First, however, we need to make an architectural decision. I decided that I wanted my library to form an abstract class. I like the idea of having a Broker class which handles all calls necessary to create the classes being plugged in. So our final result will be an abstract class with protected static methods for methods the broker will use (so our broker doesn't need to be instanced) and private static methods for all other calls.

public abstract class PluginLoader
{
  protected static Type FindAssembly(string PluginClass, 
    string Class, string[] Folders, params Type[] PluginTypes)
  {
    // This will find the Assembly requested.

  }
}

The parameters I selected can be explained fairly easily. The PluginClass distinguishes this set of plugins from any other set. The Class parameter specifies the actual class you want to create. This can be fully namespaced, but it can be given as just the class name if you trust that this is unique. The Folders provide a list of (fully qualified) paths to search through. The last parameter, PluginTypes, is an array of System.Type objects which provide the list of types the plugins may be derived from. This just helps our sanity later on when we need to make sure we don't load the wrong kind of plugin.

Now lets flesh the method out a bit. We're just going to have it load up our configuration file (if it exists) and call a couple other routines to populate it.

public abstract class PluginLoader
{
  protected static Type FindAssembly(string PluginClass, 
      string Class, string[] Folders, params Type[] PluginTypes)
  {
    // Look at each folder specified, but stop if we find the library

    foreach (string Folder in Folders)
    {
      // Load up the file - LoadXmlFile is tolerant of a bad file, 

      // just in case

      System.Xml.XmlDocument PluginLibrary = LoadXmlFile(
           System.IO.Path.Combine(Folder, "plugins.xml"));

      if (PluginLibrary == null)
        // Our file doesn't exist, so create one

        PluginLibrary = CreatePluginFile(Folder, PluginClass, PluginTypes);
      else
        // Our file does exist, so make sure it doesn't need updating

        PluginLibrary = UpdatePluginFile(Folder, PluginClass, PluginTypes);

      // This should never be null, but it may be if the folder is bad or

      // something.  I probably shouldn't ignore this, but oh well.

      if (PluginLibrary != null)
      {
        // TODO: Check to see if the library is in here.  

        // If so, load it up and return it

      }
    }

    // We didn't find it, so return a null

    return null;
  }

  // This just loads up that xml file and ignores any errors. 

  // I... uh... left error

  // handling out as an exercise for the reader.  Yeah, that's it...


  private static Xml.XmlDocument LoadXmlFile(string Path)
  {
    if (System.IO.File.Exists(Path))
    {
      try
      {
        Xml.XmlDocument Result = new Xml.XmlDocument();
        Result.Load(Path);
        return Result;
      }
      catch
      {
        return null;
      }
    }
    else
      return null;
  }
}

Everything should be fairly straightforward so far. We have to write the CreatePluginFile() and UpdatePluginFile() methods and fill in the TODO section. This covers the requirement to scan the folder for updates (from the list above). At this point, a discussion of the XML file is warranted.

I've decided that plugins will be scanned for pertinent classes and this data will be placed in an xml file called plugins.xml in the folder containing the libraries. Multiple types of plugins can be indexed in the same file, so this satisfies the requirement of indexing the library fairly nicely.

As you can see from the code, data is being read from a file called plugins.xml. Presumably we should write to this same file. For the format of the file, I decided to use the following:

<plugins updated="11/10/2003 2:22:05 PM">
  <retired>
    <plugin>C:\DOCUME~1\MACHINE\ASPNET\LOCALS~1\Temp\tmp7C1.tmp</plugin>
  </retired>
  <active type="widgets" updated="11/10/2003 2:22:05 PM">
    <plugin library="Widget.dll" interface="IWidget" 
           name="widget1" fullname="mynamespace.widget1">
      C:\DOCUME~1\MACHINE\ASPNET\LOCALS~1\Temp\tmp7C2.tmp
    </plugin>
    <plugin library="Widget.dll" interface="IWidget" 
          name="widget2" fullname="myothernamespace.widget2">
      C:\DOCUME~1\MACHINE\ASPNET\LOCALS~1\Temp\tmp7C2.tmp
    </plugin>
  </active>
</plugins>

There's a good amount of data in here. I'll briefly touch on the important parts. Plugins is our root node. The timestamp here is just for information purposes. Retired contains plugin elements marked for deletion. The system should delete these whenever locks on them have died to conserve server space (trust me, it bites when you unexpectedly run out of space due to this folder not being cleared out). The active block are plugins which are currently available. We'll use it's updated attribute later to determine whether files are new. The type attribute matches the PluginClass specified above. This prevents plugins of different types from bumping into each other.

In the plugin element, the interface attribute just tells us which interface was matched (so we can verify that this should be included), the name and fullname attributes specify the class name and class name along with all namespaces respectively. The contents of the element is the location of the DLL itself. You may notice that it points to files in the temp directory. We will copy the files there when we notice new ones.

So we need to load up the library and return the appropriate type. This is easy enough now that we know where in the xml file this data is. So...

System.Xml.XmlElement LibraryNode = (System.Xml.XmlElement)
     PluginLibrary.SelectSingleNode(
    "plugins/active[@type='" + PluginClass + "']/plugin[@name='" + 
     Class.ToLower() + "' or @fullname='" + Class.ToLower() + "']");
if (LibraryNode != null)
{
  System.Reflection.Assembly PluginAssembly = 
    System.Reflection.Assembly.LoadFile(LibraryNode.InnerText);
  return PluginAssembly.GetType(LibraryNode.GetAttribute("fullname"),
     false, true);
}

... replace the TODO section above with this little gem and, pending a valid file, we're all set. The rest of this deals with that "pending". For the uninitiated, the code above finds the file path for the desired assembly, loads it up using Reflection, and creates a new Type object accordingly.

Now because this article is getting a little long, here are the CreatePluginFile() and UpdatePluginFile() methods:

private static Xml.XmlDocument CreatePluginFile(string PluginFolder, 
   string PluginClass, Type[] PluginTypes)
{
  Xml.XmlDocument PluginLibrary = new Xml.XmlDocument();

  PluginLibrary.LoadXml("<RETIRED />");

  AddAssembliesToPluginFile(PluginFolder, PluginClass, 
     PluginLibrary, PluginTypes);

  PluginLibrary.Save(System.IO.Path.Combine(PluginFolder, "plugins.xml"));
  return PluginLibrary;
}



private static Xml.XmlDocument UpdatePluginFile(string PluginFolder, 
    string PluginClass, Type[] PluginTypes)
{
  Xml.XmlDocument PluginLibrary = new Xml.XmlDocument();

  try
  {
    PluginLibrary.Load(System.IO.Path.Combine(PluginFolder, "plugins.xml"));
  }
  catch
  {
    PluginLibrary = CreatePluginFile(PluginFolder, PluginClass, PluginTypes);
  }

  bool FileChanged = false;

  foreach (string PluginFile in System.IO.Directory.GetFiles(
     PluginFolder, "*.dll"))
  {
    DateTime LastUpdate = new DateTime();
    try
    {
      LastUpdate = DateTime.Parse(((Xml.XmlElement)
          PluginLibrary.SelectSingleNode("/plugins/active[@type='" + 
          PluginClass + "']")).GetAttribute("updated"));
    }
    catch
    { }
    if (System.IO.File.GetLastWriteTime(PluginFile) > LastUpdate)
    {
      foreach (Xml.XmlElement OldAssembly in PluginLibrary.SelectNodes(
          "/plugins/active[@type='" + PluginClass + "']/plugin"))
      {
        OldAssembly.ParentNode.RemoveChild(OldAssembly);
        PluginLibrary.SelectSingleNode("/plugins/retired").AppendChild(
             OldAssembly);
      }

      AddAssembliesToPluginFile(PluginFolder, PluginClass, 
         PluginLibrary, PluginTypes);

      FileChanged = true;

      break;
    }
  }

  foreach (Xml.XmlElement OldAssembly in PluginLibrary.SelectNodes(
     "/plugins/retired/plugin"))
  {
    try
    {
      System.IO.File.Delete(OldAssembly.InnerText);
      OldAssembly.ParentNode.RemoveChild(OldAssembly);

      FileChanged = true;
    }
    catch (Exception exx)
    {
      exx.GetType();
    }
  }

  try
  {
    if (FileChanged)
      PluginLibrary.Save(System.IO.Path.Combine(PluginFolder, "plugins.xml"));
  }
  catch
  { }

  return PluginLibrary;
}

These are pretty straightforward. CreatePluginFile() creates a new XML file and populates it using AddAssembliesToPluginFile(). UpdatePluginFile() looks to see if files for this active element. If any files are newer, it moves all plugin elements into the retired element and tries to delete them. This covers the "Unused files must be deleted" requirement. If any of this changed the file, this routine saves it. Now all we have left is AddAssembliesToPluginFile() which will cover our last requirement of copying files to a temporary location.

private static void AddAssembliesToPluginFile(string PluginFolder, 
  string PluginClass, Xml.XmlDocument PluginLibrary, Type[] PluginTypes)
{
  if (System.IO.Directory.Exists(PluginFolder))
  {
    foreach (string PluginFile in System.IO.Directory.GetFiles(
          PluginFolder, "*.dll"))
    {
      bool FoundOne = false;
      string NewFileName = System.IO.Path.GetTempFileName();
      string OldFileName = PluginFile.Substring(
           PluginFile.LastIndexOf("\\") + 1);

      System.IO.File.Copy(PluginFile, NewFileName, true);

      System.Reflection.Assembly PluginAssembly = 
        System.Reflection.Assembly.LoadFile(NewFileName);

      foreach (System.Type NewType in PluginAssembly.GetTypes())
      {
        bool Found = false;

        foreach (System.Type InterfaceType in NewType.GetInterfaces())
          foreach (System.Type DesiredType in PluginTypes)
          {
            if (InterfaceType == DesiredType)
            {

              string ClassName = NewType.Name.ToLower();
              if (NewType.Namespace != null)
                ClassName = NewType.Namespace.ToLower() + "." + ClassName;

              FoundOne = true;
              Xml.XmlElement NewNode = PluginLibrary.CreateElement("plugin");
              NewNode.SetAttribute("library", OldFileName);
              NewNode.SetAttribute("interface", DesiredType.Name);
              NewNode.SetAttribute("name", NewType.Name.ToLower());
              NewNode.SetAttribute("fullname", ClassName);
              NewNode.AppendChild(PluginLibrary.CreateTextNode(NewFileName));

              Xml.XmlElement Parent = 
                (Xml.XmlElement)PluginLibrary.SelectSingleNode(
                "/plugins/active[@type='" + PluginClass + "']");
              if (Parent == null)
              {
                Parent = PluginLibrary.CreateElement("active");
                Parent.SetAttribute("type", PluginClass);
                PluginLibrary.SelectSingleNode("/plugins"
                    ).AppendChild(Parent);
              }
              Parent.AppendChild(NewNode);
              Parent.SetAttribute("updated", System.DateTime.Now.ToString());

              Found = true;
              break;
            }
            if (Found) break;
          }
      }

      if (!FoundOne)
      {
        Xml.XmlElement NewNode = PluginLibrary.CreateElement("plugin");
        NewNode.AppendChild(PluginLibrary.CreateTextNode(NewFileName));

        PluginLibrary.SelectSingleNode("/plugins/retired").AppendChild(
            NewNode);
        PluginLibrary.DocumentElement.SetAttribute("updated", 
            System.DateTime.Now.ToString());
      }
    }
  }
}

This is unfortunately a little ugly. We loop over all DLL files in the folder (we really should do all DLL and EXE files, but I can't do everything for you). We copy the file into our temp folder and get the new path for it. Then we loop over all the classes in that library. Within that we loop over all interfaces each class inherits. Within that we loop over all types we want to find. If any of those match, we consider this type to be valid. Add the necessary data to the XML file and move on to the next class. Voila, all requirements are satisfied.

Usage

Now that we've got this great little library, we need to be able to put it to some use. This is a very simple WidgetBroker class which demonstrates the use of this class (Note that I added an alternate definition of FindAssembly() which accepts a single folder and passes it in as an array).

  public class WidgetBroker : Library.PluginLoader
  {
    public static IWidget Load(string InputType)
    {
      Type InputElementClass = FindAssembly("input", InputType,
        System.IO.Path.Combine(Environment.CurrentDirectory, "plugins"),
        typeof(IWidget));
      return InputElementClass.GetConstructor(System.Type.EmptyTypes).Invoke(
          System.Type.EmptyTypes) as IWidget;
    }
  }

The WidgetBroker finds the Type using the library we just created, and creates it (assuming it has a default constructor).

Failure Points

Of course, because this needed to fit into a decent sized article, I couldn't do out a full implementation of this (and because I'm lazy I haven't done one, so don't ask for it). So there are a couple failure points. One is if a lot of people are at your site while you update or add your DLLs. This isn't a huge issue because the system is just kinda tolerant of that. It builds the files on the fly, uses them, but doesn't bitch if it can't save them. This can be a problem if there's something really wrong (like your disk is full). These can all be fixed with judicious use of Reader/Writer locks. I believe though that the real solution is to have the application do this poll the first time the broker class is called. At that point it should set up a System.IO.FileSystemWatcher class to watch and see if any of the libraries change. Once changed, the individual changes can be stored to the plugin.xml file and persisted to disk.

This also doesn't copy in assemblies which are used by other assemblies appropriately. There are a few reasons this is difficult, the primary of which is that you have to change around the assembly linkage (which would have changed an "intermediate" article into an "advanced" article). If you copy the referenced libraries as-is (using the default filename), they end up getting locked in your temp folder. You can't really change the name of the file to get around the locking issues like we did before because you have to update your primary assembly to point to the new files. I decided not to mess with it and say that your plugin is not allowed to reference external files.

Summary

So we pretty much created this deal. You can add some extra methods that give you extra pieces of functionality or easier ways to call the library. If you download the source above you can see a couple that I did along with a spiffy little application which demos why this technique is useful. We laughed, we cried, I think we got some code done.

Whatever.

History

Version Notes Random Barnyard Animal
1.0 Brand Spankin' New Chicken

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here