Compressing your ASP.NET web pages and web services

Background

In this article I’m going to show you how you can compress your ASP.NET pages and services using nothing but code. IIS has for sometime had support for compression however this can be tricky to set up and/or impossible in a hosted environment. Of course built-in IIS compression can be configured to compress multiple types like JavaScript and CSS, but sadly we can only compress the things ASP.NET can handle, such as web pages and web services.

The delivery of most ASP.NET pages is in the form of text, or in technically specific terms “text/html”. The text received by the browser from the ASP.NET page on IIS is then rendered on your computer screen. Since this communication takes place over the internet it consumes bandwidth. For instance a 73KB ASP.NET page will take exactly 73KB of network bandwidth plus the bandwidth for headers sent and received by the client browser etc. To optimize this compression is used. Compression will enable a 73KB output from an ASP.NET page to be compressed to as low as 22KB, thus saving 50KB of network bandwidth. Now consider the implications of this network bandwidth savings over hundreds of thousands of requests handled by IIS each day.

Tools Used

Getting Started

First thing to do is to create a new Web Site project in Visual Studio, I’ve called mine “compression” oddly enough. After you’ve done this you need to add a Global.asax file to the site. This contains some of the global event handling code we’re going to need.

The Global.asax file should look something like this:

<%@ Application Language="Oxygene" %>

<script runat="server">

  method Application_Start(sender: Object; e: EventArgs);
  begin
    // Code that runs on application startup

  end;

  method Application_End(sender: Object; e: EventArgs);
  begin
    //  Code that runs on application shutdown

  end;

  method Application_Error(sender: Object; e: EventArgs);
  begin
    // Code that runs when an unhandled error occurs

  end;

  method Session_Start(sender: Object; e: EventArgs);
  begin
    // Code that runs when a new session is started

  end;

  method Session_End(sender: Object; e: EventArgs);
  begin
    // Code that runs when a session ends.
    // Note: The Session_End event is raised only when the sessionstate mode
    // is set to InProc in the Web.config file. If session mode is set to StateServer
    // or SQLServer, the event is not raised.

  end;

</script>

Altering Global.asax

As it stands the Global.asax file we just created is a little useless for us at the moment, what we need to do is add an event handler for the event we want to capture. It’s called PreRequestHandlerExecute and it occurs before a request to ASP.NET is handled by it, it gives us a place we can use to plug in our compression.

So we need to add the event handling code which looks something like this:

method Application_PreRequestHandlerExecute(sender: Object; e: EventArgs);
begin
  var Application: HttpApplication := (sender as HttpApplication);
  var PrevStream: Stream := Application.Response.Filter;

  Application.Response.Filter := new Compressor(Application,PrevStream);
end;

What this code does is it gets a reference to the current instance of HttpApplication and then gets a reference to the Stream for the HttpResponse.Filter. It is important we keep a reference to this for our own filter to use. Next we replace the existing filter with our own, aptly named Compressor passing it the HttpApplication reference and the Stream reference we previously got. Last but not least because we are using Stream we need to drop in an assembly reference to System.IO.

So now the Global.asax file should look something like this:

<%@ Application Language="Oxygene" %>

<%@ Import Namespace="System.IO" %>

<script runat="server">

  method Application_Start(sender: Object; e: EventArgs);
  begin
    // Code that runs on application startup

  end;

  method Application_End(sender: Object; e: EventArgs);
  begin
    //  Code that runs on application shutdown

  end;

  method Application_Error(sender: Object; e: EventArgs);
  begin
    // Code that runs when an unhandled error occurs

  end;

  method Session_Start(sender: Object; e: EventArgs);
  begin
    // Code that runs when a new session is started

  end;

  method Session_End(sender: Object; e: EventArgs);
  begin
    // Code that runs when a session ends.
    // Note: The Session_End event is raised only when the sessionstate mode
    // is set to InProc in the Web.config file. If session mode is set to StateServer
    // or SQLServer, the event is not raised.

  end;

  method Application_PreRequestHandlerExecute(sender: Object; e: EventArgs);
  begin
    var Application: HttpApplication := (sender as HttpApplication);
    var PrevStream: Stream := Application.Response.Filter;

    Application.Response.Filter := new Compressor(Application,PrevStream);
  end;

</script>

Now we need to create our Compressor class.

The Compressor

Our Compressor class is actually a Stream descendant as it is what HttpResponse.Filter expects. There is very little code we actually need to implement for our Stream descendant because it’s only got to handle write’s. What I’ve chosen to do here, and it is by no means the most efficient method, is to basically buffer the Write() calls into a MemoryStream. Once ASP.NET tells our Stream implementation to close we take this buffer and do the compression we need and then write it out the the Stream reference we got from the PreRequestHandlerExecute event.

Our Compressor looks like this (interface only not implementation):

type
  Compressor = public class(Stream)
    private
      FApplication: HttpApplication;
      FOutputStream: Stream;
      FMemory: MemoryStream;

      FCanRead: Boolean;
      FCanSeek: Boolean;
      FCanWrite: Boolean;

      method GetLength: Int64;
      method GetPosition: Int64;
      method SetPosition(Value: Int64);

      method Compress;
      method CopyStream(Source: Stream; Dest: Stream);
    public
      constructor(Application: HttpApplication; OutputStream: Stream);

      property CanRead: Boolean read FCanRead; override;
      property CanSeek: Boolean read FCanSeek; override;
      property CanWrite: Boolean read FCanWrite; override;
      property Length: Int64 read GetLength; override;
      property Position: Int64 read GetPosition write SetPosition; override;

      method Close; override;
      method Flush; override;
      method &Read(Buffer: array of Byte; Offset: Integer; Count: Integer): Integer; override;
      method Seek(Offset: Int64; Origin: SeekOrigin): Int64; override;
      method SetLength(Value: Int64); override;
      method &Write(Buffer: array of Byte; Offset: Integer; Count: Integer); override;
    end;

As you can see the constructor takes a HttpApplication and a Stream as parameters. We create a MemoryStream called FMemory and all Write() operations write instead to this.

Once ASP.NET is done with producing the content and writing it to us it will close the stream. Our implementation of Close() looks like this:

method Compressor.Close;
begin
  // Make sure our memory is flushed
  FMemory.Flush();

  // Compress
  Compress;

  // Release memory
  FMemory.Close;

  // Inherited call
  inherited;
end;

We could in theory do everything we wanted here but to keep things clean the actual guts of the compression happens in its own method.

The actual compression method:

method Compressor.Compress;
begin
  // Check for Microsoft AJAX and bail as we don't want to touch it
  if not (FApplication.Request['HTTP_X_MICROSOFTAJAX'] = nil) then
    begin
      CopyStream(FMemory,FOutputStream);
      Exit;
    end;

  // Check size, we only want to compress data greater than 1kb
  if FMemory.Length <= 1024 then
    begin
      CopyStream(FMemory,FOutputStream);
      Exit;
    end;

  // Get the accepted encoding types
  var AcceptEncoding: String := FApplication.Request.Headers['Accept-Encoding'];

  if String.IsNullOrEmpty(AcceptEncoding) then
    begin
      CopyStream(FMemory,FOutputStream);
      Exit;
    end;

  // Convert encoding to lower to make things simpler
  AcceptEncoding := AcceptEncoding.ToLower();

  // We will default to GZip and choose GZip as our first choice for
  // compression, failing that we will use Deflate
  if (AcceptEncoding.Contains("gzip")) or (AcceptEncoding = '*') then
    begin
      var GZip: GZipStream := new GZipStream(FOutputStream,CompressionMode.Compress);

      CopyStream(FMemory,GZip);
      FApplication.Response.AppendHeader('Content-Encoding','gzip');
    end
  else if AcceptEncoding.Contains('defalte') then
    begin
      var Deflate: DeflateStream := new DeflateStream(FOutputStream,CompressionMode.Compress);

      CopyStream(FMemory,Deflate);
      FApplication.Response.AppendHeader('Content-Encoding','deflate');
    end
  else
    CopyStream(FMemory,FOutputStream);

  // Write out original stream size for informational purposes
  FApplication.Response.AppendHeader('X-Uncompressed-Content-Length',FMemory.Length.ToString());
end;

What happens here is we first do some preliminary checks, we need to know if this was a request done by Microsoft’s AJAX implementation, if so then we need to stop as compressing these can lead to exceptions:

  if not (FApplication.Request['HTTP_X_MICROSOFTAJAX'] = nil) then
    begin
      CopyStream(FMemory,FOutputStream);
      Exit;
    end;

Next we check to see the size of the uncompressed page. It isn’t really efficient to compress very small pages so I’ve chosen not to compress anything less than 1KB:

  if FMemory.Length <= 1024 then
    begin
      CopyStream(FMemory,FOutputStream);
      Exit;
    end;

After this we get the Accept-Encoding header. This tells us what encodings the browser can handle and it’s where we find out if we can do GZip, Deflate, both or neither:

  var AcceptEncoding: String := FApplication.Request.Headers['Accept-Encoding'];

  if String.IsNullOrEmpty(AcceptEncoding) then
    begin
      CopyStream(FMemory,FOutputStream);
      Exit;
    end;

After all these checks have passed it’s time to get down to it. I’ve opted to do GZip over Deflate priority wise. Deflate is marginally faster but I believe GZip gives better compression. Depending on which compression we go with we also return to the client a Content-Encoding header telling them what we’ve decided:

  if (AcceptEncoding.Contains('gzip')) or (AcceptEncoding = '*') then
    begin
      var GZip: GZipStream := new GZipStream(FOutputStream,CompressionMode.Compress);

      CopyStream(FMemory,GZip);
      FApplication.Response.AppendHeader('Content-Encoding','gzip');
    end
  else if AcceptEncoding.Contains('defalte') then
    begin
      var Deflate: DeflateStream := new DeflateStream(FOutputStream,CompressionMode.Compress);

      CopyStream(FMemory,Deflate);
      FApplication.Response.AppendHeader('Content-Encoding','deflate');
    end
  else
    CopyStream(FMemory,FOutputStream);

Last but not least and just to be nice we return an extra header telling people what the original uncompressed size was:

FApplication.Response.AppendHeader('X-Uncompressed-Content-Length',FMemory.Length.ToString());

You will have noticed by now my use of a method called CopyStream(). All this does is copy one stream to another. During the checks if anything goes wrong we simply copy the buffered data directly to the original stream however in our compression method copy from our memory buffer to one of the two compression streams which in turn writes out to the original stream.

The Ending

And that is that. This should work for web pages, web handlers and web services. I’ve included the full source and test page I used for this article below. If anyone is interested in a C# version and/or a VB.NET version then let me know and I might consider at least offering a recoded download for you.

compression.zip
23.5 KB

2 Comments

talib.enigmatic  on May 20th, 2009

Hi,

Great piece of work …

BTW, can you upload the C# Version of this. Highly appreciated.

Thanks.

Bacyloony  on February 11th, 2010

Really nice site. Hope to visit it again soon

Leave a Comment