I'm trying to create an MVC4 web app using a number of plugins, i.e. essentially controllers exported via MEF plus content files unpacked into their proper locations. I found a lot of material about MVC plugins, mainly related to areas, but I had to give up with MvcContrib, which would be the most obvious solution, as it seems to be no more developed, shows some issues with the latest MVC bits, and I'd also like a minimally complex implementation for this architecture.
My requirements were thus:
a) a MEF-based MVC plugin solution where I just drop a package in my site to let it be used, ideally without even a restart. This implies storing plugins in a folder different from Bin, which also provides better isolation.
b) a solution compliant with IoC tools more complete than what can be accomplished by MEF alone. I am inclined to use Autofac for this, as it has integration with both MEF and MVC4 (RC at this time).
Apart from content like views, the essential task for this is letting MVC locate controllers among MEF plugins and instantiate them, so I need a controller factory. I found a good article about this here: http://kennytordeur.blogspot.be/2012/08/mef-in-aspnet-mvc-4-and-webapi.html (I contacted Kenny about this and I thank him for pointing me to some routing issue). The author also wrapped his code into a handy nuget package (MEF.MVC4). Anyway, I'm finding an issue which seems related to routing and namespaces: when hitting a route to a plugin controller, the MEF controller factory GetControllerInstance method gets a null controllerType, which finally results in a 404. I think I possibly found the culprit by reading these posts:
http://blog.davebouwman.com/2011/12/08/asp-net-mvc3-and-404s-for-area-controllers/
and
Custom Controller Factory, Dependency Injection / Structuremap problems with ASP.NET MVC
I suppose (but I could be wrong) the issue is in routing conventions and plugin-areas controllers namespaces: the namespace of the plugin controller is not in the same 'root' of the host web. The solution proposed in the post is just adding a new route to the web app, but this does not fit into a solution where areas work as plugins, dynamically added to the host application. My host web app must remain unaware of the plugins, and this of course should be a fairly common requirement, yet I find no obvious solution for this.
Repro solution
You can quickly create a repro solution so you can see the details of my approach by following these steps or download it from here:
1) create a blank solution.
2) create an MVC4 web app into it (HostWeb), update all the NuGet preinstalled packages and add Mef.MVC4, Autofac MVC 4 (RC) and Autofac.Mef. In my real-world app I'd like to use Autofac to inject controllers' dependencies in constructor.
3) create a Plugins folder in HostWeb and a subfolder Temp into it. This will include plugins using the Temp subfolder as a shadow copies container so that MEF catalog will be loaded from it rather than directly from Plugins. This in conjunction with some startup code should let me update the plugins without having to restart the web app (which would otherwise lock the DLLs). The startup code is a class named PreApplicationInit you can find in the Infrastructure folder (slightly modified from http://shazwazza.com/post/Developing-a-plugin-framework-in-ASPNET-with-medium-trust.aspx).
4) add a Parts area to the host web and copy into it the _ViewStart file from the views root folder (and change existing links in the layout view so that an empty area is added to the route values so that they do not get broken). All the plugin controllers will be namespaced into an Area named Parts. The host web app has such an area with no controller, just to prepare the folders structure and routes for plugin content files (views, which would be unpacked from a plugin installer module into the proper location, while binaries will be placed in Plugins).
5) in App_Start of HostWeb customize MefConfig and add IocConfig which deals with Autofac. Then add to global asax the calls to both: MefConfig.RegisterMef() and IocConfig.RegisterDependencies().
6) create another MVC4 web app into it (AlphaPlugin), update all the NuGet preinstalled packages and add Autofac MVC 4 (RC) and Autofac.Mef. I choose a web app template (rather than class library) so I can use all the VS facilities for MVC and eventually do some testing directly there.
7) add a Parts area to the host web and copy into it the _ViewStart file from the views root folder.
8) add an exportable controller in the Parts area. Mine is called AlphaController, and has just an action method named Hail which puts in the ViewBag a string and returns the default view.
9) back to the host, just add a link to the plugin controller's action in the home view to test it is accessible via MEF.
Now if I build all and copy the AlphaPlugin.dll binary into the HostWeb Plugins folder, I expect MVC to find it via MEF, but then throw a view not found error, as I've not yet copied any content files in the host web. Instead, I get the following:
Value cannot be null.
Parameter name: type
Description: An unhandled exception occurred during the execution of the current web request. Please review the stack trace for more information about the error and where it originated in the code.
Exception Details: System.ArgumentNullException: Value cannot be null.
Parameter name: type
Source Error:
An unhandled exception was generated during the execution of the current web request. Information regarding the origin and location of the exception can be identified using the exception stack trace below.
Stack Trace:
[ArgumentNullException: Value cannot be null.
Parameter name: type]
System.ComponentModel.Composition.Hosting.ExportProvider.GetExportsCore(Type type, Type metadataViewType, String contractName, ImportCardinality cardinality) +263923
System.ComponentModel.Composition.Hosting.ExportProvider.GetExports(Type type, Type metadataViewType, String contractName) +41
MEF.MVC4.MefControllerFactory.GetControllerInstance(RequestContext requestContext, Type controllerType) +84
System.Web.Mvc.DefaultControllerFactory.CreateController(RequestContext requestContext, String controllerName) +226
System.Web.Mvc.MvcHandler.ProcessRequestInit(HttpContextBase httpContext, IController& controller, IControllerFactory& factory) +326
System.Web.Mvc.MvcHandler.BeginProcessRequest(HttpContextBase httpContext, AsyncCallback callback, Object state) +177
System.Web.Mvc.MvcHandler.BeginProcessRequest(HttpContext httpContext, AsyncCallback callback, Object state) +88
System.Web.Mvc.MvcHandler.System.Web.IHttpAsyncHandler.BeginProcessRequest(HttpContext context, AsyncCallback cb, Object extraData) +50
System.Web.CallHandlerExecutionStep.System.Web.HttpApplication.IExecutionStep.Execute() +301
System.Web.HttpApplication.ExecuteStep(IExecutionStep step, Boolean& completedSynchronously) +155
Here is the most relevant code (you can find it all in the repro solution): this deals with Plugins folder contents so that the web app loads them from a copy:
[assembly: PreApplicationStartMethod(typeof(PreApplicationInit), "Initialize")]
static public class PreApplicationInit
{
///
/// The source plugin folder from which to shadow copy from.
///
/// This folder can contain sub folders to organize plugin types.
internal static DirectoryInfo PluginFolder { get; private set; }
///
/// The folder to shadow copy the plugin DLLs to use for running the app.
///
internal static DirectoryInfo ShadowCopyFolder { get; private set; }
static PreApplicationInit()
{
PluginFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/Plugins"));
ShadowCopyFolder = new DirectoryInfo(HostingEnvironment.MapPath("~/Plugins/Temp"));
}
public static void Initialize()
{
if (!Directory.Exists(ShadowCopyFolder.FullName))
Directory.CreateDirectory(ShadowCopyFolder.FullName);
else
{
foreach (FileInfo fi in ShadowCopyFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
try
{
fi.Delete();
}
catch (Exception ex)
{
// TODO log
Debug.WriteLine(ex.ToString());
}
}
}
// shadow copy files
foreach (FileInfo fi in PluginFolder.GetFiles("*.dll"))
{
try
{
File.Copy(fi.FullName, Path.Combine(ShadowCopyFolder.FullName, fi.Name), true);
}
catch (Exception ex)
{
// TODO log
Debug.WriteLine(ex.ToString());
}
}
}
}
And this is my factory, which anyway gets a null controllerType so its code is never executed beyond its 1st line:
public class MefControllerFactory : DefaultControllerFactory
{
private readonly CompositionContainer _compositionContainer;
public MefControllerFactory(CompositionContainer compositionContainer)
{
_compositionContainer = compositionContainer;
}
protected override IController GetControllerInstance(System.Web.Routing.RequestContext requestContext, Type controllerType)
{
// https://stackguides.com/questions/719678/custom-controller-factory-dependency-injection-structuremap-problems-with-asp
if (controllerType == null) return base.GetControllerInstance(requestContext, null);
var export = _compositionContainer.GetExports(controllerType, null, null).SingleOrDefault();
IController result;
if (export != null) result = export.Value as IController;
else
{
result = base.GetControllerInstance(requestContext, controllerType);
_compositionContainer.ComposeParts(result);
}
return result;
}