Saturday, September 19, 2009

Enabling client caching of dynamic content

In my last post, I was explaining how I wanted to return pictures from my ASP.NET MVC based application. In short, this is what I did:

[ReplaceMissingPicture(Picture = "~/Content/Images/nopicture.png", MimeType = "image/png")]
public ActionResult Picture(int id)
{
    var item = Service.GetItem(id);

    if (item.Picture != null)
    {
        var picture = item.Picture.ToArray();
        var mime = item.PictureMime;

        return File(picture, mime);
    }
    else
    {
        return null;
    }
}

Please see my last post for more information about what you see here.

This is all nice, but there’s a problem. Every time the client requests our “Picture” action method, the picture is returned with a HTTP status code 200. This is what you normally want, but my concern is that this happens every time. If this were a static file hosted by the web server, the web server would be smart enough to return an HTTP status code 304, meaning that the requested resource didn’t change since the last time it was requested.

How does this work? That’s relatively simple. When the resource is first requested, and the picture is returned, the web application should add a header to the HTTP response named “Etag”. This header contains a string that represents the “version” of the resource. This can really be anything.

When the client requests the same resource again, and the client has a cached copy of the resource, it sends a request containing an extra header named “If-None-Match”, meaning that if the resource still matches this version, it should return HTTP 304 and no content.

So how do we implement this?

For the version  information, I chose to place a timestamp column in the table containing the picture, and an associated property in the LINQ to SQL business object. The timestamp column type is not related to dates and times. It’s just a value that you can check for if you want to know if the record has changed in any way since you last read it. Which is perfect for this example.

So the first thing we need to do is send the version to the client. In order to keep this testable, I decided to implement it in a way that closely resembles this answer to my question on Stack Overflow. First of all, we need an interface that we can call from our Action method that deals with adding the tag and checking for it. This should do fine:

public interface ITagService
{
    string GetRequestTag();
    void SetResponseTag(string value);
}

When we add a property of this interface to our controller and initialize it in the constructor, we can decide what kind of implementation we use at runtime (whether that is the application hosted in IIS, or during testing). So we can use a mock for testing, and we can use the following class in IIS:

public class HttpTagService : ITagService
{
    public string GetRequestTag()
    {
        return HttpContext.Current.Request.Headers["If-None-Match"];
    }

    public void SetResponseTag(string value)
    {
        HttpContext.Current.Response.AppendHeader("ETag", value);
    }
}

So now our Picture action method looks like this:

[ReplaceMissingPicture(Picture = "~/Content/Images/nopicture.png", MimeType = "image/png")]
public ActionResult Picture(int id)
{
    var item = Service.GetItem(id);

    var responseTag = item.Version != null ? 
        Convert.ToBase64String(item.Version.ToArray()) :
        string.Empty;

    if (item.Picture != null)
    {
        var picture = item.Picture.ToArray();
        var mime = item.PictureMime;

        TagService.SetResponseTag(responseTag);

        return File(picture, mime);
    }
    else
    {
        return null;
    }
}

A simple ToBase64String() from our version column (which is mapped to a Binary object) should do just fine.

Before we go into how we’ll check for the tag (which is pretty trivial) we also need to know how to return the actual HTTP 304 status. The following needs to happen:

  • Response.SuppressContent must be set to true.
  • Response.StatusCode needs to be set to 304.
  • Response.StatusDescription needs a description.
  • Response needs a header “Content-Length” set to “0”.

This last one is important, as it will allow the client to keep the connection open for the next request while not expecting any more output from the current request.

In order to keep this testable, I chose to create a custom ActionResult called NotModifiedResult. Here’s the implementation:

public class NotModifiedResult : ActionResult
{
    public override void ExecuteResult(ControllerContext context)
    {
        var response = context.HttpContext.Response;

        response.SuppressContent = true;
        response.StatusCode = 304;
        response.StatusDescription = "Not Modified";
        response.AddHeader("Content-Length", "0");
    }
}

This is exactly what we need, and ASP.NET MVC makes it easy again to keep this all neat and tidy. Our tests can simply check for the type of result returned, and ExecuteResult() never needs to be executed in a test environment.

So finally, our Picture action method looks like this:

[ReplaceMissingPicture(Picture = "~/Content/Images/nopicture.png", MimeType = "image/png")]
public ActionResult Picture(int id)
{
    var item = Service.GetItem(id);

    var requestTag = TagService.GetRequestTag() ?? string.Empty;
    var responseTag = item.Version != null ? 
        Convert.ToBase64String(item.Version.ToArray()) :
        string.Empty;

    if (responseTag == requestTag)
    {
        return new NotModifiedResult();
    }

    if (item.Picture != null)
    {
        var picture = item.Picture.ToArray();
        var mime = item.PictureMime;

        TagService.SetResponseTag(responseTag);

        return File(picture, mime);
    }
    else
    {
        return null;
    }
}

I’m sure this doesn’t need any explanation.

Two more things I’d like to mention. First of all, I decided to turn the Picture column into a delay loaded column by setting its Delay Loaded property to true in the LINQ to SQL designer. This way, when the table is queried, the column is not selected by default. Only when you access the picture column a new query is started specifically for the picture. This way, when you don’t need the picture (like in a table overview of the business objects) it’s not queried for and retrieved from the database. Also, this means that our action method requires two queries: one for the Item and one for the Picture column. Except that in the case of a client cached picture, the second query doesn’t need to be executed.

Second, I want you to know that this technique here doesn’t just apply to pictures, it applies to any sort of content that can be accessed as a resource. If the client can cache it, you can enable the web application to support that caching method.

Now all I need to do, is apply the same to my ReplaceMissingPicture filter that I talked about last time.

No comments:

Post a Comment