Response Caching in ASP.NET Core

This post describes how to add response caching to controllers (classes and methods) in ASP.NET Core. Response caching reduces the load on a web server, caching means less number of requests to a server and less work to be performed by a server. Response caching is implemented with headers that specifies how clients, proxies and middleware should cache responses.

Response caching can be used when the response not is expected to change during a period of time or when it not is important to get the latest information on each request. I am going to implement response caching for an API method that returns ice hockey standings, these standings will not change very often and I want to be able to handle as many requests as possible from clients and proxies.

Cache-Control is the primary header used for caching, it is used to specify cache directives: public, private, max-age, no-cache and no-store. Other cache headers is: Age, Expires, Pragma and Vary.

Configuration

Our Web API method must work with cross-origin requests from JavaScript, we add a CORS-policy and indicate that we use CORS in the StartUp class of our project.

using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Annytab.Middleware;
using Annytab.Repositories;
using Annytab.Options;
using Annytab.Rules;

namespace Hockeytabeller
{
    /// <summary>
    /// This class handles application startup
    /// </summary>
    public class Startup
    {
        /// <summary>
        /// Variables
        /// </summary>
        public IConfiguration configuration { get; set; }

        /// <summary>
        /// Create a new startup object
        /// </summary>
        /// <param name="configuration">A reference to configuration</param>
        public Startup(IConfiguration configuration)
        {
            this.configuration = configuration;

        } // End of the constructor method

        /// <summary>
        /// This method is used to add services to the container.
        /// </summary>
        /// <param name="services"></param>
        public void ConfigureServices(IServiceCollection services)
        {
            // Add the mvc framework
            services.AddRazorPages();

            // Add memory cache
            services.AddDistributedMemoryCache();

            // Add redis distributed cache
            if (configuration.GetSection("AppSettings")["RedisConnectionString"] != "")
            {
                services.AddDistributedRedisCache(options =>
                {
                    options.Configuration = configuration.GetSection("AppSettings")["RedisConnectionString"];
                    options.InstanceName = "Hockeytabeller:";
                });
            }

            // Add cors
            services.AddCors(options =>
            {
                options.AddPolicy("AnyOrigin", builder => builder.AllowAnyOrigin());
            });

            // Add the session service
            services.AddSession(options =>
            {
                // Set session options
                options.IdleTimeout = TimeSpan.FromMinutes(20d);
                options.Cookie.Name = ".Hockeytabeller";
                options.Cookie.Path = "/";
                options.Cookie.HttpOnly = true;
                options.Cookie.SameSite = SameSiteMode.Lax;
                options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
            });

            // Create database options
            services.Configure<DatabaseOptions>(options => 
            {
                options.connection_string = configuration.GetSection("AppSettings")["ConnectionString"];
                options.sql_retry_count = 1;
            });

            // Create cache options
            services.Configure<CacheOptions>(options => 
            {
                options.expiration_in_minutes = 240d;
            });

            // Add Authentication
            services.AddAuthentication()
                .AddCookie("Administrator", options =>
                {
                    options.ExpireTimeSpan = TimeSpan.FromDays(1);
                    options.Cookie.MaxAge = TimeSpan.FromDays(1);
                    options.Cookie.HttpOnly = true;
                    options.Cookie.SameSite = SameSiteMode.Lax;
                    options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
                    options.Events.OnRedirectToLogin = (context) =>
                    {
                        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                        context.Response.Redirect("/admin_login");
                        return Task.CompletedTask;
                    };
                })
                .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("ApiAuthentication", null);


            // Add clients
            services.AddHttpClient();

            // Add repositories
            services.AddSingleton<IDatabaseRepository, MsSqlRepository>();
            services.AddSingleton<IWebsiteSettingRepository, WebsiteSettingRepository>();
            services.AddSingleton<IAdministratorRepository, AdministratorRepository>();
            services.AddSingleton<IFinalRepository, FinalRepository>();
            services.AddSingleton<IGroupRepository, GroupRepository>();
            services.AddSingleton<IStaticPageRepository, StaticPageRepository>();
            services.AddSingleton<IXslTemplateRepository, XslTemplateRepository>();
            services.AddSingleton<ISitemapRepository, SitemapRepository>();
            services.AddSingleton<IXslProcessorRepository, XslProcessorRepository>();
            services.AddSingleton<ICommonServices, CommonServices>();

        } // End of the ConfigureServices method

        /// <summary>
        /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        /// </summary>
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env, IWebsiteSettingRepository website_settings_repository)
        {
            // Use error handling
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseStatusCodePagesWithReExecute("/home/error/{0}");
            }

            // Get website settings
            KeyStringList settings = website_settings_repository.GetAllFromCache();
            bool redirect_https = settings.Get("REDIRECT-HTTPS") == "true" ? true : false;
            bool redirect_www = settings.Get("REDIRECT-WWW") == "true" ? true : false;
            bool redirect_non_www = settings.Get("REDIRECT-NON-WWW") == "true" ? true : false;

            // Add redirection and use a rewriter
            RedirectHttpsWwwNonWwwRule rule = new RedirectHttpsWwwNonWwwRule
            {
                status_code = 301,
                redirect_to_https = redirect_https,
                redirect_to_www = redirect_www,
                redirect_to_non_www = redirect_non_www,
                hosts_to_ignore = new string[] { "localhost", "hockeytabeller001.azurewebsites.net" }
            };
            RewriteOptions options = new RewriteOptions();
            options.Rules.Add(rule);
            app.UseRewriter(options);

            // Use static files
            app.UseStaticFiles(new StaticFileOptions
            {
                OnPrepareResponse = ctx =>
                {
                    // Cache static files for 30 days
                    ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=25920000");
                    ctx.Context.Response.Headers.Append("Expires", DateTime.UtcNow.AddDays(300).ToString("R", CultureInfo.InvariantCulture));
                }
            });

            // Use sessions
            app.UseSession();

            // For most apps, calls to UseAuthentication, UseAuthorization, and UseCors must 
            // appear between the calls to UseRouting and UseEndpoints to be effective.
            app.UseRouting();

            // Use CORS
            app.UseCors();

            // Use authentication and authorization middlewares
            app.UseAuthentication();
            app.UseAuthorization();

            // Routing endpoints
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    "default",
                    "{controller=home}/{action=index}/{id?}");
            });

        } // End of the Configure method

    } // End of the class

} // End of the namespace

View Template

Our Web API method returns html and the template for the response is shown below.

@using Annytab.Repositories
@using Annytab.Models
@inject IGroupRepository group_repository
@{
    // Get a group
    Group group = ViewBag.Group;
    IList<TeamInGroupStanding> teams = this.group_repository.GetTeamsFromXml(group.data_content);
    Int32 rowCounter = 0;
}

@*Get teams in the group*@
<a href="@("https://www.hockeytabeller.se/home/group/" + group.page_name)" rel="nofollow" class="annytab-ht-not-hover">
    <div class="annytab-ht-table">
        <div class="annytab-ht-row">
            <div class="annytab-ht-col-th-normal">RK</div>
            <div class="annytab-ht-col-th-wide">Lag</div>
            <div class="annytab-ht-col-th-normal">GP</div>
            <div class="annytab-ht-col-th-normal">TP</div>
        </div>

@for (int j = 0; j < teams.Count; j++)
{
    @if (teams[j].name == "")
    {
        <div class="annytab-ht-row">
            <div class="annytab-ht-col-line"></div>
            <div class="annytab-ht-col-line"></div>
            <div class="annytab-ht-col-line"></div>
            <div class="annytab-ht-col-line"></div>
        </div>
    }
    else
    {
        rowCounter++;
        <div class="@(rowCounter % 2 != 0 ? "annytab-ht-row-main" : "annytab-ht-row-alt")">
            <div class="annytab-ht-col-normal">@((rowCounter).ToString())</div>
            <div class="annytab-ht-col-wide">@teams[j].name</div>
            <div class="annytab-ht-col-normal">@teams[j].games</div>
            <div class="annytab-ht-col-normal">@teams[j].points</div>
        </div>
    }
}
    </div>
</a>

API Controller

The contents of our API controller is shown below. A ResponseCache attribute can be set for a class or for individual methods in the class. Our API-method will return a response with a Cache-Control header that has a public (ResponseCacheLocation.Any) directive and a max-age (Duration) directive of 3600 seconds.

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.Extensions.Logging;
using Annytab.Repositories;
using Annytab.Models;

namespace Hockeytabeller.Api
{
    /// <summary>
    /// This class handles groups
    /// </summary>
    [Route("api/groups/[action]")]
    public class GroupsController : Controller
    {
        #region Variables

        private readonly ILogger logger;
        private readonly IGroupRepository group_repository;

        #endregion

        #region Constructors

        /// <summary>
        /// Create a new controller
        /// </summary>
        public GroupsController(ILogger<GroupsController> logger, IGroupRepository group_repository)
        {
            // Set values for instance variables
            this.logger = logger;
            this.group_repository = group_repository;

        } // End of the constructor

        #endregion

        #region Get methods

        // Get html by page name
        // GET api/groups/get_overview_as_html/shl
        [HttpGet("{id}")]
        [Microsoft.AspNetCore.Cors.EnableCors("AnyOrigin")]
        [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
        public IActionResult get_overview_as_html(string id = "")
        {
            // Get a group
            Group group = this.group_repository.GetOneByPageName(id);

            // Create view data
            ViewDataDictionary view_data = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary());
            view_data.Add("Group", group);

            // Return a partial view
            return new PartialViewResult { ViewName = "Views/home/_group_table.cshtml", ViewData=view_data, ContentType="text/html" };

        } // End of the get_overview_as_html method

        #endregion

    } // End of the class

} // End of the namespace

Test Response Caching

You can run tests by making requests to the API-method from Postman. You can also use the code below to run tests, use Chrome DevTools to check that CORS is working and that responses is cached.

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>Test</title>
    <style>
        .annytab-ht-not-hover {
            text-decoration: none;

        }

        .annytab-ht-table {
            display: table;
            width: 100%;
            padding: 0px;
            margin: 0px;
            font-family: Arial, Helvetica, sans-serif;
            background-color: #ffffff;
            color: #000000;
            overflow: hidden;
        }

        .annytab-ht-row {
            display: table-row;
        }

        .annytab-ht-row-main {
            display: table-row;
            background-color: #ffffff;
        }

        .annytab-ht-row-alt {
            display: table-row;
            background-color: #f0f0f0;
        }

        .annytab-ht-col-th-normal {
            display: table-cell;
            padding: 4px;
            color: #3d3d3d;
            border-bottom: 1px solid #9e9e9e;
            border-top: 1px solid #9e9e9e;
            font-size: 14px;
            line-height: 18px;
            vertical-align: middle;
            text-align: center;
            width: 20%;
        }

        .annytab-ht-col-th-wide {
            display: table-cell;
            padding: 4px;
            color: #3d3d3d;
            border-bottom: 1px solid #9e9e9e;
            border-top: 1px solid #9e9e9e;
            font-size: 14px;
            line-height: 18px;
            vertical-align: middle;
            text-align: left;
            width: 40%;
        }

        .annytab-ht-col-line {
            display: table-cell;
            height: 1px;
            background-color: #000000;
            padding: 0px;
        }

        .annytab-ht-col-normal {
            display: table-cell;
            padding: 4px;
            font-size: 12px;
            line-height: 12px;
            word-break: break-word;
            vertical-align: middle;
            text-align: center;
        }

        .annytab-ht-col-wide {
            display: table-cell;
            padding: 4px;
            font-size: 12px;
            line-height: 12px;
            word-break: break-word;
            vertical-align: middle;
            text-align: left;
        }
    </style>

</head>

<body style="width:100%;font-family:Arial, Helvetica, sans-serif;padding:20px;">

    <div class="hockeytabeller.se" data-group="shl"></div>

</body>

</html>

<script>
    // Initialize when DOM content has been loaded
    document.addEventListener('DOMContentLoaded', function () {
        var elements = document.getElementsByClassName('hockeytabeller.se');
        for (var i = 0; i < elements.length; i++) {
            get_group(elements[i]);
            }
        }, false);

    // Get a group
    function get_group(element)
    {
        var xhr = new XMLHttpRequest();
        xhr.open('GET', 'https://www.hockeytabeller.se/api/groups/get_overview_as_html/' + element.getAttribute('data-group'), true);
        xhr.onload = function () {
            if (xhr.status === 200) {
                element.insertAdjacentHTML('beforeend', xhr.response);
            }
        };
        xhr.send();

    } // End of the get_group method
</script>

KeyStringList

using System;
using System.Collections.Generic;

/// <summary>
/// This class represent a dictionary with strings
/// </summary>
public class KeyStringList
{
    #region Variables

    public IDictionary<string, string> dictionary { get; set; }

    #endregion

    #region Constructors

    /// <summary>
    /// Create a new key string list with default properties
    /// </summary>
    public KeyStringList()
    {
        // Set values for instance variables
        this.dictionary = new Dictionary<string, string>(10);

    } // End of the constructor

    /// <summary>
    /// Create a new key string list
    /// </summary>
    /// <param name="capacity">The initial capacity</param>
    public KeyStringList(Int32 capacity)
    {
        // Set values for instance variables
        this.dictionary = new Dictionary<string, string>(capacity);

    } // End of the constructor

    /// <summary>
    /// Create a new key string list
    /// </summary>
    /// <param name="dictionary">A string dictionary</param>
    public KeyStringList(IDictionary<string, string> dictionary)
    {
        // Set values for instance variables
        this.dictionary = dictionary;

    } // End of the constructor

    #endregion

    #region Insert methods

    /// <summary>
    /// Add a item to the key string list
    /// </summary>
    /// <param name="key">The key as a string</param>
    /// <param name="value">The value as a string</param>
    public void Add(string key, string value)
    {
        // Add the value to the dictionary
        dictionary.Add(key, value);

    } // End of the Add method

    #endregion

    #region Update methods

    /// <summary>
    /// Update the value for the key
    /// </summary>
    /// <param name="key">The key as a string</param>
    /// <param name="value">The value as a string</param>
    public void Update(string key, string value)
    {
        // Update the value
        dictionary[key] = value;

    } // End of the Update method

    #endregion

    #region Get methods

    /// <summary>
    /// Get the value from the dictionary, the key is returned if the key not is found
    /// </summary>
    /// <param name="key">The key as a string</param>
    /// <returns>The value as a string</returns>
    public string Get(string key)
    {
        // Create the string to return
        string value = key;

        // Check if the dictionary contains the key
        if (this.dictionary.ContainsKey(key))
        {
            value = this.dictionary[key];
        }

        // Return the value
        return value;

    } // End of the Get method

    #endregion

} // End of the class

2 thoughts on “Response Caching in ASP.NET Core”

  1. This is an excellent explanation. But give some information about wrapper classes also like KeyStringList because you use it many times so it must be clear.

Leave a Reply

Your email address will not be published. Required fields are marked *