My take on ASP.NET MVC output caching

ASP.NET MVC logo Today I wanted to add server-side caching to Babil. I had already read different opinions and techniques to do it and one in particular was interesting: it was something like "of what use is caching if the only thing the server ever does is concatenating some strings and fetch data right out of the ASP.NET cache" (as is the case using NHibernate's second level cache). Since I have no idea, the only way to find out, as usual, is to measure.  :)

Turns out that generating a complex page takes almost as long as 200ms, while returning the same page stored inside the cache takes at most 10ms (these are the values I get on the ASP.NET server included with Visual Web Developer, I expect Babil to perform much better running on a real server and not with a debug build of course). Anyway, the difference is quite noticeable and convinced me to try and get output caching done.

Output Caching goals and methods

In order to enable output caching you essentially have to find a way to access all the output written while the request handler executes and store it somewhere in memory. When a second request for the same resource arrives, you can directly serve the stored contents back to the client. There are several methods for doing this: one of those is already built into ASP.NET MVC (the [OutputCache] attribute).

However, as explained in this blog post by Steve Sanderson, this approach has some issues: it uses the caching mechanisms built inside WebForms, therefore it breaks the MVC "action invocation" pipeline and doesn't get along too well with other filter attributes. Another thing the default [OutputCache] is missing is the ability to handle partial requests (check out this post for an overview of what partial requests are and how they work).

A different, but connected, interesting thing I wanted to try was to follow Jeff Atwood's advice on compressing cache items before storing them, in order to maximize cache usage with a minimal performance hit. Moreover, all output is usually already served using deflate compression (for clients that support it, which would be most existing web browsers), therefore the best solution is to take advantage of this and cache the already compressed output.

Enough talk...

Let's go to the code.  :)
What I ended up is roughly similar to what Steve Sanderson published in the post I already mentioned, but this version includes cache item compression and some differences here and there.

This is an extract of the class I implemented just to show how it works. You can download the complete package which should work on MVC Beta 1.

public class CompressAndCacheAttribute : ActionFilterAttribute {

	//Caching settings
	int _cacheDuration;
	bool _doCache;

	string _cacheKey;
	PassthroughFilter _cacheFilter = null;
	bool _compressOnTheFly = false;

	public override void OnActionExecuting(ActionExecutingContext filterContext) {
		HttpResponseBase response = filterContext.HttpContext.Response;

		bool acceptsDeflate = GetAcceptDeflated(filterContext);
		if (acceptsDeflate) {
			//If client accepts deflate, we'll always return compressed content
			response.AppendHeader("Content-encoding", "deflate");
		}

		//Check cache
		_cacheKey = this.ComputeCacheKey(filterContext);
		byte[] cachedOutput = filterContext.HttpContext.Cache.Get(_cacheKey) as byte[];

		if (cachedOutput != null && _doCache) {
			//Direct output from cache
			ActionResult cacheResult = null;
			if (acceptsDeflate) {
				cacheResult = new RawResult(cachedOutput, Encoding.UTF8, "utf-8");
			}
			else {
				//Decompress cache contents on the fly
				ContentResult contentResult = new ContentResult();
				contentResult.Content = Decompress(cachedOutput);

				cacheResult = contentResult;
			}

			filterContext.Result = cacheResult;
		}
		else {
			//Hook filter
			_cacheFilter = new PassthroughFilter(response.Filter);
			response.Filter = _cacheFilter;

			if (acceptsDeflate) {
				//Add DeflateStream to the pipeline in order to compress response on the fly
				response.Filter = new DeflateStream(response.Filter, CompressionMode.Compress);
				_compressOnTheFly = true;
			}
		}
	}

	public override void OnResultExecuted(ResultExecutedContext filterContext) {
		HttpResponseBase response = filterContext.HttpContext.Response;

		//Check whether we were caching or not
		if (_cacheFilter != null) {
			//Get output copy
			response.Flush();

			byte[] output = null;
			if (_compressOnTheFly) {
				output = _cacheFilter.GetRawContents();
			}
			else {
				//Since client doesn't accept deflate contents, compress output now
				output = Compress(_cacheFilter.GetContents(response.ContentEncoding));
			}

			//Restore filter
			response.Filter = _cacheFilter.WrappedStream;

			//Store
			filterContext.HttpContext.Cache.Add(_cacheKey, output, null,
				DateTime.Now.AddSeconds(_cacheDuration), Cache.NoSlidingExpiration, CacheItemPriority.Normal, null);
		}
	}

	protected virtual string ComputeCacheKey(ActionExecutingContext filterContext) {
		StringBuilder keyBuilder = new StringBuilder();
		foreach (KeyValuePair<string, object> pair in filterContext.RouteData.Values) {
			if (pair.Value == null) continue;
			keyBuilder.AppendFormat("rd{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
		}
		foreach (KeyValuePair<string, object> pair in filterContext.ActionParameters) {
			if (pair.Value == null) continue;
			keyBuilder.AppendFormat("ap{0}_{1}_", pair.Key.GetHashCode(), pair.Value.GetHashCode());
		}
		return keyBuilder.ToString();
	}

	/// <summary>Returns true if the client supports Deflate compression.</summary>
	protected bool GetAcceptDeflated(ActionExecutingContext filterContext) {
		string acceptEncoding = (filterContext.HttpContext.Request.Headers["Accept-Encoding"] ?? string.Empty)
			.ToLowerInvariant();

		return acceptEncoding.Contains("deflate");
	}

}

This is where almost everything happens: before executing the action we check whether an item with the same "cache key" exists. The cache key is generated using route and action parameters, but you can subclass the attribute and override this behavior in order to change this. In my case, I added a special attribute that generates different keys depending on the current locale.

If the cache key maps to an existing output item, we can send it right back to the client: this is done by using the RawResult (you'll find it in the full package) to write the raw compressed bytes to the output. If the client does not support deflate compression, the cache item is decompressed on the fly.

On the other hand, if no cached output is found, we add a special PassthroughFilter to the Response in order to intercept all output generated by the action. The PassthroughFilter is a special version of the CapturingResponseFilter in MvcContrib that, instead of completely filtering all output to memory, still pushes everything to the default output while keeping a copy in memory.

At last, after the action has been executed, if such a filter was added to the reponse, we get the output string from the filter and store it in cache. This takes advantage of the fact that we already added a DeflateStream compressor if the client supported it: the output is compressed only once and then served as is directly from cache with virtually no overhead.

Charset is not set after writing raw bytes to OutputStream

One of the strange things I found out is that, as soon as RawResult writes the raw cache contents using HttpResponse.OutputStream, ASP.NET defaults the response's Charset to "undefined". It's especially strange because the ContentEncoding property stays set on "UTF8". It isn't even a problem on most browsers, but if you want correct HTTP headers, you must manually set the response charset to "utf-8" (for instance, Opera ignores the charset set in your HTML code and therefore the HTTP charset should be set correctly).

Order is important

The ActionFilter above must absolutely be run as last: as I discovered lately, as soon as an action filter changes the action result, the current action invocation is aborted. This also means that all other action filters which did not have a chance to run, will not run, ever. If you plan on adding this caching method to your project, make sure that all filters have the right priority (using the Order priority, that takes a positive integer and orders from lowest to highest).

Cache substitution

This approach either allows you to cache the whole response or none. There's no way (yet) to specify a small section of the page that you'd want to be updated at each request (which traditionally is done using the Substitution tag on WebForms and seems to be called "Donut caching", as seen on this post by Phil Haack). Phil's approach makes use of a built in ASP.NET feature, but appears to be limited to Views.

In my case I cannot cache article pages because doing so would prevent the article statistics (number of page views, etc.) to be updated. I'll try to find a way to provide some kind of callback, both for controllers and views, to ensure that the application state and the output are kept consistent even if the actual controller action isn't executed at all.

That's it...

As said, you can get the source code here.