Posted: July 1, 2011
Speed Up The Web - Part II
Welcome my friends to a second installment and follow-up to our Speed Up The Web series.
In this installment we will be discussing another similar technique that was previously discussed in Part I.
Minifying and Concatenating your stylesheets and javascripts.
The previous process discussed a method to do this for your .Net website automatically by parsing the outputted HTML for your stylesheets and scripts, combining them into one string each, saving the strings to server cache and finally rendering them through a gzipped output.
This is all done by utilizing a .Net HttpModule.
I thought it would be the perfect solution, but have since found out otherwise. While the methology involved remains the same, the delivery method is what is changing. I have found that if you utilize Master Pages (or have developed a highly customizable CMS system like myself), this method simply does not work.
Thus, I set myself the task of making something work.
What I found was that by using a Response.Filter on the IO.Stream of the HTML rendered, we can accomplish the same goals, without any modifications to your web.config file, and without the use of an HttpModule.
In my test configuration, I simply have 3 files (default.aspx, default2.aspx, and default3.aspx);
- Default.aspx simply is an .Net HTML file with 2 references to 2 seperate stylesheet files, with the filter applied on Page_Load in the code behind
- Default2.aspx simply contains a placeholder reference for the Master Page we use for it, which happens to contain the same structure as Default.aspx
- Default3.aspx is the same file as Default.aspx, except it does not contain the filter in the code behind.
For testing we used FireFox, and the handy dandy FireBug plugin, and the results were as such:
- Default.aspx – Fresh Load – Non-Cached: 3 Requests = 2.25s | Cached: 3 Requests From Cache = 1.67s
- Default2.aspx – Fresh Load – Non-Cached: 3 Requests = 2.15s | Cached: 3 Requests From Cache = 1.77s
- Default3.aspx – Fresh Load – Non-Cached: 7 Requests = 3.32s | Cached: 7 Requests No Cache = 3.25s
The biggest thing to note here is the number of requests.
Complete File Listing:
- /App_Code/Common.vb
- /App_Code/DynamicCompression.vb
- /Scripts/custom.js
- /Scripts/jquery_latest.js
- /Scripts/jquery.pngFix.pack.js.js
- /Scripts/jquery.preloadcssimages.js.js
- /Styles/layout1.css
- /Styles/layout2.css
- /Default.aspx w/Code Behind
- /Default2.aspx w/Code Behind
- /Default3.aspx w/Code Behind
- /MasterPage.master w/Code Behind
- /Script.aspx w/o Code Behind
- /Style.aspx w/o Code Behind
- /web.config
And now, without further ado… the code (I won’t bother posting the code for the scripts and styles….that’ll be up to you to do):
/App_Code/Common.vb
Imports Microsoft.VisualBasic ''' ''' Our common class file ''' ''' Public Class Common ''' ''' Let's cache this stuff ''' ''' Public Shared Sub CacheIt() ' Allow the browser to store resposes in the 'History' folder HttpContext.Current.Response.Cache.SetAllowResponseInBrowserHistory(True) ' Set our cacheability to Public HttpContext.Current.Response.Cache.SetCacheability(HttpCacheability.Public) ' Resource is valid until the expiration has passed HttpContext.Current.Response.Cache.SetValidUntilExpires(True) ' Set out last modified date to last year HttpContext.Current.Response.Cache.SetLastModified(Date.Now.AddDays(-366)) ' We want to store and cache the resource HttpContext.Current.Response.AddHeader("Cache-Control", "store, cache") ' Set the Pragma to cache HttpContext.Current.Response.AddHeader("pragma", "cache") ' Not sure if this one really works, but it doesn't throw an error, and Google likes resources served from a cookie-less domain... eh... worth a shot HttpContext.Current.Response.AddHeader("Set-Cookie", "false") ' Make sure our cache control is Public HttpContext.Current.Response.CacheControl = "public" ' 'Set the expiration date of the resource until next year HttpContext.Current.Response.Expires = 24 * 60 * 366 HttpContext.Current.Response.ExpiresAbsolute = DateAdd(DateInterval.Hour, 24 * 366, Date.Now) End Sub ''' ''' Let's check to see if the browser accepts GZip encoding ''' ''' ''' Public Shared Function IsGZipEnabled() As Boolean Dim accEncoding As String = HttpContext.Current.Request.Headers("Accept-Encoding") 'Does the browser accept content encoding? If (Not accEncoding Is Nothing) Then If (accEncoding.Contains("gzip") Or accEncoding.Contains("deflate")) Then Return True Else Return False End If Else Return False End If End Function End Class
/App_Code/DynamicComression.vb
Imports Microsoft.VisualBasic Imports System.Web Imports System.Text.RegularExpressions Imports System.Text Imports System.IO Imports System.Web.Caching Namespace ZipCM ''' ''' Our compressor class for miniying and concatenating javascript files and css files ''' ''' Public Class Compressor Inherits System.IO.Stream #Region " Properties " #Region " Required Properties by the IO.Stream Interface " Public Overrides ReadOnly Property Length() As Long Get End Get End Property Public Overrides Property Position() As Long Get End Get Set(ByVal value As Long) End Set End Property Public Overrides ReadOnly Property CanRead() As Boolean Get End Get End Property Public Overrides ReadOnly Property CanSeek() As Boolean Get End Get End Property Public Overrides ReadOnly Property CanWrite() As Boolean Get End Get End Property #End Region #Region " Internal Properties " Private HTML As String, FileCSS As String, FileJS As String Private context As HttpContext Private PageID As Long Private Base As System.IO.Stream #End Region #End Region #Region " Methods " #Region " Public " #Region " Required by IO.Stream " Public Overrides Sub Flush() End Sub Public Overrides Function Read(ByVal buffer() As Byte, ByVal offset As Integer, ByVal count As Integer) As Integer Return Me.Base.Read(buffer, offset, count) End Function Public Overrides Function Seek(ByVal offset As Long, ByVal origin As System.IO.SeekOrigin) As Long End Function Public Overrides Sub SetLength(ByVal value As Long) End Sub #End Region ''' ''' Fire up our class passing in our Response Stream ''' '''''' Public Sub New(ByVal ResponseStream As System.IO.Stream) ' Just in case it does not exist, let's throw up ;) If ResponseStream Is Nothing Then Throw New ArgumentNullException("ResponseStream") ' Set our Response to the IO.Stream Me.Base = ResponseStream End Sub ''' ''' We want to overwrite our default Write method so we can do our stuff ''' '''''''''''' Public Overrides Sub Write(ByVal buffer() As Byte, ByVal offset As Integer, ByVal count As Integer) ' Get HTML code from the Stream HTML = System.Text.Encoding.UTF8.GetString(buffer, offset, count) 'Compress and Concatenate the CSS Me.CompressStyles(HTML) 'Compress and Concatenate the JS Me.CompressJavascript(HTML) ' Send output, but replace some unneeded stuff first, like tabs, and multi-spaces HTML = HTML.Replace(vbTab, String.Empty).Replace(" ", String.Empty).Replace(vbCrLf, String.Empty) ' Set the buffer to the Bytes gotten from our HTML buffer = System.Text.Encoding.UTF8.GetBytes(HTML) ' Write It Out (not unlike 'Spit It Out - Slipknot' Me.Base.Write(buffer, 0, buffer.Length) End Sub #End Region #Region " Internal " ''' ''' Compress and Concatenate Our Style Sheets ''' '''''' Private Sub CompressStyles(ByVal StrFile As String) Dim FilesM As MatchCollection, FileName As String ' Grab all references to stylesheets as they are in our HTML FilesM = Regex.Matches(StrFile, " <link.*?href=""(.*?)"".*?>") Dim M(FilesM.Count - 1) As String ' Now that we have all our files in a match collection, ' we'll loop through each file and put each line together into one string For i As Long = 0 To M.Length - 1 M(i) = FilesM(i).Groups(1).Value FileName = HttpContext.Current.Server.MapPath(M(i).Replace("/", "")) FileName = FileName.Replace("\", "") ' Make sure the file exists locally, then read the entire thing to add into our string If File.Exists(FileName) Then Using objFile As New StreamReader(FileName) FileCSS += objFile.ReadToEnd objFile.Close() End Using ' Now that we have our concatenated string, ' let's replace the unneeded stuff ' Comments FileCSS = Regex.Replace(FileCSS, "/*.+?*/", "", RegexOptions.Singleline) ' 2 Spaces FileCSS = FileCSS.Replace(" ", String.Empty) ' Carriage Return FileCSS = FileCSS.Replace(vbCr, String.Empty) ' Line Feed FileCSS = FileCSS.Replace(vbLf, String.Empty) ' Hard Return FileCSS = FileCSS.Replace(vbCrLf, String.Empty) ' Tabs w/ a single space FileCSS = FileCSS.Replace(vbTab, " ") FileCSS = FileCSS.Replace("t", String.Empty) ' Get rid of spaces next to the special characters FileCSS = FileCSS.Replace(" {", "{") FileCSS = FileCSS.Replace("{ ", "{") FileCSS = FileCSS.Replace(" }", "}") FileCSS = FileCSS.Replace("} ", "}") FileCSS = FileCSS.Replace(" :", ":") FileCSS = FileCSS.Replace(": ", ":") FileCSS = FileCSS.Replace(", ", ",") FileCSS = FileCSS.Replace("; ", ";") FileCSS = FileCSS.Replace(";}", "}") ' Another comment FileCSS = Regex.Replace(FileCSS, "/*[^*]**+([^/*]**+)*/", "$1") End If Next Dim tmpCt As Long = 0 ' One more loop to get rid of the multiple references, ' and replace it with a sigle reference to our new and improved stylesheet file For Each tmpS As Match In FilesM tmpCt += 1 If tmpCt = FilesM.Count Then ' If our count = the number of matches then replace StrFile = StrFile.Replace(tmpS.Groups(0).ToString(), " ") Else ' if not, just get rid of it StrFile = StrFile.Replace(tmpS.Groups(0).ToString(), String.Empty) End If Next FilesM = Nothing ' Need to put the New and Improved CSS string somewhere! ' What a better place for it, then the server cache HttpContext.Current.Cache("CSS") = FileCSS ' We also need to return the improved HTML HTML = StrFile End Sub ''' ''' Compress Our Scripts ''' '''''' Private Sub CompressJavascript(ByVal StrFile As String) Dim FilesM1 As MatchCollection, FileName As String ' Grab all references to script files as they are in our HTML FilesM1 = Regex.Matches(StrFile, "<script.*?src=""(.*?)"".*?>") Dim M1(FilesM1.Count - 1) As String ' Now that we have all our files in a match collection, ' we'll loop through each file and put each line together into one string For j As Long = 0 To M1.Length - 1 M1(j) = FilesM1(j).Groups(1).Value FileName = HttpContext.Current.Server.MapPath(M1(j).Replace("/", "")) FileName = FileName.Replace("\", "") ' Make sure the file exists locally, then read the entire thing to add into our string If File.Exists(FileName) Then Using objFile1 As New StreamReader(FileName) FileJS += objFile1.ReadToEnd objFile1.Close() End Using ' Now that we have our concatenated string, ' let's replace the unneeded stuff ' Comments FileJS = Regex.Replace(FileJS, "(// .*?$)", "", RegexOptions.Multiline) FileJS = Regex.Replace(FileJS, "(/*.*?*/)", "", RegexOptions.Multiline) ' 2 Spaces with 1 Space FileJS = FileJS.Replace(" ", " ") ' Carriage Return FileJS = FileJS.Replace(vbCr, vbLf) ' Hard Return w/ Line Feed FileJS = FileJS.Replace(vbCrLf, vbLf) ' Tabs with a single space FileJS = FileJS.Replace(vbTab, " ") End If Next Dim tmpCt1 As Long = 0 ' One more loop to get rid of the multiple references, ' and replace it with a sigle reference to our new and improved stylesheet file For Each tmpS As Match In FilesM1 tmpCt1 += 1 If tmpCt1 = FilesM1.Count Then ' If our count = the number of matches then replace StrFile = StrFile.Replace(tmpS.Groups(0).ToString(), "") Else ' if not, just get rid of it StrFile = StrFile.Replace(tmpS.Groups(0).ToString(), String.Empty) End If Next FilesM1 = Nothing ' Need to put the New and Improved JS Somewhere! ' What a better place for it then the server cache HttpContext.Current.Cache("JS") = FileJS ' Also need to return the New and Improved HTML HTML = StrFile End Sub #End Region #End Region End Class End Namespace
/Default.aspx
<%@ Page Language="VB" AutoEventWireup="false" CodeFile="Default.aspx.vb" Inherits="_Default" Debug="true" %> Â Â Just a test to make sure the CSS and JS works Click Here
/Default.aspx.vb
Partial Class _Default Inherits System.Web.UI.Page Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load 'Enable GZip Encoding for this page If Common.IsGZipEnabled() Then Dim accEncoding As String accEncoding = Context.Request.Headers("Accept-Encoding") If accEncoding.Contains("gzip") Then Response.Filter = New System.IO.Compression.GZipStream(Response.Filter, System.IO.Compression.CompressionMode.Compress) Response.AppendHeader("Content-Encoding", "gzip") Else Response.Filter = New System.IO.Compression.DeflateStream(Response.Filter, System.IO.Compression.CompressionMode.Compress) Response.AppendHeader("Content-Encoding", "deflate") End If End If 'Cache our response in the browser Common.CacheIt() 'Concatenate and Minify our Scripts and Stylesheets Response.Filter = New ZipCM.Compressor(Response.Filter) End Sub End Class
/Default2.aspx – blank Code-Behind
<%@ Page Language="VB" MasterPageFile="~/MasterPage.master" AutoEventWireup="false" CodeFile="Default2.aspx.vb" Inherits="Default2" title="Untitled Page" %> Just a test to make sure the CSS and JS works Click Here
/Default3.aspx – Blank Code-Behind, contains the exact same code as /Default.aspx, except without the Code-Behind Processing
/MasterPage.master
<%@ Master Language="VB" CodeFile="MasterPage.master.vb" Inherits="MasterPage" %> Â Â Â
/MasterPage.master.vb
Partial Class MasterPage Inherits System.Web.UI.MasterPage Protected Sub Page_Load(ByVal sender As Object, ByVal e As System.EventArgs) Handles Me.Load 'Enable GZip Encoding for this page If Common.IsGZipEnabled() Then Dim accEncoding As String accEncoding = Context.Request.Headers("Accept-Encoding") If accEncoding.Contains("gzip") Then Response.Filter = New System.IO.Compression.GZipStream(Response.Filter, System.IO.Compression.CompressionMode.Compress) Response.AppendHeader("Content-Encoding", "gzip") Else Response.Filter = New System.IO.Compression.DeflateStream(Response.Filter, System.IO.Compression.CompressionMode.Compress) Response.AppendHeader("Content-Encoding", "deflate") End If End If 'Cache our response in the browser Common.CacheIt() 'Concatenate and Minify our Scripts and Stylesheets Response.Filter = New ZipCM.Compressor(Response.Filter) End Sub End Class
/Script.aspx
<%@ Page Language="VB" %> <% 'Enable GZip Encoding If Common.IsGZipEnabled() Then Dim accEncoding As String accEncoding = Context.Request.Headers("Accept-Encoding") If accEncoding.Contains("gzip") Then Context.Response.Filter = New System.IO.Compression.GZipStream(Context.Response.Filter, System.IO.Compression.CompressionMode.Compress) Context.Response.AppendHeader("Content-Encoding", "gzip") Else Context.Response.Filter = New System.IO.Compression.DeflateStream(Context.Response.Filter, System.IO.Compression.CompressionMode.Compress) Context.Response.AppendHeader("Content-Encoding", "deflate") End If End If 'Cache our response in the browser Common.CacheIt() 'Set the content type to output true javascript Response.ContentType = "text/javascript" If Not (Cache("JS") Is Nothing) Then 'write out the javascript string from our cache Response.Write(Cache("JS").ToString()) End If %>
/Style.aspx
<%@ Page Language="VB" %> <% 'Enable GZip Encoding If Common.IsGZipEnabled() Then Dim accEncoding As String accEncoding = Context.Request.Headers("Accept-Encoding") If accEncoding.Contains("gzip") Then Context.Response.Filter = New System.IO.Compression.GZipStream(Context.Response.Filter, System.IO.Compression.CompressionMode.Compress) Context.Response.AppendHeader("Content-Encoding", "gzip") Else Context.Response.Filter = New System.IO.Compression.DeflateStream(Context.Response.Filter, System.IO.Compression.CompressionMode.Compress) Context.Response.AppendHeader("Content-Encoding", "deflate") End If End If 'Cache our response in the browser Common.CacheIt() 'Set the content type to output true CSS Response.ContentType = "text/css" If Not (Cache("CSS") Is Nothing) Then 'Write it out from the cache Response.Write(Cache("CSS").ToString()) End If %>