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>

Leave a Reply

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