Minify and cache static files in ASP.NET Core

This post describes how you can minify and cache static files in ASP.NET Core. You may want to do this in order to make your website faster and to improve in search rankings (SEO).

One important thing to think about before you cache static files is that you need a method to invalidate the cache when your static files changes. Every static file that you save can have a unique name like a GUID or you can append a parameter to the filename that is unique for every new version of the file. You can use a combination of these methods, images can have GUID names and you can use a version parameter for other files.

Bundler and minifier

I use a NuGet package called BuildBundlerMinifier that is created by Mads Kristensen to minify static files in my projects. You also need a Bundler & Minifier extension in Visual Studio to get a task that minifies files (Task Runner Explorer). When you have installed this package, a json file called bundleconfig.json is added to your project in Visual Studio. You can edit this file directly or minfy static files by right-clicking on them in Visual Studio. The contents of the bundleconfig.json file looks like this.

[
  {
    "outputFileName": "wwwroot/css/admin_default.min.css",
    "inputFiles": [ "wwwroot/css/admin_default.css" ]
  },
  {
    "outputFileName": "wwwroot/css/admin_layout.min.css",
    "inputFiles": [ "wwwroot/css/admin_layout.css" ]
  }
]

Invalidate cache

We need a method to invalidate cache for static files. We have a common class in which we have injected IMemoryCache and IWebHostEnvironment, we create a reference to a IFileProvider file_provider from the environment variable.

public CommonServices(IMemoryCache cache, IWebHostEnvironment environment)
{
    this.cache = cache;
    this.environment = environment;
    this.file_provider = environment.WebRootFileProvider;

} // End of the constructor

Our method to invalidate cache will store the relative path to the static file and an MD5 hash of the file in memory cache. The file provider will watch for changes in the file.

/// <summary>
/// Get a file path with a version hash
/// </summary>
public string GetFilePath(string relative_path)
{
    // Create a variable for a hash
    string hash = "";

    // Get the hash
    if(this.cache.TryGetValue(relative_path, out hash) == false)
    {
        // Create an absolute path
        string absolute_path = this.environment.WebRootPath + relative_path;

        // Make sure that the file exists
        if(File.Exists(absolute_path) == false)
        {
            return relative_path;
        }

        // Create cache options
        MemoryCacheEntryOptions cache_entry_options = new MemoryCacheEntryOptions();

        // Add an expiration token that watches for changes in a file
        cache_entry_options.AddExpirationToken(this.file_provider.Watch(relative_path));
                
        // Create a hash of the file
        using (MD5 md5 = MD5.Create())
        {
            using (Stream stream = File.OpenRead(absolute_path))
            {
                hash = Convert.ToBase64String(md5.ComputeHash(stream));
            }
        }

        // Insert the hash to cache
        this.cache.Set(relative_path, hash, cache_entry_options);
    }

    // Return the url
    return $"{relative_path}?v={hash}";

} // End of the GetFilePath method

Add references to static files

We will use our GetFilePath method when we add references to static files in our layout view, for images it might be better to use a timestamp as version parameter if the names not is unique. Images can be large and many, save the timestamp in your database and append it to the version parameter.

<environment names="Development">
        <link href="@this.tools.GetFilePath(imageUrls.Get("small_icon"))" rel="icon" type="image/x-icon" />
        <link href="@this.tools.GetFilePath("/css/standard_layout.css")" media="screen and (min-width:1344px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/medium_layout.css")" media="only screen and (min-width:1024px) and (max-width:1343px)" rel="stylesheet" />
    </environment>
    <environment names="Staging,Production">
        <link href="@this.tools.GetFilePath(imageUrls.Get("small_icon"))" rel="icon" type="image/x-icon" />
        <link href="@this.tools.GetFilePath("/css/standard_layout.min.css")" media="screen and (min-width:1344px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/medium_layout.min.css")" media="only screen and (min-width:1024px) and (max-width:1343px)" rel="stylesheet" />
    </environment>

Services

We need to add a service for memory cache in the ConfigureServices method in the StartUp class to be able to cache relative paths and a hash.

// Add memory cache
services.AddDistributedMemoryCache();

We also need to add headers to static files that indicates that they can be cached by a browser and for how long they can be cached. We add this in the Configure method in the StartUp class.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // Use static files
    app.UseStaticFiles(new StaticFileOptions
    {
        OnPrepareResponse = ctx =>
        {
            // Cache static files for 30 days
            ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=2592000");
            ctx.Context.Response.Headers.Append("Expires", DateTime.UtcNow.AddDays(30).ToString("R", CultureInfo.InvariantCulture));
        }
    });

    // More code ...
    
} // End of the Configure method

You can inspect headers for static files in Chrome under Network in Developer tools to make sure that they have correct values.

Startup.cs

using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Annytab.Middleware;
using Annytab.Repositories;
using Annytab.Options;
using Fotbollstabeller.Middleware;
using Microsoft.AspNetCore.Mvc;

namespace Fotbollstabeller
{
    /// <summary>
    /// This class handles application startup
    /// </summary>
    public class Startup
    {
        /// <summary>
        /// Variables
        /// </summary>
        public IConfiguration configuration { get; set; }

        /// <summary>
        /// Create a new startup object
        /// </summary>
        /// <param name="configuration">A reference to configuration</param>
        public Startup(IConfiguration configuration)
        {
            this.configuration = configuration;

        } // End of the constructor method

        /// <summary>
        /// This method is used to add services to the container.
        /// </summary>
        /// <param name="services"></param>
        public void ConfigureServices(IServiceCollection services)
        {
            // Add the mvc framework
            services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
            //services.AddRazorPages();

            // Add memory cache
            services.AddDistributedMemoryCache();

            // Add redis distributed cache
            if (configuration.GetSection("AppSettings")["RedisConnectionString"] != "")
            {
                services.AddDistributedRedisCache(options =>
                {
                    options.Configuration = configuration.GetSection("AppSettings")["RedisConnectionString"];
                    options.InstanceName = "Fotbollstabeller:";
                });
            }

            // Add the session service
            services.AddSession(options =>
            {
                // Set session options
                options.IdleTimeout = TimeSpan.FromMinutes(20d);
                options.Cookie.Name = ".Fotbollstabeller";
                options.Cookie.Path = "/";
                options.Cookie.HttpOnly = true;
                options.Cookie.SameSite = SameSiteMode.Lax;
                options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
            });

            // Create database options
            services.Configure<DatabaseOptions>(options => 
            {
                options.connection_string = configuration.GetSection("AppSettings")["ConnectionString"];
                options.sql_retry_count = 1;
            });

            // Create cache options
            services.Configure<CacheOptions>(options => 
            {
                options.expiration_in_minutes = 240d;
            });
            
            // Add Authentication
            services.AddAuthentication()
                .AddCookie("Administrator", options =>
                {
                    options.ExpireTimeSpan = TimeSpan.FromDays(1);
                    options.Cookie.MaxAge = TimeSpan.FromDays(1);
                    options.Cookie.HttpOnly = true;
                    options.Cookie.SameSite = SameSiteMode.Lax;
                    options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
                    options.Events.OnRedirectToLogin = (context) =>
                    {
                        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
                        context.Response.Redirect("/admin_login");
                        return Task.CompletedTask;
                    };
                })
                .AddScheme<AuthenticationSchemeOptions, BasicAuthenticationHandler>("ApiAuthentication", null);

            // Add clients
            services.AddHttpClient();

            // Add repositories
            services.AddSingleton<IDatabaseRepository, MsSqlRepository>();
            services.AddSingleton<IWebsiteSettingRepository, WebsiteSettingRepository>();
            services.AddSingleton<IAdministratorRepository, AdministratorRepository>();
            services.AddSingleton<IFinalRepository, FinalRepository>();
            services.AddSingleton<IGroupRepository, GroupRepository>();
            services.AddSingleton<IStaticPageRepository, StaticPageRepository>();
            services.AddSingleton<IXslTemplateRepository, XslTemplateRepository>();
            services.AddSingleton<ISitemapRepository, SitemapRepository>();
            services.AddSingleton<IXslProcessorRepository, XslProcessorRepository>();
            services.AddSingleton<ICommonServices, CommonServices>();

        } // End of the ConfigureServices method

        /// <summary>
        /// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        /// </summary>
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            // Use redirection
            app.UseMiddleware<RedirectMiddleware>();

            // Use error handling
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseStatusCodePagesWithReExecute("/home/error/{0}");
            }

            // Use static files
            app.UseStaticFiles(new StaticFileOptions
            {
                OnPrepareResponse = ctx =>
                {
                    // Cache static files for 30 days
                    ctx.Context.Response.Headers.Append("Cache-Control", "public,max-age=25920000");
                    ctx.Context.Response.Headers.Append("Expires", DateTime.UtcNow.AddDays(300).ToString("R", CultureInfo.InvariantCulture));
                }
            });

            // Use sessions
            app.UseSession();

            //// For most apps, calls to UseAuthentication, UseAuthorization, and UseCors must 
            //// appear between the calls to UseRouting and UseEndpoints to be effective.
            //app.UseRouting();

            //// Use authentication and authorization middlewares
            app.UseAuthentication();
            //app.UseAuthorization();

            //// Routing endpoints
            //app.UseEndpoints(endpoints =>
            //{
            //    endpoints.MapControllerRoute(
            //        "default",
            //        "{controller=home}/{action=index}/{id?}");
            //});

            // Routing
            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=home}/{action=index}/{id?}");
            });

        } // End of the Configure method

    } // End of the class

} // End of the namespace

CommonServices.cs

using System;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Collections.Generic;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.FileProviders;

namespace Annytab.Repositories
{
    /// <summary>
    /// This class is a container for common services
    /// </summary>
    public class CommonServices : ICommonServices
    {
        #region Variables

        private readonly IMemoryCache cache;
        private readonly IHostingEnvironment environment;
        private readonly IFileProvider file_provider;

        #endregion

        #region Constructors

        /// <summary>
        /// Create a new repository
        /// </summary>
        public CommonServices(IMemoryCache cache, IHostingEnvironment environment)
        {
            // Set values for instance variables
            this.cache = cache;
            this.environment = environment;
            this.file_provider = environment.WebRootFileProvider;

        } // End of the constructor

        #endregion

        #region Get methods

        /// <summary>
        /// Get image urls for the domain
        /// </summary>
        /// <returns>A key string list with urls</returns>
        public KeyStringList GetDomainImageUrls(bool showNoImageIcon)
        {
            // Create the list to return
            KeyStringList imageUrls = new KeyStringList(5);

            // Add images to the key string list
            imageUrls.Add("background_image", "/images/background_image.jpg");
            imageUrls.Add("default_logotype", "/images/default_logotype.jpg");
            imageUrls.Add("mobile_logotype", "/images/mobile_logotype.jpg");
            imageUrls.Add("big_icon", "/images/big_icon.jpg");
            imageUrls.Add("small_icon", "/images/small_icon.jpg");

            if (showNoImageIcon == true)
            {
                // Create the no image path
                string noImagePath = "/images/no_image_wide.jpg";

                // Get all the keys in the dictionary
                List<string> keys = imageUrls.dictionary.Keys.ToList<string>();

                // Loop all the keys
                for (int i = 0; i < keys.Count; i++)
                {
                    // Get the url
                    string url = this.environment.WebRootPath + imageUrls.Get(keys[i]);

                    // Check if the file exists
                    if (System.IO.File.Exists(url) == false)
                    {
                        imageUrls.Update(keys[i], noImagePath);
                    }
                }
            }

            // Return the list
            return imageUrls;

        } // End of the GetDomainImageUrls method

        /// <summary>
        /// Get a file path with a version hash
        /// </summary>
        public string GetFilePath(string relative_path)
        {
            // Create a variable for a hash
            string hash = "";

            // Get the hash
            if(this.cache.TryGetValue(relative_path, out hash) == false)
            {
                // Create an absolute path
                string absolute_path = this.environment.WebRootPath + relative_path;

                // Make sure that the file exists
                if(File.Exists(absolute_path) == false)
                {
                    return relative_path;
                }

                // Create cache options
                MemoryCacheEntryOptions cache_entry_options = new MemoryCacheEntryOptions();

                // Add an expiration token that watches for changes in a file
                cache_entry_options.AddExpirationToken(this.file_provider.Watch(relative_path));
                
                // Create a hash of the file
                using (MD5 md5 = MD5.Create())
                {
                    using (Stream stream = File.OpenRead(absolute_path))
                    {
                        hash = Convert.ToBase64String(md5.ComputeHash(stream));
                    }
                }

                // Insert the hash to cache
                this.cache.Set(relative_path, hash, cache_entry_options);
            }

            // Return the url
            return $"{relative_path}?v={hash}";

        } // End of the GetFilePath method

        #endregion

    } // End of the class

} // End of the namespace

_standard_layout.cshtml

@using Microsoft.AspNetCore.Hosting
@using Annytab.Repositories
@using Annytab.Models
@inject IHostingEnvironment environment
@inject IStaticPageRepository static_page_repository
@inject ICommonServices tools
@{
    // Get form values
    List<BreadCrumb> breadCrumbs = ViewBag.BreadCrumbs;
    KeyStringList imageUrls = this.tools.GetDomainImageUrls(false);
    IList<StaticPage> staticPages = this.static_page_repository.GetAllActiveLinks("sort_value", "ASC");
}

<!DOCTYPE html>
<html lang="sv" prefix="og: https://ogp.me/ns#">
<head>
    @*Title and meta tags*@
    <title>@(ViewBag.Title + " - Fotbollstabeller.nu")</title>
    <meta name="description" content="@ViewBag.MetaDescription" />
    <meta name="keywords" content="@ViewBag.MetaKeywords" />
    <link rel="canonical" href="@ViewBag.MetaCanonical" />
    <meta name="robots" content="@(Context.Request.Host.Value != "fotbollstabeller.azurewebsites.net" ? ViewBag.MetaRobots : "noindex, nofollow")" />
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,height=device-height,initial-scale=1.0,user-scalable=yes" />
    <meta name="google" content="notranslate">

    @*Facebook meta tags*@
    <meta property="og:title" content="@ViewBag.Title" />
    <meta property="og:description" content="@ViewBag.MetaDescription" />
    <meta property="og:type" content="website">
    <meta property="og:url" content="@ViewBag.MetaCanonical" />
    <meta property="og:image" content="@($"http://www.fotbollstabeller.nu{imageUrls.Get("big_icon")}")" />
    <meta property="og:site_name" content="Fotbollstabeller.nu" />

    @*Resources*@
    <environment names="Development">
        <link href="@this.tools.GetFilePath(imageUrls.Get("small_icon"))" rel="icon" type="image/x-icon" />
        <link href="@this.tools.GetFilePath("/css/standard_layout.css")" media="screen and (min-width:1344px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/medium_layout.css")" media="only screen and (min-width:1024px) and (max-width:1343px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/mobile_layout.css")" media="only screen and (max-width:1023px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/default_style.css")" rel="stylesheet" />
    </environment>
    <environment names="Staging,Production">
        <link href="@this.tools.GetFilePath(imageUrls.Get("small_icon"))" rel="icon" type="image/x-icon" />
        <link href="@this.tools.GetFilePath("/css/standard_layout.min.css")" media="screen and (min-width:1344px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/medium_layout.min.css")" media="only screen and (min-width:1024px) and (max-width:1343px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/mobile_layout.min.css")" media="only screen and (max-width:1023px)" rel="stylesheet" />
        <link href="@this.tools.GetFilePath("/css/default_style.min.css")" rel="stylesheet" />
    </environment>

    @* Google analytics *@
    <script async src="https://www.googletagmanager.com/gtag/js?id=UA-4388854-23"></script>
    <script>
        window.dataLayer = window.dataLayer || [];
        function gtag() { dataLayer.push(arguments); }
        gtag('js', new Date());
        gtag('config', 'UA-4388854-23');
    </script>

    @* Google ads *@
    <script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js"></script>

    @*Google mobile ads*@
    <script>
        (adsbygoogle = window.adsbygoogle || []).push({
            google_ad_client: "ca-pub-3070633924070834",
            enable_page_level_ads: true
        });
    </script>

</head>
<body>
    @*Cookie consent*@
    <div class="annytab-cookie-consent">
        <div class="annytab-basic-padding">
            <span class="annytab-cookie-consent-text">Cookies hjälper oss att tillhandahålla våra tjänster. Genom att använda våra tjänster samtycker du till att vi använder cookies.</span>
            <button id="btnCookieConsent" type="button" class="annytab-basic-button">Jag förstår</button>
            <a href="/home/page/sekretesspolicy" class="cookie-consent-text">Läs mer</a>
        </div>
    </div>

    @*The background image*@
    <img id="backgroundImage" alt="Fotbollstabeller.nu" src="@this.tools.GetFilePath(imageUrls.Get("background_image"))" class="annytab-background-image" />

    @*Master outer container*@
    <div class="annytab-layout-outer-container">

        @*Master inner container*@
        <div class="annytab-layout-inner-container">

            @*Standard header*@
            <div class="annytab-layout-standard-header">
                <a href="/"><img alt="Fotbollstabeller.nu, logotype" class="annytab-logotype" src="@this.tools.GetFilePath(imageUrls.Get("default_logotype"))" /></a>
            </div>

            @*Mobile header*@
            <div class="annytab-layout-mobile-header">
                <div id="toggleMobileMenu" class="annytab-layout-header-left"><i class="fas fa-bars fa-2x" aria-hidden="true"></i></div>
                <div class="annytab-basic-divider annytab-layout-header-right"></div>
                <a href="/"><img alt="Fotbollstabeller.nu, logotype" class="annytab-logotype" src="@this.tools.GetFilePath(imageUrls.Get("mobile_logotype"))" /></a>
            </div>

            @* Breadcrumbs *@
            @if (breadCrumbs.Count > 0)
            {
                <div class="annytab-breadcrumb-container">
                    @for (int i = 0; i < breadCrumbs.Count; i++)
                    {
                        if (i > 0)
                        {
                            <span class="annytab-basic-text-normal"> // </span>
                        }
                        <a href="@breadCrumbs[i].link" rel="nofollow" class="annytab-basic-text-normal">@breadCrumbs[i].name</a>
                    }
                </div>
            }

            @*Menu*@
            <div id="menu" class="annytab-layout-menu">
                <div class="annytab-layout-menu-padding">
                    @*Links*@
                    <a href="@("/")" class="annytab-menu-link not-hover">Startsidan</a>
                    @foreach (StaticPage post in staticPages)
                    {
                        <a href="@("/home/page/" + post.page_name)" class="annytab-menu-link not-hover">@post.link_name</a>
                    }
                </div>
            </div>

            @*Middle container*@
            <div class="annytab-layout-main-content">
                <div class="annytab-basic-padding">
                    @RenderBody()
                </div>
            </div>

            @*Footer*@
            <div class="annytab-layout-footer">
                <div class="annytab-basic-padding">
                    <span>@Html.Raw("© 2015 | Fotbollstabeller.nu")</span>
                </div>
            </div>
        </div>

        @*Right container*@
        <div class="annytab-layout-right-content">
            <div class="annytab-layout-right-ad">
                <!-- fotbollstabeller.nu - 300x600 -->
                <ins class="adsbygoogle"
                     style="display:block;width:300px;height:600px"
                     data-ad-client="ca-pub-3070633924070834"
                     data-ad-slot="3508160602"></ins>
                <script>
                    (adsbygoogle = window.adsbygoogle || []).push({});
                </script>
            </div>
        </div>

        @* Bottom spacing *@
        <div class="annytab-layout-bottom-spacing"></div>

    </div>

    <div id="fb-root"></div>

    @*Scripts*@
    <environment names="Development">
        <script src="/js/jquery/v3.3.1/jquery.js"></script>
        <script src="/js/font-awesome/v5.0.8/fontawesome-all.js"></script>
        <script src="@this.tools.GetFilePath("/js/annytab-front/annytab.default-functions.js")"></script>
    </environment>
    <environment names="Staging,Production">
        <script src="/js/jquery/v3.3.1/jquery.min.js"></script>
        <script src="/js/font-awesome/v5.0.8/fontawesome-all.min.js"></script>
        <script src="@this.tools.GetFilePath("/js/annytab-front/annytab.default-functions.min.js")"></script>
    </environment>

    @*Facebook javascript*@
    <script async defer crossorigin="anonymous" src="https://connect.facebook.net/sv_SE/sdk.js#xfbml=1&version=v4.0&autoLogAppEvents=1"></script>

    @*Twitter javascript*@
    <script>
        window.twttr = (function (d, s, id) {
            var js, fjs = d.getElementsByTagName(s)[0],
                t = window.twttr || {};
            if (d.getElementById(id)) return t;
            js = d.createElement(s);
            js.id = id;
            js.src = "https://platform.twitter.com/widgets.js";
            fjs.parentNode.insertBefore(js, fjs);

            t._e = [];
            t.ready = function (f) {
                t._e.push(f);
            };

            return t;
        }(document, "script", "twitter-wjs"));
    </script>

    @RenderSection("scripts", required: false)
</body>
</html>

1 thought on “Minify and cache static files in ASP.NET Core”

Leave a Reply

Your email address will not be published. Required fields are marked *