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
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.
Thank you for your comment, appended the KeyStringList class to the post.