Introduction
This article will present a technique for delivering CSS and JavaScript files to a client within an ASP.NET MVC application. More specifically it will show how to deliver compressed, minified, combined and cached CSS / JavaScript files in the context of an ASP.NET MVC application. The goal was to improve overall performance on the client side. Similarly, it will show how we can force a client side refresh of the same CSS and JavaScript files (meaning we don't have to change our file names). This becomes increasingly important when using AJAX techniques within an application that introduce new features and functionality. In short, we can ensure our clients have the necessary upgraded JavaScript files in order execute new functionality and not the files stored in the browser's cache.
The Code
The impetus for this article was based on the article by Khaled Atashbahar. I use a lot of the same techniques but the main difference is that I use a custom Controller and ActionResult rather than a HttpHandler
to handle the minify/combine/render. I wanted to stay consistent with MVC pattern by leveraging my routes. In addition, I was using Structure Map dependency injection. When using a path to an HTTPHandler
in my markup that was not registered with my IContollerFactory
factory, DI, MVC and the HTTP handler were not playing nicely. To this end, let’s get right into the code and show how this works. First in my master page, I place the following markup. For the CSS files, I have:
<link type="text/css" rel="Stylesheet"
href="http://www.codeproject.com/cache/cachecontent/CSSInclude/
<%=GetApplicationVersionID() %>/css" />
And for the JavaScript files, I have:
<script type="text/javascript"
src="http://www.codeproject.com/cache/cachecontent/JavaScriptInclude/
<%=GetApplicationVersionID() %>/javascript" />
These are URL paths to the controller that will handle writing the files out to the client. Now in order to understand how the path references work, let’s take a look at the applicable route I define in the Global.asax file.
routes.Add(new
Route ("cache/{action}/{key}/{version}/{type}", new MvcRouteHandler()) {
Defaults = new RouteValueDictionary(new { controller = "Cache",
action = "CacheContent", key = "", version = "", type = ""
}),
}
);
So what does this route say? It says when we encounter a URL in a format like /cache/cachecontent/CSSInclude/12345/css, the request should be handed off the Cache controller and specifically the CacheContent
action method in order to handle the event. A few comments about the format of the URL:
{action} = The method we call when the controller receives the request.
{key} = The name of the key in the web.config that holds a comma separated value list of all the files we want to combine/compress and minify. Let’s look at this example snippet from a web.config file.
<add key="CssInclude" value="~/Content/styles/globalStylesheet.css,
~/Content/styles/ie6.css"/>
<add key="JavaScriptInclude" value=
"~/Content/scripts/jquery=1.2.6.min.js,
~/Content/scripts/jquery-DatePickerUI.js,
~/Content/scripts/jQuery.BlockUI.js,
~/Content/scripts/jquery.selectboxes.min.js,
~/Content/scripts/jquery.swfobject.js,
~/Content/scripts/MicrosoftAjax.js,
~/Content/scripts/MvcAjax.js,~/Content/scripts/DataServices.js,
~/Content/scripts/s_code.js,
~/Content/scripts/template.js,
~/Content/scripts/ValidationServices.js" />
Here we see we have a key name for CSS and a key name for JavaScript. If we use JavaScriptInclude
in our URL, we will combine all the JavaScript files in the value list.
{version} = The version number that we should use on the files we will be rendering. I use a number within the URL path so we can change the URL to make the browser think it has not seen the file in the past. The version could come from the database or even the web.config. The key point is we can leverage the version value to control client side caching. In development, I use a random number but in production I use something that has been configured in the web.config. The reason for the random number is I can ensure my browser always uses the latest and greatest version of my JavaScript files. This alleviates the need to clear the browsers cache during development. I am sure we all have been burned by forgetting to clear the cache while tracking down a piece of functionality that is not working as expected.
{type} = A name value to inform whether we are handling a CSS or JavaScript request.
Next, let’s take a look at our cache controller.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Mvc.Ajax;
using System.Drawing;
using System.Web.Configuration;
using System.Reflection;
using System.Web.Routing;
using System.IO;
using System.Web.Caching;
namespace Cache
{
public class CacheResult : ActionResult
{
private string _keyname;
private string _version;
private string _type;
public CacheResult(string keyname, string version, string type)
{
this._keyname = keyname;
this._version = version;
if ( type.ToLower().Contains("css") )
{
this._type = @"text/css";
}
if ( type.ToLower().Contains("javascript") )
{
this._type = @"text/javascript";
}
}
public override void ExecuteResult(ControllerContext context)
{
if (context == null)
throw new ArgumentNullException("context");
ScriptCombiner myCombiner = new ScriptCombiner
( this._keyname, this._version, this._type);
myCombiner.ProcessRequest(context.HttpContext);
}
}
public static class CacheControllerExtensions
{
public static CacheResult RenderCacheResult
(string keyname, string version, string type)
{
return new CacheResult(keyname, version, type);
}
}
public class CacheController :Controller
{
#region Constructor Definitions
public CacheController()
: base()
{
}
#endregion
#region Method Definitions
#region public
public CacheResult CacheContent(string key, string version, string type)
{
return CacheControllerExtensions.RenderCacheResult(key, version, type);
}
public CacheResult ClearCache()
{
}
#endregion
#endregion
}
}
Here we see that our CacheContent
method invokes a ControllerExtension
method called RenderCacheResult
. It passes in the key name of the files to render, the version it should use as well as the type of content (CSS or JavaScript) it will be rendering. The heavy lifting is completed by the ExecuteResult
method within custom CacheResult
class. This method uses the techniques mentioned in the beginning of the article by Khaled Atashbahar. For completeness, I include the code below. I do however want to point out a few changes I made to his code. First, I changed the class so it can handle both CSS and JavaScript files by adding the type parameter to the CacheResult
constructor. Next, I removed the use of querystring parameters in favor of values passed into the constructor. I also changed the GetScriptFileNames
to leverage the file names stored in the web.config file rather than something passed via the querystring. Last, I forgo the minify step during ProcessRequest
to make it easier to debug JavaScript files when in debug mode. Here is the code for your reference.
using System;
using System.IO;
using System.IO.Compression;
using System.Text;
using System.Web;
using System.Web.Mvc;
namespace SomeNameSpace
{
public class ScriptCombiner
{
private readonly static TimeSpan CACHE_DURATION = TimeSpan.FromDays(30);
private System.Web.HttpContextBase context;
private string _ContentType;
private string _key;
private string _version;
public ScriptCombiner(string keyname, string version, string type)
{
this._ContentType = type;
this._key = keyname;
this._version = version;
}
public void ProcessRequest(System.Web.HttpContextBase context)
{
this.context = context;
HttpRequestBase request = context.Request;
string setName = _key;
string version = _version;
string contentType = _ContentType;
bool isCompressed = this.CanGZip(context.Request);
if (!this.WriteFromCache(setName, version, isCompressed, contentType))
{
using (MemoryStream memoryStream = new MemoryStream(8092))
{
using (Stream writer = isCompressed ?
(Stream)(new GZipStream(memoryStream, CompressionMode.Compress)) :
memoryStream)
{
StringBuilder allScripts = new StringBuilder();
foreach (string fileName in GetScriptFileNames(setName))
allScripts.Append(File.ReadAllText(context.Server.MapPath(fileName)));
var minifier = new JavaScriptMinifier();
string minified = minifier.Minify(allScripts.ToString());
#if DEBUG
minified = allScripts.ToString();
#endif
byte[] bts = Encoding.UTF8.GetBytes(minified);
writer.Write(bts, 0, bts.Length);
}
byte[] responseBytes = memoryStream.ToArray();
context.Cache.Insert(GetCacheKey(setName, version, isCompressed),
responseBytes, null, System.Web.Caching.Cache.NoAbsoluteExpiration,
CACHE_DURATION);
this.WriteBytes(responseBytes, isCompressed, contentType);
}
}
}
private bool WriteFromCache(string setName, string version,
bool isCompressed, string ContentType)
{
byte[] responseBytes = context.Cache[GetCacheKey
(setName, version, isCompressed)] as byte[];
if (responseBytes == null || responseBytes.Length == 0)
return false;
this.WriteBytes(responseBytes, isCompressed, ContentType);
return true;
}
private void WriteBytes(byte[] bytes, bool isCompressed, string ContentType)
{
HttpResponseBase response = context.Response;
response.AppendHeader("Content-Length", bytes.Length.ToString());
response.ContentType = ContentType;
if (isCompressed)
response.AppendHeader("Content-Encoding", "gzip");
else
response.AppendHeader("Content-Encoding", "utf-8");
context.Response.Cache.SetCacheability(HttpCacheability.Public);
context.Response.Cache.SetExpires(DateTime.Now.Add(CACHE_DURATION));
context.Response.Cache.SetMaxAge(CACHE_DURATION);
response.ContentEncoding = Encoding.Unicode;
response.OutputStream.Write(bytes, 0, bytes.Length);
response.Flush();
}
private bool CanGZip(HttpRequestBase request)
{
string acceptEncoding = request.Headers["Accept-Encoding"];
if (!string.IsNullOrEmpty(acceptEncoding) &&
(acceptEncoding.Contains("gzip") || acceptEncoding.Contains("deflate")))
return true;
return false;
}
private string GetCacheKey(string setName, string version, bool isCompressed)
{
return "HttpCombiner." + setName + "." + version + "." + isCompressed;
}
public bool IsReusable
{
get { return true; }
}
private static string[] GetScriptFileNames(string setName)
{
var scripts = new System.Collections.Generic.List<string>();
string setDefinition =
System.Configuration.ConfigurationManager.AppSettings[setName] ?? "";
string[] fileNames = setDefinition.Split(new char[] { ',' },
StringSplitOptions.RemoveEmptyEntries);
foreach (string fileName in fileNames)
{
if (!String.IsNullOrEmpty(fileName))
scripts.Add(fileName);
}
return scripts.ToArray();
}
}
}
To summarize this article, we introduced a Custom Controller called CacheController
and a route within the controller that allows for minifying/compressing /combining and caching our JavaScript and CSS files within an ASP.NET MVC application. We also introduced a mechanism within the controller that we can leverage to force a client side refresh of JavaScript and CSS files.
I hope you find this helpful. Let me know your thoughts or ideas you may have to improve this technique.
History
- 8th March, 2009: Initial version