This blog contains reflections and thoughts on my work as a software engineer

mandag den 14. december 2009

Optimizing website performance: Using ASP.NET Webcontrols to combine your CSS and JS files

So – this month we’ve been going through a performance optimization based on the suggestions provided by YSlow. There is no absolute truth in performance so the suggestions provided by YSlow should be put in a context but it is great for inspiration and lots of the advices proposed do make a lot of sense.

Untitled

One of the things we’ve pinpointed was the massive amount of files needed to load our front page (50+) so we are working on sprites as replacement for single files and I’ve been working on a solution to combine our CSS and Javascript files into one single style  The number of physical files you need to download to view a webpage is important because requests get queued and the browser consumes only two downloads in parallel – every other request sits waiting in the queue so the more requests you make the slower your website is going to be.

We needed a solution and my collegue proposed an ASP.NET Webcontrol which would act as a placeholder for our styles. After 2 days of work I came up with control made for for CSS and Javascript files which can:

  • Combine any number of CSS /Javascript files into one combined file
  • Output either combined file or single files (debug mode)
  • Removes whitespace if needed
The control ended up like the following snippet:
   <cc1:StaticFileCollection runat="server" ID="cssCollection" StaticFileType="CSS" Outputfile="/css/FrontpageAspxCssCollection.css" TrimWhiteSpace="true">      
    <cc1:StaticFile ID="StaticFile1" runat="server" Url="/script/ext-2.0/resources/css/form.css"  />
    <cc1:StaticFile ID="StaticFile2" runat="server" Url="/script/ext-2.0/resources/css/combo.css"  />
    <cc1:StaticFile ID="StaticFile3" runat="server" Url="/css/global.css"  />
    <cc1:StaticFile ID="StaticFile4" runat="server" Url="/css/article.css"  />
    <cc1:StaticFile ID="StaticFile5" runat="server" Url="/css/boxes.css"  />
    <cc1:StaticFile ID="StaticFile6" runat="server" Url="/css/ext-overrides.css"  />       
</cc1:StaticFileCollection>



What happens on PreRender is that every StaticFile is being opened and placed in a StringBuilder. It is being output in the file “/css/FrontpageAspxCssCollection.css” if the size has changed (that means that one of the sourcefiles have been altered, i.e. during development). A reference to /css/FrontpageAspxCssCollection.css is written in a Literal control. Plain and simple – no magic attached. So if you place the StaticFileCollection above in you <head> section of your webpage what you get back is this:

<link href="/css/FrontpageAspxCssCollection.css" rel="stylesheet" type="text/css">

…where the FrontPageAspxCssCollection.css is all your StaticFile’s combined into one single, physical file.

The code works for both Javascript and CSS styles. Enjoy   :o)

    [ToolboxData("<{0}:StaticFile runat=\"server\"></{0}:STATICFILE>")]
public class StaticFile : WebControl
{
public string Url { get; set; }

public override bool Visible
{
get
{
return false;
}
set
{
base.Visible = value;
}
}
}



[ToolboxData("<{0}:StaticFileCollection runat=\"server\"></{0}:StaticFileCollection>")]
public class StaticFileCollection : PlaceHolder
{
    private string _staticFileType;
    public string StaticFileType { get { return _staticFileType.ToLower();} set { _staticFileType = value;} }
    public bool TrimWhiteSpace { get; set; }
    public bool Debug { get; set; }
    public string Outputfile { get; set; }
    protected override void OnPreRender(EventArgs e)
    {        
        if (!CheckInput())
            return;
        if (Debug)           
            DoNotRenderCombinedFile();                       
        else                   
            RenderCombinedFile();


        base.OnPreRender(e);
    }

    /// <summary>
    /// Render every file in separate
    /// </summary>
    private void DoNotRenderCombinedFile()
    {
        var controls = new List<Control>();
        foreach (var control in Controls)
        {
            var c = control as StaticFile;
            controls.Add(new LiteralControl(GetScriptReference(c.Url)));
        }
        controls.ForEach(x => Controls.Add(x));
    }
    /// <summary>
    /// Render all files combined to OutputFile
    /// </summary>
    private void RenderCombinedFile()
    {
        var combinedFileString = CollectFileContent();
        string outputFile = HttpContext.Current.Server.MapPath("/" + Outputfile);
        //Get current file
        string currentContent = string.Empty;
        var currentFile = new FileInfo(outputFile);
        if (currentFile.Exists)
        {
            using (var sr = currentFile.OpenText())
                currentContent = sr.ReadToEnd();
        }
        if (TrimWhiteSpace)
        {
            var r = new Regex("\\s+", RegexOptions.Multiline);
            combinedFileString = r.Replace(combinedFileString, @" ");
        }
        //Only create new file if content has changed to maintain timestamp (avoid download to client on every hit)
        if (currentContent.Length != combinedFileString.Length)
        {              
            using (var sw = new StreamWriter(outputFile))
            {
                sw.Write(combinedFileString);
                sw.Close();
            }
        }
        Controls.Add(new LiteralControl(GetScriptReference(Outputfile)));       
    }
    private string CollectFileContent()
    {
        var cssBuilder = new StringBuilder();
        DateTime begin = DateTime.Now;
        foreach (var control in Controls)
        {
            FileInfo fi = GetFileInfoObject(control);
            using (var content = fi.OpenText())
            {
                cssBuilder.Append(string.Format("/*** {0} start ***/", fi.Name));
                cssBuilder.Append(content.ReadToEnd());
                cssBuilder.Append(string.Format("/*** {0} end ***/", fi.Name));
                cssBuilder.Append("");
            }
        }
        return cssBuilder.ToString();
    }
    private FileInfo GetFileInfoObject(object control)
    {
        var c = control as StaticFile;
        if (c == null)
            throw new ArgumentException("Only StaticFile controls can be childresn to a StaticFileCollection");
        var fi = new FileInfo(HttpContext.Current.Server.MapPath(c.Url));
        if (!fi.Exists)
            throw new ArgumentException(c.Url + " does not exist!");
        if (fi.Extension.ToLower() != "." + StaticFileType)
            throw new ArgumentException(string.Format("{0} is not of type {1}", c.Url, StaticFileType));
        return fi;
    }
    private string GetScriptReference(string url)
    {
        //Not so nice... But a strategy pattern impl. is way overkill
        string str = string.Format("<link type=\"text/css\" rel=\"stylesheet\" href=\"{0}\" />", url);
        if (StaticFileType.Equals("js"))
            str = string.Format("<script type=\"text/javascript\" src=\"{0}\" />", url);
        return str;           
    }


    private bool CheckInput()
    {
        if (string.IsNullOrEmpty(Outputfile))
            throw new ArgumentNullException("Outputfile must be set on a StaticFileCollection");
        if (StaticFileType != "js" && StaticFileType != "css")
            throw new ArgumentNullException("StaticFileType should be either JS eller CSS");
        return true;
    }
}

5 kommentarer:

Andy Davies sagde ...

"Bake rather than fry"

This is something that should be done at build or deploy time rather than runtime.

In this approach you're trading latency for CPU time.

Serving out static files as static files is the efficient way to do it, it also allows cache-control headers to be set, code to be minified and gzipped too.

Lickety split had a good article on this the other week

Kristian Erbou sagde ...

@Andy D.: We have been focusing our attention to the user experience - if that means that the servers require more I/O and CPU time to serve it's clients that is a debt we're willing to pay. In this approach we _are_ serving the client a static CSS file - with all possibilities for caching, gzipping etc.

goldenratio sagde ...

You should be able the set EnableCache or something similar for the control, which will cache the output and not require extra CPU per pageview.

Kristian Erbou sagde ...

@goldenratio: I do agree that the control lacks caching features but unless your website is being hit by millions of users every day the time penalty is insignificant for loading the CSS files every time. The System Cache in Windows works very well for small files so I would question the business value in caching the files over a period of time.

http://msdn.microsoft.com/en-us/library/aa364218%28VS.85%29.aspx

Anonym sagde ...
Denne kommentar er fjernet af en blogadministrator.