« Fulfilling the 'Freshness' pattern with extension methods | Main | Disposing handlers in ASP.NET AJAX: addHandler vs. addHandlers »

March 04, 2008

How to deliver ASP.NET Themes as a single, optimized resource

I use ASP.NET Themes in many of my projects to take advantage of organizing styles and images in one logical area that is dynamically configurable at runtime. I also like to create a separate stylesheet for each component and organize them in their own folders, so I know exactly where to look when I'm changing my site's appearance. After declaring a theme for my pages, ASP.NET fetches my style information for me and I go about my business.

At runtime, however, my best practices turn into a huge list of header links to each one of those nested style files I've carefully created. With little effort, I can surpass IE's limit on the number of external CSS styles allowed on a page (it's 30) and crash my application, which is always a difficult problem to diagnose. On top of that, because ASP.NET is doing the moving, I can't compress, minify, or cache those themes on the server, I just have to grin and bear it, tweak IIS, remove my precious comments and readability and compact the CSS myself, or find an alternative to using ASP.NET Themes how I'd like.

I decided to build an IHttpHandler that will let me combine all of my theme scripts at runtime, minify them (using my port of the YUI Compressor's CSS minification algorithm), and cache them on the server based on their file dependencies, so that the server will invalidate and rebuild the resource if I change any of the style files associated with that theme. Add stream compression and client caching for good measure, and what I get is a great style combiner that I don't have to think about.

Ready to build this?

Our first task is to build out the IHttpHandler whose responsibility is to detect when we're asking for a CSS resource and to perform the compression, combining, and caching mechanisms. Since we're dealing with themes, we'll need to be able to collect all of the CSS files located in a Theme folder, just as ASP.NET Themes does for us automatically.

First we'll cook up a generic handler base that provides a compression method. Thanks to .NET 2.0, adding compression to an HttpResponse is straightforward: we just check what the client browser prefers to accept, GZip or Deflate, and wrap the original stream in the preferred flavor.

namespace Dimebrain.Handlers
{
    public abstract class CompressionHandler : IHttpHandler
    {
        public abstract void ProcessRequest(HttpContext context);
 
        public static Stream Compress(HttpContext context, HttpResponse response, Stream stream)
        {
            var encoding = context.Request.Headers["Accept-Encoding"];
            if (encoding.IsNotNullOrEmpty())
            {
                var gzip = "gzip";
                var deflate = "deflate";
                var header = "Content-Encoding";
 
                if(encoding.ContainsIgnoreCase(gzip))
                {
                    response.AddHeader(header, gzip);
                    stream = new GZipStream(stream, CompressionMode.Compress);
                }
                else if (encoding.ContainsIgnoreCase(deflate))
                {
                    response.AddHeader(header, deflate);
                    stream = new DeflateStream(stream, CompressionMode.Compress);
                }
            }
            return stream;
        }
 
        public virtual bool IsReusable
        {
            get
            {
                return true;
            }
        }
    }
}

The base class defers implementing IHttpHandler's ProcessRequest method to its inheritor, and declares itself reusable by default, since we're assuming that the kind of handler that wants to compress a request is the same kind of handler that wants to do the same thing to multiple requests; that is, we don't need to create a new instance of this handler for every single resource we're compressing.

And before you ask, here are the extension methods I used in the base class above:

public static class Extensions
{
    public static bool IsNotNullOrEmpty(this string input)
    {
        return !String.IsNullOrEmpty(input);
    }
 
    public static bool ContainsIgnoreCase(this string left, string right)
    {
        var pattern = new Regex(right, RegexOptions.IgnoreCase);
        return pattern.IsMatch(left);
    }
}


Now we have a base class for our CSS handler. Our handler has access to the HttpContext request, just like a Page, so we can pass it a query string. To make it more useful for other purposes, we'll define query string parameters that allow it to handle a theme, but also a single file, a directory, or a particular page.

public class CssHandler : CompressionHandler
{
    public const string ContentType = "text/css";
 
    public override void ProcessRequest(HttpContext context)
    {
        // Collect the query parameters
        var page = context.Request.QueryString["p"];
        var theme = context.Request.QueryString["t"];
        var file = context.Request.QueryString["f"];
        var directory = context.Request.QueryString["d"];
 
        // Identify the response as CSS
        var response = context.Response;
        response.ContentType = ContentType;
 
        // Wrap the response in a compression stream
        var output = response.OutputStream;
        output = Compress(context, response, output);
 
        // Process any query parameters
        ProcessByPage(context, page, output);
        ProcessByTheme(context, theme, output);
        ProcessByFile(context, file, output);
        ProcessByDirectory(context, directory, output);
    }
}


So far, our handler is simply collecting any parameters passed to it in the query string, setting the output to return as a compressed stream if the browser supports it by making use of our base class method, and calling methods to process the query parameters that are found. Next we'll fill in our processing methods.

private static void ProcessByPage(HttpContext context, string page, Stream output)
{
    if (page.IsNotNullOrEmpty())
    {
        switch (page)
        {
            case "MyPage":
                // Typically you would determine what paths to combine using a data store
                Combine(context, output, "~/Css");
                break;
            default:
                break;
        }
    }
}
 
private static void ProcessByTheme(HttpContext context, string theme, Stream output)
{
    if (theme.IsNotNullOrEmpty())
    {
        // Get all theme directories
        var path = context.Server.MapPath("~/App_Themes/{0}".Fill(theme));
        CombineFromDirectory(context, output, path);
    }
}
 
private static void ProcessByFile(HttpContext context, string file, Stream output)
{
    if (file.IsNotNullOrEmpty())
    {
        Combine(context, output, file.MapPathReverse());
    }
}
 
private static void ProcessByDirectory(HttpContext context, string directory, Stream output)
{
    if (directory.IsNotNullOrEmpty())
    {
        directory = context.Server.MapPath(directory);
        CombineFromDirectory(context, output, directory);
    }
}

Each of these methods has a companion static Combine method that's going to do the real work. The processing methods are used to determine which directories we'll be scanning for CSS files to combine. Remember, our handler wants to cache the results of combining so that we aren't taxing our server every time a new client requests this information; if the underlying files haven't changed, we should continue to serve the same content right out of the cache.

  • ProcessByPage: This is a rudimentary example of how the handler might process a query string for a page. For example, I might include in my header control the following link in a page called MyPage.aspx:
    <link href="css.axd?p=MyPage" type="text/css" rel="stylesheet" />
    In the handler, we could define, or retrieve, a manifest list of directories that contain all of the styles that page requires.
  • ProcessByTheme: This is the method we're most interested in. It will take a theme name, and use the CombineByDirectory method to collect all of the files in the entire sub-directory tree within that theme in order to combine them.
  • ProcessByFile: This process will simply attempt to process a single file that is defined as a virtual path including the file name.
  • ProcessByDirectory: Borrowing from the themes implementation, this will process an entire directory, including its sub-folders.

Here are the extension methods I'm using in the code example above:

public static class Extensions
{
    public static bool IsNotNull(this object instance)
    {
        return instance != null;
    }
 
    // Returns the virtual file path for a specified physical file path
    public static string MapPathReverse(this string path)
    {
        var context = HttpContext.Current;
        if (context.IsNotNull())
        {
            if (context.Request.PhysicalApplicationPath.IsNotNull())
            {
                var root = context.Request.PhysicalApplicationPath.TrimEnd('\\');
                var relative = path.Replace(root, String.Empty);
                var clean = relative.Replace('\\', '/');
 
                return clean.Insert(0, "~");
            }
        }
        return String.Empty;
    }
 
    public static string Fill(this string format, params object[] args)
    {
        return String.Format(format, args);
    }
}

Now that we have our common handler API in place, we can fill in the combination methods that do the work of combining CSS files and caching them per their parent relative directory.

private static void CombineFromDirectory(HttpContext context, Stream output, string directory)
{
    // Get a list of all sub-directories and add the parent
    var directories = Directory.GetDirectories(directory, "*", SearchOption.AllDirectories).ToList();
    directories.Add(directory);
 
    // Convert paths back to virtual
    for (var p = 0; p < directories.Count; p++)
    {
        directories[p] = directories[p].MapPathReverse();
    }
 
    // Combine the directory manifest
    Combine(context, output, directories);
}
 
private static void Combine(HttpContext context, Stream output, string relativePath)
{
    Combine(context, output, new[] { relativePath });
}
 
private static void Combine(HttpContext context, Stream output, params string[] relativePaths)
{
    Combine(context, output, relativePaths.ToList());
}
 
private static void Combine(HttpContext context, Stream output, IEnumerable<string> relativePaths)
{
    using (var sw = new StreamWriter(output))
    {
        // HttpRuntime is faster than HttpContext.Current
        var cache = HttpRuntime.Cache;
 
        foreach (var relativePath in relativePaths)
        {
            // Check the cache for this relative path first
            if (cache[relativePath].IsNull())
            {
                var sb = new StringBuilder();
                var path = context.Server.MapPath(relativePath);
                var files = new List<string>();
 
                // Test that the provided path is not a full file
                var pathAsFile = Path.GetFileName(path);
                if (!pathAsFile.Contains('.'))
                {
                    files.AddRange(Directory.GetFiles(path));
                }
                else
                {
                    // Already a file
                    files.Add(path);
                }
 
                foreach (var file in files)
                {
                    if (file.ContainsIgnoreCase(".css"))
                    {
                        // Read the file and minify it
                        var minified = File.ReadAllText(file).CssMinify();
 
                        // Write the file to a temporary builder
                        sb.Append(minified);
                    }
                }
 
                // Combine the string content
                var content = sb.ToString();
 
                // Create a cache dependency based on the files we just combined
                var dependency = new CacheDependency(files.ToArray());
 
                // Cache the content by path name, so we don't combine it twice
                cache.Insert(relativePath, content, dependency);
                sw.Write(content);
            }
            else
            {
                // It's already cached, so serve it up
                var existing = cache[relativePath].ToString();
                sw.Write(existing);
            }
        }
    }
}
 
The Combine method will traverse every directory it is passed, combine the minified CSS output for any CSS file it finds in those directories, and then cache that content by directory with file dependencies, so that any changes to a file will invalidate its directory's cache (but not the entire request's style info) out to the response's output stream. Here are the extension methods:
 
public static class Extensions
{
    public static bool IsNull(this object instance)
    {
        return instance == null;
    }        
 
    public static string CssMinify(this string input)
    {
        // See my post, "A better CSS minifier"
        return input;
    }
}

All we need to do now is add client caching headers in the ProcessRequest method and we're ready to move on to other challenges.

// Cache our work on the client
var clientCache = context.Response.Cache;
clientCache.SetCacheability(HttpCacheability.Public);
clientCache.SetValidUntilExpires(true);
clientCache.SetLastModifiedFromFileDependencies();
clientCache.SetETagFromFileDependencies();
(Note: You may want to customize this cache policy depending on your situation, i.e. you prefer cache control headers over ETags)
 
Okay, our handler is ready to go, so we'll declare it in the web.config as a path. We're going to do this because CSS files routinely reference image files as URLs that are within it's own scope, but if we called our handler explicitly, our combined CSS would look for the images relative to the page we requested it on, which would break those URLs.

<add verb="*" path="css.axd" type="Dimebrain.Handlers.CssHandler" validate="false"/>

To provide automatic theme combining, we override the Page's OnPreInit method, where we expect to find the links ASP.NET added to the page for us. What we're going to do is collect all of those links, and replace them with a link to our handler requesting the page's theme:

public class MyPage : Page
{
    protected override void OnPreRender(EventArgs e)
    {
        CombineCssLinks();
 
        base.OnPreRender(e);
    }
 
    private void CombineCssLinks()
    {
        var hasTheme = false;