This is the next in a series of posts on using ImageSharp to resize images in an ASP.NET Core application. I showed how you could define an MVC action that takes a path to a file stored in the wwwroot folder, resizes it, and serves the resized file.

The biggest problem with this is that resizing an image is relatively expensive, taking multiple seconds to process large images. In the previous post I showed how you could use the IDistributedCache interface to cache the resized image, and use that for subsequent requests.

This works pretty well, and avoids the need to process the image multiple times, but in the implementation I showed, there were a couple of drawbacks. The main issue was the lack of caching headers and features at the HTTP level - whenever the image is requested, the MVC action will return the whole data to the browser, even though nothing has changed.

In the following image, you can see that every request returns a 200 response and the full image data. The subsequent requests are all much faster than the original because we're using data cached in the IDistributedCache, but the browser is not caching our resized image.

Uncached data

In this post I show a different approach to caching the data - instead of storing the file in an IDistributedCache, we instead write the file to disk in the wwwroot folder. We then use StaticFileMiddleware to serve the file directly, without ever hitting the MVC middleware after the initial request. This lets us take advantage of the built in caching headers and etag behaviour that comes with the StaticFileMiddleware.

Note: James Jackson-South has been working hard on some extensible ImageSharp middleware to provide the functionality in these blog posts. He's even written a post blog introducing it, so check it out!

The system design

The approach I'm using in this post is shown in the following figure:

Description of the system

With this design a request for resizing an image, e.g. to /resized/200/120/original.jpg, would go through a number of steps:

  1. A request arrives for /resized/200/120/original.jpg
  2. The StaticFileMiddleware looks for the original.jpg file in the folder wwwroot/resized/200/120/, but it doesn't exist, so the request passes on to the MvcMiddleware
  3. The MvcMiddleware invokes the ResizeImage middleware, and saves the resized file in the folder wwwroot/resized/200/120/.
  4. On the next request, the StaticFileMiddleware finds the resized image in the wwwroot folder, and serves it as usual, short-circuiting the middleware pipeline before the MvcMiddleware can run.
  5. All subsequent requests for the resized file are served by the StaticFileMiddleware.

Writing a resized file to the wwwroot folder

After we first resize an image using the MvcMiddleware, we need to store the resized image in the wwwroot folder. In ASP.NET Core there is an abstraction called IFileProvider which can be used to obtain information about files. The IHostingEnvironment includes two such IFileProvders:

  • ContentRootFileProvider - an `IFileProvider for the Content Root, where your application files are stored, usually the project root or publish folder.
  • WebRootFileProvider - an IFileProvider for the wwwroot folder

We can use the WebRootFileProvider to open a stream to our destination file, which we will write the resized image to. The outline of the method is as follows, with preconditions, and the DOS protection code removed for brevity:`

public class HomeController : Controller  
{
    private readonly IFileProvider _fileProvider;
    public HomeController(IHostingEnvironment env)
    {
        _fileProvider = env.WebRootFileProvider;
    }

    [Route("/resized/{width}/{height}/{*url}")]
    public IActionResult ResizeImage(string url, int width, int height)
    {
        // Preconditions and sanitsation 
        // Check the original image exists
        var originalPath = PathString.FromUriComponent("/" + url);
        var fileInfo = _fileProvider.GetFileInfo(originalPath);
        if (!fileInfo.Exists) { return NotFound(); }

        // Replace the extension on the file (we only resize to jpg currently) 
        var resizedPath = ReplaceExtension($"/resized/{width}/{height}/{url}");

        // Use the IFileProvider to get an IFileInfo
        var resizedInfo = _fileProvider.GetFileInfo(resizedPath);
        // Create the destination folder tree if it doesn't already exist
        Directory.CreateDirectory(Path.GetDirectoryName(resizedInfo.PhysicalPath));

        // resize the image and save it to the output stream
        using (var outputStream = new FileStream(resizedInfo.PhysicalPath, FileMode.CreateNew))
        using (var inputStream = fileInfo.CreateReadStream())
        using (var image = Image.Load(inputStream))
        {
            image
                .Resize(width, height)
                .SaveAsJpeg(outputStream);
        }

        return PhysicalFile(resizedInfo.PhysicalPath, "image/jpg");
    }

    private static string ReplaceExtension(string wwwRelativePath)
    {
        return Path.Combine(
            Path.GetDirectoryName(wwwRelativePath),
            Path.GetFileNameWithoutExtension(wwwRelativePath)) + ".jpg";
    }
}

The overall design of this method is pretty simple.

  1. Check the original file exists.
  2. Create the destination file path. We're replacing the file extension with jpg at the moment because we are always resizing to a jpeg.
  3. Obtain an IFileInfo for the destination file. This is relative to the wwwroot folder as we are using the WebRootFileProvider on IHostingEnvironment.
  4. Open a file stream for the destination file.
  5. Open the original image, resize it, and save it to the output file stream.

With this method, we have everything we need to cache files in the wwwroot folder. Even better, nothing else needs to change in our Startup file, or anywhere else in our program.

Trying it out

Time to take it for a spin! If we make a number of requests for the same page again, and compare it to the first image in this post, you can see that we still have the fast response times for requests after the first, as we only resize the image once. However, you can also see the some of the requests now return a 304 response, and just 208 bytes of data. The browser uses its standard HTTP caching mechanisms on the client side, rather than caching only on the server.

This is made possible by the etag and Last-Modified headers sent automatically by the StaticFileMiddleware.

Etag header

Note, we are not actually sending any caching headers by default - I wrote a post on how to do this here, which gives you control over how much caching browsers should do.

It might seem a little odd that there are three 200 requests before we start getting 304s. This is because:

  1. The first request is handled by the ResizeImage MVC method, but we are not adding any cache-related headers like ETag etc - we are just serving the file using the PhysicalFileResult.
  2. The second request is handled by the StaticFileMiddleware. It returns the file from disk, including an ETag and a Last-Modified header.
  3. The third request is made with additional headers - If-Modified-Since and If-None-Match headers. This returns the image data with a new ETag.
  4. Subsequent requests send the new ETag in the If-None-Match header, and the server responds with 304s.

I'm not entirely sure why we need three requests for the whole data here - it seems like two would suffice, given that the third request is made with the If-Modified-Since and If-None-Match headers. Why would the ETag need to change between requests two and three? I presume this is just standard behaviour though, and something I need to look at in more detail when I have time!

Summary

This post takes an alternative approach to caching compared to my last post on ImageSharp. Instead of caching the resized images in an IDistributedCache, we save them directly to the wwwroot folder. That way we can use all of the built in file response capabilities of the StaticFileMiddleware, without having to write it ourselves.

Having said that, James Jackson-South has written some middleware to take a similar approach, which handles all the caching headers for you. If this series has been of interest, I encourage you to check it out!