[Web API] 壓縮和解壓縮

當有大量資料 Client/Server 之間往返時,可以考慮使用壓縮/解壓縮來降低網路流量的往返,不過,這伴隨而來副作用就是伺服器的資源損耗,使用時務必深思;壓縮/解壓縮是要彼此搭配,一方壓,另一方解,演算法也要能對應的比較常見的就是 GZip/Deflate 了,等下為了減少篇幅,我會只會呈現 Deflate 的實作,其餘的代碼就到 github 看

本文連結

開發環境

  • Windows 10 Enterprise
  • VS 2019 Enterprise
  • DotNetZip 1.13.3
  • .NET Framework 4.7.2

事前準備

Server - Web API 專案

安裝套件

Install-Package DotNetZip
Install-Package Faker.Net
Install-Package Swagger-Net

Client - 單元測試專案

這裡會用 OWIN 把 Web API 建立起來,測試案例就可以打進去,由於這是 Lab,案例都是為了演示寫的

安裝套件

Install-Package Microsoft.AspNet.WebApi.OwinSelfHost
Install-Package Microsoft.Owin.Diagnostics
Install-Package Microsoft.Owin.Host.SystemWeb

 

Startup.cs

配置 Server,這裡會用到 Web API 專案的 WebApiConfig.Register(configuration)

public class Startup
{
    public void Configuration(IAppBuilder app)
    {
        var configuration = new HttpConfiguration();
        WebApiConfig.Register(configuration);
        //app.UseErrorPage();
        //app.UseWelcomePage("/Welcome");
        app.UseWebApi(configuration);
    }
}

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress.UnitTest/Startup.cs
 

MsTestHook.cs

WebApp.Start 套用 Startup 把站台建立起來

[TestClass]
public class MsTestHook
{
    private const  string      HOST_ADDRESS = "http://localhost:9527";
    private static IDisposable s_webApp;
    public static HttpClient  Client;
 
    [AssemblyCleanup]
    public static void AssemblyCleanup()
    {
        s_webApp.Dispose();
    }
 
    [AssemblyInitialize]
    public static void AssemblyInitialize(TestContext context)
    {
        s_webApp = WebApp.Start<Startup>(HOST_ADDRESS);
        Console.WriteLine("Web API started!");
        Client             = new HttpClient();
        Client.BaseAddress = new Uri(HOST_ADDRESS);
    }
}

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress.UnitTest/MsTestHook.cs
 

壓縮/解壓縮

public class Deflate
{
    public static byte[] Compress(byte[] sourceBytes)
    {
        if (sourceBytes == null)
        {
            return null;
        }
 
        using (var output = new MemoryStream())
        {
            using (var compressor = new DeflateStream(output,
                                                      CompressionMode.Compress,
                                                      CompressionLevel.BestSpeed))
            {
                compressor.Write(sourceBytes, 0, sourceBytes.Length);
            }
 
            return output.ToArray();
        }
    }
 
    public static byte[] Decompress(byte[] sourceBytes)
    {
        if (sourceBytes == null)
        {
            return null;
        }
 
        using (var output = new MemoryStream())
        {
            using (var compressor = new DeflateStream(output,
                                                      CompressionMode.Decompress,
                                                      CompressionLevel.BestSpeed))
            {
                compressor.Write(sourceBytes, 0, sourceBytes.Length);
            }
 
            return output.ToArray();
        }
    }
}

完整範例

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress/Deflate.cs
https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress/GZip.cs
 

情境 - Client 向 Server 上傳大量資源

[TestMethod]
public void Client_DeflateCompress_Server_DeflateDecompress()
{
    Console.WriteLine("用戶端用Deflate壓縮資料→伺服器端解壓縮後回傳結果→驗證解壓縮結果和Client壓縮前是否相同");
    var url = "api/test/DeflateDecompression";
    var builder = CreateData();
 
    var contentBytes = Encoding.UTF8.GetBytes(builder);
    var zipContent = Deflate.Compress(contentBytes);
 
    var request = new HttpRequestMessage(HttpMethod.Post, url)
    {
        Content = new ByteArrayContent(zipContent)
    };
    var response = MsTestHook.Client.SendAsync(request).Result;
    var result = response.Content.ReadAsStringAsync().Result;
    Assert.AreEqual(response.StatusCode, HttpStatusCode.OK);
    Assert.AreEqual(builder, result);
}

 

Server 收到後解壓縮,我要把解壓縮的邏輯抽離 Action,進入 Action 之前得先解壓縮,這裡我複寫 ActionFilter 的 OnActionExecuting

[DeflateDecompression]
public IHttpActionResult DeflateDecompression()
{
    var content = this.Request.Content.ReadAsByteArrayAsync().Result;
    var result  = Encoding.UTF8.GetString(content);
    return new ResponseMessageResult(new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content    = new StringContent(result, Encoding.UTF8)
    });
}

 

public class DeflateDecompressionAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var content           = actionContext.Request.Content;
        var zipContentBytes   = content         == null ? null : content.ReadAsByteArrayAsync().Result;
        var unzipContentBytes = zipContentBytes == null ? new byte[0] : Deflate.Decompress(zipContentBytes);
        actionContext.Request.Content = new ByteArrayContent(unzipContentBytes);
        base.OnActionExecuting(actionContext);
    }
}

 

看看執行效果,Client 壓縮前是86582,壓縮後27263

Server 收到之後解壓縮

資料傳遞的 byte 長度好像不太一樣,不過解壓縮的內容是完全一樣的

 

情境 - Server  回傳大量資料給 Client

Server 收到後先產生大量資料,然後壓縮資料

[DeflateCompression]
[HttpGet]
public IHttpActionResult DeflateCompression(string id)
{
    var content = CreateData(id);
    return new ResponseMessageResult(new HttpResponseMessage
    {
        StatusCode = HttpStatusCode.OK,
        Content    = new StringContent(content, Encoding.UTF8)
    });
}

 

Action 執行完畢後再進行壓縮,這裡我複寫 ActionFilter 的 OnActionExecuted

public class DeflateCompressionAttribute : ActionFilterAttribute
{
    public override void OnActionExecuted(HttpActionExecutedContext actionContext)
    {
        var content = actionContext.Response.Content;
        var sourceBytes   = content == null ? null : content.ReadAsByteArrayAsync().Result;
        var zipContent = sourceBytes == null ? new byte[0] : Deflate.Compress(sourceBytes);
        actionContext.Response.Content = new ByteArrayContent(zipContent);
        actionContext.Response.Content.Headers.Remove("Content-Type");
        actionContext.Response.Content.Headers.Add("Content-encoding", "deflate");
        actionContext.Response.Content.Headers.Add("Content-Type", "application/json;charset=utf-8");
 
        base.OnActionExecuted(actionContext);
    }
}

 

Client 端收到後解壓縮

[TestMethod]
public void Server_DeflateCompress_Client_DeflateDecompress_should_be_yao()
{
    Console.WriteLine("用戶端用訪問伺服器→伺服器用Deflate壓縮資料→Client解壓縮,驗證解壓縮結果是否包含關鍵字");
 
    var url = "api/test/DeflateCompression/yao";
    var response = MsTestHook.Client.GetAsync(url).Result;
    var content = response.Content.ReadAsByteArrayAsync().Result;
    var decompress = Deflate.Decompress(content);
    var result = Encoding.UTF8.GetString(decompress);
    Assert.AreEqual(response.StatusCode, HttpStatusCode.OK);
    Assert.AreEqual(true, result.Contains("yao"));
}

PS.由於資料是動態產生的,所以我只驗證我傳入的關鍵字,有沒有被解出來

 

用瀏覽器也能順利的解壓縮得到資料

 

情境 - Client 向 Server 上傳大量資源 Part 2

為了要壓縮各種不同的 Content, 我實作了 CompressContent 提供給 Client 用,他會把壓縮方式放到 Header 的 ContentEncoding 裡面,Server 會再根據它進行判斷

public class CompressContent : HttpContent
{
    private readonly CompressMethod _compressionMethod;
    private readonly HttpContent       _originalContent;
 
    public CompressContent(HttpContent content, CompressMethod compressionMethod)
    {
        if (content == null)
        {
            throw new ArgumentNullException(nameof(content));
        }
        
        this._originalContent   = content;
        this._compressionMethod = compressionMethod;
 
        foreach (var header in this._originalContent.Headers)
        {
            this.Headers.TryAddWithoutValidation(header.Key, header.Value);
        }
 
        this.Headers.ContentEncoding.Add(this._compressionMethod.ToString().ToLowerInvariant());
    }
 
    protected override async Task SerializeToStreamAsync(Stream stream, TransportContext context)
    {
        if (this._compressionMethod == CompressMethod.GZip)
        {
            using (var gzipStream = new GZipStream(stream, 
                                                   CompressionMode.Compress,
                                                   CompressionLevel.BestSpeed,
                                                   true))
            {
                await this._originalContent.CopyToAsync(gzipStream);
            }
        }
        else if (this._compressionMethod == CompressMethod.Deflate)
        {
            using (var deflateStream = new DeflateStream(stream, 
                                                         CompressionMode.Compress,
                                                         CompressionLevel.BestSpeed,
                                                         true))
            {
                await this._originalContent.CopyToAsync(deflateStream);
            }
        }
    }
 
    protected override bool TryComputeLength(out long length)
    {
        length = -1;
        return false;
    }
}

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress.ViaDecompressHandler/Contents/CompressContent.cs
 

 

使用方式

[TestMethod]
public void Client_DeflateCompressHttpContent_Server_DeflateHandlerDecompress()
{
    var url  = "api/test/Decompression";
    var data = CreateData();
 
    var content = new CompressContent(new StringContent(data, Encoding.UTF8, "text/plain"),
                                      CompressMethod.Deflate);
 
    var request = new HttpRequestMessage(HttpMethod.Post, url)
    {
        Content = content
    };
    var response = MsTestHook.Client.SendAsync(request).Result;
    var result   = response.Content.ReadAsStringAsync().Result;
    Assert.AreEqual(response.StatusCode, HttpStatusCode.OK);
    Assert.AreEqual(data,                result);
}

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress.ViaDecompressHandler.UnitTest/TestControllerUnitTests.cs
 

 

Server 端改用 Handler 來實作解壓縮,收到 Requre Header 的 ContentEncoding 有 gzip/deflate 關鍵字,就會進行解壓縮

public class DecompressHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
                                                             CancellationToken cancellationToken)
    {
        if (request.Method == HttpMethod.Post)
        {
            var sourceContent = request.Content;
            var encodings = sourceContent.Headers.ContentEncoding;
            var isGzip = encodings.Contains("gzip");
            var isDeflate = !isGzip && encodings.Contains("deflate");
 
            if (isGzip || isDeflate)
            {
                var compressStream = await sourceContent.ReadAsStreamAsync();
                var decompressStream = new MemoryStream();
                if (isGzip)
                {
                    using (var gzipStream = new GZipStream(compressStream,
                                                           CompressionMode.Decompress,
                                                           CompressionLevel.BestSpeed,
                                                           true))
                    {
                        await gzipStream.CopyToAsync(decompressStream);
                    }
                }
                else if (isDeflate)
                {
                    using (var gzipStream = new DeflateStream(compressStream,
                                                              CompressionMode.Decompress,
                                                              CompressionLevel.BestSpeed,
                                                              true))
                    {
                        await gzipStream.CopyToAsync(decompressStream);
                    }
                }
 
                decompressStream.Seek(0, SeekOrigin.Begin);
 
                var targetContent = new StreamContent(decompressStream);
 
                foreach (var header in sourceContent.Headers)
                {
                    targetContent.Headers.Add(header.Key, header.Value);
                }
 
                request.Content = targetContent;
            }
        }
 
        return await base.SendAsync(request, cancellationToken);
    }
}

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress.ViaDecompressHandler/Handlers/DecompressHandler.cs
 

使用方式,註冊全域就可以了

public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        // Web API configuration and services
        config.MessageHandlers.Add(new DecompressHandler());
 
        // Web API routes
        config.MapHttpAttributeRoutes();
 
        config.Routes.MapHttpRoute(
                                   "DefaultApi",
                                   "api/{controller}/{action}/{id}",
                                   new {id = RouteParameter.Optional}
                                  );
    }
}

https://github.com/yaochangyu/sample.dotblog/blob/master/WebAPI/Lab.Compress/Lab.Compress.ViaDecompressHandler/App_Start/WebApiConfig.cs
 

專案位置

https://github.com/yaochangyu/sample.dotblog/tree/master/WebAPI/Lab.Compress
 

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo