26
Apr 10

ASP.NET MVC2 Plugin Architecture Tutorial

This is part 1 of a multi-part tutorial:

I recently started a new project with a requirement that the software be able support plugin modules. I assumed that this sort of functionality would be well documented and demonstrated on the internet, but had a surprisingly hard time finding information.   Throw Visual Studio 2010 and ASP.NET MVC2 into the mix and all I could really find were some proof-of-concept examples, the best being J Wynia’s example which ended up forming the basis for my solution. I took Wynia’s example (and some of the subsequent discussion in the comments) and expanded it to meet my requirements:

  • I want to use Visual Studio 2010, .NET 4.0,  ASP.NET MVC2.
  • Plugins are not a PITA to develop and test.
  • Plugins live in separate Visual Studio solutions.
  • Plugins can stand alone as applications (primarily for ease of development)
  • Plugins can be loaded without restarting/redeploying the main application.
  • Plugins can provide information to the hosting application.

In this series of posts, I will walk through creating a basic plugin framework from scratch, starting with describing Wynia’s VirtualPathProvider concept and then building on it to form a solution that can be used in the real world (at least my twisted definition of real world!).

For this tutorial, I am going to assume you have Visual Studio 2010 installed, understand the basic concept of MVC, and are generally familiar with the ASP.NET MVC2 framework.  Also, while not strictly necessary, I will taking the occasional dive into the MVC2 source code, so you may want to have a copy of that as well.

Let’s get started.  First, create a new ASP.NET MVC2 Web Application project, let’s call it “FzySqrPluginHost“. Do not create any unit tests for this project.  Add a new folder named “PluginLib” to the project.  Within this new folder, add two new classes.  We will name one “AssemblyResourceProvider” and will it inherit System.Web.Hosting.VirtualPathProvider.  Name the other class ”AssemblyResourceVirtualFile” and make it inherit System.Web.Hosting.VirtualFile.

The VirtualPathProvider class provides developers a way to intercept the calls MVC makes to retrieve files from the filesystem and provide these files however we see fit (in our case directly from an assembly).  The AssemblyResourceProvider class we are about to build is the cornerstone of the plugin architecture and I would encourage you to go read Wynia’s tutorial about this before continuing.  It a nutshell, we are going to embed our plugin controllers and views into separate assemblies and then load them on demand using our custom VirtualPathProvider class.

Ok, back to code.  The AssemblyResourceProvider class will override three methods in VirtualPathProvider:

  • public virtual bool FileExists(string virtualPath)
  • public virtual VirtualFile GetFile(string virtualPath)
  • public virtual CacheDependency GetCacheDependency(string virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart)

and we will add one helper method:

  • private bool IsAppResourcePath(string virtualPath)

IsAppResource() looks like this:

private bool IsAppResourcePath(string virtualPath) {
    String checkPath = VirtualPathUtility.ToAppRelative(virtualPath);
    return checkPath.StartsWith("~/Plugins/", StringComparison.InvariantCultureIgnoreCase);
}

Its job is to check a virtual path and decide whether the resource is provided by the plugin or the plugin host. To do this, we use the convention that all paths destined for plugin modules will begin with “~/Plugins/”. In other words, the path ~/Plugins/MyPlugin.dll/SomeResource.aspx indicates that the resource it represents is not a physical file and needs to be loaded from the MyPlugin.dll (more on this later).

Our new FileExists() method looks like this:

public override bool FileExists(string virtualPath) {
     if (IsAppResourcePath(virtualPath)) {
         string path = VirtualPathUtility.ToAppRelative(virtualPath);
         string[] parts = path.Split('/');
         string assemblyName = parts[2];
         string resourceName = parts[3];

         assemblyName = Path.Combine(HttpRuntime.BinDirectory, assemblyName);
         byte[] assemblyBytes = File.ReadAllBytes(assemblyName);
         Assembly assembly = Assembly.Load(assemblyBytes);

         if (assembly != null) {
             string[] resourceList = assembly.GetManifestResourceNames();
             bool found = Array.Exists(resourceList, delegate(string r) { return r.Equals(resourceName); });

             return found;
         }
         return false;
     }
     else
         return base.FileExists(virtualPath);
 }

FileExists() is called by the framework to check for the existence of a physical file. We want to override the method so we can check for the “~/Plugins/” string in our path. If we find it, then we use reflection to determine if we have an embedded view in the targeted assembly that matches the requested file name.  An example of a parameter that would be passed into this method would be “~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.HelloWorld.Index.aspx”.  Given this path for a view, this code would load the FzySqrPlugin.dll and look for a resource with named FzySqrPlugin.Views.HelloWorld.Index.aspx.

If the”~/Plugins/” string is not found, then the super class’s FileExist() method is called and the normal behavior ensues.

The new GetFile() method looks like this:

public override VirtualFile GetFile(string virtualPath) {
    if (IsAppResourcePath(virtualPath))
        return new AssemblyResourceVirtualFile(virtualPath);
    else
        return base.GetFile(virtualPath);
}

If we have a plugin path, then return our custom AssemblyResourceVirtualFile type, otherwise provide the standard behavior.

GetCacheDependency() looks like this:

public override CacheDependency GetCacheDependency(string virtualPath, IEnumerable virtualPathDependencies, DateTime utcStart) {
    if (IsAppResourcePath(virtualPath)) {
        return null;
    }
    return base.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
}

Again, if we have a plugin path, return null (for now), otherwise invoke the super class’s method.

Now we have to build out the second class we added, AssemblyResourceVirtualFile. The purpose of this class is to actually provide the bytes for a given resource. We subclass VirtualFile and override Open() to read the requested resource out of our plugin assembly:

public class AssemblyResourceVirtualFile : System.Web.Hosting.VirtualFile {
    private string path;

    public AssemblyResourceVirtualFile(string virtualPath)
        : base(virtualPath) {
        path = VirtualPathUtility.ToAppRelative(virtualPath);
    }

    public override Stream Open() {
        string[] parts = path.Split('/');
        string assemblyName = parts[2];
        string resourceName = parts[3];

        assemblyName = Path.Combine(HttpRuntime.BinDirectory, assemblyName);
        byte[] assemblyBytes = File.ReadAllBytes(assemblyName);
        Assembly assembly = Assembly.Load(assemblyBytes);

        if (assembly != null)
            return assembly.GetManifestResourceStream(resourceName);

        return null;
    }
}

There is nothing particularly interesting here, but I will take this time to point out a nifty little technique that will matter more later we when get to hot deploying our plugins:

While it is possible to directly load an assembly using Assembly.LoadFile() or Assembly.LoadFrom(), these methods will lock the DLL file for the duration of the process (e.g. until you restart IIS). A much better way to load an assembly is to create an in-memory copy of the DLL and load that, which is what we are doing here:

assemblyName = Path.Combine(HttpRuntime.BinDirectory, assemblyName);
byte[] assemblyBytes = File.ReadAllBytes(assemblyName);
assembly = Assembly.Load(assemblyBytes);

This technique will be more important in the next part of the series, but is generally good practice.

Finally, openGlobal.asax.cs and register our fancy new AssemblyResourceProvider class by adding the following line to the Application_Start() method:

HostingEnvironment.RegisterVirtualPathProvider(new AssemblyResourceProvider());

This just tells the framework to use our custom AssemblyResourceProvider when accessing files.

Save this project, close the solution, and put it aside for the moment. We will now turn our attention to the plugin side of equation.  First, create a new project (the idea is that the plugin lives entirely separate from the plugin host solution/project) and name it “FzySqrPlugin”.  This time, go ahead and create the unit tests as well.  Add a new controller named “HelloWorldController”.  Add a new view for the Index() action and modify the new view to say… “hello world” (I know, original).

The one tricky piece here is the name of view.  We will not be able to depend on the plugin host finding our view.  Instead, we need to specify the full location:

  • First start with the “~/Plugins/” string to tell the plugin host that this is a plugin based view,
  • add the name of the plugin DLL the view will be embedded in,
  • then add the full namespace of the view

The best way to figure out this last part is to load up the FzySqrPlugin.dll in .NET Reflector:

Thus, our view string will be: ”~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.HelloWorld.Index.aspx”

Your code will look like this when you are done:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;

namespace FzySqrPlugin.Controllers
{
    public class HelloWorldController : Controller
    {
        public ActionResult Index()
        {
             return View("~/Plugins/FzySqrPlugin.dll/FzySqrPlugin.Views.HelloWorld.Index.aspx");
        }
    }
}

and

<asp:Content ID="Content1" ContentPlaceHolderID="TitleContent" runat="server">
    Index
</asp:Content>

<asp:Content ID="Content2" ContentPlaceHolderID="MainContent" runat="server">

    <h2>Hello World</h2>

</asp:Content>

Note: The .aspx page must inherit from System.Web.Mvc.ViewPage, not System.Web.Mvc.ViewPage<dynamic>. If you do not remove the <dynamic>  tag, you will get an error later on.

Simple enough right?

We have one last step. Save the Index.aspx file, then right click on it and view its properties. Set the Build Action: field to “Embedded Resource”.  This tells the compiler to include the view in the assembly during the build.  Build the whole project and  browse to the bin directory in Windows Explorer.  Find the FzySqrPlugin.dll file and copy it to the FzySqrPluginHost\bin\ directory.

Fire it up!  Open the FzySqrPluginHost solution and run the project in the debugger and… BLAM!   Exception:

Multiple types were found that match the controller named ‘Home’. This can happen if the route that services this request (‘{controller}/{action}/{id}’) does not specify namespaces to search for a controller that matches the request. If this is the case, register this route by calling an overload of the ‘MapRoute’ method that takes a ‘namespaces’ parameter.

The request for ‘Home’ has found the following matching controllers:
FzySqrPluginHost.Controllers.HomeController
FzySqrPlugin.Controllers.HomeController

This doesn’t look too good.  As it turns out, MVC2 will go ahead and check the DLLs in the bin directory for controller types and even though it does not actually know how to load any views from the FzySqrPlugin.dll (without further action on our part), it gets upset that we would have the gall to put a HomeController type in there.

We have two choices at this point: remove the duplicate controllers in the plugin, or tell MVC2 where it should look for it’s plugins.  Luckily for us, the latter option is quite easy.

In the Global.asax.cs file, we will override the routes.MapRoute() method call with an additional parameter:

routes.MapRoute(
    "Default", 
    "{controller}/{action}/{id}", 
    new { controller = "Home", action = "Index", id = UrlParameter.Optional }, 
    new string[] { "FzySqrPluginHost.Controllers" }  // <------------New string array with namespaces to search
);

The MapRoute() method can optionally be overridden to provide a restricted set of namespaces to search for controller types. We use this to force it to only look for controllers in the FzySqrPluginHost.Controllers namespace.

Ok, let us try this again. Fire up the debugger on FzySqrPluginHost and browse to: http://localhost:XXXX/HelloWorld/Index/

You should get something like this.  Yay!

I would like to take a moment now to say two things.  First, THANK YOU J WYNIA.  Without your code I would taken about 30 years longer to get this point.

Second, WTF!  How did MVC load my view?  You remember, the view with the funky long ~/Plugins/dll/blahblahblah, right?  Well, the controller loaded it of course.  But how did it find my controller?  We never actually told it where to go and furthermore, we specified a specific namespace in FzySqrPluginHost! I too was confused, until I realized I had the power to unconfuse myself.

Behold, the magic of open source!

Quite possibly the best part of ASP.NET MVC is that we have the source code.  I hooked up (I’ll do a quick how-to on this in a few days) the MVC code in my project and fired up the debugger.  Turns out, that the GetControllerType() method in DefaultControllerFactory was responsible for the unexpected behavior:

protected internal virtual Type GetControllerType(RequestContext requestContext, string controllerName) {
    if (String.IsNullOrEmpty(controllerName)) {
        throw new ArgumentException(MvcResources.Common_NullOrEmpty, "controllerName");
    }

    // first search in the current route's namespace collection
    object routeNamespacesObj;
    Type match;
    if (requestContext != null && requestContext.RouteData.DataTokens.TryGetValue("Namespaces", out routeNamespacesObj)) {
        IEnumerable routeNamespaces = routeNamespacesObj as IEnumerable;
        if (routeNamespaces != null && routeNamespaces.Any()) {
            HashSet nsHash = new HashSet(routeNamespaces, StringComparer.OrdinalIgnoreCase);
            match = GetControllerTypeWithinNamespaces(requestContext.RouteData.Route, controllerName, nsHash);

            // the UseNamespaceFallback key might not exist, in which case its value is implicitly "true"
            if (match != null || false.Equals(requestContext.RouteData.DataTokens["UseNamespaceFallback"])) {
                // got a match or the route requested we stop looking
                return match;
            }
        }
    }

    // then search in the application's default namespace collection
    if (ControllerBuilder.DefaultNamespaces.Count > 0) {
        HashSet nsDefaults = new HashSet(ControllerBuilder.DefaultNamespaces, StringComparer.OrdinalIgnoreCase);
        match = GetControllerTypeWithinNamespaces(requestContext.RouteData.Route, controllerName, nsDefaults);
        if (match != null) {
            return match;
        }
    }

    // if all else fails, search every namespace
    return GetControllerTypeWithinNamespaces(requestContext.RouteData.Route, controllerName, null /* namespaces */);
}

There is a lot going on here, but the main point is clearly explained in the comments:

  • // first search in the current route’s namespace collection
  • // then search in the application’s default namespace collection
  • // if all else fails, search every namespace

After we modified our Global.asax.cs to specify the namespace, MVC finds the Home controller in the first call to GetControllerTypeWithinNamespaces() and then exits. Later on, when we pass in the HelloWorld controller, it fails to find the controller in the first two calls to GetControllerTypeWithinNamespaces() and finally calls it again with null for the namespace parameter.  I won’t dive into GetControllerTypeWithinNamespaces() here, but basically, about 10 calls down the stack, it will load our FzySqrPlugin.dll just because it is hanging out in the bin directory of the host.

That’s it for today!  We learned how to create a basic plugin/host architecture and got it all up and running.  We also took a bit of a dive into how MVC handles its controllers and resources.  In the next post, we will look into making plugins ignorant of their plugin status and set them up to run as standalone applications.

Here is the code for everything I discussed. Have fun and let me know if you have any questions. FzySqr.com_PluginTutorial_Day1.zip



Go to Part 2 of this tutorial.