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 browsers cache.
The impetus for this article was based on this article by Khaled Atashbahar ( http://www.atashbahar.com/post/Combine-minify-compress-JavaScript-files-to-load-ASPNET-pages-faster.aspx ). 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="/cache/cachecontent/CSSInclude/<%=GetApplicationVersionID()
%>/css"
/>
And for the JavaScript files I have
<script type="text/javascript" :src="/cache/cachecontent/JavaScriptInclude/<%=GetApplicationVersionID()
%>/javascript" temp_src="/cache/cachecontent/JavaScriptInclude/<%=GetApplicationVersionID()
%>/javascript" /> </script>
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()
{
//LOGIC TO CLEAR OUT CACHE
}
#endregion
#endregion
} // End class CacheController
}
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;
// Read setName, version from query string
string setName = _key;
string version = _version;
string contentType = _ContentType;
// Decide if browser supports compressed response
bool isCompressed = this.CanGZip(context.Request);
// If the set has already been cached, write the response directly from
// cache. Otherwise generate the response and cache it
if (!this.WriteFromCache(setName, version, isCompressed, contentType))
{
using (MemoryStream memoryStream = new MemoryStream(8092))
{
// Decide regular stream or gzip stream based on whether the response can be compressed
or not
//using (Stream writer = isCompressed ? (Stream)(new GZipStream(memoryStream,
CompressionMode.Compress)) : memoryStream)
using (Stream writer = isCompressed ?
(Stream)(new GZipStream(memoryStream, CompressionMode.Compress)) :
memoryStream)
{
// Read the files into one big string
StringBuilder
allScripts = new StringBuilder();
foreach (string fileName in GetScriptFileNames(setName))
allScripts.Append(File.ReadAllText(context.Server.MapPath(fileName)));
// Minify the combined script files and remove comments and white spaces
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);
}
// Cache the combined response so that it can be directly written
// in subsequent calls
byte[] responseBytes = memoryStream.ToArray();
context.Cache.Insert(GetCacheKey(setName,
version, isCompressed),
responseBytes,
null, System.Web.Caching.Cache.NoAbsoluteExpiration,
CACHE_DURATION);
// Generate the response
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 helper method that return an array of file names inside the text file
stored in App_Data folder
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.
<link type="text/css"
rel="Stylesheet" href="/cache/cachecontent/CSSInclude/<%=GetApplicationVersionID()
%>/css" />