ASP.Net ViewEngine caching and how to hack it

In ASP.Net MVC, view engines are responsible for finding your views. You can have as many view engines as you like, and MVC will ask each view engine in turn whether it knows about a particular view.

For performance reasons, view engines cache their results for 15 minutes using sliding expiration. When requesting a view, MVC will first ask all registered view engines whether they have already found a view for the current controller context and view name. The first engine to say yes gets to render the view. If no view engine can render the view from its cache, MVC asks each view engine in turn whether it knows how to render the view without using its cache.

In an app, some controller actions render different views depending on what the current host is. For example:

  • www.example.co.uk
  • www.example.org
Both resolve to the "Home" controller's "Index" action in the same application which always returns View(). However, there are two folders of views:

    Views - Home - Index.cshtml
    Views_CO - Home - Index.cshtml

It is the job of the registered view engines to figure out which view to render.

In the app, we have static class that enables easy access to the current domain:

Domain.Current; // eg. example_co_uk

Originally we just created the following class (Note, there is a bug):

public class COViewEngine : RazorViewEngine
{
readonly string[] viewLocations = new[]
{
"~/Views_CO/{1}/{0}.cshtml",
"~/Views_CO/Shared/{0}.cshtml"
};

readonly string[] areaViewLocations = new[]
{
"~/Areas/{2}/Views_CO/{1}/{0}.cshtml",
"~/Areas/{2}/Views_CO/Shared/{0}.cshtml"
};

public COViewEngine() : base(null)
{
SetAreaViewLocations(areaViewLocations);
SetViewLocations(viewLocations);
FileExtensions = new[]
{
"cshtml"
};
}

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
return Domain.IsCOUK 
                    base.FindView(controllerContext, viewName, masterName, useCache) 
                    new ViewEngineResult(new string[]{});
}

public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
{
return Domain.ISCOUK ? 
                    base.FindPartialView(controllerContext, partialViewName, useCache)
                    new ViewEngineResult(new string[] {});
}

void SetAreaViewLocations(string[] locations)
{
AreaViewLocationFormats = locations;
AreaMasterLocationFormats = locations;
AreaPartialViewLocationFormats = locations;
}

void SetViewLocations(string[] locations)
{
ViewLocationFormats = locations;
MasterLocationFormats = locations;
PartialViewLocationFormats = locations;
}
}

And registered it in global.asax.cs:


ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new COViewEngine());
ViewEngines.Engines.Add(new RazorViewEngine());

With the idea that, the COViewEngine will always get first chance to render the view and if it cant, the RazorViewEngine will do it for us.

The problem is that the result is stored in the cache without any domain information. So there will only be one entry for Index requested from the Home/Index action. This means that whichever engine rendered the view the first time will keep rendering it no matter which domain we are on!

To get round this, I created the DomainCachedRazorViewEngine below:


public class DomainCachedRazorViewEngine : RazorViewEngine
{
public DomainCachedRazorViewEngine()
: base(null)
{
}

private string CacheAddition
{
get { return "%" + Domain.Current + "%"; }
}

public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
{
if (!IsSpecificPath(viewName))
            {
                 viewName+= CacheAddition;
            }
return base.FindView(controllerContext, viewName, masterName, useCache);
}

public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
{
if (!IsSpecificPath(partialViewName))
            {
                 partialViewName += CacheAddition;
            }
return base.FindPartialView(controllerContext, partialViewName, useCache);
}

protected override IView CreatePartialView(ControllerContext controllerContext, string partialPath)
{
partialPath = partialPath.Replace(CacheAddition, string.Empty);
return base.CreatePartialView(controllerContext, partialPath);
}

protected override IView CreateView(ControllerContext controllerContext, string viewPath, string masterPath)
{
viewPath = viewPath.Replace(CacheAddition, string.Empty);
return base.CreateView(controllerContext, viewPath, masterPath);
}

protected override bool FileExists(ControllerContext controllerContext, string virtualPath)
{
virtualPath = virtualPath.Replace(CacheAddition, string.Empty);
return base.FileExists(controllerContext, virtualPath);
}

        private bool IsSpecificPath(string name) 
        {
             var c = name[0];
             return (c == '~' || c == '/');
        }
}

This injects a string while finding a view that adds domain information to the cache. Various methods are overwritten to remove the string at key places so that the ViewEngine looks in the right locations on disk.

All we needed to now was modify the COViewEngine to derive from this class instead of the RazorViewEngine and change the global.asax.cs to register the following:

ViewEngines.Engines.Clear();
ViewEngines.Engines.Add(new COViewEngine());
ViewEngines.Engines.Add(new DomainCachedRazorViewEngine());

The DomainCachedRazorViewEngine could be modified to make the view engine cache differentiate on any number of conditions.

Comments

  1. I like the look of this! It seems like it solves the problem really elegantly.

    ReplyDelete

Post a Comment

Popular posts from this blog

Trimming strings in action parameters in ASP.Net Web API

Full text search in Entity Framework 6 using command interception

Composing Expressions in C#