Enter the View Engine
This is all very well and good as our requirements are quite clear, but the next step is making the above happen!
ASP.NET MVC provides the facility to override the View Engine, which is the component that determines how views are rendered.
This can be used to simply load views in from a different location, or even to allow completely bespoke mark-up to be transformed into HTML (Ala the Spark View Engine).
By default, the framework will use System.Web.Mvc.WebFormViewEngine, which is what loads the views from the View directory using the default convention and returns a ViewEngineResult containing a WebFormView which eventually ends up being used to render out the view.
The WebFormViewEngine class itself is extendable, and by inheriting from it we can change the search paths it uses to locate the views and partial views.
Naturally this is the first place we look to solve our problem, as writing less code is always preferable if we can get away with it.
The set-up of WebFormViewEngine is that in the constructor we can give it a selection of search paths - which means for the life-time of WebFormViewEngine those search paths are set.
They can be modified per-request, but WebFormViewEngine inherits from VirtualPathProviderViewEngine which caches paths under which it has found files (or at least, reading the source it looks like it does!).
For performance purposes (per-configuration path caching), it would probably therefore be best implementing a ViewEngine from scratch, but as the main body of work is achieved through the return result of the view engine methods, this is not as daunting an experience as we might think.
This is what IViewEngine looks like when we first create it:
public class ModuleViewEngine : IViewEngine
{
public ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
{
throw new NotImplementedException();
}
public ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
throw new NotImplementedException();
}
public void ReleaseView(ControllerContext controllerContext, IView view)
{
throw new NotImplementedException();
}
}
First things first, ReleaseView doesn't need to do anything unless the views you return implement IDisposable, and for that the following code can be used.
public void ReleaseView(ControllerContext controllerContext, IView view)
{
IDisposable disposable = view as IDisposable;
if (disposable != null)
{
disposable.Dispose();
}
}
The next thing of note is that the searching logic for locating the files is the same regardless of whether the engine is looking for a view or partial view, so we can create the following method and forget about it for now:
private string ResolvePath(String requestedFile, ControllerContext controllerContext)
{
throw new NotImplementedException();
}
FindPartialView and FindView both return the same type, and with similar values - I won't go into detail because the procedure is well documented elsewhere, but my methods in this example look like this:
public ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
{
String foundFile = ResolvePath(string.Format("{0}.ascx", partialViewName), controllerContext);
return new ViewEngineResult(
new WebFormView(foundFile),
this
);
}
public ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
String foundFile = ResolvePath(string.Format("{0}.aspx", viewName), controllerContext);
return new ViewEngineResult(
new WebFormView(
foundFile,
masterName),
this);
}
Note: This example will not deal with absolute paths being specified, it will also not deal gracefully with the file not being found at all - this simply involves returning a list of the searched locations on failure and isn't worth discussing further here.
ResolvePath is entirely dependent on the logic you want to follow when searching for your per configuration module provided views, but a reference implementation might look like the following:
private string ResolvePath(String requestedFile, ControllerContext controllerContext)
{
String result = string.Empty;
// Reverse the module order so we search from most recently ordered first
var searchModules = mConfigurationProvider
.GetActiveConfiguration()
.Modules
.Reverse()
.Select(m => m.Id);
// Search through each module in turn
foreach (String module in searchModules)
{
// Try the controller specific view folder first
String controllerName = controllerContext.RouteData.Values["controller"] as string;
result = GetFilename(requestedFile, controllerContext, module, controllerName);
if (string.IsNullOrEmpty(result))
{
result = GetFilename(requestedFile, controllerContext, module, "Shared");
}
if (!String.IsNullOrEmpty(result)) { return result; }
}
// Error!
return null;
}
private string GetFilename(String requestedFile, ControllerContext controllerContext, String module, String controllerName)
{
String path = string.Format("~/Views/{0}/{1}/{2}/", module, controllerName, requestedFile);
String filename = controllerContext.HttpContext.Server.MapPath(path);
if (File.Exists(filename)) { return path; }
return null;
}
Where mConfurationProvider is the IConfigurationProvider mentioned earlier.
In this implementation, we reverse the order of the loaded modules to get the most recently loaded first, and then select just the module id.
That gives us a list of folders names to search through in order to find the view, first attempting to find the file within the folder for the current action, and then the shared directory (just like the default WebFormViewEngine).
If it's not found, we return null and cross our fingers and hope for the best.
Just to re-iterate, in the real world you need to add error handling for when a view is not located, and code to deal with absolute paths (although maybe you don't support them and don't need to write that code!).
Because we have the current configuration, we can perform the caching of file locations on a per-configuration basis - just remember to disable caching during testing and debugging!
Summary
I haven't gone into a lot of detail about the implementation of the view engine because it's beyond the scope of this blog entry - a lot of information about writing custom view engines can be found with a "Bing" (or Google search *cough*) and it was not my intention of repeating them.
What we have covered is how we might utilise the power of view engines and a set of folder conventions to allow modules to create/override views and partial views.
As with all of these entries, the actual implementation is up to you and your particular product needs and the code examples should not be taken as gospel.
Next entry we'll be getting even more technical and covering how we can allow the modules to provide actions for these added views - and even how to override controller actions that have already been defined in other modules.
Examples of this code can be found in the DDD8 code samples here.