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 )
);
}
}