ASP.NET Core靜態文件中間件[4]: StaticFileMiddleware 中間件全解析


上面的實例演示(搭建文件服務器條件請求以提升性能區間請求以提供部分內容)從提供的功能和特性的角度對StaticFileMiddleware中間件進行了全面的介紹,下面從實現原理的角度對這個中間件進行全面解析。

public class StaticFileMiddleware
{
    public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, options, ILoggerFactory loggerFactory);
    public Task Invoke(HttpContext context);
}

目錄
一、配置選項StaticFileOptions
二、擴展方法UseStaticFiles
三、媒體類型的解析
四、完整處理流程
     獲取目標文件
     條件請求解析
     請求區間解析
     設置響應報頭
     發送響應

一、配置選項StaticFileOptions

如上面的代碼片段所示,除了用來將當前請求分發給后續管道的參數next,StaticFileMiddleware的構造函數還包含3個參數。其中,參數hostingEnv和參數loggerFactory分別表示當前承載環境與用來創建ILogger的ILoggerFactory對象,最重要的參數options表示為這個中間件指定的配置選項。至於具體可以提供什么樣的配置選項,我們只需要了解StaticFileOptions類型提供了什么樣的屬性成員。StaticFileOptions繼承自如下所示的抽象類SharedOptionsBase。基類SharedOptionsBase定義了請求路徑與對應IFileProvider對象之間的映射關系(默認為PhysicalFileProvider)。

public abstract class SharedOptionsBase
{
    protected SharedOptions SharedOptions { get; private set; }
    public PathString RequestPath { get => SharedOptions.RequestPath; set => SharedOptions.RequestPath = value; }
    public IFileProvider FileProvider 
    { 
        get => SharedOptions.FileProvider; 
        set => SharedOptions.FileProvider = value; 
    }
    protected SharedOptionsBase(SharedOptions sharedOptions) => SharedOptions = sharedOptions;
}

public class SharedOptions
{
    public PathString RequestPath { get; set; } = PathString.Empty;
    public IFileProvider FileProvider { get; set; }
}
定義在StaticFileOptions中的前三個屬性都與媒體類型的解析有關,其中ContentTypeProvider屬性返回一個根據請求相對地址解析出媒體類型的IContentTypeProvider對象。如果這個IContentTypeProvider對象無法正確解析出目標文件的媒體類型,就可以利用DefaultContentType設置一個默認媒體類型。但只有將另一個名為ServeUnknownFileTypes的屬性設置為True,中間件才會采用這個默認設置的媒體類型。
public class StaticFileOptions : SharedOptionsBase
{
    public IContentTypeProvider ContentTypeProvider { get; set; }
    public string DefaultContentType { get; set; }
    public bool ServeUnknownFileTypes { get; set; }
    public HttpsCompressionMode HttpsCompression { get; set; }
    public Action<StaticFileResponseContext> OnPrepareResponse { get; set; }

    public StaticFileOptions();
    public StaticFileOptions(SharedOptions sharedOptions);
}

public enum HttpsCompressionMode
{
    Default = 0,
    DoNotCompress,
    Compress
}

public class StaticFileResponseContext
{
    public HttpContext Context { get; }
    public IFileInfo File { get; }
}

二、擴展方法UseStaticFiles

StaticFileOptions的HttpsCompression屬性表示在壓縮中間件存在的情況下,采用HTTPS方法請求的文件是否應該被壓縮,該屬性的默認值為Compress(即默認情況下會對文件進行壓縮)。StaticFileOptions還有一個OnPrepareResponse屬性,它返回一個Action<StaticFileResponseContext>類型的委托對象,利用這個委托對象可以對最終的響應進行定制。作為輸入的StaticFileResponseContext對象可以提供表示當前HttpContext上下文和描述目標文件的IFileInfo對象。

針對StaticFileMiddleware中間件的注冊一般都是調用針對IApplicationBuilder對象的UseStaticFiles擴展方法來完成的。如下面的代碼片段所示,我們共有3個UseStaticFiles方法重載可供選擇。

public static class StaticFileExtensions
{
    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app) => app.UseMiddleware<StaticFileMiddleware>();

    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, StaticFileOptions options) 
        => app.UseMiddleware<StaticFileMiddleware>(Options.Create<StaticFileOptions>(options));

    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, string requestPath)
    {       
        var options = new StaticFileOptions
        {
            RequestPath = new PathString(requestPath)
        };
        return app.UseStaticFiles(options);
    }
}

三、媒體類型的解析

StaticFileMiddleware中間件針對靜態文件請求的處理並不僅限於完成文件內容的響應,它還需要為目標文件提供正確的媒體類型。對於客戶端來說,如果無法確定媒體類型,獲取的文件就像是一部無法解碼的天書,毫無價值。StaticFileMiddleware中間件利用指定的IContentTypeProvider對象來解析媒體類型。如下面的代碼片段所示,IContentTypeProvider接口定義了唯一的方法TryGetContentType,從而根據當前請求的相對路徑來解析這個作為輸出參數的媒體類型。

public interface IContentTypeProvider
{
    bool TryGetContentType(string subpath, out string contentType);
}

StaticFileMiddleware中間件默認使用的是一個具有如下定義的FileExtensionContentTypeProvider類型。顧名思義,FileExtensionContentTypeProvider利用物理文件的擴展名來解析對應的媒體類型,並利用其Mappings屬性表示的字典維護了擴展名與媒體類型之間的映射關系。常用的數百種標准的文件擴展名和對應的媒體類型之間的映射關系都會保存在這個字典中。如果發布的文件具有一些特殊的擴展名,或者需要將現有的某些擴展名映射為不同的媒體類型,都可以通過添加或者修改擴展名/媒體類型之間的映射關系來實現。

public class FileExtensionContentTypeProvider : IContentTypeProvider
{
    public IDictionary<string, string> Mappings { get; }

    public FileExtensionContentTypeProvider();
    public FileExtensionContentTypeProvider(IDictionary<string, string> mapping);

    public bool TryGetContentType(string subpath, out string contentType);
}

四、完整處理流程

為了使讀者對針對靜態文件的請求在StaticFileMiddleware中間件的處理有更加深刻的認識,下面采用相對簡單的代碼來重新定義這個中間件。這個模擬中間件具有與StaticFileMiddleware相同的能力,它能夠將目標文件的內容采用正確的媒體類型響應給客戶端,同時能夠處理條件請求和區間請求。StaticFileMiddleware中間件處理針對靜態文件請求的整個處理流程大體上可以划分為如下3個步驟。

  • 獲取目標文件:中間件根據請求的路徑獲取目標文件,並解析出正確的媒體類型。在此之前,中間件還會驗證請求采用的HTTP方法是否有效,它只支持GET請求和HEAD請求。中間件還會獲取文件最后被修改的時間,並根據這個時間戳和文件內容的長度生成一個標簽,響應報文的Last-Modified報頭和ETag報頭的內容就來源於此。
  • 條件請求解析:獲取與條件請求相關的4個報頭(If-Match、If-None-Match、If-Modified-Since和If-Unmodified-Since)的值,根據HTTP規范計算出最終的條件狀態。
  • 響應請求:如果是區間請求,中間件會提取相關的報頭(Range和If-Range)並解析出正確的內容區間。中間件最終根據上面計算的條件狀態和區間相關信息設置響應報頭,並根據需要響應整個文件的內容或者指定區間的內容。

下面按照上述流程重新定義StaticFileMiddleware中間件,但在此之前需要先介紹預先定義的幾個輔助性的擴展方法。如下面的代碼片段所示,UseMethods擴展方法用於確定請求是否采用指定的HTTP方法,而TryGetSubpath方法用於解析請求的目標文件的相對路徑。TryGetContentType方法會根據指定的StaticFileOptions攜帶的IContentTypeProvider對象解析出正確的媒體類型,而TryGetFileInfo方法則根據指定的路徑獲取描述目標文件的IFileInfo對象。IsRangeRequest方法會根據是否攜帶Rang報頭判斷指定的請求是否是一個區間請求。

public static class Extensions
{
    public static bool UseMethods(this HttpContext context, params string[] methods) 
        => methods.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase);

    public static bool TryGetSubpath(this HttpContext context, string requestPath, out PathString subpath)
        => new PathString(context.Request.Path).StartsWithSegments(requestPath, out subpath);

    public static bool TryGetContentType(this StaticFileOptions options, PathString subpath, out string contentType)
        => options.ContentTypeProvider.TryGetContentType(subpath.Value, out contentType) || (!string.IsNullOrEmpty(contentType = options.DefaultContentType) && options.ServeUnknownFileTypes);

    public static bool TryGetFileInfo(this StaticFileOptions options, PathString subpath, out IFileInfo fileInfo)
        => (fileInfo = options.FileProvider.GetFileInfo(subpath.Value)).Exists;

    public static bool IsRangeRequest(this HttpContext context)
        => context.Request.GetTypedHeaders().Range != null;
}

模擬類型 StaticFileMiddleware的定義如下。如果指定的StaticFileOptions沒有提供IFileProvider對象,我們會創建一個針對WebRoot目錄的PhysicalFileProvider對象。如果一個具體的IContentTypeProvider對象沒有顯式指定,我們使用的就是一個FileExtensionContentTypeProvider對象。這兩個默認值分別解釋了兩個問題:為什么請求的靜態文件將WebRoot作為默認的根目錄,為什么目標文件的擴展名會決定響應的媒體類型。

public class StaticFileMiddleware
{
    private readonly RequestDelegate _next;
    private readonly StaticFileOptions _options;

    public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment env, IOptions<StaticFileOptions> options)
    {
        _next = next;
        _options = options.Value;
        _options.FileProvider = _options.FileProvider ?? env.WebRootFileProvider;
        _options.ContentTypeProvider = _options.ContentTypeProvider ?? new FileExtensionContentTypeProvider();
    }
    ...
}

上述3個步驟分別實現在對應的方法(TryGetFileInfo、GetPreconditionState和SendResponseAsync)中,所以StaticFileMiddleware中間件類型的InvokeAsync方法按照如下方式先后調用這3個方法完成對整個文件請求的處理。

public class StaticFileMiddleware
{
    public async Task InvokeAsync(HttpContext context)
    {
        if (this.TryGetFileInfo(context, out var contentType, out var fileInfo, out var lastModified, out var etag))
        {
            var preconditionState = GetPreconditionState(context, lastModified.Value, etag);
            await SendResponseAsync(preconditionState, context, etag, lastModified.Value, contentType, fileInfo);
            return;
        }
        await _next(context);
    }    
    ...
}

獲取目標文件

下面重點介紹這3個方法的實現。首先介紹TryGetFileInfo方法是如何根據請求的路徑獲得描述目標文件的IFileInfo對象的。如下面的代碼片段所示,如果目標文件存在,這個方法除了將目標文件的IFileInfo對象作為輸出參數返回,與這個文件相關的數據(媒體類型、最后修改時間戳和封裝標簽的ETag)也會一並返回。

public class StaticFileMiddleware
{
    public bool TryGetFileInfo(HttpContext context, out string contentType, out IFileInfo fileInfo, out DateTimeOffset? lastModified, out EntityTagHeaderValue etag)
    {
        contentType = null;
        fileInfo = null;

        if (context.UseMethods("GET", "HEAD") && context.TryGetSubpath(_options.RequestPath, out var subpath) &&
            _options.TryGetContentType(subpath, out contentType) && _options.TryGetFileInfo(subpath, out fileInfo))
        {
            var last = fileInfo.LastModified;
            long etagHash = last.ToFileTime() ^ fileInfo.Length;
            etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
            lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();
            return true;
        }

        etag = null;
        lastModified = null;
        return false;
    }
}

GetPreconditionState方法旨在獲取與條件請求相關的4個報頭(If-Match、If-None-Match、If-Modified-Since和If-Unmodified-Since)的值,並通過與目標文件當前的狀態進行比較,進而得到一個最終的檢驗結果。針對這4個請求報頭的檢驗最終會產生4種可能的結果,所以我們定義了如下所示的一個PreconditionState枚舉來表示它們。

private enum PreconditionState
{
    Unspecified = 0,
    NotModified = 1,
    ShouldProcess = 2,
    PreconditionFailed = 3,
}

對於定義在這個枚舉類型中的4個選項,Unspecified表示請求中不包含這4個報頭。如果將請求報頭If-None-Match的值與當前文件標簽進行比較,或者將請求報頭If-Modified-Since的值與文件最后修改時間進行比較確定目標文件不曾被更新,檢驗結果對應的枚舉值為NotModified,反之則對應的枚舉值為ShouldProcess。

條件請求解析

如果目標文件當前的狀態不滿足If-Match報頭或者If-Unmodified-Since報頭表示的條件,那么檢驗結果對應的枚舉值為PreconditionFailed;反之,對應的枚舉值為ShouldProcess。如果請求攜帶多個報頭,針對它們可能會得出不同的檢驗結果,那么值最大的那個將作為最終的結果。如下面的代碼片段所示,GetPreconditionState方法正是通過這樣的邏輯得到表示最終條件檢驗結果的PreconditionState枚舉的。

public class StaticFileMiddleware
{
    private PreconditionState GetPreconditionState(HttpContext context,DateTimeOffset lastModified, EntityTagHeaderValue etag)
    {
        PreconditionState ifMatch, ifNonematch, ifModifiedSince, ifUnmodifiedSince;
        ifMatch = ifNonematch = ifModifiedSince = ifUnmodifiedSince = PreconditionState.Unspecified;

        var requestHeaders = context.Request.GetTypedHeaders();
        //If-Match:ShouldProcess or PreconditionFailed
        if (requestHeaders.IfMatch != null)
        {
            ifMatch = requestHeaders.IfMatch.Any(it
                => it.Equals(EntityTagHeaderValue.Any) || it.Compare(etag, true))
                ? PreconditionState.ShouldProcess
                : PreconditionState.PreconditionFailed;
        }

        //If-None-Match:NotModified or ShouldProcess
        if (requestHeaders.IfNoneMatch != null)
        {
            ifNonematch = requestHeaders.IfNoneMatch.Any(it => it.Equals(EntityTagHeaderValue.Any) || it.Compare(etag, true))
                ? PreconditionState.NotModified
                : PreconditionState.ShouldProcess;
        }

        //If-Modified-Since: ShouldProcess or NotModified
        if (requestHeaders.IfModifiedSince.HasValue)
        {
            ifModifiedSince = requestHeaders.IfModifiedSince < lastModified
                ? PreconditionState.ShouldProcess
                : PreconditionState.NotModified;
        }

        //If-Unmodified-Since: ShouldProcess or PreconditionFailed
        if (requestHeaders.IfUnmodifiedSince.HasValue)
        {
            ifUnmodifiedSince = requestHeaders.IfUnmodifiedSince > lastModified
                ? PreconditionState.ShouldProcess
                : PreconditionState.PreconditionFailed;
        }

        //Return maximum.
        return new PreconditionState[] {ifMatch, ifNonematch, ifModifiedSince, ifUnmodifiedSince }.Max();
    }
    ...
}

請求區間解析

針對靜態文件的處理最終在SendResponseAsync方法中實現,這個方法會設置相應的響應報頭和狀態碼,如果需要,它還會將目標文件的內容寫入響應報文的主體中。為響應選擇什么樣的狀態碼,設置哪些報頭,以及響應主體內容的設置除了決定於GetPreconditionState方法返回的檢驗結果,與區間請求相關的兩個報頭(Range和If-Range)也是決定性因素之一。所以,我們定義了如下所示的TryGetRanges方法,用於解析這兩個報頭並計算出正確的區間。

public class StaticFileMiddleware
{
    private bool TryGetRanges(HttpContext context, DateTimeOffset lastModified, EntityTagHeaderValue etag, long length, out IEnumerable<RangeItemHeaderValue> ranges)
    {
        ranges = null;
        var requestHeaders = context.Request.GetTypedHeaders();

        //Check If-Range
        var ifRange = requestHeaders.IfRange;
        if (ifRange != null)
        {
            bool ignore = (ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, true)) || (ifRange.LastModified.HasValue && ifRange.LastModified < lastModified);
            if (ignore)
            {
                return false;
            }
        }

        var list = new List<RangeItemHeaderValue>();
        foreach (var it in requestHeaders.Range.Ranges)
        {
            //Range:{from}-{to} Or {from}-
            if (it.From.HasValue)
            {
                if (it.From.Value < length - 1)
                {
                    long to = it.To.HasValue
                        ? Math.Min(it.To.Value, length - 1)
                        : length - 1;
                    list.Add(new RangeItemHeaderValue(it.From.Value, to));
                }
            }
            //Range:-{size}
            else if (it.To.Value != 0)
            {
                long size = Math.Min(length, it.To.Value);
                list.Add(new RangeItemHeaderValue(length - size, length - 1));
            }
        }
        return (ranges = list) != null;
    }
    ...
}

如上面的代碼片段所示,TryGetRanges方法先獲取If-Range報頭的值,並將它與目標文件當前的狀態進行比較。如果當前狀態不滿足If-Range報頭表示的條件,就意味着目標文件內容發生變化,那么請求Range報頭攜帶的區間信息將自動被忽略。而Range報頭攜帶的值具有不同的表現形式(如bytes={from}-{to}、bytes={from}-和bytes=-{size}),並且指定的端點有可能超出目標文件的長度,所以TryGetRanges方法定義了相應的邏輯來檢驗區間定義的合法性並計算出正確的區間范圍。

對於區間請求,TryGetRanges方法的返回值表示目標文件的當前狀態是否與If-Range報頭攜帶的條件相匹配。由於HTTP規范並未限制Range報頭中設置的區間數量(原則上可以指定多個區間),所以TryGetRanges方法通過輸出參數返回的區間信息是一個元素類型為RangeItemHeaderValue的集合。如果集合為空,就表示設置的區間不符合要求。

設置響應報頭

實現在SendResponseAsync方法中針對請求的處理基本上是指定響應狀態碼、設置響應報頭和寫入響應主體內容。我們將前兩項工作實現在HttpContext如下所示的SetResponseHeaders擴展方法中。該方法不僅可以將指定的響應狀態碼應用到HttpContext上,還可以設置相應的響應報頭。

public static class Extensions
{
    public static void SetResponseHeaders(this HttpContext context, int statusCode, EntityTagHeaderValue etag, DateTimeOffset lastModified, string contentType, long contentLength, RangeItemHeaderValue range = null)
    {
        context.Response.StatusCode = statusCode;
        var responseHeaders = context.Response.GetTypedHeaders();
        if (statusCode < 400)
        {
            responseHeaders.ETag = etag;
            responseHeaders.LastModified = lastModified;
            context.Response.ContentType = contentType;
            context.Response.Headers[HeaderNames.AcceptRanges] = "bytes";
        }
        if (statusCode == 200)
        {
            context.Response.ContentLength = contentLength;
        }

        if (statusCode == 416)
        {
            responseHeaders.ContentRange = new ContentRangeHeaderValue(contentLength);
        }

        if (statusCode == 206 && range != null)
        {
            responseHeaders.ContentRange = new ContentRangeHeaderValue(range.From.Value, range.To.Value, contentLength);
        }
    }
}

如上面的代碼片段所示,對於所有非錯誤類型的響應(主要是指狀態碼為“200 OK”、“206 Partial Content”和“304 Not Modified”的響應),除了表示媒體類型的Content-Type報頭,還有3個額外的報頭(Last-Modified、ETag和Accept-Range)。針對區間請求的兩種響應(“206 Partial Content”和“416 Range Not Satisfiable”)都有一個Content-Range報頭。

發送響應

如下所示的代碼片段是SendResponseAsync方法的完整定義。它會根據條件請求和區間請求的解析結果來決定最終采用的響應狀態碼。響應狀態和相關響應報頭的設置是通過調用上面的SetResponseHeaders方法來完成的。對於狀態碼為“200 OK”或者“206 Partial Content”的響應,SetResponseHeaders方法會將整個文件的內容或者指定區間的內容寫入響應報文的主體部分。而文件內容的讀取則調用表示目標文件的FileInfo對象的CreateReadStream方法,並利用其返回的輸出流來實現。

public class StaticFileMiddleware
{
    private async Task SendResponseAsync(PreconditionState state, HttpContext context, EntityTagHeaderValue etag, DateTimeOffset lastModified, string contentType, IFileInfo fileInfo)
    {
        switch (state)
        {
            //304 Not Modified
            case PreconditionState.NotModified:
                {
                    context.SetResponseHeaders(304, etag, lastModified, contentType, fileInfo.Length);
                    break;
                }
            //416 Precondition Failded
            case PreconditionState.PreconditionFailed:
                {
                    context.SetResponseHeaders(412, etag, lastModified, contentType, fileInfo.Length);
                    break;
                }
            case PreconditionState.Unspecified:
            case PreconditionState.ShouldProcess:
                {
                    //200 OK
                    if (context.UseMethods("HEAD"))
                    {
                        context.SetResponseHeaders(200, etag, lastModified, contentType, fileInfo.Length);
                        return;
                    }

                    IEnumerable<RangeItemHeaderValue> ranges;
                    if (context.IsRangeRequest() && this.TryGetRanges(context, lastModified, etag, fileInfo.Length, out ranges))
                    {
                        RangeItemHeaderValue range = ranges.FirstOrDefault();
                        //416 
                        if (null == range)
                        {
                            context.SetResponseHeaders(416, etag, lastModified, contentType, fileInfo.Length);
                            return;
                        }
                        else
                        {
                            //206 Partial Content
                            context.SetResponseHeaders(206, etag, lastModified, contentType, fileInfo.Length, range);
                            context.Response.GetTypedHeaders().ContentRange = new ContentRangeHeaderValue(range.From.Value, range.To.Value, fileInfo.Length);
                            using (Stream stream = fileInfo.CreateReadStream())
                            {
                                stream.Seek(range.From.Value, SeekOrigin.Begin);
                                await StreamCopyOperation.CopyToAsync(stream, context.Response.Body, range.To - range.From + 1, context.RequestAborted);
                            }
                            return;
                        }
                    }
                    //200 OK
                    context.SetResponseHeaders(200, etag, lastModified, contentType, fileInfo.Length);
                    using (Stream stream = fileInfo.CreateReadStream())
                    {
                        await StreamCopyOperation.CopyToAsync(stream, context.Response.Body, fileInfo.Length, context.RequestAborted);
                    }
                    break;
                }
        }
    }
}

靜態文件中間件[1]: 搭建文件服務器
靜態文件中間件[2]: 條件請求以提升性能
靜態文件中間件[3]: 區間請求以提供部分內容
靜態文件中間件[4]: StaticFileMiddleware
靜態文件中間件[5]: DirectoryBrowserMiddleware & DefaultFilesMiddleware


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM