10
Sep 10

ASP.NET MVC2 Plugin Architecture Tutorial Part 3

This is part 3 of a multi-part tutorial:

So here it is. Part 3. When I said coming soon, I meant it. Honest! But I had a busy summer. Really busy. I made a list:

  • New job
  • Sold house
  • Laptop stolen
  • Moved across state

I love lists.

Let’s get on with this shall we? In the last tutorial, we had figured out how to make the whole process of developing a plugin a bit less painful. Things like adding the funky view locations to our view engines search path and automatically embedding the plugin’s views into its assembly make building plugins pretty much like building a standalone mvc app. Astute readers, however, may have noticed that the part 2 solution has a slight problem. In the global.asax:

ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new PluginViewEngine(
    new string[] { "~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.{1}.{0}.aspx"} ));

Yep. Our plugin host is coupled to our view. Anytime we add a new plugin, we will have to rebuild and redeploy the host. Unacceptable! So that is what this tutorial is about: making the host blissfully ignorant of what kind of plugins it has, or how many there are.

We will achieve the separation by defining a custom attribute class in our host that can be implemented by our plugins.  Add a class to the plugin host as follows:

[AttributeUsage(AttributeTargets.Assembly)]
public class : Attribute
{
   public string[] viewLocations { get; set; }
   public bool addLink { get; set; }
   public string name { get; set; }
   public string controller { get; set; }
   public string action { get; set; }

   public FzySqrPluginViewLocations(string[] viewLocations, bool addLink)
   {
       this.viewLocations = viewLocations;
       this.addLink = addLink;
   }
}

By declaring this custom attribute in a plugin, we can indicate to the host that the plugin assembly actually is a plugin. It will also let us pass information back to the host at runtime. In this example, we will just be building a link on the plugin page, but you could use the same technique to pass any type of information that a host may wish to know about its plugins.

To use the attribute, each plugin must have a reference to the host’s assembly (FzySqrPluginHost.dll). Implement the custom attribute in the AssemblyInfo.cs file. The example below shows an example for our HelloWorld plugin:

[assembly: FzySqrPluginViewLocations(
    new string[] { "~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.{1}.{0}.aspx" },
    true,
    action = "index",
    controller = "HelloWorld",
    name = "HelloWorld")]

Now we are going to create a helper class (PluginHelper) to gather up all the view locations and dynamically build links to each plugin. There is a lot of code to go through, so I will just cover the interesting bits. Download the demo code to see the full implementation.

PluginHelper’s first job is to identify all the plugin assemblies. It does this by using reflection to look through all the assemblies loaded in the application domain:

IEnumerable<Assembly> pluginAssemblies = 
    AppDomain.CurrentDomain.GetAssemblies().
    Where(a => a.GetCustomAttributes(typeof(FzySqrPluginViewLocations), false).
    Count() > 0).AsEnumerable();

We can then loop through the loaded assemblies and extract their view locations:

    foreach (Assembly plugin in pluginAssemblies)
    {
        var pluginAttribute = 
            plugin.GetCustomAttributes(typeof(FzySqrPluginViewLocations), 
            false).FirstOrDefault() as FzySqrPluginViewLocations;

        if (pluginAttribute != null)
            viewLocations.AddRange(pluginAttribute.viewLocations);
    }

Once we have gathered the view locations, we load our custom view engine:

    ViewEngines.Engines.Clear();
    ViewEngines.Engines.Add(new PluginViewEngine(viewLocations.ToArray()));

The second task of the helper is to build the dynamic links defined by the plugins. To do this, it loops through each plugin assembly and pulls out the action, controller, and plugin name:

foreach (Assembly plugin in pluginAssemblies)
{
    var pluginAttribute = 
        plugin.GetCustomAttributes(typeof(FzySqrPluginViewLocations), 
        false).FirstOrDefault() as FzySqrPluginViewLocations;

    if (pluginAttribute.addLink)
    {
        pluginLinks.Add(new PluginAction()
        {
            Name = pluginAttribute.name,
            Action = pluginAttribute.action,
            Controller = pluginAttribute.controller
        });
    }
}

We can then dynamically add the links to our Site.Master:

<% foreach (var pluginLink in 
    FzySqrPluginHost.PluginLib.PluginHelper.GetPluginActions())
{  %>
    <li><%: Html.ActionLink(pluginLink.Name, 
                            pluginLink.Action, 
                            pluginLink.Controller)%></li>
<% } %>

You probably do not want to use GetPluginActions() as it is written. It will have to reflect every single time it is run, which means once for every single page load because I am using it in the Site.Master… A smarter way would be to load the links once on Application_Start() and cache them. But for the sake of simplicity, we will leave that to a future discussion.

The last step is to change Application_Start() in global.asax.cs to use the PluginHelper’s initialize method:

protected void Application_Start() {
     AreaRegistration.RegisterAllAreas();

     HostingEnvironment.RegisterVirtualPathProvider(new AssemblyResourceProvider());

     PluginHelper.InitializePluginsAndLoadViewLocations();

     RegisterRoutes(RouteTable.Routes);
 }

If everything works, you should see the plugin link on the menu:

Main Page

Clicking it loads our plugin:

Loaded Plugin

That completes (finally) part three of the tutorial. Our plugins can now be developed independently of our host with minimal awareness of their plugin status. Using the custom attribute, we can define a pseudo interface through which a plugin can provide specific information back to the host at runtime. This can be used to build links and add menu times to plugin views. Get the source code for version three here and check it out.

Next steps to go from here? You might want to go beyond the simple attribute and define an interface for the plugin to implement in order to provide a more robust means to pass information back and forth between the host and the plugin. I would also want to look into compiling images, scripts, and stylesheets into the assembly so that plugins could bring the resources they depend on with them. - New job - Sold house - Laptop stolen - Moved across state

I love lists.

Let’s get on with this shall we? In the last tutorial, we had figured out how to make the whole process of developing a plugin a bit less painful. Things like adding the funky view locations to our view engines search path and automatically embedding the plugin’s views into its assembly make building plugins pretty much like building a standalone mvc app. Astute readers, however, may have noticed that the part 2 solution has a slight problem. In the global.asax:

ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new PluginViewEngine(
    new string[] { "~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.{1}.{0}.aspx"} ));

Yep. Our plugin host is coupled to our view. Anytime we add a new plugin, we will have to rebuild and redeploy the host. Unacceptable! So that is what this tutorial is about: making the host blissfully ignorant of what kind of plugins it has, or how many there are.

We will achieve the separation by defining a custom attribute class in our host that can be implemented by our plugins.  Add a class to the plugin host as follows:

[AttributeUsage(AttributeTargets.Assembly)]
public class : Attribute
{
   public string[] viewLocations { get; set; }
   public bool addLink { get; set; }
   public string name { get; set; }
   public string controller { get; set; }
   public string action { get; set; }

   public FzySqrPluginViewLocations(string[] viewLocations, bool addLink)
   {
       this.viewLocations = viewLocations;
       this.addLink = addLink;
   }
}

By declaring this custom attribute in a plugin, we can indicate to the host that the plugin assembly actually is a plugin. It will also let us pass information back to the host at runtime. In this example, we will just be building a link on the plugin page, but you could use the same technique to pass any type of information that a host may wish to know about its plugins.

To use the attribute, each plugin must have a reference to the host’s assembly (FzySqrPluginHost.dll). Implement the custom attribute in the AssemblyInfo.cs file. The example below shows an example for our HelloWorld plugin:

[assembly: FzySqrPluginViewLocations(
    new string[] { "~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.{1}.{0}.aspx" },
    true,
    action = "index",
    controller = "HelloWorld",
    name = "HelloWorld")]

Now we are going to create a helper class (PluginHelper) to gather up all the view locations and dynamically build links to each plugin. There is a lot of code to go through, so I will just cover the interesting bits. Download the demo code to see the full implementation.

PluginHelper’s first job is to identify all the plugin assemblies. It does this by using reflection to look through all the assemblies loaded in the application domain:

IEnumerable<Assembly> pluginAssemblies = 
    AppDomain.CurrentDomain.GetAssemblies().
    Where(a => a.GetCustomAttributes(typeof(FzySqrPluginViewLocations), false).
    Count() > 0).AsEnumerable();

We can then loop through the loaded assemblies and extract their view locations:

    foreach (Assembly plugin in pluginAssemblies)
    {
        var pluginAttribute = 
            plugin.GetCustomAttributes(typeof(FzySqrPluginViewLocations), 
            false).FirstOrDefault() as FzySqrPluginViewLocations;

        if (pluginAttribute != null)
            viewLocations.AddRange(pluginAttribute.viewLocations);
    }

Once we have gathered the view locations, we load our custom view engine:

    ViewEngines.Engines.Clear();
    ViewEngines.Engines.Add(new PluginViewEngine(viewLocations.ToArray()));

The second task of the helper is to build the dynamic links defined by the plugins. To do this, it loops through each plugin assembly and pulls out the action, controller, and plugin name:

foreach (Assembly plugin in pluginAssemblies)
{
    var pluginAttribute = 
        plugin.GetCustomAttributes(typeof(FzySqrPluginViewLocations), 
        false).FirstOrDefault() as FzySqrPluginViewLocations;

    if (pluginAttribute.addLink)
    {
        pluginLinks.Add(new PluginAction()
        {
            Name = pluginAttribute.name,
            Action = pluginAttribute.action,
            Controller = pluginAttribute.controller
        });
    }
}

We can then dynamically add the links to our Site.Master:

<% foreach (var pluginLink in 
    FzySqrPluginHost.PluginLib.PluginHelper.GetPluginActions())
{  %>
    <li><%: Html.ActionLink(pluginLink.Name, 
                            pluginLink.Action, 
                            pluginLink.Controller)%></li>
<% } %>

You probably do not want to use GetPluginActions() as it is written. It will have to reflect every single time it is run, which means once for every single page load because I am using it in the Site.Master… A smarter way would be to load the links once on Application_Start() and cache them. But for the sake of simplicity, we will leave that to a future discussion.

The last step is to change Application_Start() in global.asax.cs to use the PluginHelper’s initialize method:

protected void Application_Start() {
     AreaRegistration.RegisterAllAreas();

     HostingEnvironment.RegisterVirtualPathProvider(new AssemblyResourceProvider());

     PluginHelper.InitializePluginsAndLoadViewLocations();

     RegisterRoutes(RouteTable.Routes);
 }

If everything works, you should see the plugin link on the menu:

Main Page

Clicking it loads our plugin:

Loaded Plugin

That completes (finally) part three of the tutorial. Our plugins can now be developed independently of our host with minimal awareness of their plugin status. Using the custom attribute, we can define a pseudo interface through which a plugin can provide specific information back to the host at runtime. This can be used to build links and add menu times to plugin views. Get the source code for version three here and check it out.

Next steps to go from here? You might want to go beyond the simple attribute and define an interface for the plugin to implement in order to provide a more robust means to pass information back and forth between the host and the plugin. I would also want to look into compiling images, scripts, and stylesheets into the assembly so that plugins could bring the resources they depend on with them.