Custom ASP.NET MVC Action Result Cache Attribute

If you’re working on an application built using ASP.NET MVC, you’re hopefully aware of the OutputCacheAttribute attribute which can be used to statically cache your dynamic web pages. By adding this attribute to a controller or action method, the output of the method(s) will be stored in memory. For example, if your action method renders a view, then the view page will be cached in memory. This cached view page is then available to the application for all subsequent requests (or until the item expires out of the cache), which can retrieve it from the memory rather than redoing the work to re-create the result again. This is the essence of caching: trading memory for performance.

The OutputCacheAttribute is a really powerful way to improve performance in your MVC application, but isn’t always the most practical. Because it caches the entire page as raw HTML, it circumvents a large part of the MVC pipeline and thus also skips the code that runs to generate the page. This means that if your view has dynamic content that comes from session or ViewData, such as displaying the currently logged in user’s name in the top bar, or the current time of day, or the resulting view of an invalid form post which tells your user to correct their input errors, you’ll quickly discover the error of your ways when you try to cache that page. When David accesses the logged in page for the first time and caches it, everybody else who logs in will be called David on the page. And if David fills out your empty form and presses submit, only to cache the resulting input validation error page, then everybody will see David’s completed form when they have errors too – maybe even including sensitive data like his username, password, or even his credit card information. I think that most of us have seen this kind of (often humorous) caching error before. It’s scary stuff, nonetheless.

A great way to balance the benefits of output caching with the dynamic content and features that the modern ASP.NET MVC web application offers is to create a custom caching attribute. This attribute can cache the ActionResult instead of the raw HTML of the page, and in doing so will allow you to cache all of the work that is done to generate the ActionResult (be it ViewResult or otherwise). By executing within the MVC pipeline, this custom caching attribute will not interrupt or short-circuit the MVC pipeline. This allows for things like SessionState or ViewData to vary per cached request! It’s not quite as efficient as the true OutputCacheAttribute, but my custom ActionResultCacheAttribute is an excellent tradeoff between performance and dynamic data:

/// <summary>
/// Caches the result of an action method.
/// NOTE: you'll need refs to System.Web.Mvc and System.Runtime.Caching
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public class ActionResultCacheAttribute : ActionFilterAttribute
{
    private static readonly Dictionary<string, string[]> _varyByParamsSplitCache = new Dictionary<string, string[]>();
    private static readonly ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
    private static readonly MemoryCache _cache = new MemoryCache("ActionResultCacheAttribute");

    /// <summary>
    /// The comma separated parameters to vary the caching by.
    /// </summary>
    public string VaryByParam { get; set; }

    /// <summary>
    /// The sliding expiration, in seconds.
    /// </summary>
    public int SlidingExpiration { get; set; }

    /// <summary>
    /// The duration to cache before expiration, in seconds.
    /// </summary>
    public int Duration { get; set; }

    /// <summary>
    /// Occurs when an action is executing.
    /// </summary>
    /// <param name="filterContext">The filter context.</param>
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        // Create the cache key
        var cacheKey = CreateCacheKey(filterContext.RouteData.Values, filterContext.ActionParameters);

        // Try and get the action method result from cache
        var result = _cache.Get(cacheKey) as ActionResult;
        if (result != null)
        {
            // Set the result
            filterContext.Result = result;
            return;
        }

        // Store to HttpContext Items
        filterContext.HttpContext.Items["__actionresultcacheattribute_cachekey"] = cacheKey;
    }

    /// <summary>
    /// Occurs when an action has executed.
    /// </summary>
    /// <param name="filterContext">The filter context.</param>
    public override void OnActionExecuted(ActionExecutedContext filterContext)
    {
        // Don't cache errors
        if (filterContext.Exception != null)
        {
            return;
        }

        // Get the cache key from HttpContext Items
        var cacheKey = filterContext.HttpContext.Items["__actionresultcacheattribute_cachekey"] as string;
        if (string.IsNullOrWhiteSpace(cacheKey))
        {
            return;
        }

        // Cache the result of the action method
        if (SlidingExpiration != 0)
        {
            _cache.Add(cacheKey, filterContext.Result, TimeSpan.FromSeconds(SlidingExpiration));
            return;
        }

        if (Duration != 0)
        {
            _cache.Add(cacheKey, filterContext.Result, DateTime.UtcNow.AddSeconds(Duration));
            return;
        }

        // Default to 1 hour
        _cache.Add(cacheKey, filterContext.Result, DateTime.UtcNow.AddSeconds(60 * 60));
    }

    /// <summary>
    /// Creates the cache key.
    /// </summary>
    /// <param name="routeValues">The route values.</param>
    /// <returns>The cache key.</returns>
    private string CreateCacheKey(RouteValueDictionary routeValues, IDictionary<string, object> actionParameters)
    {
        // Create the cache key prefix as the controller and action method
        var sb = new StringBuilder(routeValues["controller"].ToString());
        sb.Append("_").Append(routeValues["action"].ToString());

        if (string.IsNullOrWhiteSpace(VaryByParam))
        {
            return sb.ToString();
        }

        // Append the cache key from the vary by parameters
        object varyByParamObject = null;
        string[] varyByParamsSplit = null;
        bool gotValue = false;

        _lock.EnterReadLock();
        try
        {
            gotValue = _varyByParamsSplitCache.TryGetValue(VaryByParam, out varyByParamsSplit);
        }
        finally
        {
            _lock.ExitReadLock();
        }

        if (!gotValue)
        {
            _lock.EnterWriteLock();
            try
            {
                varyByParamsSplit = VaryByParam.Split(new[] { ',', ' ' }, StringSplitOptions.RemoveEmptyEntries);
                _varyByParamsSplitCache[VaryByParam] = varyByParamsSplit;
            }
            finally
            {
                _lock.ExitWriteLock();
            }
        }

        foreach (var varyByParam in varyByParamsSplit)
        {
            // Skip invalid parameters
            if (!actionParameters.TryGetValue(varyByParam, out varyByParamObject))
            {
                continue;
            }

            // Sometimes a parameter will be null
            if (varyByParamObject == null)
            {
                continue;
            }

            sb.Append("_").Append(varyByParamObject.ToString());
        }

        return sb.ToString();
    }
}

You can use this method on a controller to affect all action methods:

[ActionResultCache(Duration = 60 * 60 * 24)]
public class HomeController : Controller
{
    public async Task<ActionResult> TermsOfService()
    {
        return View();
    }
}

Or just apply it to individual action methods:

[ActionResultCache(Duration = 60 * 60 * 24)]
public async Task<ActionResult> TermsOfService()
{
    return View();
}

You can also use it with the VaryByParam property to vary the cached result by the parameter(s) of the action method:

[ActionResultCache(Duration = 60 * 60 * 24, VaryByParam = "username")]
public async Task<ActionResult> ViewUser(string username)
{
    var model = new UserModel
    {
        Username = username,
        ...
    };

    return View(model);
}

The main benefit of this custom caching attribute is that your session state and all global action filter attributes, etc. still run in the MVC pipeline as they would normally. The only code cached and skipped over is the method body of the action method.

Please use and enjoy! Feedback welcomed in the comments.


See also