Simple Extensibility in .NET

I've used this approach a few times when I essentially need a really simple plugin / provider model within my applications so I thought I'd jot down the relevant details here for posterity using an old project for adding post commit hooks to subversion.

Consider this a somewhat simplistic approach, not suitable for production code without a bit more plumbing. If you are going all out and need true add-in's for your .NET based product I recommend checking out the managed add-in framework , very robust stuff and not that hard to implement. In a lot of cases though the isolation, discoverability, communication pipelines etc are a bit overkill. The example I'll show is a subversion hook that allows for very simple addition of new .NET "actions" to execute on PostCommit. In this case the "add-ins" are only written in house, and editing a config file to hook them up is completely acceptable etc etc.

The solution

Subversion.Contracts : This project is the bridge between our dispatcher and the plugins that will do the work.
Subversion.Plugins : Any of the actions we wish to take post commit are added here, but could just as easily be distributed across as many assemblies and projects as necessary as long as they reference the contracts.
Subversion.Dispatcher : This is the console application that actually receives the arguments from subversion and translates them into our contracts, then executes the appropriate actions (note no references to the plugins project)


The Contract

The contracts are relatively simple, but whatever you put in them this is the interface for the "plugin" that will need to implement. In our case this is IPostCommitHandler :

using System;

namespace Subversion.Contracts
{
 public interface IPostCommitHandler
 {
  void ExecuteCommand(PostCommitArgs a);
 }
}

Pretty simple, essentially just a "do whatever you want" method that passes the arguments from subversion wrapped up in a simple class. See the attached zip if you want the guts of the subversion specific stuff.


The Plugin

using System;
using Subversion.Contracts;

namespace Subversion.Plugins
{
 public class ExecuteForAllCommits : IPostCommitHandler
 {
  #region IPostCommitHandler Members

  public void ExecuteCommand(PostCommitArgs a)
  {
   SendEmailNotification.SendEmail(a.Argument, a.Revision);
  }

  #endregion
 }
}


Again, very simple and in this case we're passing off the execution to a static class that again is not shown, but what gets executed isn't all that important in this case.. simply fill in what you need.

The Dispatcher (Plugin Host)

using System;
using System.Collections;
using System.Text.RegularExpressions;
using System.Configuration;
using Subversion.Contracts;

namespace Subversion.Dispatcher
{
 ///
 /// 
 /// Summary description for PostCommit.
 /// 
 class PostCommit
 {

  private static string subversionPath = ConfigurationSettings.AppSettings["SubversionPath"];

  static void Main(string[] args)
  {
   SubversionRevision rev = ParseRevision(args);
   ArrayList commands = DispatchGlobalCommands(rev);
   DispatchNamedCommands(rev, commands);
  }

  private static SubversionRevision ParseRevision(string[] args)
  {
   SubversionRevision rev;
   if (args.Length == 2)
   {
    rev = new SubversionRevision(subversionPath, args[0], args[1]);
   }
   else
   {
    rev = new SubversionRevision(subversionPath, string.Empty, string.Empty);
   }
   return rev;
  }

  private static void DispatchNamedCommands(SubversionRevision rev, ArrayList commands)
  {
   string[] commitLines = rev.CommitLog.Split(Environment.NewLine[0]);
   // Handle Named Commands
   string registeredCommands = String.Join("|", (string[])commands.ToArray(typeof(string)));
   Regex CommandSearch = new Regex(@"(" + registeredCommands + @")\s*:\s*(.+)?", RegexOptions.IgnoreCase);
   foreach (string line in commitLines)
   {
    string lowerline = line.ToLower();
    for (Match Matches = CommandSearch.Match(lowerline); Matches.Success; Matches = Matches.NextMatch())
    {
     string handlerString = ConfigurationSettings.AppSettings["command:" + Matches.Groups[1].ToString()];
     DispatchCommand(handlerString, Matches.Groups[2].ToString(), rev);
    }
   }
  }

  private static ArrayList DispatchGlobalCommands(SubversionRevision rev)
  {
   // Handle global commands 
   ArrayList commands = new ArrayList();
   for (int i = 0; i < ConfigurationSettings.AppSettings.Count; i++)
   {
    string key = ConfigurationSettings.AppSettings.GetKey(i);
    string val = ConfigurationSettings.AppSettings.Get(i);
    string[] cmdParts = key.Split(':');
    if (cmdParts.Length == 2 && cmdParts[0] == "command")
    {
     if (cmdParts[1].StartsWith("*"))
     {
      DispatchCommand(val, cmdParts[1].Substring(cmdParts[1].IndexOf(",") + 1), rev);
     }
     else
     {
      commands.Add(cmdParts[1]);
     }
    }
   }
   return commands;
  }


  /// 
  /// Call the appropriate method for the command name given with the argument given
  /// no processing of the argument happens here. 
  /// 
private static void DispatchCommand(string handlerString, string argument, SubversionRevision rev)
  {
   // We don't want properly configured commands to stop working because of errors so trap
   // everything here...
   try
   {
    if (handlerString != null && handlerString.Length > 0)
    {
     string[] typeAndAssembly = handlerString.Split(',');
     if (typeAndAssembly.Length == 2)
     {
      System.Reflection.Assembly a = System.Reflection.Assembly.Load(typeAndAssembly[1]);
      System.Type t = a.GetType(typeAndAssembly[0], true);
      object handler = System.Activator.CreateInstance(t);
      if (handler is IPostCommitHandler)
      {
       ((IPostCommitHandler)handler).ExecuteCommand(new PostCommitArgs(argument,rev));
      }
     }
    }
   }
   catch (Exception) 
   { //TODO: log errors 
   }
  } 
 }
}


There is some plumbing in this class that isn't directly related to this post, but I've left it all anyway. Subversion will run this command every time a checkin is made, and the process ends and starts over again each time. This allows for some pretty simple handling of loaded assemblies and whatnot, if you have a longer running process or are dealing with some scale be cautious. ;-)

The Main function has two jobs, parse and create the revision, then read the application configuration file and start issueing commands for the received revision. Commands are in two parts, those defined in config to be executed always (global commands) and those that are interpreted from the subversion commit log itself, parsed out and executed with arguments from the revision log.

Here are some example commands defined in the config
<!-- Commands -->
<add key="command:*,chris" value="Subversion.Plugins.ExecuteForAllCommits,Subversion.Plugins" />
<add key="command:*,check-ins" value="Subversion.Plugins.ExecuteForAllCommits,Subversion.Plugins" />
<add key="command:bug" value="Subversion.Plugins.UpdateBugTracker,Subversion.Plugins" />
<add key="command:cc" value="Subversion.Plugins.SendEmailNotification,Subversion.Plugins" />


  • in the key we have "command:[name]" signifies a command arriving in a revision where somewhere in the revision log we'll see the command name followed by a colon, anything following the colon is then passed to the plugin as an argument. If the name is an asterisk then we simply execute for all, with an optional argument being passed to the plugin. (so the first example emails chris for all revisions, and the second emails an account named check-ins
  • the value portion here is what directs the program where to look for the appropriate plugin and class to execute. I copied the format I found in a web.config file which is to put the class name followed by the assembly name separated by a comma.

In retrospect if I were doing something similar again I'd probably create a better structured format rather than relying on all this string parsing... but old code is what it is in this case.

Finally we call DispatchCommand for each parsed out command which is the last piece of this old code that I'm attempting to document here for reuse. DispatchCommand will read the class name and assembly name, load the assembly name and attempt to instantiate the class/type named in order to call it using our IPostCommitHandler interface.

There are a few ways to do this, and for this project I'm simply calling "System.Reflection.Assembly.Load" which relies on the fact that my plugins are located in my bin directory. I've also done this using a "plugin store" which is a fancy way to say I had a dynamic path configured that I could read my assemblies from. In this case you can use LoadFile or LoadFrom, LoadFrom will load dependencies automatically while LoadFile loads just the assembly and will potentially load duplicate copies. (see the documentation) In order to get the dll's in place for this project we just simply add a post build event like so...

copy $(TargetDir)*.* $(SolutionDir)\Subversion.Dispatcher\$(OutDir)

If after instantiating the named type from the loaded assembly we actually have an IPostCommitHandler then make the call! Done.

System.Reflection.Assembly a = System.Reflection.Assembly.Load(typeAndAssembly[1]);
System.Type t = a.GetType(typeAndAssembly[0], true);
object handler = System.Activator.CreateInstance(t);
if (handler is IPostCommitHandler)
{
 ((IPostCommitHandler)handler).ExecuteCommand(new PostCommitArgs(argument,rev));
}

So that's that. You can download the code here - it should basically work as is if you are looking for a shortcut to extending subversion with .NET. I was relatively lazy with getting this posted - so if you got this far, can use the code, and have problems with it leave a comment and I'll try to help if I can.