我們通過《以Web的形式發布靜態文件》和《條件請求與區間請求》中的實例演示,以及上面針對條件請求和區間請求的介紹,從提供的功能和特性的角度對這個名為StaticFileMiddleware的中間進行了全面的介紹,接下來我們將更近一步,將從實現原理的角度來進一步認識這個中間件。 [本文已經同步到《ASP.NET Core框架揭秘》之中]
目錄
一、StaticFileMiddleware
二、ContentTypeProvider
三、利用配置指定StaticFileOptions
四、實現原理
不過在此之前,我們先來看看StaticFileMiddleware這個類型的定義。
1: public class StaticFileMiddleware
2: {
3: public StaticFileMiddleware(RequestDelegate next, IHostingEnvironment hostingEnv, IOptions<StaticFileOptions> options, ILoggerFactory loggerFactory);
4: public Task Invoke(HttpContext context);
5: }
如上面的代碼片段所示,除了作為“下一個中間件”的next參數之前,StaticFileMiddleware的構造函數還包含三個參數。其中hostingEnv和loggerFactory這兩個參數分別表示當前執行環境和用來創建Logger的工廠,最重要的options參數表示為這個中間件指定的配置選項,至於具體可以提供怎樣的配置選項,我們只需要看看 StaticFileOptions這個類型提供了怎樣的屬性成員。
1: public class StaticFileOptions : SharedOptionsBase
2: {
3: public IContentTypeProvider ContentTypeProvider { get; set; }
4: public string DefaultContentType { get; set; }
5: public bool ServeUnknownFileTypes { get; set; }
6: public Action<StaticFileResponseContext> OnPrepareResponse { get; set; }
7:
8: public StaticFileOptions();
9: public StaticFileOptions(SharedOptions sharedOptions);
10: }
11:
12: public abstract class SharedOptionsBase
13: {
14: protected SharedOptionsBase(SharedOptions sharedOptions);
15: public IFileProvider FileProvider { get; set; }
16: public PathString RequestPath { get; set; }
17: }
18:
19: public class SharedOptions
20: {
21: public IFileProvider FileProvider { get; set; }
22: public PathString RequestPath { get; set; }
23: }
24:
25: public class StaticFileResponseContext
26: {
27: public HttpContext Context { get; }
28: public IFileInfo File { get; }
29: }
如上面的代碼片段所示,StaticFileOptions繼承自抽象類型SharedOptionsBase,後者實際上體現的是兩個路徑之間的映射關系,一個是HTTP請求采用的路徑,另一個則是文件的物理地址,後者體現為一個FileProvider對象。不過也正是因為文件的讀取是通過這個FileProvider來完成的,而FileProvider未必就一定對應著具體的物理文件,所以StaticFileMiddleware並不限於針對專門處理“物理文件”。
直接定義在StaticFileOptions中的前三個類型都與媒體類型的解析有關,其中ContentTypeProvider屬性返回一個根據請求相對地址進行媒體類型的ContentTypeProvider對象。如果這個ContentTypeProvider不能正確解析出目標文件的媒體類型,我們可以利用DefaultContentType設置一個默認媒體類型。但是只有將另一個名為ServeUnknownFileTypes的屬性設置為True的情況下,媒體類型不能正常識別的請求采用使用這個默認設置的媒體類型。
StaticFileOptions還具有一個OnPrepareResponse屬性,它返回一個Action<StaticFileResponseContext>類型的委托對象,我們可以為這屬性指定的委托對象來對最終的響應進行定制。至於作為委托輸入參數的是一個類型為StaticFileResponseContext的對象,我們利用它可以獲得當前的HTTP上下文和目標文件。
針對StaticFileMiddleware這個中間件的注冊一般都是調用針對ApplicationBuilder的UseStaticFiles擴展方法來完成的。具體來說,一共具有三個UseStaticFiles方法重載供我們選擇,如下所示的代碼片段展示了這三個擴展方法的實現。
1: public static class StaticFileExtensions
2: {
3: public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app)
4: {
5: return app.UseMiddleware<StaticFileMiddleware>();
6: }
7:
8: public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app,StaticFileOptions options)
9: {
10: return app.UseMiddleware<StaticFileMiddleware>(Options.Create<StaticFileOptions>(options));
11: }
12:
13: public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, string requestPath)
14: {
15: StaticFileOptions options = new StaticFileOptions
16: {
17: RequestPath = new PathString(requestPath)
18: };
19: return app.UseStaticFiles(options);
20: }
21: }
StaticFileMiddleware針對物理文件請求的處理並不僅僅限於完成文件內容的響應,還需要針對文件的格式解析出正確的媒體類型。對於客戶端來說,如果無法確定媒體類型,獲取的文件就像是一步無法解碼的天書,毫無意義。StaticFileMiddleware利用指定的ContentTypeProvider來解析媒體類型,ContentTypeProvider是我們對實現了IContentTypeProvider接口的所有類型以及對應對象的統稱。如下面的代碼片段所示,IContentTypeProvider接口定義了唯一的方法TryGetContentType根據當前請求的相對路徑來解析這個作為輸出參數的媒體類型。
1: public interface IContentTypeProvider
2: {
3: bool TryGetContentType(string subpath, out string contentType);
4: }
StaticFileMiddleware默認使用的ContentProvider是一個具有如下定義的FileExtensionContentTypeProvider對象。顧名思義,FileExtensionContentTypeProvider利用物理文件的擴展名來解析對應的媒體類型,它利用其Mappings屬性表示的字典維護了一個擴展名與媒體類型之間的映射關系。我們常用的數百種標准的文件擴展名和對應的媒體類型之間的映射關系都會保存在愛這個字典中。如果我們發布的文件具有一些特殊的擴展名,或者我們需要現有的某些擴展名映射為不同的媒體類型,這些通過添加或者修改映射關系來實現。
1: public class FileExtensionContentTypeProvider : IContentTypeProvider
2: {
3: public IDictionary<string, string> Mappings { get; }
4:
5: public FileExtensionContentTypeProvider();
6: public FileExtensionContentTypeProvider(IDictionary<string, string> mapping);
7:
8: public bool TryGetContentType(string subpath, out string contentType);
9: }
由於StaticFileMiddleware的構造函數用來設置相關選項的options參數類型為IOptions<StaticFileOptions>,所以我們可以根據Options模式將StaticFileOptions對象承載的部分選項定義在配置文件中。比如我們利用如下所示的一個JSON文件開啟了針對未知文件類型的支持,並設置了默認使用媒體類型(“application/octet-stream”),這兩個配置項對應著StaticFileOptions的同名屬性。
1: {
2: "serveUnknownFileTypes" : true,
3: "defaultContentType" : "application/octet-stream"
4: }
有了這個配置文件(假設文件名為“StaticFileOptions.json”),我們就可以按照如下的方式加載它並生成對應的Configuration對象,然後采用Options模式特有的編程模式實現與StaticFileOptions類型的映射。這樣的配置將會自動應用到注冊的StaticFileMiddleware中間件上。
1: public class Program
2: {
3: public static void Main()
4: {
5: IConfiguration config = new ConfigurationBuilder()
6: .AddJsonFile("StaticFileOptions.json")
7: .Build();
8:
9: new WebHostBuilder()
10: .UseContentRoot(Directory.GetCurrentDirectory())
11: .UseKestrel()
12: .ConfigureServices(svsc=>svsc.Configure<StaticFileOptions>(config))
13: .Configure(app=>app.UseStaticFiles())
14: .Build()
15: .Run();
16: }
17: }
對於上面這樣的應用,所有未知文件類型都將自動映射為“application/octet-stream”媒體類型。如果使用浏覽器請求一個未知類型的文件(比如前面演示的“~/wwwroot/img/ dophin1.img”),目標文件將以如下圖所示的形式以一個附件的形式被下載。
為了上讀者朋友們對針對靜態文件的請求在StaticFileMiddleware中間件的處理具有更加深刻的認識,接下來我們會采用相對簡單的代碼來重新定義這個中間件。這部分作為選修內容供有興趣的讀者朋友閱讀,忽略這些內容不會影響對後續內容的理解。這個模擬中間件具有與StaticFileMiddleware相同的能力,它能夠將目標文件的內容采用正確的媒體類型響應給客戶端,同時能夠處理條件請求和區間請求。
StaticFileMiddleware中間處理針對靜態文件請求的整個處理流程大體上可以劃分為如上圖所示的三個步驟:
接下來我們按照上述的這個流程來重新定義這個StaticFileMiddleware,不過在此之前先來了解一下我們預先定義的幾個輔助性的擴展方法。如下面代碼片段所示,擴展方法UseMethods用於判指定的請求是否采用指定的HTTP方法,而TryGetSubpath用於解析請求的目標文件的相對路徑。TryGetContentType方法會根據指定的StaticFileOptions攜帶的ContentTypeProvider解析出正確的媒體類型,而TryGetFileInfo則根據指定的路徑獲取描述目標文件的FileInfo對象。至於最後的IsRangeRequest方法,它會根據是否攜帶Rang報頭判斷指定的請求是否是一個區間請求。
1: public static class Extensions
2: {
3: public static bool UseMethods(this HttpContext context, params string[] methods)
4: {
5: return methods.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase);
6: }
7:
8: public static bool TryGetSubpath(this HttpContext context, string requestPath, out PathString subpath)
9: {
10: return new PathString(context.Request.Path).StartsWithSegments(requestPath, out subpath);
11: }
12:
13: public static bool TryGetContentType(this StaticFileOptions options, PathString subpath, out string contentType)
14: {
15: return options.ContentTypeProvider.TryGetContentType(subpath.Value, out contentType) ||(!string.IsNullOrEmpty(contentType = options.DefaultContentType) && options.ServeUnknownFileTypes);
16: }
17:
18: public static bool TryGetFileInfo(this StaticFileOptions options, PathString subpath, out IFileInfo fileInfo)
19: {
20: return (fileInfo = options.FileProvider.GetFileInfo(subpath.Value)).Exists;
21: }
22:
23: public static bool IsRangeRequest(this HttpContext context)
24: {
25: return context.Request.GetTypedHeaders().Range != null;
26: }
27: }
如下所示的模擬類型 StaticFileMiddleware的定義。如果指定的StaticFileOptions沒有提供FileProvider,我們會默認使用指向WebRoot目錄的那個PhysicalFileProvider。如果一個具體的ContentTypeProvider沒有顯式指定,我們使用的則是一個FileExtensionContentTypeProvider對象。這兩個默認值分別解釋了兩個問題,為什麼請求的靜態文件將WebRoot作為默認的根目錄,以及為什麼目標文件的擴展名決定響應的媒體類型。
1: public class StaticFileMiddleware
2: {
3: private RequestDelegate _next;
4: private StaticFileOptions _options;
5:
6: public StaticFileMiddleware(RequestDelegate next, IHostingEnvironment env, IOptions<StaticFileOptions> options)
7: {
8: _next = next;
9: _options = options.Value;
10: _options.FileProvider = _options.FileProvider??env.WebRootFileProvider;
11: _options.ContentTypeProvider = _options.ContentTypeProvider ?? new FileExtensionContentTypeProvider();
12: }
13: ...
14: }
我們上述的三個步驟分別實現在三個對應的方法(TryGetFileInfo、ResolvePreconditionState和SendResponseAsync)中,所以StaticFileMiddleware的Invoke方法按照如下的方式先後調用這三個方法完整對整個請求的處理。
1: public class StaticFileMiddleware
2: {
3: public async Task Invoke(HttpContext context)
4: {
5: IFileInfo fileInfo;
6: string contentType;
7: DateTimeOffset? lastModified;
8: EntityTagHeaderValue etag;
9:
10: if (this.TryGetFileInfo(context, out contentType, out fileInfo, out lastModified, out etag))
11: {
12: PreconditionState preconditionState = this.GetPreconditionState(context, lastModified.Value, etag);
13: await this.SendResponseAsync(preconditionState, context, etag, lastModified.Value, contentType, fileInfo);
14: return;
15: }
16: await _next(context);
17: }
18: ...
19: }
接下來我們的重點就集中到上述這三個方法的實現上。我們首先看看TryGetFileInfo方法是如何根據請求的路徑獲得描述目標文件的FileInfo對象的。如下面的代碼片段所示,如果目標文件存在,這個方法除了將目標文件的FileInfo對象作為輸出參數返回之外,與這個文件相關的數據(媒體類型、最後修改時間戳和封裝簽名的ETag)。
1: public class StaticFileMiddleware
2: {
3: public bool TryGetFileInfo(HttpContext context, out string contentType, out IFileInfo fileInfo, out DateTimeOffset? lastModified, out EntityTagHeaderValue etag)
4: {
5: contentType = null;
6: fileInfo = null;
7: PathString subpath;
8:
9: if (context.UseMethods("GET", "HEAD") &&context.TryGetSubpath(_options.RequestPath, out subpath) &&_options.TryGetContentType(subpath, out contentType) &&_options.TryGetFileInfo(subpath, out fileInfo))
10: {
11: DateTimeOffset last = fileInfo.LastModified;
12: long etagHash = last.ToFileTime() ^ fileInfo.Length;
13: etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
14: lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();
15: return true;
16: }
17:
18: etag = null;
19: lastModified = null;
20: return false;
21: }
22: }
方法 GetPreconditionState旨在獲取與條件請求相關的四個報頭(If-Match、If-None-Match、If-Modified-Since和If-Unmodified-Since)的值,並通過與目標文件當前的狀態進行比較,進而得到一個最終的檢驗結果。針對這四個請求報頭的檢驗最終會產生四種可能的結果,所以我們定義了如下一個PreconditionState枚舉來表示它們。
1: private enum PreconditionState
2: {
3: Unspecified = 0,
4: NotModified = 1,
5: ShouldProcess = 2,
6: PreconditionFailed = 3,
7: }
對於定義在這個枚舉類型中的四個選項,Unspecified表示請求中不包含這四個報頭。如果將請求報頭If-None-Match的值與當前文件簽名進行比較,或者將請求If-Modified-Since報頭的值與文件最後修改時間進行比較確定目標文件不曾改變,檢驗結果對應的枚舉值為NotModified,反之對應的枚舉值為ShouldProcess。如果目標文件當前的狀態不滿足If-Match或者If-Unmodified-Since報頭表示的條件,檢驗結果對應的枚舉值為PreconditionFailed,反之對應的枚舉值為ShouldProcess。如果請求攜帶多個報頭,針對它們會得出不同的檢驗結果,那麼值最大的那個將最為最終的結果。如下面的代碼片段所示,GetPreconditionState方法正是通過這樣的邏輯得到這個標識最終條件檢驗結果的PreconditionState枚舉。
1: public class StaticFileMiddleware
2: {
3: private PreconditionState GetPreconditionState(HttpContext context, DateTimeOffset lastModified, EntityTagHeaderValue etag)
4: {
5: PreconditionState ifMatch,ifNonematch, ifModifiedSince, ifUnmodifiedSince;
6: ifMatch = ifNonematch = ifModifiedSince = ifUnmodifiedSince = PreconditionState.Unspecified;
7:
8: RequestHeaders requestHeaders = context.Request.GetTypedHeaders();
9: //If-Match:ShouldProcess or PreconditionFailed
10: if (requestHeaders.IfMatch != null)
11: {
12: ifMatch = requestHeaders.IfMatch.Any(it => it.Equals(EntityTagHeaderValue.Any) || it.Compare(etag, true))
13: ? PreconditionState.ShouldProcess
14: : PreconditionState.PreconditionFailed;
15: }
16:
17: //If-None-Match:NotModified or ShouldProcess
18: if (requestHeaders.IfNoneMatch != null)
19: {
20: ifNonematch = requestHeaders.IfNoneMatch.Any(it => it.Equals(EntityTagHeaderValue.Any) || it.Compare(etag, true))
21: ? PreconditionState.NotModified
22: : PreconditionState.ShouldProcess;
23: }
24:
25: //If-Modified-Since: ShouldProcess or NotModified
26: if (requestHeaders.IfModifiedSince.HasValue)
27: {
28: ifModifiedSince = requestHeaders.IfModifiedSince < lastModified
29: ? PreconditionState.ShouldProcess
30: : PreconditionState.NotModified;
31: }
32:
33: //If-Unmodified-Since: ShouldProcess or PreconditionFailed
34: if (requestHeaders.IfUnmodifiedSince.HasValue)
35: {
36: ifUnmodifiedSince = requestHeaders.IfUnmodifiedSince > lastModified
37: ? PreconditionState.ShouldProcess
38: : PreconditionState.PreconditionFailed;
39: }
40:
41: //Return maximum.
42: return new PreconditionState[] { ifMatch, ifNonematch, ifModifiedSince, ifUnmodifiedSince }.Max();
43: }
44: ...
45: }
針對靜態文件的處理最終實現在SendResponseAsync方法中,這個方法最終會設置相應的響應報頭和狀態碼,如果需要還會將目標文件的內容寫入到響應報文的主體。為響應選擇怎樣的狀態碼,設置哪些報頭,以及響應內容的選擇除了決定於GetPreconditionState方法返回的條件檢驗結果外,與區間請求相關的兩個報頭(Range和If-Range)也是決定因素之一。為了我們定義了如下這個TryGetRanges方法來解析這兩個報頭並計算出正確的區間。
1: public class StaticFileMiddleware
2: {
3: private bool TryGetRanges(HttpContext context, DateTimeOffset lastModified, EntityTagHeaderValue etag, long length, out IEnumerable<RangeItemHeaderValue> ranges)
4: {
5: ranges = null;
6: RequestHeaders requestHeaders = context.Request.GetTypedHeaders();
7:
8: //Check If-Range
9: RangeConditionHeaderValue ifRange = requestHeaders.IfRange;
10: if (ifRange != null)
11: {
12: bool ignore = (ifRange.EntityTag != null && !ifRange.EntityTag.Compare(etag, true)) ||(ifRange.LastModified.HasValue && ifRange.LastModified < lastModified);
13: if (ignore)
14: {
15: return false;
16: }
17: }
18:
19: List<RangeItemHeaderValue> list = new List<RangeItemHeaderValue>();
20: foreach (var it in requestHeaders.Range.Ranges)
21: {
22: //Range:{from}-{to} Or {from}-
23: if (it.From.HasValue)
24: {
25: if (it.From.Value < length - 1)
26: {
27: long to = it.To.HasValue ? Math.Min(it.To.Value, length - 1) : length - 1;
28: list.Add(new RangeItemHeaderValue(it.From.Value, to));
29: }
30: }
31: //Range:-{size}
32: else if (it.To.Value != 0)
33: {
34: long size = Math.Min(length, it.To.Value);
35: list.Add(new RangeItemHeaderValue(length - size, length - 1));
36: }
37: }
38: return ( ranges = list) != null;
39: }
40: …
41: }
如上面的代碼片段所示,TryGetRanges方法會先獲取If-Range報頭的值,並與目標位文件當前的狀態進行比較。如果當前狀態不滿足If-Range報頭表示的條件,這種情況意味著目標文件內容發生變化,那麼請求Range報頭攜帶的區間信息將自動被忽略。至於Range報頭攜帶的值,考慮到它具有不同的表現形式(比如“bytes={from}-{to}”、“bytes={from}-”或者“bytes=-{size}”)以及指定的端點是否超出目標文件長度,這個方法定義了相應的邏輯來檢驗區間定義的合法性以及計算出正確的區間范圍。
對於區間請求,TryGetRanges的返回值表示目標文件的當前狀態是否滿足If-Range攜帶的條件相匹配。由於HTTP規范並未限制Range報頭中設置的區間數量,所以這個方法通過輸出參數返回的區間信息是一個元素類型為RangeItemHeaderValue的集合。如果集合為空,表示設置的區間不符合要求。
實現在SendResponseAsync方法中針對請求的處理不外乎指定響應狀態碼、設置響應報頭和寫入響應主體內。我們將前兩個工作實現在HttpContext如下這個擴展方法SetResponseHeaders中。該方法會我們指定的響應狀態碼應用到指定的HttpContext,並設置相應的響應報頭。
1: public static class Extensions
2: {
3: public static void SetResponseHeaders(this HttpContext context, int statusCode, EntityTagHeaderValue etag, DateTimeOffset lastModified, string contentType,long contentLength, RangeItemHeaderValue range = null)
4: {
5: context.Response.StatusCode = statusCode;
6: var responseHeaders = context.Response.GetTypedHeaders();
7: if (statusCode < 400)
8: {
9: responseHeaders.ETag = etag;
10: responseHeaders.LastModified = lastModified;
11: context.Response.ContentType = contentType;
12: context.Response.Headers[HeaderNames.AcceptRanges] = "bytes";
13: }
14: if (statusCode == 200)
15: {
16: context.Response.ContentLength = contentLength;
17: }
18:
19: if (statusCode == 416)
20: {
21: responseHeaders.ContentRange = new ContentRangeHeaderValue(contentLength);
22: }
23:
24: if (statusCode == 206 && range != null)
25: {
26: responseHeaders.ContentRange = new ContentRangeHeaderValue(range.From.Value, range.To.Value, contentLength);
27: }
28: }
29: }
如上面的代碼片段所示,對於所有非錯誤類型的響應(主要指“200 OK”、“206 partial Content”和“304 Not Modified”),除了表示媒體類型的Content-Type報頭之外,它們還具有三個額外的報頭(Last-Modified、ETag和Accept-Range)。針對區間請求的兩種響應(“206 partial Content”和“416 Range Not Satisfiable”),它們都具有一個Content-Range報頭。
如下所示的是 SendResponseAsync方法的完整定義。它會根據條件請求和區間請求的解析結果來決定最終采用的響應狀態碼。響應狀態和相關響應報頭的設置通過調用上面這個SetResponseHeaders方法來完成。對於狀態碼為“200 OK”或者“206 Partial Content”的響應,這個方法會分別將整個文件內容或者指定區間的內容寫入到響應報文的主體部分。至於文件的內容的讀取,我們直接可以利用表示目標文件的FileInfo的CreateReadStream方法創建的讀取文件輸出流來實現。
1: public class StaticFileMiddleware
2: {
3: private async Task SendResponseAsync(PreconditionState state, HttpContext context, EntityTagHeaderValue etag, DateTimeOffset lastModified, string contentType, IFileInfo fileInfo)
4: {
5: switch (state)
6: {
7: //304 Not Modified
8: case PreconditionState.NotModified:
9: {
10: context.SetResponseHeaders(304, etag, lastModified, contentType, fileInfo.Length);
11: break;
12: }
13: //416 Precondition Failded
14: case PreconditionState.PreconditionFailed:
15: {
16: context.SetResponseHeaders(412, etag, lastModified, contentType,fileInfo.Length);
17: break;
18: }
19: case PreconditionState.Unspecified:
20: case PreconditionState.ShouldProcess:
21: {
22: //200 OK
23: if (context.UseMethods("HEAD"))
24: {
25: context.SetResponseHeaders(200, etag, lastModified, contentType, fileInfo.Length);
26: return;
27: }
28:
29: IEnumerable<RangeItemHeaderValue> ranges;
30: if (context.IsRangeRequest() && this.TryGetRanges(context, lastModified, etag, fileInfo.Length, out ranges))
31: {
32: RangeItemHeaderValue range = ranges.FirstOrDefault();
33: //416
34: if (null == range)
35: {
36: context.SetResponseHeaders(416, etag, lastModified,
37: contentType, fileInfo.Length);
38: return;
39: }
40: else
41: {
42: //206 Partial Content
43: context.SetResponseHeaders(206, etag, lastModified, contentType, fileInfo.Length, range);
44: context.Response.GetTypedHeaders().ContentRange = new ContentRangeHeaderValue(range.From.Value, range.To.Value, fileInfo.Length);
45: using (Stream stream = fileInfo.CreateReadStream())
46: {
47: stream.Seek(range.From.Value, SeekOrigin.Begin);
48: await StreamCopyOperation.CopyToAsync(stream, context.Response.Body, range.To - range.From + 1, context.RequestAborted);
49: }
50: return;
51: }
52: }
53: //200 OK
54: context.SetResponseHeaders(200, etag, lastModified, contentType, fileInfo.Length);
55: using (Stream stream = fileInfo.CreateReadStream())
56: {
57: await StreamCopyOperation.CopyToAsync(stream, context.Response.Body, fileInfo.Length, context.RequestAborted);
58: }
59: break;
60: }
61: }
62: }
63: }
ASP.NET Core應用針對靜態文件請求的處理[1]: 以Web的形式發布靜態文件
ASP.NET Core應用針對靜態文件請求的處理[2]: 條件請求與區間請求
ASP.NET Core應用針對靜態文件請求的處理[3]: StaticFileMiddleware中間件如何處理針對文件請求
ASP.NET Core應用針對靜態文件請求的處理[4]: DirectoryBrowserMiddleware中間件如何呈現目錄結構
ASP.NET Core應用針對靜態文件請求的處理[5]: DefaultFilesMiddleware中間件如何顯示默認頁面