Scalable and Performant ASP.NET Core Web APIs: Server Caching
Carrying on from the last post in this series on creating performant and scalable web APIs using ASP.NET Core, we’re going to continue to focus on caching. In this post we’ll implement a shared cache on the server and clean the code up so that it can be easily reused.
Benchmark
Before we see the impact of caching data on server, let’s take a bench mark on
contacts/{contactId}
, where we have just added our ETag in the
last post.
We’ll load test on a record that hasn’t been modified, so, should get a 304.
We’ll use WebSurge again for the load test.
The results are in the screenshot below:
Implementing a redis server cache
So, let’s implement the server cache now. We’ll choose redis for our cache - have a look at this post for how to get started with redis and why it’s a great choice.
First, we need to add the Microsoft.Extensions.Caching.Redis
nuget package and
then wire this up in Startup.ConfigureServices()
public void ConfigureServices(IServiceCollection services)
{
...
services.AddDistributedRedisCache(options => { options.Configuration = "localhost:6379"; //location of redis server }); ...
}
IDistributedCache
is then available to be injected into our controller:
public class ContactsController : Controller
{
private readonly string _connectionString;
private readonly IDistributedCache _cache;
public ContactsController(IConfiguration configuration, IDistributedCache cache) {
_connectionString = configuration.GetConnectionString("DefaultConnection");
_cache = cache; }
}
Here’s what our revised action method looks like with the caching in place. In this implementation, we only read from the cache if an ETag has been supplied in the request - this allows the client to determine whether the server cache should be used.
[HttpGet("{contactId}")]
public IActionResult GetContact(string contactId)
{
Contact contact = null;
// Get the requested ETag
string requestETag = "";
bool haveCachedContact = false;
if (Request.Headers.ContainsKey("If-None-Match"))
{
requestETag = Request.Headers["If-None-Match"].First();
if (!string.IsNullOrEmpty(requestETag)) { // The client has supplied an ETag, so, get this version of the contact from our cache // Construct the key for the cache which includes the entity type (i.e. "contact"), the contact id and the version of the contact record (i.e. the ETag value) string oldCacheKey = $"contact-{contactId}-{requestETag}"; // Get the cached item string cachedContactJson = _cache.GetString(oldCacheKey); // If there was a cached item then deserialise this into our contact object if (!string.IsNullOrEmpty(cachedContactJson)) { contact = JsonConvert.DeserializeObject<Contact>(cachedContactJson); haveCachedContact = (contact != null); } } }
// If we have no cached contact, then get the contact from the database
if (contact == null)
{
using (SqlConnection connection = new SqlConnection(_connectionString))
{
connection.Open();
string sql = @"SELECT ContactId, Title, FirstName, Surname, RowVersion
FROM Contact
WHERE ContactId = @ContactId";
contact = connection.QueryFirst<Contact>(sql, new { ContactId = contactId });
}
}
// If no contact was found, then return a 404
if (contact == null)
{
return NotFound();
}
// Construct the new ETag
string responseETag = Convert.ToBase64String(contact.RowVersion);
// Add the contact to the cache for 30 mins if not already in the cache
if (!haveCachedContact) { string cacheKey = $"contact-{contactId}-{responseETag}"; _cache.SetString(cacheKey, JsonConvert.SerializeObject(contact), new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.Now.AddMinutes(30) }); }
// Return a 304 if the ETag of the current record matches the ETag in the "If-None-Match" HTTP header
if (Request.Headers.ContainsKey("If-None-Match") && responseETag == requestETag)
{
return StatusCode((int)HttpStatusCode.NotModified);
}
// Add the current ETag to the HTTP header
Response.Headers.Add("ETag", responseETag);
return Ok(contact);
}
When our API is hit for the first time we get an ETag:
The data is also cached in redis:
If we then hit our API again, this time passing the ETag, we get a 304 in a fast response:
So, let’s now load test this again passing the ETag, with the redis cached item in place:
That’s a decent improvement!
In the above example we set the cache to expire after a certain amount of time (30 mins). The other approach is to proactively remove the cached item when the resource is updated via IDistributedCache.Remove(cacheKey).
Code clean up
That’s a lot of code that we need to write in every cacheable action method!
Let’s extract the code out into a reusable class called ETagCache
. We expose a
method, GetCachedObject
, that retreives an object from the redis cache for the
requested ETag. We also expose a method, SetCachedObject
, that sets an object
in the cache and adds an “ETag” HTTP header.
public class ETagCache
{
private readonly IDistributedCache _cache;
private readonly HttpContext _httpContext;
public ETagCache(IDistributedCache cache, IHttpContextAccessor httpContextAccessor)
{
_cache = cache;
_httpContext = httpContextAccessor.HttpContext;
}
public T GetCachedObject<T>(string cacheKeyPrefix)
{
string requestETag = GetRequestedETag();
if (!string.IsNullOrEmpty(requestETag))
{
// Construct the key for the cache
string cacheKey = $"{cacheKeyPrefix}-{requestETag}";
// Get the cached item
string cachedObjectJson = _cache.GetString(cacheKey);
// If there was a cached item then deserialise this
if (!string.IsNullOrEmpty(cachedObjectJson))
{
T cachedObject = JsonConvert.DeserializeObject<T>(cachedObjectJson);
return cachedObject;
}
}
return default(T);
}
public bool SetCachedObject(string cacheKeyPrefix, dynamic objectToCache)
{
if (!IsCacheable(objectToCache))
{
return true;
}
string requestETag = GetRequestedETag();
string responseETag = Convert.ToBase64String(objectToCache.RowVersion);
// Add the contact to the cache for 30 mins if not already in the cache
if (objectToCache != null && responseETag != null)
{
string cacheKey = $"{cacheKeyPrefix}-{responseETag}";
string serializedObjectToCache = JsonConvert.SerializeObject(objectToCache);
_cache.SetString(cacheKey, serializedObjectToCache, new DistributedCacheEntryOptions() { AbsoluteExpiration = DateTime.Now.AddMinutes(30) });
}
// Add the current ETag to the HTTP header
_httpContext.Response.Headers.Add("ETag", responseETag);
bool IsModified = !(_httpContext.Request.Headers.ContainsKey("If-None-Match") && responseETag == requestETag);
return IsModified;
}
private string GetRequestedETag()
{
if (_httpContext.Request.Headers.ContainsKey("If-None-Match"))
{
return _httpContext.Request.Headers["If-None-Match"].First();
}
return "";
}
private bool IsCacheable(dynamic objectToCache)
{
var type = objectToCache.GetType();
return type.GetProperty("RowVersion") != null;
}
}
We make this available to be injected into our controllers by registering the
service in Startup
. We also need to allow ETagCache
get access to
HttpContext
by registering HttpContextAccessor
- see a
previous blog post on
this.
public class Startup
{
...
public void ConfigureServices(IServiceCollection services)
{
services.AddDistributedRedisCache(options =>
{
options.Configuration = "localhost:6379";
});
services.AddScoped<ETagCache>(); services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>(); services.AddMvc();
}
...
}
We can then inject ETagCache
into our controllers and use it within the
appropriate action methods:
[Route("api/contacts")]
public class ContactsController : Controller
{
private readonly string _connectionString;
private readonly ETagCache _cache; public ContactsController(IConfiguration configuration, ETagCache cache) {
_connectionString = configuration.GetConnectionString("DefaultConnection");
_cache = cache; }
[HttpGet("{contactId}")]
public IActionResult GetContact(string contactId)
{
Contact contact = _cache.GetCachedObject<Contact>($"contact-{contactId}");
// If we have no cached contact, then get the contact from the database
if (contact == null)
{
using (SqlConnection connection = new SqlConnection(_connectionString))
{
connection.Open();
string sql = @"SELECT ContactId, Title, FirstName, Surname, RowVersion
FROM Contact
WHERE ContactId = @ContactId";
contact = connection.QueryFirst<Contact>(sql, new { ContactId = contactId });
}
}
// If no contact was found, then return a 404
if (contact == null)
{
return NotFound();
}
bool isModified = _cache.SetCachedObject($"contact-{contactId}", contact);
if (isModified)
{
return Ok(contact);
}
else
{
return StatusCode((int)HttpStatusCode.NotModified);
}
}
}
Conclusion
Once we’ve setup a bit of generic infrastructure code, it’s pretty easy to implement ETags with server side caching in our action methods. It does give a good performance improvement as well - particularly when it prevents an expensive database query.
So, that’s it for this post focused on caching. Our API is already performing pretty well but next up we’ll see if making operations asynchronous will yield any more improvements …
Comments
Rajan September 25, 2018
Hello, I find your article very useful…. My question is that instead of using “ETagCache” or “IDistributedCache” directly in the api / controller…. can it be used further down the application layers… and remove the heavy lifting of code from the controller itself?
Carl September 27, 2018
Thanks Rajan, Great question! Yes, you could put the check to see if the object is in cache and the saving of the object to cache in middleware. The controller action method wouldn’t then be reached if the object is in cache
Henry October 19, 2018
Thank Carl,
The article is very useful with nice explanation. One question about how to store a object as List or nested lists in Redis Cache ? i have found ServiceStack.Redis can solve but it’s not free, any suggestion?
Carl October 19, 2018
Thanks Henry. I would have thought that JsonConvert.SerializeObject() would convert the list or nested list to a string. You are then just caching a string in redis.
Thanh November 13, 2018
options.Configuration = “localhost:6379”; //location of redis server
From this: do we need setup redis server as well?
Carl November 13, 2018
Thanks for the question Thanh,
Yes, this post only deals with configuring asp.net core to cache in a redis server. I wrote a post a while ago on setting up a redis server at https://www.carlrippon.com/getting-started-with-redis/
John January 21, 2019
bool isModified = _cache.SetCachedObject($”contact-{contactId}”, contact);
what if we add this when contact == null
not every time.. as its hurting performance. why it has to re-add everytime?
Carl January 22, 2019
Hi John,
Thanks for the question. Yes we could cache contactId’s that aren’t found – there is no reason not to. We just need to move the caching statement up bit:
bool isModified = _cache.SetCachedObject($”contact-{contactId}”, contact);
// If no contact was found, then return a 404
if (contact == null)
{
return NotFound();
}
Regards, Carl
If you to learn about using React with ASP.NET Core you might find my book useful: