Cookies

This website uses cookies to obtain statistics from users navigation. If you go on browsing we consider you accept them.

ETagFilePathResult, an ActionResult to save bandwidth in ASP.NET MVC.

ASP.NET MVC provides several ways to serve files from controllers, but all of them require to serve the entire file content through the network, something that will make our website slow and more expensive while it’s ridiculous to transfer the same content over the network every time the resource is requested. Client-side HTTP caching can easily prevent downloading all those extra bytes. In this post we offer a solution to it without doing any complicated stuff, using the ActionResult abstract class to integrate a solution based on ETags in ASP.NET MVC to implement the client-side caching.

Why do I need to return static files from a controller?

It might seem ridiculous to return static files from a controller. Well, it is not. In several scenarios it is very useful and secures your web application. For instance, when there are private resources that might be readable only for a subset of users you must validate in the controller if the resource is readable for the user who requests it, if not you’re creating a security hole by mistake. Other use cases are dynamically generated files backed by a server-side cache, or even customized stylesheets backed on a server file too.

Alright, but let me see the code!

This class is analogous to other classes that are already in ASP.NET MVC like FilePathResult or FileStreamResult. While these classes work, they do not implement any client-side caching mechanism. So, with ETagFilePathResult an ETag will be calculated from the server file full path and the file last write time and returned to the client, letting the client know that the resource can be cached and indexed by this ETag.

public ActionResult Image(int id)
{
    // Find the image that matches with the incoming id.
    return new ETagFilePathResult(
        imagePath, "image/png", Request.Headers["If-None-Match"]
    );
}
public class ETagFilePathResult : ActionResult
{
    private string fileName;
    private string contentType;
    private string previousETag;
    private string uniqueSource;

    public ETagFilePathResult(string fileName, string contentType,
            string previousETag, string uniqueSource = "")
    {
        this.fileName = fileName;
        this.contentType = contentType;
        this.previousETag = previousETag;
        this.uniqueSource = uniqueSource ?? "";
    }

    public override void ExecuteResult(ControllerContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException("context");
        }

        HttpResponseBase response = context.HttpContext.Response;
        response.ContentType = contentType;

        FileInfo fileInfo = new FileInfo(fileName);
        string eTag = CalculateETag(fileInfo);

        response.Headers.Add("Content-Length", fileInfo.Length.ToString());
        response.Cache.SetCacheability(HttpCacheability.ServerAndPrivate);
        response.Cache.SetETag(eTag);

        if (eTag == previousETag)
        {
            response.StatusCode = (int) HttpStatusCode.NotModified;
        }
        else
        {
            response.TransmitFile(fileName);
            response.Flush();
        }
    }

    protected virtual string CalculateETag(FileInfo fileInfo)
    {            
        string hashSource;
        byte[] bContentsToHash, hash;

        hashSource = String.Join("-", new string[] {
            fileInfo.FullName,
            fileInfo.LastWriteTime.ToString(),
            uniqueSource
        );
        bContentsToHash = Encoding.Unicode.GetBytes(hashSource);
        hash = ((HashAlgorithm) CryptoConfig.CreateFromName("MD5"))
                                            .ComputeHash(bContentsToHash);

        string eTag = BitConverter.ToString(hash)
                                  .Replace("-", string.Empty)
                                  .ToLower();

        return eTag;
    }
}

The next time the browser asks for this resource, it will add an If-None-Match header with the ETag, and the server will check if that ETag matches the current file stored in the filesystem. If it does, it returns only an HTTP Not Modified status code, and doesn’t transmit the file content to the client, authorizing the browser to keep using the same copy it downloaded previously for this resource. Isn’t it cool? And no extra code needed in your controller!

Given this code, it is pretty straightforward to implement alternatives, such as an ActionResult that serves a file from a stream or a byte array and etags it. In this case we would not be able to use the last write time, but we could simply hash the content of the stream and calculate the ETag from that.

I hope this helps and if you implement any alternative don’t hesitate to share in comments!