1. 程式人生 > >ASP.NET Core靜態檔案中介軟體[4]: StaticFileMiddleware 中介軟體全解析

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 & DefaultFilesMi