Today we shall be discussing caching. The concept of caching is rather straight-forward. The idea is to store your data on a faster secondary source, typically in memory, and not just on your primary data source, typically database. That way when your application receives requests, the data is pulled from the faster source and therefore faster response time.
In .Net Core there are two options, memory or distributed caching. Memory caching is as the name implies, in memory and it’s contained within the memory of the web server the application is running on. If your application runs on multiple web servers then distributed caching (or sticky sessions) would be a better option. Distributed caching makes your application scalable, allows session data to be shared between the web servers and does not reset when a new version is deployed. Both types of caching store values as key-value pairs. In this post I will be using Redis as my choice for distributed caching. But what if we use them both at the same time? Here’s a small POC to show how they can work together.
I created a new .Net Core solution and selected the API template. This template comes with a default WeatherForecast controller and I used that as my skeleton to implement memory and distributed caching. I figured that the temperature is a realistic value that can be cached for a few minutes since it’s not a value that changes rapidly.
I left that untouched for now and instead created a class library to act as my business layer. In there I added a new interface and this will act as my caching service. In here I implemented the following logic; check if key is in the memory cache and if found return value. If key not found then check in distributed caching and if found return value. If key not found then look up value from primary source and save value in both memory and distributed caching. In order to connect to Redis I had to download and install the Nuget package StackExchange.Redis.
public class CacheService : ICacheService | |
{ | |
private readonly IConnectionMultiplexer _muxer; | |
private readonly IDatabase _conn; | |
private readonly IMemoryCache _memCache; | |
public CacheService(IConnectionMultiplexer muxer, IMemoryCache memCache) | |
{ | |
_muxer = muxer; | |
_conn = _muxer.GetDatabase(); | |
_memCache = memCache; | |
} | |
public async Task<T> GetOrSet<T>(string key, Func<Task<T>> factory, TimeSpan cacheExpiry) | |
{ | |
var value = await _memCache.GetOrCreateAsync<T>(key, entry => | |
{ | |
entry.AbsoluteExpiration = DateTime.UtcNow.Add(cacheExpiry); | |
return GetFromRedis(key, factory, cacheExpiry); | |
}); | |
return value; | |
} | |
private async Task<T> GetFromRedis<T>(string key, Func<Task<T>> factory, TimeSpan cacheExpiry) | |
{ | |
try | |
{ | |
var value = await _conn.StringGetAsync(key); | |
if (value.HasValue) | |
{ | |
try | |
{ | |
return JsonConvert.DeserializeObject<T>(value); | |
} | |
catch (Exception) | |
{ | |
return (T)Convert.ChangeType(value, typeof(T)); | |
} | |
} | |
var item = await factory.Invoke(); | |
if (item != null) | |
{ | |
var serializedValue = JsonConvert.SerializeObject(item); | |
await _conn.StringSetAsync(key, serializedValue, cacheExpiry, When.Always, CommandFlags.None); | |
return item; | |
} | |
return default(T); | |
} | |
catch (Exception) | |
{ | |
return default(T); | |
} | |
} | |
} |
I decided to choose an API HTTP request as my primary source instead of a database call. Sticking with the weather theme I decided to consume the Open Weather API to get that feeling of playing around with live data. Because the second parameter in the caching service endpoint is a function, I created a new weather service whose responsibility is to consume the Open Weather API. Like I said earlier this function could be a database call. In that case we would need to inject the function that retrieves the data. For completeness sake and just in case anyone would want a code snippet how to consume the Open Weather API, here’s my implementation.
public class WeatherService : IWeatherService | |
{ | |
public WeatherService() | |
{ | |
} | |
public async Task<OpenWeather> GetWeather(string cityName) | |
{ | |
if (string.IsNullOrWhiteSpace(cityName)) | |
throw new ArgumentNullException("Provide city name"); | |
var weather = new OpenWeather(); | |
var apiKey = "your OpenWeather API key"; | |
using (var httpClient = new HttpClient()) | |
{ | |
using (var response = await httpClient.GetAsync($"https://api.openweathermap.org/data/2.5/weather?q={cityName}&appid={apiKey}&units=metric")) | |
{ | |
weather = JsonConvert.DeserializeObject<OpenWeather>(await response.Content.ReadAsStringAsync()); | |
} | |
} | |
return weather; | |
} | |
} |
I then updated the default WeatherForecast controller to use the caching service and weather service. Originally this was returning some random data and was not connected to any data source whatsoever.
[ApiController] | |
[Route("[controller]")] | |
public class WeatherForecastController : ControllerBase | |
{ | |
private readonly ILogger<WeatherForecastController> _logger; | |
private readonly ICacheService _cacheService; | |
private readonly IWeatherService _weatherService; | |
public WeatherForecastController(ILogger<WeatherForecastController> logger, ICacheService cacheService, IWeatherService weatherService) | |
{ | |
_logger = logger; | |
_cacheService = cacheService; | |
_weatherService = weatherService; | |
} | |
[HttpGet] | |
public async Task<WeatherForecast> GetAsync(string city) | |
{ | |
var weather = new OpenWeather(); | |
var cacheExpiry = new TimeSpan(0, 0, 10); | |
weather = await _cacheService.GetOrSet<OpenWeather>(city, () => _weatherService.GetWeather(city), cacheExpiry); | |
return new WeatherForecast | |
{ | |
Date = DateTime.Now, | |
TemperatureC = weather.main.temp, | |
Summary = weather.weather[0].description | |
}; | |
} | |
} |
The services were injected in the WeatherForecast controller using dependency injection and therefore I had to update the ConfigureServices method inside the Startup class and instantiate both services. I also added a reference to the memory and distributing caching services.
public void ConfigureServices(IServiceCollection services) | |
{ | |
services.AddControllers(); | |
services.AddMemoryCache(); | |
services.AddSingleton<IConnectionMultiplexer>(provider => ConnectionMultiplexer.Connect("your redis connection string")); | |
services.AddScoped<ICacheService, CacheService>(); | |
services.AddScoped<IWeatherService, WeatherService>(); | |
} |
Last but not least I also created some unit tests to wrap everything up nice and easy.
[TestClass] | |
public class CacheServiceTests | |
{ | |
private CacheService _cacheService; | |
private Mock<IConnectionMultiplexer> _mockMuxer; | |
private Mock<IDatabase> _mockRedisDb; | |
public CacheServiceTests() | |
{ | |
_mockMuxer = new Mock<IConnectionMultiplexer>(); | |
_mockRedisDb = new Mock<IDatabase>(); | |
} | |
[TestMethod] | |
public async Task GetOrSet_KeyFoundInMemoryCache_ReturnsValue() | |
{ | |
// Arrange | |
var key = "TestKey"; | |
var value = "TestValue"; | |
var memoryCache = new MemoryCache(new MemoryCacheOptions()); | |
memoryCache.Set(key, value); | |
_cacheService = new CacheService(_mockMuxer.Object, memoryCache); | |
// Act | |
var result = await _cacheService.GetOrSet<string>(key, () => Task.FromResult(value), TimeSpan.FromSeconds(30)); | |
// Assert | |
Assert.IsInstanceOfType(result, typeof(string)); | |
Assert.AreEqual(value, result); | |
} | |
} |
You can find the entire solution in one of my Github repositories and feel free to test it out or make your own changes. This was just a proof of concept and can certainly do with some improvements such as storing sensitive keys or connection strings in a more secure location, or supplying two different expiry times for the memory and distributed caching. Equally the caching service could easily be put inside a “common” project and then re-used as a nuget package/artifact by different solutions.
That’s a wrap for today and I hope you enjoyed this blog post. Don’t be shy to leave any comments or get in touch if anything is unclear.
Peace out,
Bjorn