Splendr ™ Software Development Solutions

Response caching in .NET Core

Recently, in one of the project we were working on we encountered some issues related to long response time. In the beginning, despite the large number of HTTP calls, the application worked perfectly and everything looked good… until the database became more and more populated with data and then requests started to take too much time to respond.

.NET has built-in solution for response caching but in our case that didn’t work because requests had Authorization header (conditions for caching) and library didn’t cache those responses.

To solve this problem we came up with a solution that uses caching techniques and later on we turned it into a generic solution and created a library [Check Here]. The library provides two options of caching:

  • Memory caching
  • Distributed caching

Solution

When we started to implement the solution we were thinking to do it on one of the mentioned ways

  • Creating a generic service and injecting it on controllers,
  • Creating a middleware combined with caching rules,
  • Using the power of [Attributes].

After some researches made, we decided to use the [Attributes]. Below we are going to describe the implementation step-by-step.

First we create a simple contract that holds two methods, one for storing the response and another for reading cached response.

using System;
using System.Collections.Generic;
using System.Text;
using System.Threading.Tasks;

namespace ResponseCache.Services
{
    public interface ICacheService
    {
        Task CacheResponse(string key, object response, TimeSpan timeToLive);
        Task<string> GetCachedResponse(string key);
    }
}

As we mentioned above the library provides two options of caching — Memory and Distributed caching. So for each one we’ve created separated services that implements [ICacheService].

using Microsoft.Extensions.Caching.Distributed;
using ResponseCache.Helpers;
using System;
using System.Threading.Tasks;

namespace ResponseCache.Services
{
    public class DistributedCacheService : ICacheService
    {
        private readonly IDistributedCache _distributedCache;
        public DistributedCacheService(IDistributedCache distributedCache)
        {
            _distributedCache = distributedCache;
        }

        public async Task CacheResponseAsync(string key, object response, TimeSpan timeToLive)
        {
            if (response is null)
                return;

            var options = new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = timeToLive
            };

            var serializedResponse = JsonConverter.SerializeObject(response);

            await _distributedCache.SetStringAsync(key, serializedResponse, options);
        }

        public async Task<string> GetCachedResponseAsync(string key)
        {
            var cachedResponse = await _distributedCache.GetStringAsync(key);
            return cachedResponse;
        }
    }
}

To be able to use distributed cache we created a new class that holds ConnectionString of server (Redis or something else) where the data will be add.

using System;
using System.Collections.Generic;
using System.Text;

namespace ResponseCache.Config
{
    public class CacheSettings
    {
        public string ConnectionString { get; set; }
    }
}

Also we’ve created another service that implements memory cache technique.

sing Microsoft.Extensions.Caching.Memory;
using ResponseCache.Helpers;
using System;
using System.Threading.Tasks;

namespace ResponseCache.Services
{
    public class InMemoryCacheService : ICacheService
    {
        private readonly IMemoryCache _cache;
        public InMemoryCacheService(IMemoryCache cache)
        {
            _cache = cache;
        }

        public Task CacheResponseAsync(string key, object response, TimeSpan timeToLive)
        {
            if (response is null)
                return Task.CompletedTask;

            var options = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = timeToLive
            };

            var serializedResponse = JsonConverter.SerializeObject(response);

            _cache.Set<string>(key, serializedResponse, options);

            return Task.CompletedTask;
        }

        public Task<string> GetCachedResponseAsync(string key)
        {
            _cache.TryGetValue(key, out string cachedResponse);
            return Task.FromResult(cachedResponse);
        }
    }
}

Now let’s get to the main part of the library — creating new attribute called [ResponseCacheAttribute]. Our custom attribute inherits both Attribute class and IAsyncActionFilter interface and implements the method OnActionExecutionAsync which holds two parameters:

  1. ActionExecutingContext — provides context data to the filter and in our case is used to get an instance of ICacheService.
  2. ActionExecutingDelegate — contains the action method (or the next filter) to be executed which allows us to check the response of next action (executedContext.Result) and based on it to decide caching or not.

First we create a unique key per request and then check if that key holds an already cached response, if so we simply return that response otherwise we let the delegate to be executed and then if the context was executed successfully we call our method to cache response.

The method CacheResponse accepts three arguments

  • Key — unique, generated from request
  • Response
  • TimeToLive — this argument is defined on attribute
using Microsoft.AspNetCore.Mvc.Filters;
using System;
using Microsoft.AspNetCore.Http;
using System.Text;
using System.Threading.Tasks;
using System.Net;
using Microsoft.AspNetCore.Mvc;
using ResponseCache.Services;
using System.Security.Cryptography;

namespace ResponseCache
{
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
    public class ResponseCacheAttribute : Attribute, IAsyncActionFilter
    {
        private readonly int _timeToLive;
        
        public ResponseCacheAttribute(int timeToLive)
        {
            _timeToLive = timeToLive;
        }

        public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
        {
            var cacheService = (ICacheService)context.HttpContext.RequestServices.GetService(typeof(ICacheService));
            var key = BuildKey(context.HttpContext.Request);
            var cachedResponse = await cacheService.GetCachedResponse(key);

            if (!string.IsNullOrEmpty(cachedResponse))
            {
                context.HttpContext.Response.ContentType = "application/json";
                context.HttpContext.Response.StatusCode = (int)HttpStatusCode.OK;
                await context.HttpContext.Response.WriteAsync(cachedResponse);
                return;
            }                

            var executedContext = await next();
            if (executedContext.Result is OkObjectResult result)
            {
                var timeToLiveSeconds = TimeSpan.FromSeconds(_timeToLive);
                await cacheService.CacheResponse(key, result.Value, timeToLiveSeconds);
            }
        }

        private string BuildKey(HttpRequest request)
        {
            var key = $"{request.Path}{request.QueryString}";
            var bytes = Encoding.UTF8.GetBytes(key);

            using var algorithm = new SHA1Managed();
            var hash = algorithm.ComputeHash(bytes);

            return Convert.ToBase64String(hash);
        }
    }
}

To use the services of the library we need to create extension methods AddDistributedResponseCache and AddInMemoryResponseCache which can be used on your Startup.cs.

using Microsoft.Extensions.DependencyInjection;
using ResponseCache.Services;

namespace ResponseCache
{
    /// <summary>
    /// Extension methods for the <see cref="IServiceCollection"/>
    /// </summary>
    public static class ServiceCollectionExtensions
    {
        public static void AddDistributedResponseCache(this IServiceCollection services)
        {
            services.AddSingleton<ICacheService, DistributedCacheService>();
        }

        public static void AddInMemoryResponseCache(this IServiceCollection services)
        {
            services.AddSingleton<ICacheService, InMemoryCacheService>();
        }
    }
}

Setup

Nuget Install

Install-Package ResponseCache

Memory cache configuration

Cache responses on memory cache by simply adding service for memory cache and our custom service AddInMemoryResponseCache.

public void ConfigureServices(IServiceCollection services)
 {
      services.AddMemoryCache();
      services.AddInMemoryResponseCache();
 }

Distributed Cache configuration — Redis

To use distributed cache first we need to add distributed cache connection string on appsettings.json and bind it to CacheSettings class.

"CacheSettings": {
    "ConnectionString": "localhost:6379"
 }

after that add distributed cache services (Redis in this case) and finally add our custom serviceAddDistributedResponseCache.

public void ConfigureServices(IServiceCollection services)
 {
      var cacheSettings = new CacheSettings();
      Configuration.GetSection(nameof(CacheSettings)).Bind(cacheSettings);
      services.AddSingleton(cacheSettings);

      services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(cacheSettings.ConnectionString));
      services.AddDistributedRedisCache(options => options.Configuration = cacheSettings.ConnectionString);

      services.AddDistributedResponseCache();
 }

Usage

Use the [ResponseCache(TTL)] attribute on any action/endpoint you want. TTL is an integer representing cache living time in seconds.

Example

Here is an example that cache response list of products for 1 minute. As you can see we’ve put [ResponseCache(60)] right above List() action.

[ApiController]
[Route("[controller]")]
public class ProductsController : ControllerBase
{
    private readonly DbContext _context;

    public ProductsController(DbContext context)
    {
        _context = context;
    }

    [HttpGet]
    [ResponseCache(60)]
    public async Task<IActionResult> List()
    {
        return Ok(await _context.Products.ToListAsync());
    }
}

Conclusion

The average user expects the web page to load within 2 seconds some of them will wait up to 10 seconds before they leave the page. So always consider performance and scalability of your application because when application is performing and scaling good it will definitely be stable and pages will be loaded within 2 seconds for sure.

Hope you enjoyed reading. You can check repo or install package here.