Carl Rippon

Building SPAs

Carl Rippon
BlogBooks / CoursesAbout
This site uses cookies. Click here to find out more

Scalable and Performant ASP.NET Core Web APIs: Server Caching

March 13, 2018
dotnet

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: Data Cache 1st hit

The data is also cached in redis: Data Cache Redis

If we then hit our API again, this time passing the ETag, we get a 304 in a fast response: Data Cache 2nd hit

So, let’s now load test this again passing the ETag, with the redis cached item in place: Caching ETag ServerCache LoadTest

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:

ASP.NET Core 5 and React

ASP.NET Core 5 and React
Find out more

Want more content like this?

Subscribe to receive notifications on new blog posts and courses

Required
© Carl Rippon
Privacy Policy