8
votes

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;
    }
1
Man, takes ages to read this. Is there a question in here?Peter Lillevold
Sorry, I wanted to include the details because the web is full of MEF+MVC samples but often they miss a specific point or they refer to older versions. The question is: I want to create a MVC web app with plug-in controllers using MEF (+a dependency injection tool like Autofac), but if I create my custom MEF-based controller factory it always gets a null controllerType, so it fails. This seems due to the different namespaces in plugin and host, and to the nature of MVC routing with regard to this. How can I let my controller factory work in this context?Naftis
Did you ever get anywhere with this? I am just starting to go down the same path and any insight you can shed would be helpful.ryanrdl
I'm sorry I didn't, also because I planned a major shift in my system, by using for my movig parts single page apps built with JS and connected to the server side exposing a RESTful API via WebAPI. Now my plugins just provide WebAPI and SPA (=views with their JS), and this is much easier to manage in MVC. Yet, this fits my requirements but does not solve the posted issue. I'd really like more infrastructure provided by MVC for things like areas and membership.Naftis

1 Answers

1
votes

The controller factory needs a hand in locating the right controller Type for external MEF components. Override the GetControllerType method of the MefControllerFactory class like so.

public class MefControllerFactory : DefaultControllerFactory
{
    protected override Type GetControllerType(RequestContext requestContext, string controllerName)
    {
        var controllerType = base.GetControllerType(requestContext, controllerName);

        if (controllerType == null)
        {
            var controller = _compositionContainer.GetExports<IController, IControllerMetaData>().SingleOrDefault(x => x.Metadata.ControllerName == controllerName).Value;

            if (controller != null)
            {
                return controller.GetType();
            }
       }
       return controllerType;
    }
}

Where IControllerMetaData is an interface that specifies the name of the controller

public interface IControllerMetaData
{
    string ControllerName { get;}
}

And your controller specifies the controllerName in the meta data. E.g.

[Export (typeof(IController))]
[ExportMetadata("ControllerName", "Home")]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class HomeController : Controller, IController
{
    public ActionResult Index()
    {
        return new EmptyResult();
    }
}