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)
{
}
}
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)
{
foreach (string Folder in Folders)
{
System.Xml.XmlDocument PluginLibrary = LoadXmlFile(
System.IO.Path.Combine(Folder, "plugins.xml"));
if (PluginLibrary == null)
PluginLibrary = CreatePluginFile(Folder, PluginClass, PluginTypes);
else
PluginLibrary = UpdatePluginFile(Folder, PluginClass, PluginTypes);
if (PluginLibrary != null)
{
}
}
return null;
}
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 |