1
\$\begingroup\$

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();
asked May 11, 2021 at 14:46
\$\endgroup\$
2
  • \$\begingroup\$ Could you please elaborate on this: something about all this feels wrong? \$\endgroup\$ Commented 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\$ Commented May 11, 2021 at 14:51

1 Answer 1

3
\$\begingroup\$

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();
answered May 11, 2021 at 17:25
\$\endgroup\$
1
  • 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 add CacheProfile similar to UseHttpCacheProfile. this would make things more easier to maintain. \$\endgroup\$ Commented May 11, 2021 at 20:25

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.