ASP.NET Core中的響應壓縮

yi念之間發表於2020-07-29

介紹

    響應壓縮技術是目前Web開發領域中比較常用的技術,在頻寬資源受限的情況下,使用壓縮技術是提升頻寬負載的首選方案。我們熟悉的Web伺服器,比如IIS、Tomcat、Nginx、Apache等都可以使用壓縮技術,常用的壓縮型別包括Brotli、Gzip、Deflate,它們對CSS、JavaScript、HTML、XML 和 JSON等型別的效果還是比較明顯的,但是也存在一定的限制對於圖片效果可能沒那麼好,因為圖片本身就是壓縮格式。其次,對於小於大約150-1000 位元組的檔案(具體取決於檔案的內容和壓縮的效率,壓縮小檔案的開銷可能會產生比未壓縮檔案更大的壓縮檔案。在ASP.NET Core中我們可以使用非常簡單的方式來使用響應壓縮。

使用方式

    在ASP.NET Core中使用響應壓縮的方式比較簡單。首先,在ConfigureServices中新增services.AddResponseCompression注入響應壓縮相關的設定,比如使用的壓縮型別、壓縮級別、壓縮目標型別等。其次,在Configure新增app.UseResponseCompression攔截請求判斷是否需要壓縮,大致使用方式如下

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddResponseCompression();
    }

    public void Configure(IApplicationBuilder app, IHostingEnvironment env)
    {
        app.UseResponseCompression();
    }
}

如果需要自定義一些配置的話還可以手動設定壓縮相關

public void ConfigureServices(IServiceCollection services)
{
    services.AddResponseCompression(options =>
    {
        //可以新增多種壓縮型別,程式會根據級別自動獲取最優方式
        options.Providers.Add<BrotliCompressionProvider>();
        options.Providers.Add<GzipCompressionProvider>();
        //新增自定義壓縮策略
        options.Providers.Add<MyCompressionProvider>();
        //針對指定的MimeType來使用壓縮策略
        options.MimeTypes = 
            ResponseCompressionDefaults.MimeTypes.Concat(
                new[] { "application/json" });
    });
    //針對不同的壓縮型別,設定對應的壓縮級別
    services.Configure<GzipCompressionProviderOptions>(options => 
    {
        //使用最快的方式進行壓縮,單不一定是壓縮效果最好的方式
        options.Level = CompressionLevel.Fastest;

        //不進行壓縮操作
        //options.Level = CompressionLevel.NoCompression;

        //即使需要耗費很長的時間,也要使用壓縮效果最好的方式
        //options.Level = CompressionLevel.Optimal;
    });
}

    關於響應壓縮大致的工作方式就是,當發起Http請求的時候在Request Header中新增Accept-Encoding:gzip或者其他你想要的壓縮型別,可以傳遞多個型別。服務端接收到請求獲取Accept-Encoding判斷是否支援該種型別的壓縮方式,如果支援則壓縮輸出內容相關並且設定Content-Encoding為當前使用的壓縮方式一起返回。客戶端得到響應之後獲取Content-Encoding判斷服務端是否採用了壓縮技術,並根據對應的值判斷使用了哪種壓縮型別,然後使用對應的解壓演算法得到原始資料。

原始碼探究

通過上面的介紹,相信大家對ResponseCompression有了一定的瞭解,接下來我們通過檢視原始碼的方式瞭解一下它大致的工作原理。

AddResponseCompression

首先我們來檢視注入相關的程式碼,具體程式碼承載在ResponseCompressionServicesExtensions擴充套件類中[點選檢視原始碼?]

public static class ResponseCompressionServicesExtensions
{
    public static IServiceCollection AddResponseCompression(this IServiceCollection services)
    {
        services.TryAddSingleton<IResponseCompressionProvider, ResponseCompressionProvider>();
        return services;
    }

    public static IServiceCollection AddResponseCompression(this IServiceCollection services, Action<ResponseCompressionOptions> configureOptions)
    {
        services.Configure(configureOptions);
        services.TryAddSingleton<IResponseCompressionProvider, ResponseCompressionProvider>();
        return services;
    }
}

主要就是注入ResponseCompressionProvider和ResponseCompressionOptions,首先我們來看關於ResponseCompressionOptions[點選檢視原始碼?]

public class ResponseCompressionOptions
{
    // 設定需要壓縮的型別
    public IEnumerable<string> MimeTypes { get; set; }

    // 設定不需要壓縮的型別
    public IEnumerable<string> ExcludedMimeTypes { get; set; }

    // 是否開啟https支援
    public bool EnableForHttps { get; set; } = false;

    // 壓縮型別集合
    public CompressionProviderCollection Providers { get; } = new CompressionProviderCollection();
}

關於這個類就不做過多介紹了,比較簡單。ResponseCompressionProvider是我們提供響應壓縮演算法的核心類,具體如何自動選用壓縮演算法都是由它提供的。這個類中的程式碼比較多,我們就不逐個方法講解了,具體原始碼可自行查閱[點選檢視原始碼?],首先我們先看ResponseCompressionProvider的建構函式

public ResponseCompressionProvider(IServiceProvider services, IOptions<ResponseCompressionOptions> options)
{
    var responseCompressionOptions = options.Value;
    _providers = responseCompressionOptions.Providers.ToArray();
    //如果沒有設定壓縮型別預設採用Br和Gzip壓縮演算法
    if (_providers.Length == 0)
    {
        _providers = new ICompressionProvider[]
        {
            new CompressionProviderFactory(typeof(BrotliCompressionProvider)),
            new CompressionProviderFactory(typeof(GzipCompressionProvider)),
        };
    }
    //根據CompressionProviderFactory建立對應的壓縮演算法Provider比如GzipCompressionProvider
    for (var i = 0; i < _providers.Length; i++)
    {
        var factory = _providers[i] as CompressionProviderFactory;
        if (factory != null)
        {
            _providers[i] = factory.CreateInstance(services);
        }
    }
    //設定預設的壓縮目標型別預設為text/plain、text/css、text/html、application/javascript、application/xml
    //text/xml、application/json、text/json、application/was
    var mimeTypes = responseCompressionOptions.MimeTypes;
    if (mimeTypes == null || !mimeTypes.Any())
    {
        mimeTypes = ResponseCompressionDefaults.MimeTypes;
    }
   //將預設MimeType放入HashSet
    _mimeTypes = new HashSet<string>(mimeTypes, StringComparer.OrdinalIgnoreCase);
    _excludedMimeTypes = new HashSet<string>(
        responseCompressionOptions.ExcludedMimeTypes ?? Enumerable.Empty<string>(),
        StringComparer.OrdinalIgnoreCase
    );
    _enableForHttps = responseCompressionOptions.EnableForHttps;
}

其中BrotliCompressionProvider、GzipCompressionProvider是具體提供壓縮方法的地方,我們們就看比較常用的Gzip的Provider的大致實現[點選檢視原始碼?]

public class GzipCompressionProvider : ICompressionProvider
{
    public GzipCompressionProvider(IOptions<GzipCompressionProviderOptions> options)
    {
        Options = options.Value;
    }

    private GzipCompressionProviderOptions Options { get; }

    // 對應的Encoding名稱
    public string EncodingName { get; } = "gzip";

    public bool SupportsFlush => true;

    // 核心程式碼就是這句 將原始的輸出流轉換為壓縮的GZipStream
    // 我們設定的Level壓縮級別將決定壓縮的效能和質量
    public Stream CreateStream(Stream outputStream)
        => new GZipStream(outputStream, Options.Level, leaveOpen: true);
}

關於ResponseCompressionProvider其他相關的方法我們們在講解UseResponseCompression中介軟體的時候在具體看用到的方法,因為這個類是響應壓縮的核心類,現在提前說了,到中介軟體使用的地方可能會忘記了。接下來我們就看UseResponseCompression的大致實現。

UseResponseCompression

UseResponseCompression具體也就一個無參的擴充套件方法,也比較簡單,因為配置和工作都由注入的地方完成了,所以我們直接檢視中介軟體裡的實現,找到中介軟體位置ResponseCompressionMiddleware[點選檢視原始碼?]

public class ResponseCompressionMiddleware
{
    private readonly RequestDelegate _next;
    private readonly IResponseCompressionProvider _provider;

    public ResponseCompressionMiddleware(RequestDelegate next, IResponseCompressionProvider provider)
    {
        _next = next;
        _provider = provider;
    }

    public async Task Invoke(HttpContext context)
    {
        //判斷是否包含Accept-Encoding頭資訊,不包含直接大喊一聲"抬走下一個"
        if (!_provider.CheckRequestAcceptsCompression(context))
        {
            await _next(context);
            return;
        }
        //獲取原始輸出Body
        var originalBodyFeature = context.Features.Get<IHttpResponseBodyFeature>();
        var originalCompressionFeature = context.Features.Get<IHttpsCompressionFeature>();
        //初始化響應壓縮Body
        var compressionBody = new ResponseCompressionBody(context, _provider, originalBodyFeature);
        //設定成壓縮Body
        context.Features.Set<IHttpResponseBodyFeature>(compressionBody);
        context.Features.Set<IHttpsCompressionFeature>(compressionBody);

        try
        {
            await _next(context);
            await compressionBody.FinishCompressionAsync();
        }
        finally
        {
            //恢復原始Body
            context.Features.Set(originalBodyFeature);
            context.Features.Set(originalCompressionFeature);
        }
    }
}

這個中介軟體非常的簡單,就是初始化了ResponseCompressionBody。看到這裡你也許會好奇,並沒有觸發呼叫壓縮相關的任何程式碼,ResponseCompressionBody也只是呼叫了FinishCompressionAsync都是和釋放相關的,不要著急我們來看ResponseCompressionBody類的結構

internal class ResponseCompressionBody : Stream, IHttpResponseBodyFeature, IHttpsCompressionFeature
{
}

    這個類實現了IHttpResponseBodyFeature,我們使用的Response.Body其實就是獲取的HttpResponseBodyFeature.Stream屬性。我們使用的Response.WriteAsync相關的方法,其實內部都是在呼叫PipeWriter進行寫操作,而PipeWriter就是來自HttpResponseBodyFeature.Writer屬性。可以大致概括為,輸出相關的操作其核心都是在操作IHttpResponseBodyFeature。有興趣的可以自行查閱HttpResponse相關的原始碼可以瞭解相關資訊。所以我們的ResponseCompressionBody其實是重寫了輸出操作相關方法。也就是說,只要你呼叫了Response相關的Write或Body相關的,其實本質都是在操作IHttpResponseBodyFeature,由於我們開啟了響應輸出相關的中介軟體,所以會呼叫IHttpResponseBodyFeature的實現類ResponseCompressionBody相關的方法完成輸出。和我們常規理解的還是有偏差的,一般情況下我們認為,其實只要針對輸出的Stream做操作就可以了,但是響應壓縮中介軟體竟然重寫了輸出相關的操作。
    瞭解到這個之後,相信大家就沒有太多疑問了。由於ResponseCompressionBody重寫了輸出相關的操作,程式碼相對也比較多,就不逐一貼上出來了,我們只檢視設計到響應壓縮核心相關的程式碼,關於ResponseCompressionBody原始碼相關的細節有興趣的可以自行查閱[點選檢視原始碼?],輸出的本質其實都是在呼叫Write方法,我們就來檢視一下Write方法相關的實現

public override void Write(byte[] buffer, int offset, int count)
{
    //這是核心方法有關於壓縮相關的輸出都在這
    OnWrite();
    //_compressionStream初始化在OnWrite方法裡
    if (_compressionStream != null)
    {
        _compressionStream.Write(buffer, offset, count);
        if (_autoFlush)
        {
            _compressionStream.Flush();
        }
    }
    else
    {
        _innerStream.Write(buffer, offset, count);
    }
}

通過上面的程式碼我們看到OnWrite方法是核心操作,我們直接檢視OnWrite方法實現

private void OnWrite()
{
    if (!_compressionChecked)
    {
        _compressionChecked = true;
        //判斷是否滿足執行壓縮相關的邏輯
        if (_provider.ShouldCompressResponse(_context))
        {
            //匹配Vary頭資訊對應的值
            var varyValues = _context.Response.Headers.GetCommaSeparatedValues(HeaderNames.Vary);
            var varyByAcceptEncoding = false;
            //判斷Vary的值是否為Accept-Encoding
            for (var i = 0; i < varyValues.Length; i++)
            {
                if (string.Equals(varyValues[i], HeaderNames.AcceptEncoding, StringComparison.OrdinalIgnoreCase))
                {
                    varyByAcceptEncoding = true;
                    break;
                }
            }
            if (!varyByAcceptEncoding)
            {
                _context.Response.Headers.Append(HeaderNames.Vary, HeaderNames.AcceptEncoding);
            }
            //獲取最佳的ICompressionProvider即最佳的壓縮方式
            var compressionProvider = ResolveCompressionProvider();
            if (compressionProvider != null)
            {
                //設定選定的壓縮演算法,放入Content-Encoding頭的值裡
                //客戶端可以通過Content-Encoding頭資訊判斷服務端採用的哪種壓縮演算法
                _context.Response.Headers.Append(HeaderNames.ContentEncoding, compressionProvider.EncodingName);
                //進行壓縮時,將 Content-MD5 刪除該標頭,因為正文內容已更改且雜湊不再有效。
                _context.Response.Headers.Remove(HeaderNames.ContentMD5); 
                //進行壓縮時,將 Content-Length 刪除該標頭,因為在對響應進行壓縮時,正文內容會發生更改。
                _context.Response.Headers.Remove(HeaderNames.ContentLength);
                //返回壓縮相關輸出流
                _compressionStream = compressionProvider.CreateStream(_innerStream);
            }
        }
    }
}

private ICompressionProvider ResolveCompressionProvider()
{
    if (!_providerCreated)
    {
        _providerCreated = true;
       //呼叫ResponseCompressionProvider的方法返回最合適的壓縮演算法
        _compressionProvider = _provider.GetCompressionProvider(_context);
    }
    return _compressionProvider;
}

從上面的邏輯我們可以看到,在執行壓縮相關邏輯之前需要判斷是否滿足執行壓縮相關的方法ShouldCompressResponse,這個方法是ResponseCompressionProvider裡的方法,這裡就不再貼上程式碼了,本來就是判斷邏輯我直接整理出來大致就是一下幾種情況

  • 如果請求是Https的情況下,是否設定了允許Https情況下壓縮的設定,即ResponseCompressionOptions的EnableForHttps屬性設定
  • Response.Head裡不能包含Content-Range頭資訊
  • Response.Head裡之前不能包含Content-Encoding頭資訊
  • Response.Head裡之前必須要包含Content-Type頭資訊
  • 返回的MimeType裡不能包含配置的不需要壓縮的型別,即ResponseCompressionOptions的ExcludedMimeTypes
  • 返回的MimeType裡需要包含配置的需要壓縮的型別,即ResponseCompressionOptions的MimeTypes
  • 如果不滿足上面的兩種情況,返回的MimeType裡包含*/*也可以執行響應壓縮
    接下來我們檢視ResponseCompressionProvider的GetCompressionProvider方法看它是如何確定返回哪一種壓縮型別的
public virtual ICompressionProvider GetCompressionProvider(HttpContext context)
{
    var accept = context.Request.Headers[HeaderNames.AcceptEncoding];
    //判斷請求頭是否包含Accept-Encoding信心
    if (StringValues.IsNullOrEmpty(accept))
    {
        Debug.Assert(false, "Duplicate check failed.");
        return null;
    }
    //獲取Accept-Encoding裡的值,判斷是否包含gzip、br、identity等,並返回匹配資訊
    if (!StringWithQualityHeaderValue.TryParseList(accept, out var encodings) || !encodings.Any())
    {
        return null;
    }
    //根據請求資訊和設定資訊計算匹配優先順序
    var candidates = new HashSet<ProviderCandidate>();
    foreach (var encoding in encodings)
    {
        var encodingName = encoding.Value;
        //Quality涉及到一個非常複雜的演算法,有興趣的可以自行查閱
        var quality = encoding.Quality.GetValueOrDefault(1);
        //quality需大於0
        if (quality < double.Epsilon)
        {
            continue;
        }
        //匹配請求頭裡encodingName和設定的providers壓縮演算法裡EncodingName一致的演算法
        //從這裡可以看出匹配的優先順序和註冊providers裡的順序也有關係
        for (int i = 0; i < _providers.Length; i++)
        {
            var provider = _providers[i];
            if (StringSegment.Equals(provider.EncodingName, encodingName, StringComparison.OrdinalIgnoreCase))
            {
                candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider));
            }
        }
        //如果請求頭裡EncodingName是*的情況則在所有註冊的providers裡進行匹配
        if (StringSegment.Equals("*", encodingName, StringComparison.Ordinal))
        {
            for (int i = 0; i < _providers.Length; i++)
            {
                var provider = _providers[i];
                candidates.Add(new ProviderCandidate(provider.EncodingName, quality, i, provider));
            }
            break;
        }
        //如果請求頭裡EncodingName是identity的情況,則不對響應進行編碼
        if (StringSegment.Equals("identity", encodingName, StringComparison.OrdinalIgnoreCase))
        {
            candidates.Add(new ProviderCandidate(encodingName.Value, quality, priority: int.MaxValue, provider: null));
        }
    }

    ICompressionProvider selectedProvider = null;
    //如果匹配的只有一個則直接返回
    if (candidates.Count <= 1)
    {
        selectedProvider = candidates.FirstOrDefault().Provider;
    }
    else
    {
        //如果匹配到多個則按照Quality倒序和Priority正序的負責匹配第一個
        selectedProvider = candidates
            .OrderByDescending(x => x.Quality)
            .ThenBy(x => x.Priority)
            .First().Provider;
    }
    //如果沒有匹配到selectedProvider或是identity的情況直接返回null
    if (selectedProvider == null)
    {
        return null;
    }
    return selectedProvider;
}

通過以上的介紹我們可以大致瞭解到響應壓縮的大致工作方式,簡單總結一下

  • 首先設定壓縮相關的演算法型別或是壓縮目標的MimeType
  • 其次我們可以設定壓縮級別,這將決定壓縮的質量和壓縮效能
  • 通過響應壓縮中介軟體,我們可以獲取到一個優先順序最高的壓縮演算法進行壓縮,這種情況主要是針對多種壓縮型別的情況。這個壓縮演算法與內部機制和註冊壓縮演算法的順序都有一定的關係,最終會選擇權重最大的返回。
  • 響應壓縮中介軟體的核心工作類ResponseCompressionBody通過實現IHttpResponseBodyFeature,重寫輸出相關的方法實現對響應的壓縮,不需要我們手動進行呼叫相關方法,而是替換掉預設的輸出方式。只要設定了響應壓縮,並且請求滿足響應壓縮,那麼有呼叫輸出的地方預設都是執行ResponseCompressionBody裡壓縮相關的方法,而不是攔截具體的輸出進行統一處理。至於為什麼這麼做,目前我還沒有理解到設計者真正的考慮。

總結

    在檢視相關程式碼之前,本來以為關於響應壓縮相關的邏輯會非常的簡單,看過了原始碼才知道是自己想的太簡單了。其中和自己想法出入最大的莫過於在ResponseCompressionMiddleware中介軟體裡,本以為是通過統一攔截輸出流來進行壓縮操作,沒想到是對整體輸出操作進行重寫。因為在之前我們使用Asp.Net相關框架的時候是統一寫Filter或者HttpModule進行處理的,所以存在思維定式。可能是Asp.Net Core設計者有更深層次的理解,可能是我理解的還不夠徹底,不能夠體會這樣做的好處究竟是什麼,如果你有更好的理解或則答案歡迎在評論區裡留言解惑。

?歡迎掃碼關注我的公眾號? ASP.NET Core中的響應壓縮

相關文章