I'm trying to build a response cache with custom key values (from headers and cookies). Here's the code I have so far, and it kinda works, but I need a second opinion or suggestions for improvements.
Are there any possible errors, faster key generation methods etc.
Here's the code for middleware and cache key building:
app.Use(async (ctx, next) => {
//cache only get requests
if (ctx.Request.Method != "GET") {
await next();
return;
}
//remove cache key header in case it's appended already
ctx.Request.Headers.Remove(ResponseCacheKeyProvider.CacheKeyHeader);
//remove cache control header. I would like to control this (and response cache does not work if header is set to 0
if (ctx.Request.Headers.ContainsKey("Cache-Control"))
ctx.Request.Headers.Remove("Cache-Control");
ctx.Request.GetTypedHeaders().CacheControl = new Microsoft.Net.Http.Headers.CacheControlHeaderValue() {
Public = true,
MaxAge = TimeSpan.FromSeconds(60)
};
//set cookie value if user does not have it (for a/b) testing
ABTestingCookieSetter.SetAbCookie(ctx);
//build a key for cache
var key = ResponseCacheKeyProvider.BuildKey(ctx);
//attach a key to header
if (!ctx.Request.Headers.ContainsKey(ResponseCacheKeyProvider.CacheKeyHeader))
ctx.Request.Headers.Add(ResponseCacheKeyProvider.CacheKeyHeader, key);
await next();
});
//mark necessary flags in request, so we know when to render international layout
app.Use(async (ctx, next) => {
if (ctx.Request.GetUri().AbsolutePath.StartsWith("/eng") || ctx.Request.GetUri().AbsolutePath.StartsWith("/mobile/eng"))
ctx.LayoutOptions().SetAsInternational();
await next();
});
//attach response caching middleware
app.UseResponseCaching();
//add header to see when cache was generated (so i can debug is it working)
app.Use(async (ctx, next) => {
if (ctx.Request.Method != "GET") {
await next();
return;
}
ctx.Response.Headers.Remove("Index-Generated-Utc");
if (!ctx.Response.Headers.ContainsKey("Index-Generated-Utc"))
ctx.Response.Headers.Add("Index-Generated-Utc", DateTime.UtcNow.ToString("HH:mm:ss"));
await next();
});
//class to build a cache key
public class ResponseCacheKeyProvider {
public
const string CacheKeyHeader = "Index-Cache-Key";
//header names to take values from for cache key
private static readonly List < string > defaultVaryByHeaders = new List < string > {
"CF-IPCountry",
"Accept-Encoding"
};
//cookie names, to take values from for cache key
private static readonly List < string > defaultVaryByCookies = new List < string > {
ABTestUser.cookieName,
"dark-theme",
"weatherSelectedCity",
"hiddenBreakingNews"
};
public static string BuildKey(HttpContext context) {
//mobile/desktop device resolver
var _deviceResolver = context.RequestServices.GetService < IDeviceResolver > ();
var sb = new StringBuilder();
sb.Append(context.Request.GetUri().AbsoluteUri);
sb.Append(_deviceResolver.Device.Type.ToString());
for (var i = 0; i < defaultVaryByHeaders.Count; i++) {
var headerValues = context.Request.Headers[defaultVaryByHeaders[i]];
for (var j = 0; j < headerValues.Count(); j++) {
sb.Append(headerValues[j]);
}
}
for (var i = 0; i < defaultVaryByCookies.Count; i++) {
var cookieValue = context.Request.Cookies[defaultVaryByCookies[i]];
sb.Append(defaultVaryByCookies[i]);
if (cookieValue != null && cookieValue != string.Empty)
sb.Append(cookieValue);
else if (cookieValue == string.Empty)
sb.Append("empty"); //if cookie has empty value, append this as a part of the string, to avoid empty Cookie corruptingCache
}
return CalculateHash(sb.ToString());
}
private static string CalculateHash(string read) {
var data = Encoding.ASCII.GetBytes(read);
using(SHA384 shaM = new SHA384Managed()) {
var hash = shaM.ComputeHash(data);
return Convert.ToBase64String(hash);
}
}
}
And here is where is set response caching profiles:
services.AddMvc(options =>
{
options.CacheProfiles.Add("HomePage", new CacheProfile()
{
Duration = Constants.HomePageOutputCacheInSeconds,
Location = ResponseCacheLocation.Any,
VaryByHeader = ResponseCacheKeyProvider.CacheKeyHeader
});
options.CacheProfiles.Add("Article", new CacheProfile()
{
Duration = Constants.ArticleOutputCacheInSeconds,
Location = ResponseCacheLocation.Any,
VaryByHeader = ResponseCacheKeyProvider.CacheKeyHeader
});
options.CacheProfiles.Add("Default", new CacheProfile()
{
Duration = Constants.DefaultOutputCacheInSeconds,
Location = ResponseCacheLocation.Any,
VaryByHeader = ResponseCacheKeyProvider.CacheKeyHeader
});
}).SetCompatibilityVersion(Microsoft.AspNetCore.Mvc.CompatibilityVersion.Version_2_2);
services.AddMemoryCache();
services.AddResponseCaching();
-
\$\begingroup\$ Could you please elaborate on this: something about all this feels wrong? \$\endgroup\$Peter Csala– Peter Csala2021年05月11日 14:49:47 +00:00Commented May 11, 2021 at 14:49
-
\$\begingroup\$ @petercsala I've removed it to avoid a confusion. It's a personal matter, where I always think I did something wrong :) \$\endgroup\$Robert– Robert2021年05月11日 14:51:52 +00:00Commented May 11, 2021 at 14:51
1 Answer 1
I can see a few improvements that can be made. First, you can make ResponseCacheKeyProvider
static. and rename it to something like HttpCacheProfileProvider
so you can control all Requests/Responses headers, and can be related to the CacheProfiles
class as well.
Then, you can make some improvements to it like using IReadOnlyList
instead of List
to have read-only values. Add methods to Add Headers to response and headers (so you don't need to duplicates your code). Then, implement a method for each necessary header, and try to minimize each method scope and responsibility. For instance in Build
method, you're building request headers and cookies, you might need to have a method for building the request headers, and another for cookies. this would narrow your method scope.
Finally, you need to implement an extension to your middleware to recall it in the Startup
.
Here is an example on the above points :
Provider :
public static class HttpCacheProfileProvider
{
public const string CacheKeyHeader = "Index-Cache-Key";
public const string CacheControl = "Cache-Control";
private static readonly IReadOnlyList<string> _defaultVaryByHeaders = new List<string> {
"CF-IPCountry",
"Accept-Encoding"
};
private static readonly IReadOnlyList<string> _defaultVaryByCookies = new List<string> {
ABTestUser.cookieName,
"dark-theme",
"weatherSelectedCity",
"hiddenBreakingNews"
};
public static void AddRequestHeader(HttpContext context, string key, string value)
{
//remove cache key header in case it's appended already
if (context.Request.Headers.ContainsKey(key))
{
context.Request.Headers.Remove(key);
}
context.Request.Headers.Add(key, value);
}
public static void AddResponseHeader(HttpContext context, string key, string value)
{
//remove cache key header in case it's appended already
if (context.Response.Headers.ContainsKey(key))
{
context.Response.Headers.Remove(key);
}
context.Response.Headers.Add(key, value);
}
public static void SetCacheControl(HttpContext context) {
//remove cache control header. I would like to control this (and response cache does not work if header is set to 0
if (context.Request.Headers.ContainsKey(CacheControl))
{
context.Request.Headers.Remove(CacheControl);
}
context.Request.GetTypedHeaders().CacheControl = new Microsoft.Net.Http.Headers.CacheControlHeaderValue() {
Public = true,
MaxAge = TimeSpan.FromSeconds(60)
};
}
public static void SetIndexCacheKey(HttpContext context) {
var key = BuildKey(context);
AddRequestHeader(context, CacheKeyHeader, key);
}
private static string GetDefaultVaryByHeader()
{
var sb = new StringBuilder();
for (var i = 0; i < _defaultVaryByHeaders.Count; i++) {
var key = _defaultVaryByHeaders[i];
if (_context.Request.Headers.ContainsKey(key))
{
var headerValues = _context.Request.Headers[key];
foreach(var header in headerValues)
{
sb.Append(header);
}
}
}
return sb.ToString();
}
private static string GetDefaultVaryByCookies()
{
var sb = new StringBuilder();
for (var i = 0; i < _defaultVaryByCookies.Count; i++) {
var key = _defaultVaryByCookies[i];
var cookieValue = context.Request.Cookies[key];
sb.Append(key);
if (!string.IsNullOrWhiteSpace(cookieValue))
{
sb.Append(cookieValue);
}
else if (cookieValue == string.Empty) {
sb.Append("empty");
}
}
return sb.ToString();
}
public static string BuildKey(HttpContext context) {
//mobile/desktop device resolver
var deviceResolver = context.RequestServices.GetService <IDeviceResolver>();
var sb = new StringBuilder();
sb.Append(context.Request.GetUri().AbsoluteUri);
sb.Append(deviceResolver.Device.Type.ToString());
sb.Append(GetDefaultVaryByHeader());
sb.Append(GetDefaultVaryByCookies());
return CalculateHash(sb.ToString());
}
private static string CalculateHash(string read) {
var data = Encoding.ASCII.GetBytes(read);
using(SHA384 shaM = new SHA384Managed()) {
var hash = shaM.ComputeHash(data);
return Convert.ToBase64String(hash);
}
}
}
Extension :
public static class ResponseCacheKeyBuilderExtension
{
public static IApplicationBuilder UseHttpCacheProfile(this IApplicationBuilder app)
{
app.Use(async (ctx, next) => {
//cache only get requests
if (ctx.Request.Method != "GET") {
await next();
return;
}
HttpCacheProfileProvider.SetCacheControl(ctx);
HttpCacheProfileProvider.SetIndexCacheKey(ctx);
//set cookie value if user does not have it (for a/b) testing
ABTestingCookieSetter.SetAbCookie(ctx);
await next();
});
//mark necessary flags in request, so we know when to render international layout
app.Use(async (ctx, next) => {
var absolutePath = ctx.Request.GetUri().AbsolutePath;
if (absolutePath.StartsWith("/eng") || absolutePath.StartsWith("/mobile/eng"))
ctx.LayoutOptions().SetAsInternational();
await next();
});
//attach response caching middleware
app.UseResponseCaching();
//add header to see when cache was generated (so i can debug is it working)
app.Use(async (ctx, next) => {
if (ctx.Request.Method != "GET") {
await next();
return;
}
HttpCacheProfileProvider.AddResponseHeader("Index-Generated-Utc", DateTime.UtcNow.ToString("HH:mm:ss"));
await next();
});
}
return app;
}
in your Startup
:
app.UseHttpCacheProfile();
-
1\$\begingroup\$ @Robert I forgot to mention that I've skipped validations for simplicity sake. Also, you might need to add an extension method for
IServiceCollection
to addCacheProfile
similar toUseHttpCacheProfile
. this would make things more easier to maintain. \$\endgroup\$iSR5– iSR52021年05月11日 20:25:12 +00:00Commented May 11, 2021 at 20:25