Cache Busting with AspNetCore TagHelpers

By Sam Steele On September 01, 2019

Cache Busting with AspNetCore TagHelpers
Caching of client-side files is a functionality, that when properly implemented, can greatly improve the load speed and perceived responsiveness of your web application for end-users. In this blog post, I'll go over enabling the Cache-Control header for static files. I'll also show you how to implement an ASP.NET Core Tag Helper that can be used to fingerprint static file URLs to enable browser cache-busting when those files change on the server.

Configuring Static File Caching

To cache static files in .NET Core, the Cache-Control HTTP header must set in response to a request for a file. This is done by configuring a StaticFileOptions object, and passing it to the UseStaticFiles( IApplicationBuilder app, StaticFileOptions options ) override within your startup file:
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;

public class Startup
{
 
   public void Configure( IApplicationBuilder app )
    {
        var options = new StaticFileOptions
        {
            OnPrepareResponse = context => context.Context.Response.GetTypedHeaders()
                .CacheControl = new CacheControlHeaderValue
                {
                    Public = true,
                    MaxAge = TimeSpan.FromDays( 365 ) // 1 year
                }
        }

        app.UseStaticFiles( options );
    }

}

More information on how the Static Files middleware functions can be found in on ms-docs.

This would reduce requests needed for a browser to load a page if a static file had been requested on a previous navigation. However, if that file changes on the server, the browser won’t redownload it until a year after the first time it downloaded it (MaxAge). For the file caching to be effective, we need a means to force the browser to redownload a cached file if it has changed on the server. This is typically done via fingerprinting: adding a unique value to the URL requested by the browser, based on the requested file.

When using a build tool like webpack, we can configure the tool to fingerprint (append a unique hash) to the file name at build time. This is great for SPAs, but extremely cumbersome for Mvc applications, where a developer would need to continually update a script tag to point to the correct version of a file.

“What if the script element could update itself?”

This is where Tag Helpers come into play. Tag Helpers are similar to MVC  HTML Helpers, but in a syntax that blurs the lines between HTML, Razor, and C#. They enable an MVC developer to customize/override the rendering of HTML elements and/or element attributes.

Fingerprint Files using a TagHelper

Using the HtmlTargetElementAttribute, we can define a Tag Helper that targets img, link, and script elements, in order to define a custom cache-* attribute, which will result in the attribute’s value (a url to a file), to be fingerprinted:
<!-- If our source Razor markup (cshtml) contains the following... -->
<script cache-src="/wwwroot/js/test.js"></script>
<img cache-src="/wwwroot/images/test.png" />
<link cache-href="/wwwroot/styles/test.css" />
<!-- We want the following HTML markup to be written to the browser -->
<script src="/wwwroot/js/test.js?v=123dsd8asd9..."></script>
<img src="/wwwroot/images/test.png?v=41341s234as..." />
<link href="/wwwroot/styles/test.css?v=231g231gh11..." />

Generating Fingerprints (File Hashing)

The first step in implementing this Tag Helper is to define a method that can be used for generating the fingerprint (file hash) that will be used to make the URL to the file unique. In ASP.NET Core, file system access has been abstracted through File Providers and the common IFileProvider interface. The Static File middleware uses this abstraction to locate static files and write them to responses. We’ll also use this abstraction to locate static files, in order to create a unique hash of them, using the IFileProvider.GetFileInfo( string ) method. This method returns an IFileInfo object, and is the type upon which we’ll define a generic extension method for generating a file hash using a specified HashAlgorithm:
using System.Security.Cryptography;
using System.Linq;
using System.Text;
using Microsoft.Extensions.FileProviders;

public static class IFileInfoExtensions
{

    public static string ComputeHash<THashAlgorithm>( this IFileInfo file, string format = "x2" )
        where THashAlgorithm : HashAlgorithm, new()
    {
        // nothing to compute
        if( file?.Exists != true || file.IsDirectory || file.Length == 0 )
        {
            return null;
        }

        using( var hasher = new THashAlgorithm() )
        using( var stream = file.CreateReadStream() )
        {

            byte[] bytes =  hasher.ComputeHash( stream );
            stream.Close();

            return bytes.Aggregate(
                new StringBuilder( bytes.Length * 2 ),
                ( hash, value ) => hash.Append( value.ToString( format ) )
            ).ToString();
        }
    }

}

Targeting Element Attributes

Now that we have a generic method that can be used to generate a file hash or fingerprint, our next step is to define the Tag Helper that will replace the usage of cache-* attributes on img, link, and script elements with the proper attributes for those elements.
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Razor.TagHelpers;

// only target img/link/script elements that with a cache-* attribute
[HtmlTargetElement( "img", Attributes = CacheContentTagHelper.CacheSrcAttributeName )]
[HtmlTargetElement( "link", Attributes = CacheContentTagHelper.CacheHrefAttributeName )]
[HtmlTargetElement( "script", Attributes = CacheContentTagHelper.CacheSrcAttributeName )]
public sealed class CacheContentTagHelper : TagHelper
{
    #region Fields
    internal const string CacheHrefAttributeName = "cache-href";
    internal const string CacheSrcAttributeName = "cache-src";
    #endregion


    private TagHelperAttribute GetCacheAttribute( TagHelperContext context )
    {
        TagHelperAttribute attribute = null;
        switch( context.TagName )
        {
            case "img":
            case "script":
                context.AllAttributes.TryGetAttribute( CacheSrcAttributeName, out attribute );
                break;

            case "link":
                context.AllAttributes.TryGetAttribute( CacheHrefAttributeName, out attribute );
                break;

            default: 
                throw new NotSupportedException( $"Unsupported tag '{context.TagName}'." );
        }

        return attribute;
    }

    public override async Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
    {
        var cacheAttribute = GetCacheAttribute( context );

        string cacheSrcValue = cacheAttribute?.Value?.ToString();
        string srcValue = cacheSrcValue;
             
        // set the target attribute, and remove the `cache-*` attribute
        output.Attributes.SetAttribute( cacheAttribute.Name.Substring( "cache-".Length ), srcValue );
        output.Attributes.RemoveAll( cacheAttribute.Name );
    }
}

Generating a Fingerprinted Url

With the code there to swap out the usage of a cache-href/cache-src attribute with href/src attributes, we can now implement the code that will retrieve an IFileInfo object and generate the fingerprinted URL. Due to some static files being quite large, such as images, we will update our Tag Helper with a dependency on ASP.NET’s IDistributedCache abstraction, so that we can cache the file’s hash. This will prevent the app from performing expensive reads + computations required to calculate the hash on every request.

IDistributedCache is used to handle scaling (load balancing). For a more straightforward implementation, you may consider using the IMemoryCache abstraction.
using System;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Options;

[HtmlTargetElement( "img", Attributes = CacheContentTagHelper.CacheSrcAttributeName )]
[HtmlTargetElement( "link", Attributes = CacheContentTagHelper.CacheHrefAttributeName )]
[HtmlTargetElement( "script", Attributes = CacheContentTagHelper.CacheSrcAttributeName )]
public sealed class CacheContentTagHelper : TagHelper
{
    #region Fields
    internal const string CacheHrefAttributeName = "cache-href";
    internal const string CacheSrcAttributeName = "cache-src";
    private static string FileHashKey( string path ) => $"{path}.hash";

    private readonly IDistributedCache cache;
    private readonly IFileProvider fileProvider;
    private readonly IUrlHelper urlHelper;
    #endregion

    public CacheContentTagHelper( IDistributedCache cache, IOptions<StaticFileOptions> staticFileOptions, IUrlHelper urlHelper )
    {
        this.cache = cache;

        // use the IFileProvider configured for the Static Files middleware
        this.fileProvider = staticFileOptions.Value.FileProvider;
        this.urlHelper = urlHelper;
    }  

    private TagHelperAttribute GetCacheAttribute( TagHelperContext context )
    { /* ... */ }

    private async Task<string> GetFileHashAsync( IFileInfo file )
    {

        // generate a cache key
        string key = FileHashKey( file.PhysicalPath );        

        // has this file been hashed?
        string hash = await cache.GetStringAsync( key );
        if( hash != null )
        {
            return hash;
        }

        // hash + cache!
        hash = file.ComputeHash<SHA256Managed>();
        await cache.SetStringAsync( key, hash );

        return hash;
    }

    private bool IsValidPath( string path )
        => !string.IsNullOrWhiteSpace( path )
            && !path.StartsWith( "//" )
            && !path.StartsWith( "http:// ")
            && !path.StartsWith( "https:// ");

    public override async Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
    {
        var cacheAttribute = GetCacheAttribute( context );

        string cacheSrcValue = cacheAttribute?.Value?.ToString();
        string srcValue = cacheSrcValue;

        if( IsValidPath( cacheSrcValue ) )
        {
            // get the web-url path to the file using UrlHelper (this resolve relative paths)
            string path = urlHelper.Content( cacheSrcValue );
            var file = fileProvider.GetFileInfo( path );
         
            // does the file exist (on the filesystem)?
            if(
                file?.Exists == true &&
                !file.IsDirectory && 
                !string.IsNullOrWhiteSpace( file.PhysicalPath )
            )
            {
                string hash = await GetFileHashAsync( file );
                srcValue = !string.IsNullOrWhiteSpace( hash )
                    ? $"{path}?v={hash}"
                    : path;
            }
        }

        // set the target attribute, and remove the `cache-*` attribute
        output.Attributes.SetAttribute( cacheAttribute.Name.Substring( "cache-".Length ), srcValue );
        output.Attributes.RemoveAll( cacheAttribute.Name );
    }
}

IUrlHelper is not registered to the service collection by default, you need to register it yourself:
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

public static class IServiceCollectionExtensions
{

    public static void AddUrlHelper( this IServiceCollection services )
    {
        services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();
        services.TryAddSingleton<IUrlHelperFactory, UrlHelperFactory>();
       
        services.TryAddScoped<IUrlHelper>( 
            factory => factory.GetRequiredService<IUrlHelperFactory>()
                .GetUrlHelper( factory.GetRequiredService<IActionContextAccessor>().ActionContext ) 
        );
    }

}

Share This Post:

Twitter Pinterest Facebook Google+
Click here to read more Tutorials posts
Start a Project with Us

About the author

Sam loves learning. While teaching himself to program, Sam found a place of friends in the Open Source Community where he was able to grow his skills with other young developers. His primary goal at BizStream is to learn and gain as much experience from the rest of the team. When he's not programming, you can find Sam playing his favorite video game, Rainbow Six Siege, watching movies old movies, or tinkering with his car.

View other posts by Sam

Subscribe to Updates

Stay up to date on what BizStream is doing and keep in the loop on the latest with Kentico.