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();
\$\endgroup\$
2
  • \$\begingroup\$ Could you please elaborate on this: something about all this feels wrong? \$\endgroup\$ May 11 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
    May 11 at 14:51
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();
\$\endgroup\$
2
  • \$\begingroup\$ Wow, nice. Thanks! \$\endgroup\$
    – Robert
    May 11 at 19:22
  • 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\$
    – iSR5
    May 11 at 20:25

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.