Iâve recently spent quite a lot of time researching and prototyping different ways to create a plugin engine in ASP.NET MVC3 and primarily finding a nice way to load plugins (DLLs) in from outside of the âbinâ folder. Although this post focuses on MVC3, I am sure that the same principles will apply for other MVC versions.
The Issues
Loading DLLs from outside of the âbinâ folder isnât really anything new or cutting edge, however when working with MVC this becomes more difficult. This is primarily due to how MVC loads/finds types that it needs to process including controllers, view models (more precisely the generic argument passed to a ViewPage or used with the @model declaration in Razor), model binders, etc⌠MVC is very tied to the BuildManager which is the mechanism for compiling views, and locating other services such as controllers. By default the BuildManager is only familiar with assembies in the âbinâ folder and in the GAC, so if you start putting DLLs in folders outside of the âbinâ then it wonât be able to locate the MVC services and objects that you might want it to be referencing.
Another issue that needs to be dealt with is DLL file locking. When a plugin DLL is loaded and is in use the CLR will lock the file. This becomes an an issue if developers want to update the plugin DLL while the website is running since they wonât be able to unless they bump the web.config or take the site down. This holds true for MEF and how it loads DLLs as well.
.Net 4 to the rescue⌠almost
One of the new features in .Net 4 is the ability to execute code before the app initializes which compliments another new feature of the BuildManager that lets you add assembly references to it at runtime (which must be done on application pre-init). Hereâs a nice little reference to these new features from Phil Haack: http://haacked.com/archive/2010/05/16/three-hidden-extensibility-gems-in-asp-net-4.aspx. This is essential to making a plugin framework work with MVC so that the BuildManager knows where to reference your plugin DLLs outside of the âbinâ. However, this isnât the end of the story.
Strongly typed Views with model Types located in plugin DLLs
Unfortunately if you have a view that is strongly typed to a model that exists outside of the âbinâ, then youâll find out very quickly that it doesnât work and it wonât actually tell you why. This is because the RazorViewEngine uses the BuildManager to compile the view into a dynamic assembly but then uses Activator.CreateInstance to instantiate the newly compiled object. This is where the problem lies, the current AppDomain doesnât know how to resolve the model Type for the strongly typed view since it doesnât exist in the âbinâ or GAC. An even worse part about this scenario is that you donât get any error message telling you why this isnât working, or where the problem is. Instead you get the nice MVC view not found error: ââŚor its master was not found or no view engine supports the searched locations. The following locations were searched: âŚ.â telling you that it has searched for views in all of the ViewEngine locations and couldnât find it⌠which is actually not the error at all. Deep in the MVC3 source, it tries to instantiate the view object from the dynamic assembly and it fails so it just keeps looking for that view in the rest of the ViewEngine paths.
NOTE: Even though in MVC3 thereâs a new IViewPageActivator which should be responsible for instantiating the views that have been compiled with the BuildManager, implementing a custom IViewPageActivator to handle this still does not work because somewhere in the MVC3 codebase fails before the call to the IViewPageActivator which has to do with resolving an Assembly that is not in the âbinâ.
Full trust
When working in Full Trust we have a few options for dealing with the above scenario:
- Use the AppDomainâs ResolveAssembly event
- By subscribing to this event, you are able to instruct the AppDomain where to look when it canât find a reference to a Type.
- This is easily done by checking if your plugin assemblies match the assembly being searched for, and then returning the Assembly object if found:
static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
{
var pluginsFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/Plugins"));
return (from f in pluginsFolder.GetFiles("*.dll", SearchOption.AllDirectories)
let assemblyName = AssemblyName.GetAssemblyName(f.FullName)
where assemblyName.FullName == args.Name || assemblyName.FullName.Split(',')[0] == args.Name
select Assembly.LoadFile(f.FullName)).FirstOrDefault();
}
- Shadow copy your plugin DLLs into the AppDomainâs DynamicDirectory.
- This is the directory that the BuildManager compiles itâs dynamic assemblies into and is also a directory that the AppDomain looks to when resolving Typeâs from Assemblies.
- You can shadow copy your plugin DLLs to this folder on app pre-init and everything âshould just workâ
- Replace the RazorViewEngine with a custom razor view engine that compiles views manually but makes references to the appropriate plugin DLLs
- I actually had this working in an Umbraco v5 prototype but it is hugely overkill and unnecessary plus you actually would have to replace the RazorViewEngine which is pretty absurd.
The burden of Medium Trust
In the MVC world thereâs only a couple hurdles to jump when loading in plugins from outside of the âbinâ folder in Full Trust. In Medium Trust however, things get interesting. Unfortunately in Medium Trust it is not possible to handle the AssemblyResolve event and itâs also not possible to access the DynamicDirectory of the AppDomain so the above two solutions get thrown out the window. Further to this it seems as though you canât use CodeDom in Medium Trust to custom compile views.
Previous attempts
For a while I began to think that this wasnât possible and I thought I tried everything:
- Shadow copying DLLs from the plugins folder into the âbinâ folder on application pre-init
- This fails because even during app pre-init, the application pool will still recycle. Well, it doesnât actually âfailâ unless you keep re-copying the DLL into the bin. If you check if it already exists and donât copy into the bin than this solution will work for you but itâs hardly a âsolutionâ since you might as well just put all your DLLs into the âbinâ in the first place.
- Trying to use sub folders of the âbinâ folder to load plugins.
- Turns out that ASP.Net doesnât by default load in DLLs that exist in sub folders of the bin, though from research it looks like standard .Net apps actually do.
- Another interesting point was that if you try to copy a DLL into a sub folder of the bin during application pre-init you get a funky error: âStorage scopes cannot be created when _AppStart is executingâ. It seems that ASP.Net is monitoring all changes in the bin folder regardless of whether or not they are in sub folders but still doesnât load or reference those assemblies.
An easy solution
So, the easy solution is to just set a âprivatePathâ on the âprobingâ element in your web.config to tell the AppDomain to also look for Assemblies/Types in the specified folders. I did try this before when trying to load plugins from sub folders in the bin and couldnât get it to work. Iâm not sure if I was âdoing it wrongâ but it definitely wasnât working then, either that or attempting to set this in sub folders of the bin just doesnât work.
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="Plugins/temp" />
DLL file locking
Since plugin DLLs get locked by the CLR when they are loaded, we need to work around this. The solution is to shadow copy the DLLs to another folder on application pre-init. As mentioned previously, this is one of the ways to get plugins loaded in Full Trust and in my opinion is the nicest way to do it since it kills 2 birds with one stone. In Medium Trust however, weâll have to jump through some hoops and shadow copy the DLLs to a temp folder that exists within the web application. IMPORTANT: When youâre copying DLLs you might be tempted to modify the name of the DLL by adding a version number or similar, but this will NOT work and youâll get a âThe located assembly's manifest definition ⌠does not match the assembly reference.â exception.
Solution
UPDATE: The latest version of this code can be found in the Umbraco v5 source code. The following code does work but thereâs been a lot of enhancements to it in the Umbraco core. Hereâs the latest changeset as of 16/16/2012 Umbraco v5 PluginManager.cs
Working in Full Trust, the simplest solution is to shadow copy your plugin DLLs into your AppDomain DynamicDirectory. Working in Medium Trust youâll need to do the following:
- On application pre-init:
- Shadow copy all of your plugin DLLs to a temporary folder in your web application (not in the âbinâ)
- Add all of the copied DLLs to be referenced by the BuildManager
- Add all folder paths to the privatePath attribute of the probing element in your web.config to point to where you will be copying your DLLs
- If you have more than one, you need to semi-colon separate them
Thanks to Glenn Block @ Microsoft who gave me a few suggestions regarding DLL file locking with MEF, Assembly load contexts and probing paths! You put me back on track after I had pretty much given up.
Hereâs the code to do the shadow copying and providing the Assemblies to the BuildManager on application pre-init (make sure you set the privatePath on the probing element in your web.config first!!)
using System.Linq;
using System.Web;
using System.IO;
using System.Web.Hosting;
using System.Web.Compilation;
using System.Reflection;
[assembly: PreApplicationStartMethod(typeof(PluginFramework.Plugins.PreApplicationInit), "Initialize")]
namespace PluginFramework.Plugins
{
public class PreApplicationInit
{
static PreApplicationInit()
{
PluginFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/plugins"));
ShadowCopyFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/plugins/temp"));
}
/// <summary>
/// The source plugin folder from which to shadow copy from
/// </summary>
/// <remarks>
/// This folder can contain sub folderst to organize plugin types
/// </remarks>
private static readonly DirectoryInfo PluginFolder;
/// <summary>
/// The folder to shadow copy the plugin DLLs to use for running the app
/// </summary>
private static readonly DirectoryInfo ShadowCopyFolder;
public static void Initialize()
{
Directory.CreateDirectory(ShadowCopyFolder.FullName);
//clear out plugins)
foreach (var f in ShadowCopyFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
f.Delete();
}
//shadow copy files
foreach (var plug in PluginFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
var di = Directory.CreateDirectory(Path.Combine(ShadowCopyFolder.FullName, plug.Directory.Name));
// NOTE: You cannot rename the plugin DLL to a different name, it will fail because the assembly name is part if it's manifest
// (a reference to how assemblies are loaded: http://msdn.microsoft.com/en-us/library/yx7xezcf )
File.Copy(plug.FullName, Path.Combine(di.FullName, plug.Name), true);
}
// Now, we need to tell the BuildManager that our plugin DLLs exists and to reference them.
// There are different Assembly Load Contexts that we need to take into account which
// are defined in this article here:
// http://blogs.msdn.com/b/suzcook/archive/2003/05/29/57143.aspx
// * This will put the plugin assemblies in the 'Load' context
// This works but requires a 'probing' folder be defined in the web.config
foreach (var a in
ShadowCopyFolder
.GetFiles("*.dll", SearchOption.AllDirectories)
.Select(x => AssemblyName.GetAssemblyName(x.FullName))
.Select(x => Assembly.Load(x.FullName)))
{
BuildManager.AddReferencedAssembly(a);
}
// * This will put the plugin assemblies in the 'LoadFrom' context
// This works but requires a 'probing' folder be defined in the web.config
// This is the slowest and most error prone version of the Load contexts.
//foreach (var a in
// ShadowCopyFolder
// .GetFiles("*.dll", SearchOption.AllDirectories)
// .Select(plug => Assembly.LoadFrom(plug.FullName)))
//{
// BuildManager.AddReferencedAssembly(a);
//}
// * This will put the plugin assemblies in the 'Neither' context ( i think )
// This nearly works but fails during view compilation.
// This DOES work for resolving controllers but during view compilation which is done with the RazorViewEngine,
// the CodeDom building doesn't reference the plugin assemblies directly.
//foreach (var a in
// ShadowCopyFolder
// .GetFiles("*.dll", SearchOption.AllDirectories)
// .Select(plug => Assembly.Load(File.ReadAllBytes(plug.FullName))))
//{
// BuildManager.AddReferencedAssembly(a);
//}
}
}
}