天天看點

ASP.NET Core中的響應壓縮

介紹

    響應壓縮技術是目前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中的響應壓縮