Gal Segal's Blog

Thoughts of a programmer with a soul

Sep 14

MVC Output Cache With Cache Profiles

Tags: ,

Dream

MVC and Output Cache are a good match, and since MVC3 this match is even better. But there are some occasions where this match is not good enough. I recently discovered an unwelcome behavior when dealing with ajax and had to come up with a solution.

My team is working on a big website – OpenBook, which involve a lot of ajax calls (we are using jQuery as our basic JavaScript lib).  We also used Output cache on our ajax actions and all went pretty smooth, until, for some reasons, we had to disable caching of ajax calls on the client. What we discovered is that out Output Cache was also disabled – and this behavior I wanted to prevent.

The way jQuery disables browser caching is by adding “_=[TIMESTAMP]” on each call’s query string, so every new call as a distinct URL. By doing so it disabled Output Cache on the server as well. Why?

The reason for this is that Output Cache is a very early in the life cycle of the request-response model, before the routing and controller creation. Lets say we have a controller called “TaskController” and an action called “Edit” that receives “ID” as parameter. When requesting the URL ”/task/edit/1″ the MVC framework will assign ID with the value “1″. Any other parameters will not be used. If jQuery adds “_=[TIMESTAMP]” to the request, our action will not know about it, but Output Cache will and if you use “VaryByParam” it will not cache anything, since one of the parameters as changed.

Output Cache takes all the request’s parameters and creates a string that is uses as a key identifier for the response output.It caches the response with this key, and once this key is being requested again, it will pull the relevant resource from the cache and return it immediately. This is a good thing because the request will not pass through the MVC cycle and the request will not hit a controller or action.

I wanted to come up with a solution that will ignore the “_” parameter. I also wanted to keep using the cache profiles configuration on the web.config. The result is a mix and match of some code I found here and here with my additions.

The Code

public class ActionOutputCacheAttribute : ActionFilterAttribute
{
	private static readonly OutputCacheSection _cacheConfig = (OutputCacheSection)ConfigurationManager.GetSection("system.web/caching/outputCache");
	private static readonly OutputCacheSettingsSection _cacheConfigSettings = (OutputCacheSettingsSection)ConfigurationManager.GetSection("system.web/caching/outputCacheSettings");
	private OutputCacheProfile _cacheProfile;
	private string _cacheKey;

	public ActionOutputCacheAttribute(string cacheProfileName)
	{
		if (string.IsNullOrWhiteSpace(cacheProfileName))
		{
			throw new ArgumentException("Cache profile is required");
		}

		_cacheProfile = _cacheConfigSettings.OutputCacheProfiles[cacheProfileName];

		if (_cacheProfile == null)
		{
			throw new ArgumentException(string.Format("Caching profile '{0}' was not found", _cacheProfile));
		}
	}

	public override void OnActionExecuting(ActionExecutingContext filterContext)
	{
		if (_cacheConfig.EnableOutputCache && CacheOnServer())
		{
			_cacheKey = GenerateKey(filterContext);
			if (filterContext.HttpContext.Cache[_cacheKey] != null)
			{
				filterContext.Result = (ActionResult)filterContext.HttpContext.Cache[_cacheKey];
			}
		}

		base.OnActionExecuting(filterContext);
	}

	public override void OnActionExecuted(ActionExecutedContext filterContext)
	{
		if (_cacheConfig.EnableOutputCache && CacheOnServer())
		{
			filterContext.HttpContext.Cache.Add(
				_cacheKey,
				filterContext.Result,
				null,
				DateTime.Now.AddSeconds(_cacheProfile.Duration),
				Cache.NoSlidingExpiration,
				CacheItemPriority.Normal,
				null
				);
		}
		base.OnActionExecuted(filterContext);
	}

	public override void OnResultExecuted(ResultExecutedContext filterContext)
	{
		if (_cacheConfig.EnableOutputCache && CacheOnClient())
		{
			HttpCachePolicyBase cache = filterContext.HttpContext.Response.Cache;
			TimeSpan cacheDuration = TimeSpan.FromSeconds(_cacheProfile.Duration);

			cache.SetCacheability(HttpCacheability.Public);
			cache.SetExpires(DateTime.Now.Add(cacheDuration));
			cache.SetMaxAge(cacheDuration);
			cache.AppendCacheExtension("must-revalidate, proxy-revalidate");
		}
		base.OnResultExecuted(filterContext);
	}

	private string GenerateKey(ControllerContext filterContext)
	{
		StringBuilder cacheKey = new StringBuilder();

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

		if (!string.IsNullOrEmpty(_cacheProfile.VaryByParam))
		{
			var varyByParamCollection = _cacheProfile.VaryByParam.Split(';');
			foreach (KeyValuePair<string, object> pair in filterContext.RouteData.Values)
			{
				if (_cacheProfile.VaryByParam == "*" || varyByParamCollection.Contains(pair.Key))
				{
					cacheKey.Append("_");
					cacheKey.Append(pair.Key);
					cacheKey.Append("=");
					cacheKey.Append(pair.Value.ToString());
				}
			}
		}
		if (!string.IsNullOrEmpty(_cacheProfile.VaryByHeader))
		{
			var varyByHeaderCollection = _cacheProfile.VaryByHeader.Split(';');
			foreach (var header in varyByHeaderCollection)
			{
				cacheKey.AppendFormat("_{0}={1}", header, filterContext.HttpContext.Request.Headers[header]);
			}
		}

		return cacheKey.ToString();
	}

	private bool CacheOnClient()
	{
		return _cacheProfile.Location == OutputCacheLocation.Client ||
			   _cacheProfile.Location == OutputCacheLocation.ServerAndClient ||
			   _cacheProfile.Location == OutputCacheLocation.Any ||
			   (int)_cacheProfile.Location == -1;
	}

	private bool CacheOnServer()
	{
		return _cacheProfile.Location == OutputCacheLocation.Server ||
			   _cacheProfile.Location == OutputCacheLocation.ServerAndClient ||
			   _cacheProfile.Location == OutputCacheLocation.Any ||
			   (int)_cacheProfile.Location == -1;
	}
}

Usage

The Action:

[ActionOutputCache("SomeAction")]
public ActionResult SomeAction(int id)
{
	///do something...
	return View();
}

and configuration:

<caching>
      <outputCache enableOutputCache = "true" />
      <outputCacheSettings>
          <outputCacheProfiles>
	      <add name="SomeAction" duration="60" varyByParam="*" varyByHeader="Accept-Language" location="ServerAndClient"/>
          </outputCacheProfiles>
      </outputCacheSettings>
</caching>

Happy Coding :)

Back to top
  • http://twitter.com/michelwilker Michel Wilker

    Great article!