Caching is the practice of storing data in a temporary storage area, called a cache, to reduce the number of times the data must be retrieved from a slower data store.
It is an important technique in web development because it can improve the performance and scalability of web applications by reducing the load on the server and the network
Redis as a Cache Store
Redis (Remote Dictionary Server) is an in-memory data structure store that can be used as a database, cache, and message broker. It is known for its lightning-fast read and write speeds and ability to handle large amounts of data, making it a popular choice for use as a cache store.
Redis can be used as a distributed cache, allowing multiple application instances to share cache data. It also supports cache expiration and eviction policies, making it easy to automatically remove stale or unused items from the cache.
In addition to its basic cache capabilities, Redis also supports more advanced features such as cache dependencies and change notifications, which can be used to invalidate cache items when the data they depend on changes.
Redis also supports a wide variety of data types, including strings, lists, sets, and hashes, making it easy to store and retrieve complex data structures thus allowing it to be used as a compressive database for all needs and not just a caching store.
Setting up a Redis server
There are several different ways we can set up a Redis server for development purposes:
Using a Redis container: We can use a Redis Docker container to set up a Redis server on your local machine. This is the fastest way and we would be using the same for this article.
Using a managed Redis service such as Redis Cloud or Amazon ElastiCache. These services allow you to easily create and manage a Redis instance in the cloud.
basketdb:
image: redis:alpine
container_name: basketdb
restart: always
ports:
- "6379:6379"
Configuring Redis in Asp.Net Core Application
To use Redis as a cache store in an Asp.Net Core application, we will need to install StackExchange.Redis NuGet package and configure it in our application.
Open the NuGet package manager and search for "StackExchange.Redis".
Select the "StackExchange.Redis" package and click "Install" to add it to your project.
In the application's appsettings.json file, add a connection string for the Redis server in the following format: "redis://{hostname}:{port}" or "{containerName}:{port} in case of Docker
"CacheSettings": { "RedisCache": "basketdb:6379" }
In the application's Startup.cs file, add the following code to the ConfigureServices method to register the Redis cache as a service
services.AddStackExchangeRedisCache(options => { options.Configuration = Configuration.GetValue<string>("CacheSettings:RedisCache"); }); services.Add(ServiceDescriptor.Singleton<IDistributedCache, RedisCache>());
Storing and Retrieving simple cached items
To store and retrieve simple cache items in Redis, we can use the Set and Get methods of the IDistributedCache interface that needs to be injected as a dependency in the constructor of the class where we intend to use it.
private readonly IDistributedCache _redisCache;
public BasketRepository(IDistributedCache redisCache)
{
_redisCache = redisCache;
}
await _redisCache.SetAsync("key", Encoding.UTF8.GetBytes("value"));
var cachedValue = await _cache.GetAsync("key");
To store complex objects they need to be serialized to string at the time of storing and then once fetched have to be deserialised so that we get the desired class object.
Here's a sample shopping cart object which we will be storing in the cache
public class ShoppingCart
{
public ShoppingCart()
{
}
public ShoppingCart(string _userName)
{
UserName = _userName;
}
public string UserName { get; set; }
public List<ShoppingCartItems> Items { get; set; } = new List<ShoppingCartItems>();
public decimal TotalPrice
{
get
{
decimal totalPrice = 0;
Items.ForEach(x =>
{
totalPrice += x.Price * x.Quantity;
});
return totalPrice;
}
}
}
public class ShoppingCartItems
{
public int Quantity { get; set; }
public string Color { get; set; }
public decimal Price { get; set; }
public string ProductId { get; set; }
public string ProductName { get; set; }
}
Caching the Shopping Cart
public async Task<ShoppingCart> UpdateBasket(ShoppingCart basket)
{
string basketJson = JsonConvert.SerializeObject(basket);
await _redisCache.SetStringAsync(basket.UserName, basketJson);
return await GetBasket(basket.UserName);
}
Getting the Cached Shopping Cart
public async Task<ShoppingCart> GetBasket(string userName)
{
string basketJson = await _redisCache.GetStringAsync(userName);
if (string.IsNullOrEmpty(basketJson)) return null;
var basket = JsonConvert.DeserializeObject<ShoppingCart>(basketJson);
return basket;
}
Removing the Shopping Cart from Cache
public async Task DeleteBasket(string userName)
{
await _redisCache.RemoveAsync(userName);
}
Expiration Policies
Redis supports cache expiration and eviction policies that can be used to automatically remove cache items that are no longer needed or that have exceeded a certain age. This can help to keep the cache size under control and prevent it from consuming too much memory.
The SetStringAsync method used earlier allows for specifying an absolute expiration time for a cache item, after which the item will be automatically removed from the cache. This can be used for JWT tokens if we plan to store them in the cache.
await _redisCache.SetStringAsync(basket.UserName, basketJson,
new DistributedCacheEntryOptions { AbsoluteExpiration = DateTime.Now.AddMinutes(30) });
Unit Testing our Redis Cache Implementation using NUnit
Unit testing our IDistributedCache implementation can have several benefits:
Verifying that the code correctly stores and retrieves data from the cache and that it handles errors and edge cases correctly.
Detecting and fixing bugs early in the development cycle before the code is deployed to production.
Improving the design and structure of the code, by encouraging to write modular, testable code that follows good software engineering practices.
Here, we will use the power of dependency injection to replace the RedisCache injection with a new InMemoryDistributedCache in our test class.
Mock<BasketRepository> _basketRepoMock;
IDistributedCache _inMeoryCache;
[SetUp]
public void Setup()
{
var opts = Options.Create<MemoryDistributedCacheOptions>(new MemoryDistributedCacheOptions());
_inMeoryCache = new MemoryDistributedCache(opts);
}
So, this will allow us to use the same methods in the repository but instead of the cache being our containerized Redis instance, it will be an in-memory cache which is temporary.
The following Test comprises two test cases which allow us to test our GetBasket method.
[Test]
[TestCase("CorrectKey", true)]
[TestCase("InCorrectKey", false)]
public async Task WhenKey_Returns_Values(string key, bool expectedResult)
{
var corectKeyCart = MockShoppingCart(key);
_inMeoryCache.SetString("CorrectKey", JsonConvert.SerializeObject(corectKeyCart));
_inMeoryCache.SetString("InCorrectKey", JsonConvert.SerializeObject(new ShoppingCart("InCorrectKey")));
_basketRepoMock = new Mock<BasketRepository>(_inMeoryCache);
_basketRepoMock.CallBase = true;
var result = await _basketRepoMock.Object.GetBasket(key);
_basketRepoMock.Verify();
Assert.AreEqual(true, result is ShoppingCart);
Assert.AreEqual(true, result != null);
Assert.Pass();
}
private ShoppingCart MockShoppingCart(string key)
{
return new ShoppingCart()
{
UserName = key,
Items = new System.Collections.Generic.List<ShoppingCartItems>()
{
new ShoppingCartItems()
{
Quantity = 1,
Color = "blue",
Price = 9,
ProductId = "1020301",
ProductName = $"{key} Product Name"
}
}
};
}
To test the Set cache functionality we can use the below implementation that compares the response of the UpdateBasket method with the expected response manually generated.
[Test]
public async Task WhenNewBasket_Returns_KeyValue()
{
var cartToAdd = MockShoppingCart("NewKey");
_basketRepoMock = new Mock<BasketRepository>(_inMeoryCache);
_basketRepoMock.CallBase = true;
var result = await _basketRepoMock.Object.UpdateBasket(cartToAdd);
_basketRepoMock.Verify();
Assert.AreEqual(true, result is ShoppingCart);
Assert.AreEqual(true, result.UserName == "NewKey");
Assert.AreEqual(true, result.Items.Count == 1);
Assert.Pass();
}
Best practices for designing a cache system
When using Redis as a cache store, there are several mistakes and performance issues that we should try to avoid to ensure optimal performance and scalability.
Storing large objects in the cache: Redis is an in-memory data store so try to keep the size of your cache items as small as possible and consider using compression or serialization techniques to reduce the size of your data.
Overusing cache dependencies and change notifications: Cache dependencies and change notifications can be useful for invalidating cache items when the data they depend on changes, but they can also hurt performance if used excessively. These features require additional communication between the cache and the application, which can cause overhead and reduce the overall performance of the cache.
Failing to set appropriate expiration and eviction policies: Setting appropriate expiration and eviction policies can help to keep the cache size under control and prevent it from consuming too much memory.
Conclusion
Redis is a powerful and efficient cache store that can be used in ASP.NET Core applications and today we discussed the basic integration of the same.
The complete code is available at the following GitHub Repo