6.27.2010

ASP.NET MVC 2 - Building extensible view engine.

Contents


Introduction
Understanding an Idea
Extensible version of view engine
Sample application

Introduction


In time of MVC 1, inspired by Phil Haack's blog post about Grouping Controllers with ASP.NET MVC one my colleague added a support of areas and skins to our project. Skins was fine, but about areas... my intuition asked me that things can be more simple, and there might be a better, more testable solution than proposed by Phil.
So I wrote a WebFormExtensibleViewEngine that can be easily extended to do whatever you want. After that I extend it and wrote a WebFormSkinedAreaViewEngine that supports areas (no matter nested or not) and skinning.

Then I wrote this article, but there was MVC 2.0 Beta 2 and I found there exactly similar solution. So I decided do not publish this article. But after moving to MVC 2.0 RTM, I found that this theme is still actual, so I decide to publish this article but without description how to implement areas.
Go to contents >

Understanding an Idea


First of all I want to describe an idea about how all this works and we will develop the simple version of a view engine. So, in ASP.NET MVC we have a default WebFormViewEngine. This class inherits from an abstract VirtualPathProviderViewEngine; realizes 2 abstract methods: CreatePartialView, CreateView; overrides base method FileExists and... thats all. Oh, also this class initializes 6 *LocationFormats properties of the base class in his constructor: MasterLocationFormats, ViewLocationFormats, PartialViewLocationFormats, AreaMasterLocationFormats, AreaViewLocationFormats, AreaPartialViewLocationFormats. You can see all this in source code of MVC 2.0 RTM, for readability reasons I place an implementation of WebFormViewEngine here:
public class WebFormViewEngine : VirtualPathProviderViewEngine
{
    private IBuildManager _buildManager;

    public WebFormViewEngine()
    {
        base.MasterLocationFormats = new string[] { "~/Views/{1}/{0}.master", "~/Views/Shared/{0}.master" };
        base.AreaMasterLocationFormats = new string[] { "~/Areas/{2}/Views/{1}/{0}.master", "~/Areas/{2}/Views/Shared/{0}.master" };
        base.ViewLocationFormats = new string[] { "~/Views/{1}/{0}.aspx", "~/Views/{1}/{0}.ascx", "~/Views/Shared/{0}.aspx", "~/Views/Shared/{0}.ascx" };
        base.AreaViewLocationFormats = new string[] { "~/Areas/{2}/Views/{1}/{0}.aspx", "~/Areas/{2}/Views/{1}/{0}.ascx", "~/Areas/{2}/Views/Shared/{0}.aspx", "~/Areas/{2}/Views/Shared/{0}.ascx" };
        base.PartialViewLocationFormats = base.ViewLocationFormats;
        base.AreaPartialViewLocationFormats = base.AreaViewLocationFormats;
    }

    protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
    {
        return new WebFormView(partialPath, null);
    }

    protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
    {
        return new WebFormView(viewPath, masterPath);
    }

    protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
    {
        try
        {
            return (this.BuildManager.CreateInstanceFromVirtualPath(virtualPath, typeof(object)) != null);
        }
        catch (HttpException exception)
        {
            if (exception is HttpParseException) throw;
            if ((exception.GetHttpCode() != 0x194) || base.FileExists(controllerContext, virtualPath)) throw;
            return false;
        }
    }

    internal IBuildManager BuildManager
    {
        get
        {
            if (this._buildManager == null)
                this._buildManager = new BuildManagerWrapper();
            return this._buildManager;
        }
        set { this._buildManager = value; }
    }
}
Methods CreateView, CreatePartialView used by base class (VirtualPathProviderViewEngine) to create results in FindView, FindPartialView methods respectively. Method FileExists used by base class to check if requested view really exists in the 'location' or not.

The most interesting are 6 properties initialized in the constructor. They provide 'location formats' (as you can see from their names) that used to form possible 'location' string when engine will search the requested View or PartialView or even MasterPage. As you can see View and PartialView locations are the same also there are two versions of format strings one with aspx and another with ascx file extension. This gives you an ability to use both of this formats as views/partial views as you wish. Format strings has 3 placeholders with next purpose:
{0} - name of the View\PartialView\MasterPage
{1} - name of the controller
{2} - name of the area
My idea is simple - extend list of search location formats with own custom format strings that include support for skinning, for example:
"~/Content/{3}/Views/{1}/{0}.master"
"~/Content/{3}/Views/Shared/{0}.master"
"~/Content/{3}/Areas/{2}/Views/{1}/{0}.ascx"
"~/Content/{3}/Areas/{2}/Views/Shared/{0}.ascx"
As you can see, I've added new placeholder {3}, it is used for skin name.

Since standard version of FindView and FindPartialView methods realized in VirtualPathProviderViewEngine class have no support for placeholder {3}, we must override this two methods. Overridden versions will do thing that I call 'preformatting' - replace {3} by real value and leave {0}, {1}, {2} unchanged. 'Preformatted' location can be used by base FindView, FindPartialView methods. Here is a simple implementation of view engine that supports skinning:
using System.Collections.Generic;
using System.Web.Mvc;

namespace HennadiyKurabko.SimpleViewEngine
{
    public class WebFormSimpleViewEngine : WebFormViewEngine
    {
        public WebFormSimpleViewEngine()
        {
            MasterLocationFormats = new[] 
            {
                "~/Content/{3}/Views/{1}/{0}.master",
                "~/Content/{3}/Views/Shared/{0}.master",
                "~/Views/{1}/{0}.master",
                "~/Views/Shared/{0}.master",
            };

            AreaMasterLocationFormats = new[]
            {
                "~/Content/{3}/Areas/{2}/Views/{1}/{0}.master",
                "~/Content/{3}/Areas/{2}/Views/Shared/{0}.master",
                "~/Areas/{2}/Views/{1}/{0}.master",
                "~/Areas/{2}/Views/Shared/{0}.master",
            };

            ViewLocationFormats = new[] 
            { 
                // asPx
                "~/Content/{3}/Views/{1}/{0}.aspx",
                "~/Content/{3}/Views/Shared/{0}.aspx",
                "~/Views/{1}/{0}.aspx",
                "~/Views/Shared/{0}.aspx",

                // asCx
                "~/Content/{3}/Views/{1}/{0}.ascx",
                "~/Content/{3}/Views/Shared/{0}.ascx",
                "~/Views/{1}/{0}.ascx",
                "~/Views/Shared/{0}.ascx",
            };

            AreaViewLocationFormats = new[] 
            { 
                // asPx
                "~/Content/{3}/Areas/{2}/Views/{1}/{0}.aspx",
                "~/Content/{3}/Areas/{2}/Views/Shared/{0}.aspx",
                "~/Areas/{2}/Views/{1}/{0}.aspx",
                "~/Areas/{2}/Views/Shared/{0}.aspx",

                // asCx
                "~/Content/{3}/Areas/{2}/Views/{1}/{0}.ascx",
                "~/Content/{3}/Areas/{2}/Views/Shared/{0}.ascx",
                "~/Areas/{2}/Views/{1}/{0}.ascx",
                "~/Areas/{2}/Views/Shared/{0}.ascx",
            };

            PartialViewLocationFormats = ViewLocationFormats;
            AreaPartialViewLocationFormats = PartialViewLocationFormats;
        }

        public new string[] AreaMasterLocationFormats { get; set; }
        public new string[] AreaPartialViewLocationFormats { get; set; }
        public new string[] AreaViewLocationFormats { get; set; }
        public new string[] ViewLocationFormats { get; set; }
        public new string[] MasterLocationFormats { get; set; }
        public new string[] PartialViewLocationFormats { get; set; }

        public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
        {
            base.AreaPartialViewLocationFormats = PrepareLocationFormats(controllerContext, this.AreaPartialViewLocationFormats);
            base.PartialViewLocationFormats = PrepareLocationFormats(controllerContext, this.PartialViewLocationFormats);

            return base.FindPartialView(controllerContext, partialViewName, useCache);
        }

        public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
        {
            base.AreaViewLocationFormats = PrepareLocationFormats(controllerContext, this.AreaViewLocationFormats);
            base.AreaMasterLocationFormats = PrepareLocationFormats(controllerContext, this.AreaMasterLocationFormats);
            base.ViewLocationFormats = PrepareLocationFormats(controllerContext, this.ViewLocationFormats);
            base.MasterLocationFormats = PrepareLocationFormats(controllerContext, this.MasterLocationFormats);

            if (string.IsNullOrEmpty(masterName))
                masterName = "Site";

            return base.FindView(controllerContext, viewName, masterName, useCache);
        }

        protected virtual string[] PrepareLocationFormats(ControllerContext controllerContext, string[] locationFormats)
        {
            if (locationFormats == null || locationFormats.Length == 0)
                return locationFormats;

            // get skin - this can be extracted to method
            string skin = (string)controllerContext.HttpContext.Session["SelectedSkin"] ?? "Default";

            List<string> locationFormatsPrepared = new List<string>();
            foreach (string locationFormat in locationFormats)
                locationFormatsPrepared.Add(string.Format(locationFormat, "{0}", "{1}", "{2}", skin));

            return locationFormatsPrepared.ToArray();
        }
    }
}
As you can see '*LocationFormats' properties are redeclared with new keyword, so we can easily switch between this. and base. implementations.
In constructor we initialize this. location formats. Unlike in MVC implementation, we have 4 placeholders. 0, 1, 2 is unchanged, 3 - for skin name. This was discussed above.
Also, in FindView method I check value of masterPage, and if it is null or empty string, specify it. It is done because when masterPage not specified ASP.NET will use default value from processed *.aspx file and skinning will not work.

In MasterLocationFormats list we can see '~/Content/{3}/Views/Shared/{0}.master' location. It is used for skinning, so we can have next folder tree:
~/Content
    /Default/Views/Shared/Site.master
    /RedTheme/Views/Shared/Site.master
    /BlueTheme/Views/Shared/Site.master
    /GreenTheme/Views/Shared/Site.master
Yes, I know, it is not an ASP.NET-way of skinning. There is no classic skin files. But this method have its own benefits - you can totally redesign master page. Similar folder tree used for views and partial views - so in each skin we can have duplicated tree of folders as in main site, but with skin-specific views.

In overriden FindPartialView and FindView methods we initialize the base.*LocationFormats by preformatted versions of this.*LocationFormats, and then call to the base methods. Now base methods can work fine, replacing only well-known '{0}', '{1}' and '{2}' placeholders. This trick is used to deceive our abstract friend - a VirtualPathProviderViewEngine class.

Method PrepareLocationFormats used to preformat our location formats by skin name. First of all we check if array is null or contains nothing - we just return it:
if (locationFormats == null || locationFormats.Length == 0)
return locationFormats;
Then we must prepare skin name.
string skin = (string)controllerContext.HttpContext.Session["skin"] ?? "Default";
In this simple example I decided not to use sophisticated logic for skinning. So I store skin name in the session.
You can extend this example by extracting skin-related logic to a method and implement more usable storage for skin selected by user.

The next thing I've done - create storage for our preformatted locations, and start iterating through each location.
List<string> locationFormatsPrepared = new List<string>();
foreach (string locationFormat in locationFormats)
    locationFormatsPrepared.Add(string.Format(locationFormat, "{0}", "{1}", "{2}", skin));
At the end we can convert list to an array and return it:
return locationFormatsPrepared.ToArray();
That's all about view engine - clear and simple (I guess so). In the next section I describe more extensible (in my opinion) way, how to add skin support.
Go to contents >

Extensible version of view engine


To make our view engine extensible we must extract preformatting logic, overridden FindView and FindPartialView methods and our implementation of *LocationFormats properties to the base class. Also we must add an ability to easily extend placeholders count, and link 'value-calculation' logic to related placeholder number. Here is a prototype of proposed class:

public delegate object PlaceholderValueFunc(ControllerContext controllerContext, string locationFormat, ref bool skipLocation);

public class PlaceholdersDictionary : Dictionary<int ,PlaceholderValueFunc>
{
}

public class WebFormExtensibleViewEngine : WebFormViewEngine
{
    public WebFormExtensibleViewEngine() : this(new PlaceholdersDictionary());
    public WebFormExtensibleViewEngine(PlaceholdersDictionary config) : base();

    public new string[] AreaMasterLocationFormats { get; set; }
    public new string[] AreaPartialViewLocationFormats { get; set; }
    public new string[] AreaViewLocationFormats { get; set; }
    public new string[] ViewLocationFormats { get; set; }
    public new string[] MasterLocationFormats { get; set; }
    public new string[] PartialViewLocationFormats { get; set; }
    protected PlaceholdersDictionary Config { get; set; }
    public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache);
    public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache);

    protected virtual PlaceholdersDictionary ValidateAndPrepareConfig(PlaceholdersDictionary config);
    protected virtual string[] PrepareLocationFormats(ControllerContext controllerContext, string[] locationFormats);
}

I've created a PlaceholderValueFunc delegate that represents a signature of method used to calculate value of the placeholder. This method returns an object and sends a boolean skipLocation argument by reference. skipLocation used to specify if we must skip processed location and does not perform any searching in it. Besides described delegate takes a controller context and location format string as an arguments.

To link a placeholder number with calculation method I decided to use specific dictionary - PlaceholdersDictionary. Integer key represents a placeholder number, and delegate as a value represents calculation logic.

public WebFormExtensibleViewEngine()
    : this(new PlaceholdersDictionary())
{
}

public WebFormExtensibleViewEngine(PlaceholdersDictionary config)
    : base()
{
    Config = config;
    ValidateAndPrepareConfig();
}

protected virtual void ValidateAndPrepareConfig()
{
    // Validate
    if (Config.ContainsKey(0) || Config.ContainsKey(1) || Config.ContainsKey(2))
        throw new InvalidOperationException("Placeholder index must be greater than 2. Because {0} - view name, {1} - controller name, {2} - area name.");

    // Prepare
    Config[0] = (ControllerContext controllerContext, string location, ref bool skipLocation) => "{0}";
    Config[1] = (ControllerContext controllerContext, string location, ref bool skipLocation) => "{1}";
    Config[2] = (ControllerContext controllerContext, string location, ref bool skipLocation) => "{2}";
}

Constructor takes an instance of the PlaceholdersDictionary class as an argument and calls to ValidateAndPrepareConfig method. It checks if {0}, {1}, {2} placeholders are used in dictionary, that is denied. And adds this three placeholders with anonymous delegates that simply return "{0}" for 0 placeholder, {1} for 1 placeholder, etc...

What remains unchanged from our SimpleWebFormViewEngine is *LocationFormats properties and overridden FindView, FindPartialView methods:

public new string[] AreaMasterLocationFormats { get; set; }
public new string[] AreaPartialViewLocationFormats { get; set; }
public new string[] AreaViewLocationFormats { get; set; }
public new string[] ViewLocationFormats { get; set; }
public new string[] MasterLocationFormats { get; set; }
public new string[] PartialViewLocationFormats { get; set; }

public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
{
    base.AreaPartialViewLocationFormats = PrepareLocationFormats(controllerContext, this.AreaPartialViewLocationFormats);
    base.PartialViewLocationFormats = PrepareLocationFormats(controllerContext, this.PartialViewLocationFormats);

    return base.FindPartialView(controllerContext, partialViewName, useCache);
}

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
    base.AreaViewLocationFormats = PrepareLocationFormats(controllerContext, this.AreaViewLocationFormats);
    base.AreaMasterLocationFormats = PrepareLocationFormats(controllerContext, this.AreaMasterLocationFormats);
    base.ViewLocationFormats = PrepareLocationFormats(controllerContext, this.ViewLocationFormats);
    base.MasterLocationFormats = PrepareLocationFormats(controllerContext, this.MasterLocationFormats);

    if (string.IsNullOrEmpty(masterName))
        masterName = "Site";

    return base.FindView(controllerContext, viewName, masterName, useCache);
}

And the core of our functionality is a PrepareLocationFormats method, it was rewritten to use PlaceholdersDictionary:

protected virtual string[] PrepareLocationFormats(ControllerContext controllerContext, string[] locationFormats)
{
    if (locationFormats == null || locationFormats.Length == 0)
        return locationFormats;

    List<string> locationFormatsPrepared = new List<string>();

    foreach (string locationFormat in locationFormats)
    {
        object[] formatValues = new object[Config.Count];

        bool skipLocation = false;
        for (int i = 0; i < Config.Count; i++)
        {
            object formatValue = Config[i](controllerContext, locationFormat, ref skipLocation);

            if (skipLocation) break;

            formatValues[i] = formatValue;
        }
        if (skipLocation) continue;

        locationFormatsPrepared.Add(string.Format(locationFormat, formatValues));
    }

    return locationFormatsPrepared.ToArray();
}
First it checks if locationFormats array is null or contains no items and simply returns this array as result if so. In other case, it initializes locationFormatsPrepared local variable - a list that will be used to store locations that was prepared and ready to be used by standard MVC mechanism.

Then in foreach loop, for every location format it creates an array of values that will be used to replace placeholders. Each value calculated by invoking appropriate delegate. If delegate sets skipLocation flag to true, processing of location will be stopped and such location will be skipped.

When all values were calculated, it calls 'string.Format' method with location format and array of values as arguments.

At last, preformatted location added to the locationFormatsPrepared list. This list will be converted to an array and returned as a result when all of the locations will be processed.

Thats all, now our view engine is ready to be extended with needed functionality. And the next thing we must to do is to implement concrete class that support skinning:
public class WebFormSkinnedViewEngine : WebFormExtensibleViewEngine
{
    public WebFormSkinnedViewEngine()
    {
        // ...
    }

    protected virtual object GetSkinName(ControllerContext controllerContext, string locationFormat, ref bool skipLocation)
    {
        return (string)controllerContext.HttpContext.Session["SelectedSkin"] ?? "Default";
    }
}
This class has only one method: GetSkinName. It simply retrieves name of the skin from user's session, or returns 'Default' if session is empty:

The constructor of this class initialize placeholders dictionary by linking 3rd placeholder with GetSkinName method. Then it calls ValidateAndPrepareConfig method of the base class, this method was described above. And at last it initialize *LocationFormats with appropriate values.
public WebFormSkinnedViewEngine() : base()
{
    Config = new PlaceholdersDictionary()
    {
        { 3, GetSkinName }
    };
    ValidateAndPrepareConfig();

    // Our format
    // {0} - View name
    // {1} - Controller name
    // {2} - Area name
    // {3} - Skin name

    // MVC format
    // {0} - View name
    // {1} - Controller name
    // {2} - Area name
    MasterLocationFormats = new[] 
    {
        "~/Content/{3}/Views/{1}/{0}.master",
        "~/Content/{3}/Views/Shared/{0}.master",
        "~/Views/{1}/{0}.master",
        "~/Views/Shared/{0}.master",
    };

    AreaMasterLocationFormats = new[]
    {
        "~/Content/{3}/Areas/{2}/Views/{1}/{0}.master",
        "~/Content/{3}/Areas/{2}/Views/Shared/{0}.master",
        "~/Areas/{2}/Views/{1}/{0}.master",
        "~/Areas/{2}/Views/Shared/{0}.master",
    };

    ViewLocationFormats = new[] 
    { 
        // asPx
        "~/Content/{3}/Views/{1}/{0}.aspx",
        "~/Content/{3}/Views/Shared/{0}.aspx",
        "~/Views/{1}/{0}.aspx",
        "~/Views/Shared/{0}.aspx",

        // asCx
        "~/Content/{3}/Views/{1}/{0}.ascx",
        "~/Content/{3}/Views/Shared/{0}.ascx",
        "~/Views/{1}/{0}.ascx",
        "~/Views/Shared/{0}.ascx",
    };

    AreaViewLocationFormats = new[] 
    { 
        // asPx
        "~/Content/{3}/Areas/{2}/Views/{1}/{0}.aspx",
        "~/Content/{3}/Areas/{2}/Views/Shared/{0}.aspx",
        "~/Areas/{2}/Views/{1}/{0}.aspx",
        "~/Areas/{2}/Views/Shared/{0}.aspx",

        // asCx
        "~/Content/{3}/Areas/{2}/Views/{1}/{0}.ascx",
        "~/Content/{3}/Areas/{2}/Views/Shared/{0}.ascx",
        "~/Areas/{2}/Views/{1}/{0}.ascx",
        "~/Areas/{2}/Views/Shared/{0}.ascx",
    };

    PartialViewLocationFormats = ViewLocationFormats;
    AreaPartialViewLocationFormats = PartialViewLocationFormats;
}
One important thing is that our extended formats going before standard MVC formats. So, searching will be done in Content folder first.

Thats all, functionality are implemented. You can register our view engine in Global.asax.cs using next code:
WebFormSkinnedViewEngine viewEngine = new WebFormSkinnedViewEngine();
ViewEngines.Engines.Insert(0, viewEngine);
Sample application for this post you can download here: ViewEnginesExtended.zip
Go to contents >

Good luck and happy coding!


Shout it

kick it on DotNetKicks.com

1 comment:

  1. u r a great one to learn from. your post helped me alot .

    ReplyDelete