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" ; } ) ; . . . }

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 ; string requestETag = "" ; bool haveCachedContact = false ; if ( Request . Headers . ContainsKey ( "If-None-Match" ) ) { requestETag = Request . Headers [ "If-None-Match" ] . First ( ) ; if ( ! string . IsNullOrEmpty ( requestETag ) ) { string oldCacheKey = $ "contact-{contactId}-{requestETag}" ; string cachedContactJson = _cache . GetString ( oldCacheKey ) ; if ( ! string . IsNullOrEmpty ( cachedContactJson ) ) { contact = JsonConvert . DeserializeObject < Contact > ( cachedContactJson ) ; haveCachedContact = ( contact != null ) ; } } } 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 ( contact == null ) { return NotFound ( ) ; } string responseETag = Convert . ToBase64String ( contact . RowVersion ) ; if ( ! haveCachedContact ) { string cacheKey = $ "contact-{contactId}-{responseETag}" ; _cache . SetString ( cacheKey , JsonConvert . SerializeObject ( contact ) , new DistributedCacheEntryOptions ( ) { AbsoluteExpiration = DateTime . Now . AddMinutes ( 30 ) } ) ; } if ( Request . Headers . ContainsKey ( "If-None-Match" ) && responseETag == requestETag ) { return StatusCode ( ( int ) HttpStatusCode . NotModified ) ; } 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 ) ) { string cacheKey = $ "{cacheKeyPrefix}-{requestETag}" ; string cachedObjectJson = _cache . GetString ( cacheKey ) ; 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 ) ; 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 ) } ) ; } _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 ( 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 ( 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 ” ;

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 ( contact == null ) { return NotFound ( ) ; }

Regards, Carl