ASP.NET Core Redis HTML Cache

07 March 2016 marrrcin redis , cache , html cache , asp.net-core , asp.net-5

Previous versions of ASP.NET MVC framework had out-of-the-box OutputCache attribute, which could be used to improve MVC aplications performance by - as the name describes - caching the output of actions to which the attribute was added.

In ASP.NET Core 1.0 (formerly ASP.NET 5) the attribute no longer exists. The ASP.NET team provided ResponseCache attribute, however it's purpose is only to set HTTP response headers, so the proxy servers behind which the web application could be running, could cache the response. What about local caching? So far, I didn't found out-of-the-box solution in the edge version of the framework (which is as of writing this post in RC1 state).

So... the need for that functionality became the code in my Visual Studio. I will show you how I've implemented full HTML cache for ASP.NET Core 1.0 using REDIS cache (and also in-proc memory), and I'll be glad to read comments about my solution and also discuss about it. Here it goes!

Implementing ASP.NET Core HTML cache

Prerequisites:

  • ASP.NET 5 (ASP.NET Core 1.0) RC1 installed
  • Visual Studio 2015
  • access to any Redis server*
  • ASP.NET dnx451 targeted project (there is no .NET Core support for Redis Cache yet)

*Redis is obviously optional when you use memory cache

Preparation

My cache solution will be a tandem of ASP.NET Middleware and C# Attribute usage. Let's start with creating new Web Application project. You will also need some additional packages, so open project.json and add those:

"Microsoft.Extensions.Caching.Memory": "1.0.0-rc1-final",
"Microsoft.Extensions.Caching.Redis": "1.0.0-rc1-final"
If Visual Studio fails to restore packages, make sure that you have proper package sources. Those are mine: (Nuget.config)
<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <packageSources>
        <add key="api.nuget.org" value="https://api.nuget.org/v3/index.json" />
        <add key="AspNetVNext" value="https://www.myget.org/F/aspnetvnext/api/v2/"
        />
        <add key="nuget.org" value="https://www.nuget.org/api/v2/" />
        <add key="Roslyn" value="https://www.myget.org/F/roslyn-nightly/" />
        <add key="DotNet" value="https://www.myget.org/F/dotnet-corefx/" />
    </packageSources>
</configuration>
                    

How it will work

Before writing the code, here is a description how my solution will work:

  1. Adding custom created Cache attribute adds action filter to marked action
  2. Action filter handles those events:
    1. OnActionExecuting - to prevent action executing if cached response exists, and to write cached response into current request result
    2. OnResultExecuting - to finish request handling, when cache is hit
    3. OnResultExecuted - when there was a cache miss, to save response into cache
  3. Custom middleware to intercept response stream and provide own MemoryStream to which custom code will be able to write and read.w

Implementation - abstract layer

After restoring packages and setting target framework - add new folder to your project, call it Cache - all of cache related code will land there.

In order to have some abstraction and avoid hard dependencies, I've created ICacheService interface, which exposes general purpose cache methods:

public interface ICacheService
{
    void Store(string key, object content);

    void Store(string key, object content, int duration);

    T Get(string key) where T : class;
}
                

Implementation of this service will be described later in this post.
Next - let's add CacheAttribute class, which will implement IActionFilter and inherit from ResultFilterAttribute.

public class CacheAttribute : ResultFilterAttribute, IActionFilter
{
//...
}
                

Custom middleware for accessing response stream

Before writing the implementation of this attribute, we will need access to the response stream (which is somehow locked to write/read by default). In order to obtain this functionality, add the middleware class CacheMiddleware.cs. Every middleware class requires non-interfaced method with the signature as follows:
public async Task Invoke(HttpContext httpContext)

By desing, every middleware must invoke further registered middlewares, there is no exception for this one. Easiest way to do this is to add RequestDelegate dependency in constructor, so build-in ASP.NET DI container will inject this object into our middleware.

Purpose of CacheMiddleware is to intercept request, and provide custom stream to which further middlewares could write (and read). When the middleware invocation process will return to CacheMiddleware, this stream's content will be copied to original response stream. Full implementation of this class:

public class CacheMiddleware
{
	protected RequestDelegate NextMiddleware;

	public CacheMiddleware(RequestDelegate nextMiddleware)
	{
		NextMiddleware = nextMiddleware;
	}

	public async Task Invoke(HttpContext httpContext)
	{
		using (var responseStream = new MemoryStream())
		{
			var fullResponse = httpContext.Response.Body;
			httpContext.Response.Body = responseStream;
			await NextMiddleware.Invoke(httpContext);
			responseStream.Seek(0, SeekOrigin.Begin);
			await responseStream.CopyToAsync(fullResponse);
		}
	}
}

To make use of this class, we need to add it to the request pipeline. In Startup.cs file, in Configure method, just before app.UseMvc(), add:
app.UseMiddleware<CacheMiddleware>();

Cache attribute

We are now set to write CacheAttribute implementation. First, add those properties to the CacheAttribute.cs class:

protected ICacheService CacheService { set; get; }

public int Duration { set; get; }

Duration will be handy if there is any need for adjustment of cache time. Usage of CacheService property will be described shortly.

Let's move along with caching output in OnResultExecuted method:

public override void OnResultExecuted(ResultExecutedContext context)
{
	GetServices(context);

	var cacheKey = context.HttpContext.Request.GetEncodedUrl().ToMd5();
	var httpResponse = context.HttpContext.Response;
	var responseStream = httpResponse.Body;

	responseStream.Seek(0, SeekOrigin.Begin);

	using (var streamReader = new StreamReader(responseStream, Encoding.UTF8, true, 512, true))
	{
		var toCache = streamReader.ReadToEnd();
		var contentType = httpResponse.ContentType;
		var statusCode = httpResponse.StatusCode.ToString();
		Task.Factory.StartNew(() =>
		{
			CacheService.Store(cacheKey + "_contentType", contentType, Duration);
			CacheService.Store(cacheKey + "_statusCode", statusCode, Duration);
			CacheService.Store(cacheKey, toCache, Duration);
		});

	}
	base.OnResultExecuted(context);
}

What it does? First - it uses Service Locator Pattern (which you could obviously replace with proper DI-way, it was used here for the quick implementation purposes) to get access to CacheService. Then, it takes the response stream (which was provided by our custom middleware) and reads the whole of it (with leaving the stream open, thus the monstrous StreamReader constructor usage). After all of this, method stores response, response type and status code in the Cache - this process is run in the background, so the request could process further without any blocking operations. At last, the OnResultExecuted method invokes base method, so any remaining filters can execute.

Now, when we have response cached, we need a way to intercept request and write it to the response. There goes the implementation of OnActionExecuting method:

public void OnActionExecuting(ActionExecutingContext context)
{
	GetServices(context);
	var requestUrl = context.HttpContext.Request.GetEncodedUrl();
	var cacheKey = requestUrl.ToMd5();
	var cachedResult = CacheService.Get<string>(cacheKey);
	var contentType = CacheService.Get<string>(cacheKey + "_contentType");
	var statusCode = CacheService.Get<string>(cacheKey + "_statusCode");
	if (!string.IsNullOrEmpty(cachedResult) && !string.IsNullOrEmpty(contentType) &&
		!string.IsNullOrEmpty(statusCode))
	{
		//cache hit
		var httpResponse = context.HttpContext.Response;
		httpResponse.ContentType = contentType;
		httpResponse.StatusCode = Convert.ToInt32(statusCode);

		var responseStream = httpResponse.Body;
		responseStream.Seek(0, SeekOrigin.Begin);
		if (responseStream.Length <= cachedResult.Length)
		{
			responseStream.SetLength((long)cachedResult.Length << 1);
		}
		using (var writer = new StreamWriter(responseStream, Encoding.UTF8, 4096, true))
		{
			writer.Write(cachedResult);
			writer.Flush();
			responseStream.Flush();
			context.Result = new ContentResult { Content = cachedResult };
		}
	}
	else
	{
		//cache miss
	}
}

As you can see, again - I take CacheService. This time, the code searches for the same cache keys used to store response. If all keys are found (cache hit). From this point, we set response Content Type, Status Code and finally - we write cached HTML into response stream. As you can see, there is a part of code which extends stream's length. I've encountered an issue in which I could not write to the stream (because of the length issues), so I've just extended the length of it. The line which sets context.Result is very important, as the implementation of next method - OnResultExecuting - relays on it:

public override void OnResultExecuting(ResultExecutingContext context)
{
	if (context.Result is ContentResult)
	{
		context.Cancel = true;
	}
}

This code cancels the request pipeline, if there was a Cache Hit. Of course, you can create more sophisticated condition here - I'm sure that it will not work in some cases, but this post is about general idea of HTML cache, not perfect-error-resistant-uber-implemntation.

OnResultExecuted method will be left blank.

Implementing Cache Service

We are almost done. The only missing part is the actual CacheService. As promised in the title of this post - Redis cache will be used. I will also provide my implementation of MemoryCache, so you can test HTML cache without Redis server.

public class RedisCacheService : ICacheService
{
	protected IDistributedCache Cache;
	private static int DefaultCacheDuration => 60;

	public RedisCacheService(IDistributedCache cache)
	{
		Cache = cache;
	}

	public void Store(string key, object content)
	{
		Store(key, content, DefaultCacheDuration);
	}

	public void Store(string key, object content, int duration)
	{
		string toStore;
		if (content is string)
		{
			toStore = (string)content;
		}
		else
		{
			toStore = JsonConvert.SerializeObject(content);
		}

		duration = duration <= 0 ? DefaultCacheDuration : duration;
		Cache.Set(key, Encoding.UTF8.GetBytes(toStore), new DistributedCacheEntryOptions()
		{
			AbsoluteExpiration = DateTime.Now + TimeSpan.FromSeconds(duration)
		});
	}

	public T Get<T>(string key) where T : class
	{
		var fromCache = Cache.Get(key);
		if (fromCache == null)
		{
			return null;
		}

		var str = Encoding.UTF8.GetString(fromCache);
		if (typeof(T) == typeof(string))
		{
			return str as T;
		}

		return JsonConvert.DeserializeObject<T>(str);
	}
}

Implementation depends on IDistributedCache - interface provided by the ASP.NET Core 1.0 framework itself. To use it, we need to configure it. Head to the Startup.cs file again, but this time, go to ConfigureServices method and add this code:

services.AddCaching();
string redisConnection = Configuration["Redis:ConnectionString"],
	   redisInstance = Configuration["Redis:InstanceName"];

services.AddSingleton<IDistributedCache>(factory =>
{
	var cache = new RedisCache(new RedisCacheOptions
	{
		Configuration = redisConnection,
		InstanceName = redisInstance
	});

	return cache;
});

You can see that this code uses some configuration data. By default, ASP.NET Core 1.0 configuration is stored in appsettings.json file. Open it and configure your Redis instance connection strings:

"Redis": {
    "ConnectionString": "redis-server:7331,password=correcthorsebatterystaple",
    "InstanceName": "redis"
}

Last thing to do is to add implemented CacheAttribute and RedisCacheService to the application. Just add those lines bellow the previous Redis-related ones in Startup.cs:

services.AddSingleton<ICacheService, RedisCacheService>();
services.AddTransient<CacheAttribute>();

Testing the solution

OK, let's test it! I've replaced original Home/Index.cshtml with this code:

@model dynamic
@DateTime.Now
<script type="text/javascript">
    document.write("<p>" + new Date().toLocaleString() + "</p>");
</script>

and then I've added CacheAttribute to HomeController Index method:

[Cache(Duration = 30)]
public IActionResult Index()
{
	return View();
}

Now - let's start the application using dnx web (or dnx-watch web) command and open http://localhost:5000 (default URL). First request processes along the whole pipeline, but the second one responds from cache, as you can see in the screenshots bellow.

Full request

ASP.NET Core 1.0 Redis Cached HTML

Cached request

ASP.NET Core 1.0 Redis Cached HTML

Redis server view

ASP.NET Core 1.0 Redis Cached HTML

Using memory to cache HTML instead of Redis

As my implementation is interface-only dependant, the RedisCacheService could be easily replaced by In-memory Cache. Here is the implementation:

public class MemoryCacheService : ICacheService
{
	protected IMemoryCache Cache;

	public MemoryCacheService(IMemoryCache cache)
	{
		Cache = cache;
	}

	public void Store(string key, object content)
	{
		Store(key, content, DefaultCacheDuration);
	}

	public void Store(string key, object content, int duration)
	{
		object cached;
		if (Cache.TryGetValue(key, out cached))
		{
			Cache.Remove(key);
		}

		Cache.Set(key, content,
			new MemoryCacheEntryOptions
			{
				AbsoluteExpiration = DateTime.Now + TimeSpan.FromSeconds(duration),
				Priority = CacheItemPriority.Low
			});
	}


	private static int DefaultCacheDuration => 60;

	public T Get<T>(string key) where T : class
	{
		object result;
		if (Cache.TryGetValue(key, out result))
		{
			return result as T;
		}
		return null;
	}
}

Wrap up

And that's it. I hope you like my post, please share or/and leave a comment! Of course, the whole source code can be found on github: https://github.com/marrrcin/aspnet-core-redis-html-cache
Please comment and share if you like it!

Comments