Easily Use Redis Cache In ASP.NET 6.0 Web API

Introduction 

Caching is the process of storing copies of files or data in a cache, or temporary storage location, so that they can be accessed more quickly. It is the process of storing data or files in a temporary storage for a specific period, so that from the next time onwards, when the data is requested, it can be provided from this temporary storage instead of database or from original files. In this post, we will see how to install and use Redis cache on Windows. We will create an ASP.NET 6.0 Web API and use this Redis on Windows. We will also see how to create an Azure cache for Redis on Azure portal and use it with our application.  

Distributed Caching 

A distributed cache is a cache shared by multiple application servers. Distributed cache improves application performance and scalability because it can supply the same data to multiple servers consistently and if one server restarts or crashes, the cached data is still available to other servers as normal.  

Distributed Caching in ASP.NET Core 6.0 

IDistributedCache Interface provides you with the following methods to perform actions on the actual cache. 

  • Get, GetAsync - Gets the value from the cache server based on the string key. These methods accept a key and retrieve a cached item as a byte [] array. 
  • Set, SetAsync - Accepts a string key and value and sets it to the cache server. These methods add an item (as byte [] array) to the cache using a key. 
  • Refresh, RefreshAsync - Resets the sliding expiration timeout. These methods are used to refresh an item in the cache based on its key, resetting its sliding expiration timeout. 
  • Remove, RemoveAsync – Removes the cache data based on the string key. 

To use distributed cache in ASP.NET Core, we have multiple built-in and third-party implementations to choose from.  

  • Distributed SQL Server cache - To use a SQL Server distributed cache, we need to use this package. 
  • Distributed Redis cache - To use a Redis distributed cache, we need to use this package. 
  • Distributed NCache cache - To use NCache distributed cache, we need to use this package. 

What is Redis Cache 

Redis is an open source (BSD licensed), in-memory data structure store used as a database, cache, message broker, and streaming engine. Redis provides data structures such as strings, hashes, lists, sets, sorted sets with range queries, bitmaps, hyperloglogs, geospatial indexes, and streams. Redis has built-in replication, Lua scripting, LRU eviction, transactions, and various levels of on-disk persistence, and provides high availability via Redis Sentinel and automatic partitioning with Redis Cluster. 

You can use Redis in many programming languages. It is such a popular and widely used cache that Microsoft Azure also provides its cloud-based version with the name Azure Cache for Redis. 

Running Redis server on Windows machine 

We can download the windows version of Redis server from the link below and extract it anywhere on your machine. 

https://github.com/microsoftarchive/redis/releases/tag/win-3.0.504  

Above Zip file has a total of 16 files.  

We can run redis-server.exe file to start Redis server. 

We are getting one warning message as no config file is specified. We can simply ignore this warning message. By default, Redis is running on port 6379

Redis supports one more CLI and we can check the cache accessibility using this CLI tool. We can run the redis-cli.exe inside the same folder. If you enter a command like ping, you will get a response like PONG.  

Above is a simple test to check whether the cache is alive or not.  

We can set and get values using the commands below. 

Set command will set a value using the specified key and we can retrieve the value from cache for that key using the get command.  

We can create the Web API project now. We will fetch the C# corner authors post (articles/blogs/news) details using web scraping and use this data to evaluate the caching performance. I have already authored a detailed article on this topic and please refer to the link below for more details.  

Easily do Web scraping in .NET Core 6.0  

We will use some existing code from the application mentioned in the above post and will add caching related code to this project.  

Create ASP.NET Core 6.0 Web API using Visual Studio 2022 

We can open Visual Studio 2022 and create a new project using ASP.NET Core Web API template. 

We can give a valid name for the project and choose .NET 6.0 framework. 

Project will be created after clicking the “Create” button.  

We must install the libraries below using NuGet package manager.  

  • HtmlAgilityPack  
  • Microsoft.EntityFrameworkCore.SqlServer  
  • Microsoft.EntityFrameworkCore.Tools  
  • Microsoft.Extensions.Caching.StackExchangeRedis 

HtmlAgiltyPack is used for web scraping and Caching.StackExchangeRedis is used for Redis caching. Two other libraries will be used for entity framework database operations.  

We can add database connection string, Redis connection URL and parallel task counts inside the appsettings. 

appsettings.json 

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "RedisCacheUrl": "127.0.0.1:6379",
  //"RedisCacheUrl": "sarathlal.redis.cache.windows.net:6380,password=k6CRosKzTY9vXMqH76F8rbl7m8PntopEwAzCaPcTyeM=,ssl=True,abortConnect=False", // For Azure Redis Cache. Currently this resource is not available
  "ConnectionStrings": {
    "ConnStr": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=AnalyticsDB;Integrated Security=True;ApplicationIntent=ReadWrite;MultiSubnetFailover=False"
  },
  "ParallelTasksCount": 20
}

Database connection string will be used by entity framework to connect SQL database and parallel task counts will be used by web scraping parallel foreach code. RedisCacheUrl will be used to set up a connection between the application and Redis server.  

We can create a Feed class inside a Models folder. This class will be used to get required information from C# Corner RSS feeds. 

Feed.cs 

namespace RedisCacheASP.NET6.Models
{
    public class Feed
    {
        public string Link { get; set; }
        public string Title { get; set; }
        public string FeedType { get; set; }
        public string Author { get; set; }
        public string Content { get; set; }
        public DateTime PubDate { get; set; }

        public Feed()
        {
            Link = "";
            Title = "";
            FeedType = "";
            Author = "";
            Content = "";
            PubDate = DateTime.Today;
        }
    }
}

We can create an ArticleMatrix class inside the Models folder. This class will be used to get information for each article / blog / news once we get it after web scraping. 

ArticleMatrix.cs 

using System.ComponentModel.DataAnnotations.Schema;

namespace RedisCacheASP.NET6.Models
{
    public class ArticleMatrix
    {
        public int Id { get; set; }
        public string? AuthorId { get; set; }
        public string? Author { get; set; }
        public string? Link { get; set; }
        public string? Title { get; set; }
        public string? Type { get; set; }
        public string? Category { get; set; }
        public string? Views { get; set; }
        [Column(TypeName = "decimal(18,4)")]
        public decimal ViewsCount { get; set; }
        public int Likes { get; set; }
        public DateTime PubDate { get; set; }
    }
}

We can create our DB context class for Entity framework. 

MyDbContext.cs 

using Microsoft.EntityFrameworkCore;

namespace RedisCacheASP.NET6.Models
{
    public class MyDbContext : DbContext
    {
        public MyDbContext(DbContextOptions<MyDbContext> options)
            : base(options)
        {
        }
        public DbSet<ArticleMatrix>? ArticleMatrices { get; set; }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            base.OnModelCreating(builder);
        }
    }
}

We will use this MyDbContext class later for saving data to the database. 

We can create our API controller AnalyticsController and add web scraping and caching related code inside it.  

AnalyticsController.cs 

using HtmlAgilityPack;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using RedisCacheASP.NET6.Models;
using System.Globalization;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Xml.Linq;

namespace RedisCacheASP.NET6.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class AnalyticsController : ControllerBase
    {
        readonly CultureInfo culture = new("en-US");
        private readonly MyDbContext _dbContext;
        private readonly IConfiguration _configuration;
        private static readonly object _lockObj = new();
        private readonly IDistributedCache _cache;
        public AnalyticsController(MyDbContext context, IConfiguration configuration, IDistributedCache cache)
        {
            _dbContext = context;
            _configuration = configuration;
            _cache = cache;
        }

        [HttpPost]
        [Route("CreatePosts/{authorId}")]
        public async Task<bool> CreatePosts(string authorId)
        {
            try
            {
                XDocument doc = XDocument.Load("https://www.c-sharpcorner.com/members/" + authorId + "/rss");
                if (doc == null)
                {
                    return false;
                }
                var entries = from item in doc.Root.Descendants().First(i => i.Name.LocalName == "channel").Elements().Where(i => i.Name.LocalName == "item")
                              select new Feed
                              {
                                  Content = item.Elements().First(i => i.Name.LocalName == "description").Value,
                                  Link = (item.Elements().First(i => i.Name.LocalName == "link").Value).StartsWith("/") ? "https://www.c-sharpcorner.com" + item.Elements().First(i => i.Name.LocalName == "link").Value : item.Elements().First(i => i.Name.LocalName == "link").Value,
                                  PubDate = Convert.ToDateTime(item.Elements().First(i => i.Name.LocalName == "pubDate").Value, culture),
                                  Title = item.Elements().First(i => i.Name.LocalName == "title").Value,
                                  FeedType = (item.Elements().First(i => i.Name.LocalName == "link").Value).ToLowerInvariant().Contains("blog") ? "Blog" : (item.Elements().First(i => i.Name.LocalName == "link").Value).ToLowerInvariant().Contains("news") ? "News" : "Article",
                                  Author = item.Elements().First(i => i.Name.LocalName == "author").Value
                              };

                List<Feed> feeds = entries.OrderByDescending(o => o.PubDate).ToList();
                string urlAddress = string.Empty;
                List<ArticleMatrix> articleMatrices = new();
                _ = int.TryParse(_configuration["ParallelTasksCount"], out int parallelTasksCount);

                Parallel.ForEach(feeds, new ParallelOptions { MaxDegreeOfParallelism = parallelTasksCount }, feed =>
                {
                    urlAddress = feed.Link;

                    var httpClient = new HttpClient
                    {
                        BaseAddress = new Uri(urlAddress)
                    };
                    var result = httpClient.GetAsync("").Result;

                    string strData = "";

                    if (result.StatusCode == HttpStatusCode.OK)
                    {
                        strData = result.Content.ReadAsStringAsync().Result;

                        HtmlDocument htmlDocument = new();
                        htmlDocument.LoadHtml(strData);

                        ArticleMatrix articleMatrix = new()
                        {
                            AuthorId = authorId,
                            Author = feed.Author,
                            Type = feed.FeedType,
                            Link = feed.Link,
                            Title = feed.Title,
                            PubDate = feed.PubDate
                        };

                        string category = "Videos";
                        if (htmlDocument.GetElementbyId("ImgCategory") != null)
                        {
                            category = htmlDocument.GetElementbyId("ImgCategory").GetAttributeValue("title", "");
                        }

                        articleMatrix.Category = category;

                        var view = htmlDocument.DocumentNode.SelectSingleNode("//span[@id='ViewCounts']");
                        if (view != null)
                        {
                            articleMatrix.Views = view.InnerText;

                            if (articleMatrix.Views.Contains('m'))
                            {
                                articleMatrix.ViewsCount = decimal.Parse(articleMatrix.Views[0..^1]) * 1000000;
                            }
                            else if (articleMatrix.Views.Contains('k'))
                            {
                                articleMatrix.ViewsCount = decimal.Parse(articleMatrix.Views[0..^1]) * 1000;
                            }
                            else
                            {
                                _ = decimal.TryParse(articleMatrix.Views, out decimal viewCount);
                                articleMatrix.ViewsCount = viewCount;
                            }
                        }
                        else
                        {
                            var newsView = htmlDocument.DocumentNode.SelectSingleNode("//span[@id='spanNewsViews']");
                            if (newsView != null)
                            {
                                articleMatrix.Views = newsView.InnerText;

                                if (articleMatrix.Views.Contains('m'))
                                {
                                    articleMatrix.ViewsCount = decimal.Parse(articleMatrix.Views[0..^1]) * 1000000;
                                }
                                else if (articleMatrix.Views.Contains('k'))
                                {
                                    articleMatrix.ViewsCount = decimal.Parse(articleMatrix.Views[0..^1]) * 1000;
                                }
                                else
                                {
                                    _ = decimal.TryParse(articleMatrix.Views, out decimal viewCount);
                                    articleMatrix.ViewsCount = viewCount;
                                }
                            }
                            else
                            {
                                articleMatrix.ViewsCount = 0;
                            }
                        }
                        var like = htmlDocument.DocumentNode.SelectSingleNode("//span[@id='LabelLikeCount']");
                        if (like != null)
                        {
                            _ = int.TryParse(like.InnerText, out int likes);
                            articleMatrix.Likes = likes;
                        }

                        lock (_lockObj)
                        {
                            articleMatrices.Add(articleMatrix);
                        }
                    }
                });

                _dbContext.ArticleMatrices.RemoveRange(_dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId));

                foreach (ArticleMatrix articleMatrix in articleMatrices)
                {
                    if (articleMatrix.Category == "Videos")
                    {
                        articleMatrix.Type = "Video";
                    }
                    articleMatrix.Category = articleMatrix.Category.Replace("&amp;", "&");
                    await _dbContext.ArticleMatrices.AddAsync(articleMatrix);
                }

                await _dbContext.SaveChangesAsync();
                await _cache.RemoveAsync(authorId);
                return true;
            }
            catch
            {
                return false;
            }
        }

        [HttpGet]
        [Route("GetAll/{authorId}/{enableCache}")]
        public async Task<List<ArticleMatrix>> GetAll(string authorId, bool enableCache)
        {
            if (!enableCache)
            {
                return _dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId).OrderByDescending(x => x.PubDate).ToList();
            }
            string cacheKey = authorId;

            // Trying to get data from the Redis cache
            byte[] cachedData = await _cache.GetAsync(cacheKey);
            List<ArticleMatrix> articleMatrices = new();
            if (cachedData != null)
            {
                // If the data is found in the cache, encode and deserialize cached data.
                var cachedDataString = Encoding.UTF8.GetString(cachedData);
                articleMatrices = JsonSerializer.Deserialize<List<ArticleMatrix>>(cachedDataString);
            }
            else
            {
                // If the data is not found in the cache, then fetch data from database
                articleMatrices = _dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId).OrderByDescending(x => x.PubDate).ToList();

                // Serializing the data
                string cachedDataString = JsonSerializer.Serialize(articleMatrices);
                var dataToCache = Encoding.UTF8.GetBytes(cachedDataString);

                // Setting up the cache options 
                DistributedCacheEntryOptions options = new DistributedCacheEntryOptions()
                    .SetAbsoluteExpiration(DateTime.Now.AddMinutes(5))
                    .SetSlidingExpiration(TimeSpan.FromMinutes(3));

                // Add the data into the cache
                await _cache.SetAsync(cacheKey, dataToCache, options);
            }
            return articleMatrices;
        }
    }
}

We have two methods inside the above controller.  

“CreatePosts” will fetch the authored posts data for a particular C# Corner author and save to the database.  

GetAll method in AnalyticsController.cs

        [HttpGet]
        [Route("GetAll/{authorId}/{enableCache}")]
        public async Task<List<ArticleMatrix>> GetAll(string authorId, bool enableCache)
        {
            if (!enableCache)
            {
                return _dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId).OrderByDescending(x => x.PubDate).ToList();
            }
            string cacheKey = authorId;

            // Trying to get data from the Redis cache
            byte[] cachedData = await _cache.GetAsync(cacheKey);
            List<ArticleMatrix> articleMatrices = new();
            if (cachedData != null)
            {
                // If the data is found in the cache, encode and deserialize cached data.
                var cachedDataString = Encoding.UTF8.GetString(cachedData);
                articleMatrices = JsonSerializer.Deserialize<List<ArticleMatrix>>(cachedDataString);
            }
            else
            {
                // If the data is not found in the cache, then fetch data from database
                articleMatrices = _dbContext.ArticleMatrices.Where(x => x.AuthorId == authorId).OrderByDescending(x => x.PubDate).ToList();

                // Serializing the data
                string cachedDataString = JsonSerializer.Serialize(articleMatrices);
                var dataToCache = Encoding.UTF8.GetBytes(cachedDataString);

                // Setting up the cache options 
                DistributedCacheEntryOptions options = new DistributedCacheEntryOptions()
                    .SetAbsoluteExpiration(DateTime.Now.AddMinutes(5))
                    .SetSlidingExpiration(TimeSpan.FromMinutes(3));

                // Add the data into the cache
                await _cache.SetAsync(cacheKey, dataToCache, options);
            }
            return articleMatrices;
        }

“GetAll” method will fetch the data from the database/cache for an author. I have added a parameter “enableCache” in this method to examine the performance of Caching.  

When we are fetching the data for an author, first it will check inside the cache using author id as a key. If the data is available, it will deserialize the data to model and return. But if the data is not available or expired, data will be fetched from the database and set to the Redis cache.  

We must change the Program.cs class with the code change below. So that, the Entity framework connection and Redis cache connection have been provided.  

Program.cs 

using Microsoft.EntityFrameworkCore;
using RedisCacheASP.NET6.Models;

var builder = WebApplication.CreateBuilder(args);
ConfigurationManager configuration = builder.Configuration;

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

builder.Services.AddDbContext<MyDbContext>(options => options.UseSqlServer(configuration.GetConnectionString("ConnStr")));
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = configuration["RedisCacheUrl"]; });

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseAuthorization();

app.MapControllers();

app.Run();

We can create the SQL database and table using migration commands.  

We can use the command below to create migration scripts in Package Manager Console. 

PM > add-migration InititalScript 

Above command will create a new migration script. We can use the script below to create our database and table.  

PM> update-database 

We can run the application and use Postman tool to create few C# Corner posts data and save to the database.  

We must run our local Redis cache server as well. (Execute redis-server.exe file) 

We can create posts for a particular C# Corner author. Here I am using my own author id. (You can get the author id from C# Corner profile) 

There are 100 post details created for my author id. We can try to fetch these data in Postman. 

Please note that it has taken 21 milliseconds to fetch the data. Because there is no data available in the cache and data is fetched directly from the database.  

Now we can try to fetch the data again.  

We can notice that there is a significant performance improvement. It just took 5 milliseconds only. Because data is fetched from Redis cache instead of database.  

Now we can fetch the data without Caching. We have already added a parameter in the “GetAll” method to enable or disable cache.  

This time data is fetched directly from database because we have passed cache flag as false. We can notice that it took 30 milliseconds to fetch the same data from database. If you are fetching huge data from a database, you can see more improvement using Redis cache.  

Now we can create an Azure cache for Redis and check with our application.  

Create Azure Cache for Redis on Azure Portal 

Choose Azure Cache for Redis from Databases tab. 

We can create a new resource group or choose any existing resource group and give a valid name for Redis instance. There are various pricing plans available for Redis cache. I have chosen the basic plan for testing purposes.  

Choose the Redis version. By default, the selected version is 4. Currently, there are two versions available. I have chosen version 6 for our Redis cache.  

After validation passed, we can click “Create” button to start the deployment.  

Deployment will take some time. After deployment, we can open the resource and click on Show access keys link.

This will open a pop-up window and show various cache keys. We can choose the Primary connection string.  

Get this primary connection string and replace it in appsetting (Currently, we have given local Redis server connection string).  

Now we can run the application without our local Redis server.  

If we are hosting our application on Azure, we can see significant performance improvement while using the Azure Redis cache.  

Conclusion 

In this post, we have discussed distributed caching and Redis cache. We have seen how to install and test the Redis cache server on Windows machine. We have then created an ASP.NET 6.0 Web API and it is used with the local Redis server on Windows. We have also seen how to create an Azure cache for Redis on Azure portal and used it with our application.