Request-Komprimierung mit C# und HttpClient

HTTP-Kommunikation ist Standard. Das gilt sowohl für den Browser, als auch für die allermeisten Services, die derzeit gebaut werden. Ebenfalls längst Standard ist die Komprimierung des Response – sofern der anfragende Client das auch unterstützt. Doch wie sieht es mit dem Request aus? Ich stoße in meinen Projekten gelegentlich auf Szenarien, bei denen es auch große Requests gibt, die schnell übertragen werden müssen. Insbesondere bei langsamen Übertragungswegen stößt man hier schnell auf Probleme. Request-Komprimierung kann an dieser Stelle für eine deutliche Steigerung der Performance sorgen. In C# gibt es im Standard beim HttpClient leider keinen Schalter, um eine Request-Komprimierung zu aktivieren. Man muss also selbst Hand anlegen. In diesem Post möchte ich einen eleganten Weg zeigen, um genau das zu erreichen.

Warum ist Request-Komprimierung relevant?

Bevor ich genauer auf das Wie eingehe, möchte ich zunächst noch einige Zeilen über das Warum schreiben. Eine Komprimierung des Requests ist in der Regel nicht besonders wichtig. Das liegt daran, weil in den Requests meist nur wenige Daten übertragen werden. Bei typischen Abfragen einer REST-API sind das die URL und einige HEADER. Einzig bei den HTTP Verben PUT und POST, die typischerweise zum Erstellen oder Ändern von Ressourcen genutzt werden, wird eine JSON-Struktur im Request übertragen. Ist diese JSON-Struktur entsprechend groß und der Übertragungsweg entsprechend langsam, spielt Request-Komprimierung seine Vorteile aus. Beispiele wären Client-Applikationen, die nicht im eigenen Rechenzentrum oder in der Cloud laufen, sondern stattdessen irgendwo „draußen“. Schlechte Internetverbindung, ggf. sogar über das Mobilfunknetz, kann hier für entsprechend langsame Datenübertragung sorgen. Mit dem Hintergrund eines solchen Szenarios beschäftige ich mich zum Zeitpunkt dieses Artikels mit Request-Komprimierung.

Was kann .NET?

Wie in der Einführung dieses Artikels beschrieben, stellt uns die Klasse HttpClient keine Request-Komprimierung als Feature bereit. Bevor wir uns damit beschäftigen, möchte ich den Blick kurz auf die Serverseite verschieben, da es dort unterstützt wird. ASP.NET Core bringt nämlich Request-Dekomprimierung im Standard-Funktionsumfang mit [1]. Hierbei werden zum Stand dieses Artikels drei Algorithmen unterstützt [2] – Brotli, Deflate und Gzip. In der Welt des Internets gibt es noch einige weitere [3]. In diesem Artikel und für die hier vorgestellte Lösung fokussiere ich mich aber auf die in ASP.NET Core unterstützten Algorithmen. Somit ist der hier weiter unten vorgestellte Client Code mit ASP.NET Core Servern kompatibel.

Ein für uns an dieser Stelle wichtiges Detail ist, wie der Server erkennt, dass der Request komprimiert ist. Hierfür gibt es den Header Content-Encoding [3], welcher den Namen des verwendeten Algorithmus angibt. Für Brotli ist das „br“, bei Deflate schlicht „deflate“ und bei Gzip wenig überraschend „gzip“. HTTP sieht im Header Content-Encoding sogar mehrere mögliche Einträge für den Fall vor, dass mehrere Algorithmen in Reihe geschaltet wurden. ASP.NET Core unterstützt das zum Stand dieses Artikels nicht.

Eine wiederverwendbare Lösung

Beschäftigen wir uns nun wieder mit dem Client. Bei der Suche nach möglichen Lösungen bin ich unter anderem auf die Klasse CompressedContent im Projekt WebAPIContrib gestoßen [4]. Problem: Sie basiert noch auf ASP.NET und nicht auf ASP.NET Core. Glücklicherweise ist der Code selbst aber kompatibel. Die grundsätzliche Idee ist, von der Klasse HttpContent zu erben und sich darin um die Request-Komprimierung zu kümmern. Das Objekt der Klasse geht anschließend direkt an den HttpClient als Parameter, um von dort aus zum Server geschickt zu werden.

Es gibt allerdings noch ein paar Themen, die ich daran angepasst habe:

  • Mögliche Algorithmen in die Klasse CompressedContentEncoding ausgelagert, die dem Pattern „SmartEnum“ folgt
  • Die Liste der möglichen Algorithmen um Brotli erweitert

Nach meinen Modifikationen sieht der Code wie folgt aus.

using System.Net;

// ... (namespace)

public class CompressedContent : HttpContent
{
    private readonly HttpContent _originalContent;
    private readonly CompressedContentEncoding _encodingType;

    public CompressedContent(HttpContent content, CompressedContentEncoding encodingType)
    {
        ArgumentNullException.ThrowIfNull(content);
        ArgumentNullException.ThrowIfNull(encodingType);

        _originalContent = content;
        _encodingType = encodingType;

        foreach (var header in _originalContent.Headers)
        {
            Headers.TryAddWithoutValidation(header.Key, header.Value);
        }

        Headers.ContentEncoding.Add(encodingType.EncodingName);
    }

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

    protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context)
    {
        var compressedStream = _encodingType.WrapStream(stream);

        return _originalContent.CopyToAsync(compressedStream).ContinueWith(_ =>
        {
            compressedStream.Dispose();
        });
    }
}
using System.IO.Compression;

// ... (namespace)

public class CompressedContentEncoding
{
    private readonly Func<Stream, Stream> _compressor;

    public string EncodingName { get; }

    public static readonly CompressedContentEncoding Deflate = 
        new ("deflate", stream => new ZLibStream(stream, CompressionMode.Compress, leaveOpen: true));

    public static readonly CompressedContentEncoding GZip = 
        new ("gzip", stream => new GZipStream(stream, CompressionMode.Compress, leaveOpen: true));

    public static readonly CompressedContentEncoding Brotli = 
        new ("br", stream => new BrotliStream(stream, CompressionMode.Compress, leaveOpen: true));

    private CompressedContentEncoding(string encodingName, Func<Stream, Stream> compressor)
    {
        EncodingName = encodingName;
        _compressor = compressor;
    }

    internal Stream WrapStream(Stream stream) => _compressor(stream);
}

Zeile 23 in der Datei CompressedContent.cs sorgt dafür, dass der HTTP Header Content-Encoding automatisch auf den Namen des verwendeten Algorithmus gesetzt wird. Was mir persönlich abschließend noch nicht ganz gefällt, ist die Zeile 36 in der Datei CompressedContent.cs. Der Grund dafür ist die Nutzung der Methode CopyToAsync. Technisch sorgt das dafür, dass der vollständige Inhalt des Requests im Speicher gepuffert wird. Gerade bei großen Requests möchte ich das nicht. Zumindest nicht, wenn es sich vermeiden lässt. Eine Optimierung an dieser Stelle wäre ein Thema für einen Folgeartikel.

Request-Komprimierung mit dem HttpClient nutzen

Zum Abschluss fehlt noch ein Beispiel, wie obige Klassen genutzt werden können. Nachfolgend ein Codeausschnitt für den Client Code, wenn Request-Komprimierung genutzt wird (hier Gzip). Das vollständige Beispiel mit Client und Server liegt auf GitHub unter [5]. In Zeile 6 wird zunächst der Body des Requests mittels der Klasse StringContent gebaut. Auch diese erbt von HttpContent und kann wie hier im Beispiel für Situationen genutzt werden, bei denen der Body einen String enthält. Anschließend verpacken wir das StringContent Objekt mit einem Objekt unserer Klasse CompressedContent. Letztes geht dann mittels der Methode PostAsync des HttpClient über die Leitung – mit aktivierter Request-Komprimierung.

private static async Task TriggerRequestCompressedByGzipAsync()
{
    var httpClient = new HttpClient();
    httpClient.BaseAddress = new Uri(TARGET_BASE_ADDRESS);
    
    using var httpContent = new StringContent(BODY_UNCOMPRESSED, Encoding.UTF8);
    using var compressedContent = new CompressedContent(httpContent, CompressedContentEncoding.GZip);
    
    await httpClient.PostAsync(TARGET_ENDPOINT, compressedContent);
}

Fazit

In diesem Artikel haben wir uns mit einer wiederverwendbaren Lösung für Request-Komprimierung mit dem HttpClient in C# beschäftigt. Der Quellcode der beiden Dateien CompressedContent.cs und CompressedContentEncoding.cs kann bei Bedarf direkt in eigene Projekte kopiert werden.

Downloads

Verweise

  1. Request decompression in ASP.NET Core
    https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/request-decompression?view=aspnetcore-8.0
  2. In ASP.NET Core unterstützte Algorithmen für Reqest
    https://github.com/dotnet/aspnetcore/blob/d5a539f19bf7c2dfaf1a6fadbd63f35f40da17ae/src/Middleware/RequestDecompression/src/RequestDecompressionOptions.cs
  3. Infos zum Content-Encoding Header
    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding
  4. Klasse CompressedContent im WebAPIContrib Projekt (GitHub)
    https://github.com/WebApiContrib/WebAPIContrib/blob/master/src/WebApiContrib/Content/CompressedContent.cs
  5. Beispielcode aus diesem Artikel (GitHub)
    https://github.com/RolandKoenig/HappyCoding/tree/main/2024/HappyCoding.RequestCompression

Ebenfalls interessant

  1. Typisierten HttpClient mit NSwag generieren
    https://www.rolandk.de/wp-posts/2022/11/typisierten-httpclient-mit-nswag-generieren

Schreibe einen Kommentar

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.