Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET

MVC Output Caching using custom FilterAttribute

1.00/5 (1 vote)
29 Aug 2013CPOL2 min read 15.7K  
MVC output caching using custom FilterAttribute.

As with ASP.Net Forms, MVC offers some out-of-the-box caching with their OutputCacheAttribute, however as with classic ASP.Net, one quickly realizes its limitations when building complex systems.  In particular, its very difficult, and often times impossible to flush/clear the cache based on various events that happen within your application. 

For example, consider a main menu which has an ‘Admin’ button for appropriately authorized users.  When your administrator initially views the page, the system will cache the HTML, including the Admin link.  If you later revoked this privilege, the site would continue serving the cached link even though they were technically no longer authorized to access this part of the site.

Not good.

So, with a little to-ing and fro-ing, I’ve finalized my own FilterAttribute which does this for you.  The advantage of writing your own is that you can pass in whatever parameters you like, as well as have directly access to the current HttpContext, which in turns means you can check user-specific values, access the database – whatever you need to do.

How it works

The attribute essentially consists of just a couple of methods, both overrides of the IResultFilter and IActionFilter attributes

  • OnActionExecuting.  This method fires before your Action even begins.  By checking for a cache value here, we can abort the process before any long-running code in your Action method or View rendering executes
  • OnResultExecuting.  This method fires just before HTML is rendered to our output stream.  It is here that we inject cached content (if it exists).  Otherwise, we capture the output for next time

The code

I’ve commented the code below so you can follow more-or-less what is going on.  I won’t go in to too much detail, but needless to say if you copy/paste this straight in to your work, it won’t compile due to the namespace references.  I’m also using Microsoft Unity for dependency injection, so don’t be confused by ICurrentUser etc. 

Finally, I’m got a custom cache class, whose source code I haven’t included – just switch out my lines to access your own cache instead.

C#
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;
using BlackBall.Common;
using BlackBall.Common.Localisation;
using BlackBall.Contracts.Cache;
using BlackBall.Contracts.Enums;
using BlackBall.Contracts.Exporting;
using BlackBall.Contracts.Localisation;
using BlackBall.Contracts.Security;
using BlackBall.Common.Extensions;
using BlackBall.Logic.Cache;


namespace BlackBall.MVC.Code.Mvc.Attributes
{
    public class ResultOutputCachingAttribute : FilterAttribute, IResultFilter, IActionFilter
    {

        #region Properties & Constructors

        private string ThisRequestOutput = "";
        private bool VaryByUser = true;

        private ICurrentUser _CurrentUser = null;
        private ICurrentUser CurrentUser
        {
            get
            {
                if (_CurrentUser == null) _CurrentUser = Dependency.Resolve<ICurrentUser>();
                return _CurrentUser;
            }
        }

        public ResultOutputCachingAttribute(bool varyByUser = true)
        {
            this.VaryByUser = varyByUser;
        }

        private string _CacheKey = null;
        private string CacheKey
        {
            get { return _CacheKey; }
            set
            {
                _CacheKey = value;
            }
        }

        #endregion

        /// <summary>
        /// Queries the context and writes the HTML depending on which type of result we have (View, PartialView etc)
        /// </summary>
        /// <param name="filterContext"></param>
        /// <returns></returns>
        private void CacheResult(ResultExecutingContext filterContext)
        {
            using (var sw = new StringWriter())
            {
                if (filterContext.Result is PartialViewResult)
                {
                    var partialView = (PartialViewResult)filterContext.Result;
                    var viewResult = ViewEngines.Engines.FindPartialView(filterContext.Controller.ControllerContext, partialView.ViewName);
                    var viewContext = new ViewContext(
                        filterContext.Controller.ControllerContext, viewResult.View, 
                        filterContext.Controller.ViewData, filterContext.Controller.TempData, sw);
                    viewResult.View.Render(viewContext, sw);
                }else if (filterContext.Result is ViewResult)
                {
                    var partialView = (ViewResult)filterContext.Result;
                    var viewResult = ViewEngines.Engines.FindView(
                        filterContext.Controller.ControllerContext, partialView.ViewName, partialView.MasterName);
                    var viewContext = new ViewContext(
                        filterContext.Controller.ControllerContext, viewResult.View, 
                        filterContext.Controller.ViewData, filterContext.Controller.TempData, sw);
                    viewResult.View.Render(viewContext, sw);
                }
                var html = sw.GetStringBuilder().ToString();

                // Add data to cache for next time
                if (!string.IsNullOrWhiteSpace(html))
                {
                    var cache = new CacheManager<CachableString>();
                    var cachedObject = new CachableString() { CacheKey = CreateKey(filterContext), Value = html };
                    cachedObject.AddTag(CacheTags.Project, CurrentUser.CurrentProjectID);
                    if (this.VaryByUser) cachedObject.AddTag(CacheTags.Person, this.CurrentUser.PersonID);
                    cache.Save(cachedObject);
                }
            }
        }


        /// <summary>
        /// The result is beginning to execute
        /// </summary>
        /// <param name="filterContext"></param>
        public void OnResultExecuting(ResultExecutingContext filterContext)
        {
            var cacheKey = CreateKey(filterContext);

            if (!string.IsNullOrWhiteSpace(this.ThisRequestOutput))
            {
                filterContext.HttpContext.Response.Write("<!-- Cache start " + cacheKey + " -->");
                filterContext.HttpContext.Response.Write(this.ThisRequestOutput);
                filterContext.HttpContext.Response.Write("<!-- Cache end " + cacheKey + " -->");
                return;
            }

            // Intercept the response and cache it
            CacheResult(filterContext);
        }

        /// <summary>
        /// Action executing
        /// </summary>
        /// <param name="filterContext"></param>
        public void OnActionExecuting(ActionExecutingContext filterContext)
        {
            // Break if no setting
            if (!Configuration.Current.UseOutputCaching) return;

            // Our function returns nothing because the HTML is not calculated yet - that is done in another Filter
            Func<string, CachableString> func = (ck) => new CachableString() { CacheKey = ck };

            // This is the earliest entry point into the action, so we check the cache before any code runs
            var cache = new CacheManager<CachableString>();
            var cacheKey = new CachableString() { CacheKey = CreateKey(filterContext) };
            var cachedObject = cache.Load(cacheKey, func);
            this.ThisRequestOutput = cachedObject.Value;

            // Cancel processing by setting result to some non-null value. 
            // Refer http://andrewlocatelliwoodcock.com/2011/12/15/canceling-the-actionexecutingcontext-in-the-onactionexecuting-actionfilter/
            if (!string.IsNullOrWhiteSpace(this.ThisRequestOutput))
            {
                filterContext.Result = new ContentResult();
            }
        }

        public void OnActionExecuted(ActionExecutedContext filterContext)
        {

        }

        public void OnResultExecuted(ResultExecutedContext filterContext)
        {

        }

        /// <summary>
        /// Creates a unique key for this context
        /// </summary>
        /// <param name="filterContext"></param>
        /// <returns></returns>
        private string CreateKey(ControllerContext filterContext)
        {
            
            // Append general info about the state of the system
            var cacheKey = new StringBuilder();
            cacheKey.Append(Configuration.Current.AssemblyVersion + "_");
            if (this.VaryByUser) cacheKey.Append(this.CurrentUser.PersonID.GetValueOrDefault(0) + "_");

            // Append the controller name
            cacheKey.Append(filterContext.Controller.GetType().FullName + "_");
            if (filterContext.RouteData.Values.ContainsKey("action"))
            {
                cacheKey.Append(filterContext.RouteData.Values["action"].ToString() + "_");
            }

            // Add each parameter (if available)
            foreach (var param in filterContext.RouteData.Values)
            {
                cacheKey.Append((param.Key ?? "") + "-" + (param.Value == null ? "null" : param.Value.ToString()) + "_");
            }

            return cacheKey.ToString();
        }
    }
}

Alright, hope that helps – there’s nothing like HTML caching to make you feel like the best website builder in the world!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)