September 12, 2012

ASP.NET Web API Compression

The latest code for this article can be found on Github.

When building systems on top of web APIs, performance is critical. Compression is an easy and effective way to reduce the size and increase the speed of communication between a client and remote resource.

Two common compression algorithms used on the web are GZip and Deflate. The Accept-Encoding header is used by a client to restrict the encoding types that are acceptable in the response.

Enabling Compression

I have seen a few suggestions that automatic compression (via IIS) in ASP.NET Web API is not supported. This is not actually the case, it’s just that it seems impossible to do without modifying applicationHost.config directly.

If you do have access to change applicationHost.config, you can enable GZip compression for JSON responses by adding the appropriate mime type to httpCompression -> dynamicTypes:

<httpCompression directory="%TEMP%\iisexpress\IIS Temporary Compressed Files">
    <scheme name="gzip" dll="%IIS_BIN%\gzip.dll" />
    <dynamicTypes>
        ...
		
		<!-- compress JSON responses from Web API -->			
		<add mimeType="application/json" enabled="true" /> 

        ...
    </dynamicTypes>
    <staticTypes>
        ...
    </staticTypes>
</httpCompression>

If you don’t, then I’ve found no other solution than to perform the compression directly within ASP.NET, although this itself has a few benefits.

The CompressionHandler that you can find in Fabrik.Common takes care of compressing responses based on the Accept-Encoding header supplied by the client. Credit goes to Kiran Challa for his original code - I’ve just made things a bit more SOLID.

public class CompressionHandler : DelegatingHandler
{
    public Collection<ICompressor> Compressors { get; private set; }

    public CompressionHandler()
    {
        Compressors = new Collection<ICompressor>();
        
        Compressors.Add(new GZipCompressor());
        Compressors.Add(new DeflateCompressor());
    }
    
    protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);

        if (request.Headers.AcceptEncoding.IsNotNullOrEmpty())
        {
            var encoding = request.Headers.AcceptEncoding.First();

            var compressor = Compressors.FirstOrDefault(c => c.EncodingType.Equals(encoding.Value, StringComparison.InvariantCultureIgnoreCase));

            if (compressor != null)
            {
                response.Content = new CompressedContent(response.Content, compressor);
            }
        }

        return response;
    }       
}

The handler has a collection of compressors that can be used to compress responses into different formats. By default it contains compressors for GZip and Deflate formats.

In the SendAsync method we get the response and check if the client accepts any of the encoding types supported by our Compressors. If so, we replace the response.Content with CompressedContent.

public class CompressedContent : HttpContent
{
    private readonly HttpContent content;
    private readonly ICompressor compressor;

    public CompressedContent(HttpContent content, ICompressor compressor)
    {
        Ensure.Argument.NotNull(content, "content");
        Ensure.Argument.NotNull(compressor, "compressor");

        this.content = content;
        this.compressor = compressor;

        AddHeaders();
    }

    protected override bool TryComputeLength(out long length)
    {
        length = -1;
        return false;
    }

    protected async override Task SerializeToStreamAsync(Stream stream, TransportContext context)
    {
        Ensure.Argument.NotNull(stream, "stream");

        using (content)
        {
            var contentStream = await content.ReadAsStreamAsync();
            await compressor.Compress(contentStream, stream);
        }
    }

    private void AddHeaders()
    {
        foreach (var header in content.Headers)
        {
            Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        Headers.ContentEncoding.Add(compressor.EncodingType);
    }
}

The CompressedContent class takes care of compressing the original content stream using the provided ICompressor instance. It also ensures that the correct Content-Encoding header is added to the response so that the client knows how to perform decompression.

Adding the handler

This handler should be executed last so you need to add it to the beginning of the HttpConfiguration.MessageHandlers collection:

config.MessageHandlers.Insert(0, new CompressionHandler()); // first runs last
config.MessageHandlers.Add(new EnrichingHandler());
config.MessageHandlers.Add(new ApiKeyAuthHandler());

Custom Compression

To support additional compression formats you can implement ICompressor. An even easier solution is to implement the abstract Compressor class. This simply requires you to provide a Compression/Decompression stream as you can see from the GZipCompressor:

public class GZipCompressor : Compressor
{
    private const string GZipEncoding = "gzip";

    public override string EncodingType
    {
        get { return GZipEncoding; }
    }

    public override Stream CreateCompressionStream(Stream output)
    {
        return new GZipStream(output, CompressionMode.Compress);
    }

    public override Stream CreateDecompressionStream(Stream input)
    {
        return new GZipStream(input, CompressionMode.Decompress);
    }
}

Performing Decompression

One of the main reasons that I wanted to compress my ASP.NET Web API responses was so make our .NET API client even faster.

We can achieve decompression when using HttpClient with a custom HttpClientHandler. The DecompressionHandler is very similar to the CompressionHandler above, other than that we’re decompressing the response stream.

public class DecompressionHandler : HttpClientHandler
{
    public Collection<ICompressor> Compressors;

    public DecompressionHandler()
    {
        Compressors = new Collection<ICompressor>();
        Compressors.Add(new GZipCompressor());
        Compressors.Add(new DeflateCompressor());
    }
    
    protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, System.Threading.CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);

        if (response.Content.Headers.ContentEncoding.IsNotNullOrEmpty())
        {
            var encoding = response.Content.Headers.ContentEncoding.First();

            var compressor = Compressors.FirstOrDefault(c => c.EncodingType.Equals(encoding, StringComparison.InvariantCultureIgnoreCase));

            if (compressor != null)
            {
                response.Content = await DecompressContent(response.Content, compressor);
            }
        }

        return response;
    }

    private static async Task<HttpContent> DecompressContent(HttpContent compressedContent, ICompressor compressor)
    {
        using (compressedContent)
        {
            MemoryStream decompressed = new MemoryStream();
            await compressor.Decompress(await compressedContent.ReadAsStreamAsync(), decompressed);
            var newContent = new StreamContent(decompressed);
            // copy content type so we know how to load correct formatter
            newContent.Headers.ContentType = compressedContent.Headers.ContentType;

            return newContent;
        }
    }
}

The handler checks the Content-Encoding of the response, loads the appropriate compressor and decompresses the response stream.

Our code can continue to process the response as normal, unaware that it was encoded over-the-wire:

protected async Task<Post> Get<Post>()
{
    var response = await httpClient.GetAsync("/posts");
    response.EnsureSuccessStatusCode();
    return await response.Content.ReadAsAsync<Post>();
}

To wire everything up we need to 1) Add the Accept-Encoding header to our requests and 2) Configure the HttpClient to use the DecompressionHandler.

Number 1) is easy:

httpClient.DefaultRequestHeaders.AcceptEncoding
    .Add(new StringWithQualityHeaderValue("gzip"));

To add the handler we need to use the HttpClientFactory:

var httpClient = HttpClientFactory.Create(new DecompressionHandler());

And just in case you’re not sure whether all of this is worth it:

Uncompressed vs Compressed

© 2022 Ben Foster