Yashvit Yashvit - 29 days ago 10
ASP.NET (C#) Question

MEF with MVC 4 or 5 - Pluggable Architecture (2014)

I am trying to build a MVC4/MVC5 application with a pluggable architecture like Orchard CMS. So I have a MVC application which will be the startup project and take care of auth, navigation etc. Then there will be multiple modules built separately as asp.net class libraries or stripped down mvc projects and have controllers, views, data repos etc.

I have spent all day going through tutorials on the web and downloading samples etc and found that Kenny has the best example around - http://kennytordeur.blogspot.in/2012/08/mef-in-aspnet-mvc-4-and-webapi.html

I am able to import the controllers from the modules(separate DLLs) if I add reference to those DLLs. But the reason behind using MEF is being able to add modules at runtime. I want the DLLs along with views to be copied to a ~/Modules// directory in the startup project (I have managed to do this) and MEF would just pick them up. Struggling to make MEF load these libraries.

There is also MefContrib as explained in this answer ASP.NET MVC 4.0 Controllers and MEF, how to bring these two together? which is the next thing I am about to try. But I'm surprised that MEF doesnt work out of the box with MVC.

Has anyone got a similar architecture working (with or without MefContrib)? Initially I even thought of stripping Orchard CMS and using it as a framework but it is too complex. Also would be nice to develop the app in MVC5 to take advantage of WebAPI2.

Answer

I have worked on a project that had similar pluggable architecture like the one you described and it used the same technologies ASP.NET MVC and MEF. We had a host ASP.NET MVC application that handled the authentication, authorization and all requests. Our plugins(modules) were copied to a sub-folder of it. The plugins also were ASP.NET MVC applications that had its own models, controllers, views, css and js files. These are the steps that we followed to make it work:

Setting up MEF

We created engine based on MEF that discovers all composable parts at application start and creates a catalog of the composable parts. This is a task that is pefromed only once at application start. The engine needs to discover all plugable parts, that in our case were located either in the bin folder of the host application or in the Modules(Plugins) folder.

public class Bootstrapper
{
    private static CompositionContainer CompositionContainer;
    private static bool IsLoaded = false;

    public static void Compose(List<string> pluginFolders)
    {
        if (IsLoaded) return;

        var catalog = new AggregateCatalog();

        catalog.Catalogs.Add(new DirectoryCatalog(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "bin")));

        foreach (var plugin in pluginFolders)
        {
            var directoryCatalog = new DirectoryCatalog(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules", plugin));
            catalog.Catalogs.Add(directoryCatalog);

        }
        CompositionContainer = new CompositionContainer(catalog);

        CompositionContainer.ComposeParts();
        IsLoaded = true;
    }

    public static T GetInstance<T>(string contractName = null)
    {
        var type = default(T);
        if (CompositionContainer == null) return type;

        if (!string.IsNullOrWhiteSpace(contractName))
            type = CompositionContainer.GetExportedValue<T>(contractName);
        else
            type = CompositionContainer.GetExportedValue<T>();

        return type;
    }
}

This is the sample code of the class that performs discovery of all MEF parts. The Compose method of the class is called from the Application_Start method in the Global.asax.cs file. The code is reduced for the sake of simplicity.

public class MvcApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        var pluginFolders = new List<string>();

        var plugins = Directory.GetDirectories(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Modules")).ToList();

        plugins.ForEach(s =>
        {
            var di = new DirectoryInfo(s);
            pluginFolders.Add(di.Name);
        });

        AreaRegistration.RegisterAllAreas();
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        Bootstrapper.Compose(pluginFolders);
        ControllerBuilder.Current.SetControllerFactory(new CustomControllerFactory());
        ViewEngines.Engines.Add(new CustomViewEngine(pluginFolders));
    }
}

It is assumed that all plugins are copied in a separate sub-folder of the Modules folder that is located in the root of the host application. Each plugin subfolder contains Views sub-folder and the dll from each plugin. In the Application_Start method above are also initialized the custom controller factory and the custom view engine which I will define below.

Creating controller factory that reads from MEF

Here is the code for defining custom controller factory which will discover the controller that needs to handle the request:

public class CustomControllerFactory : IControllerFactory
{
    private readonly DefaultControllerFactory _defaultControllerFactory;

    public CustomControllerFactory()
    {
        _defaultControllerFactory = new DefaultControllerFactory();
    }

    public IController CreateController(RequestContext requestContext, string controllerName)
    {
        var controller = Bootstrapper.GetInstance<IController>(controllerName);

        if (controller == null)
            throw new Exception("Controller not found!");

        return controller;
    }

    public SessionStateBehavior GetControllerSessionBehavior(RequestContext requestContext, string controllerName)
    {
        return SessionStateBehavior.Default;
    }

    public void ReleaseController(IController controller)
    {
        var disposableController = controller as IDisposable;

        if (disposableController != null)
        {
            disposableController.Dispose();
        }
    }
}

Additionally each controller must be marked with Export attribute:

[Export("Plugin1", typeof(IController))]
[PartCreationPolicy(CreationPolicy.NonShared)]
public class Plugin1Controller : Controller
{
    //
    // GET: /Plugin1/
    public ActionResult Index()
    {
        return View();
    }
}

The first parameter of the Export attribute constructor must be unique because it specifies the contract name and uniquely identifies each controller. The PartCreationPolicy must be set to NonShared because controllers cannot be reused for multiple requests.

Creating View Engine that knows to find the views from the plugins

Creation of custom view engine is needed because the view engine by convention looks for views only in the Views folder of the host application. Since the plugins are located in separate Modules folder, we need to tell to the view engine to look there also.

public class CustomViewEngine : RazorViewEngine
{
    private List<string> _plugins = new List<string>();

    public CustomViewEngine(List<string> pluginFolders)
    {
        _plugins = pluginFolders;

        ViewLocationFormats = GetViewLocations();
        MasterLocationFormats = GetMasterLocations();
        PartialViewLocationFormats = GetViewLocations();
    }

    public string[] GetViewLocations()
    {
        var views = new List<string>();
        views.Add("~/Views/{1}/{0}.cshtml");

        _plugins.ForEach(plugin =>
            views.Add("~/Modules/" + plugin + "/Views/{1}/{0}.cshtml")
        );
        return views.ToArray();
    }

    public string[] GetMasterLocations()
    {
        var masterPages = new List<string>();

        masterPages.Add("~/Views/Shared/{0}.cshtml");

        _plugins.ForEach(plugin =>
            masterPages.Add("~/Modules/" + plugin + "/Views/Shared/{0}.cshtml")
        );

        return masterPages.ToArray();
    }
}

Solve the problem with strongly typed views in the plugins

By using only the above code, we couldn't use strongly typed views in our plugins(modules), because models existed outside of the bin folder. To solve this problem follow the following link.

Comments