ASP.NET Core靜態文件中間件[5]: DirectoryBrowserMiddleware & DefaultFilesMiddleware

對於NuGet包由「Microsoft.AspNetCore.StaticFiles」提供的3個中間件來說,StaticFileMiddleware中間件旨在處理針對具體靜態文件的請求,其他兩個中間件(DirectoryBrowserMiddleware和DefaultFilesMiddleware)處理的均是針對某個目錄的請求。

目錄
一、DirectoryBrowserMiddleware中間件
二、自定義IDirectoryFormatter
三、DefaultFilesMiddleware中間件

一、DirectoryBrowserMiddleware中間件

與StaticFileMiddleware中間件一樣,DirectoryBrowserMiddleware中間件本質上還定義了一個請求基地址與某個物理目錄之間的映射關係,而目標目錄體現為一個IFileProvider對象。當這個中間件接收到匹配的請求後,會根據請求地址解析出對應目錄的相對路徑,並利用這個IFileProvider對象獲取目錄的結構。目錄結構最終會以一個HTML文檔的形式定義,而此HTML文檔最終會被這個中間件作為響應的內容。

如下面的程式碼片段所示,DirectoryBrowserMiddleware類型的第二個構造函數有4個參數。其中,第二個參數是代表當前執行環境的IWebHostEnvironment對象;第三個參數提供一個HtmlEncoder對象,當目標目錄被呈現為一個HTML文檔時,它被用於實現針對HTML的編碼,如果沒有顯式指定(調用第一個構造函數),默認的HtmlEncoder(HtmlEncoder.Default)會被使用;第四個類型為IOptions<DirectoryBrowserOptions>的參數用於提供表示配置選項的DirectoryBrowserMiddleware的DirectoryBrowserOptions對象。與前面介紹的StaticFileOptions一樣,DirectoryBrowserOptions是SharedOptionsBase的子類。

public class DirectoryBrowserMiddleware
{
    public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment env, IOptions<DirectoryBrowserOptions> options)
    public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, HtmlEncoder encoder, IOptions<DirectoryBrowserOptions> options);
    public Task Invoke(HttpContext context);
}

public class DirectoryBrowserOptions : SharedOptionsBase
{
    public IDirectoryFormatter Formatter { get; set; }

    public DirectoryBrowserOptions();
    public DirectoryBrowserOptions(SharedOptions sharedOptions);
}

DirectoryBrowserMiddleware中間件的註冊可以通過IApplicationBuilder介面的3個Use
DirectoryBrowser擴展方法來完成。在調用這些擴展方法時,如果沒有指定任何參數,就意味著註冊的中間件會採用默認配置。我們也可以顯式地執行一個DirectoryBrowserOptions對象來對註冊的中間件進行訂製。如果我們只希望指定請求的路徑,就可以直接調用第三個方法重載。

public static class DirectoryBrowserExtensions
{    
    public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app)
        => app.UseMiddleware<DirectoryBrowserMiddleware>(Array.Empty<object>());

    public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app, DirectoryBrowserOptions options)
    {          
        var args = new object[] { Options.Create<DirectoryBrowserOptions>(options) };
        return app.UseMiddleware<DirectoryBrowserMiddleware>(args);
    }

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

DirectoryBrowserMiddleware中間件的目的很明確,就是將目錄下的內容(文件和子目錄)格式化成一種可讀的形式響應給客戶端。針對目錄內容的響應最終實現在一個IDirectoryFormatter對象上,DirectoryBrowserOptions的Formatter屬性設置和返回的就是這樣的一個對象。如下面的程式碼片段所示,IDirectoryFormatter介面僅包含一個GenerateContentAsync方法。當實現這個方法的時候,我們可以利用第一個參數獲取當前HttpContext上下文。該方法的另一個參數返回一組IFileInfo的集合,每個IFileInfo代表目標目錄下的某個文件或者子目錄。

public interface IDirectoryFormatter
{
    Task GenerateContentAsync(HttpContext context, IEnumerable<IFileInfo> contents);
}

在默認情況下,請求目錄的內容在頁面上是以一個表格的形式來呈現的,包含這個表格的HTML文檔正是默認使用的IDirectoryFormatter對象生成的,該對象的類型為HtmlDirectory
Formatter。如下面的程式碼片段所示,我們在構造一個HtmlDirectoryFormatter對象時需要指定一個HtmlEncoder對象,它就是在構造DirectoryBrowserMiddleware對象時提供的那個Html
Encoder對象。

public class HtmlDirectoryFormatter : IDirectoryFormatter
{
    public HtmlDirectoryFormatter(HtmlEncoder encoder);
    public virtual Task GenerateContentAsync(HttpContext context, IEnumerable<IFileInfo> contents);
}

既然最複雜的工作(呈現目錄內容)由IDirectoryFormatter完成,那麼DirectoryBrowserMiddleware中間件自身的工作其實就會很少。為了更好地說明這個中間件在處理請求時具體做了些什麼,可以採用一種比較容易理解的方式對DirectoryBrowserMiddleware類型重新定義。

public class DirectoryBrowserMiddleware
{
    private readonly RequestDelegate _next;
    private readonly DirectoryBrowserOptions _options;

    public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment env, IOptions<DirectoryBrowserOptions> options) : this(next, env, HtmlEncoder.Default, options)
    { }

    public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment env, HtmlEncoder encoder, IOptions<DirectoryBrowserOptions> options)
    {
        _next = next;
        _options = options.Value;
        _options.FileProvider = _options.FileProvider ?? env.WebRootFileProvider;
        _options.Formatter = _options.Formatter ?? new HtmlDirectoryFormatter(encoder);
    }

    public async Task InvokeAsync(HttpContext context)
    {
        //只處理GET請求和HEAD請求
        if (!new string[] { "GET", "HEAD" }.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase))
        {
            await _next(context);
            return;
        }

        //檢驗當前路徑是否與註冊的請求路徑相匹配
        PathString path = new PathString(context.Request.Path.Value.TrimEnd('/') + "/");
        PathString subpath;
        if (!path.StartsWithSegments(_options.RequestPath, out subpath))
        {
            await _next(context);
            return;
        }

        //檢驗目標目錄是否存在
        IDirectoryContents directoryContents = _options.FileProvider.GetDirectoryContents(subpath);
        if (!directoryContents.Exists)
        {
            await _next(context);
            return;
        }

        //如果當前路徑不以「/」作為後綴,會響應一個針對「標準」URL的重定向
        if (!context.Request.Path.Value.EndsWith("/"))
        {
            context.Response.StatusCode = 302;
            context.Response.GetTypedHeaders().Location = new Uri(path.Value + context.Request.QueryString);
            return;
        }

        //利用DirectoryFormatter響應目錄內容
        await _options.Formatter.GenerateContentAsync(context, directoryContents);
    }
}

如上面的程式碼片段所示,在最終利用註冊的IDirectoryFormatter對象來響應目標目錄的內容之前,DirectoryBrowserMiddleware中間件會做一系列的前期工作:驗證當前請求是否是GET請求或者HEAD請求;當前的URL是否與註冊的請求路徑相匹配,在匹配的情況下還需要驗證目標目錄是否存在。

這個中間件要求訪問目錄的請求路徑必須以「/」作為後綴,否則會在目前的路徑上添加這個後綴,並針對修正的路徑發送一個302重定向。所以,利用瀏覽器發送針對某個目錄的請求時,雖然URL沒有指定「/」作為後綴,但瀏覽器會自動將這個後綴補上,這就是重定嚮導致的結果。

二、自定義IDirectoryFormatter

目錄結構的呈現方式完全由IDirectoryFormatter對象完成,如果默認註冊的HtmlDirectoryFormatter對象的呈現方式無法滿足需求(如我們需要這個頁面與現有網站保持相同的風格),就可以通過註冊一個自定義的DirectoryFormatter來解決這個問題。下面通過一個簡單的實例來演示如何定義一個IDirectoryFormatter實現類型。我們將自定義的IDirectoryFormatter實現類型命名為ListDirectoryFormatter,因為它僅僅將所有文件或者子目錄顯示為一個簡單的列表。

public class ListDirectoryFormatter : IDirectoryFormatter
{
    public async Task GenerateContentAsync(HttpContext context,
        IEnumerable<IFileInfo> contents)
    {
        context.Response.ContentType = "text/html";
        await context.Response.WriteAsync("<html><head><title>Index</title><body><ul>");
        foreach (var file in contents)
        {
            string href = $"{context.Request.Path.Value.TrimEnd('/')}/{file.Name}";
            await context.Response.WriteAsync($"<li><a href='{href}'>{file.Name}</a></li>");
        }
        await context.Response.WriteAsync("</ul></body></html>");
    }
}

public class Program
{
    public static void Main()
    {
        var options = new DirectoryBrowserOptions
        {
            Formatter = new ListDirectoryFormatter()
        };
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder.Configure(app => app.UseDirectoryBrowser(options)))
            .Build()
            .Run();
    }
}

如上面的程式碼片段所示,ListDirectoryFormatter最終響應的是一個完整的HTML文檔,它的主體部分只包含一個通過<ul></ul>表示的無序列表,列表元素(<li>)是一個針對文件或者子目錄的鏈接。在調用UseDirectoryBrowser擴展方法註冊DirectoryBrowserMiddleware中間件時,需要將一個ListDirectoryFormatter對象設置為指定配置選項的Formatter屬性。目錄內容最終以下圖所示的形式呈現在瀏覽器上。

3

三、DefaultFilesMiddleware中間件

DefaultFilesMiddleware中間件的目的在於將目標目錄下的默認文件作為響應內容。如果直接請求的就是這個默認文件,那麼前面介紹的StaticFileMiddleware中間件就會將這個文件響應給客戶端。如果能夠將針對目錄的請求重定向到這個默認文件上,一切問題就會迎刃而解。實際上,DefaultFilesMiddleware中間件的實現邏輯很簡單,它採用URL重寫的形式修改了當前請求的地址,即將針對目錄的URL修改成針對默認文件的URL。

下面先介紹DefaultFilesMiddleware類型的定義。與其他兩個中間件類似,DefaultFilesMiddleware中間件的構造由一個IOptions<DefaultFilesOptions>類型的參數來指定相關的配置選項。由於DefaultFilesMiddleware中間件本質上依然體現了請求路徑與某個物理目錄的映射,所以DefaultFilesOptions依然派生於SharedOptionsBase。DefaultFilesOptions的DefaultFileNames屬性包含預定義的默認文件名,由此可以看到它默認包含4個名稱(default.htm、default.html、index.htm和index.html)。

public class DefaultFilesMiddleware
{
    public DefaultFilesMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptions<DefaultFilesOptions> options);
    public Task Invoke(HttpContext context);
}

public class DefaultFilesOptions : SharedOptionsBase
{
    public IList<string> DefaultFileNames { get; set; }

    public DefaultFilesOptions() : this(new SharedOptions()) { }
    public DefaultFilesOptions(SharedOptions sharedOptions) : base(sharedOptions)
    {
        this.DefaultFileNames = new List<string> {"default.htm", "default.html", "index.htm", "index.html" };
    }
}

DefaultFilesMiddleware中間件的註冊可以通過調用IApplicationBuilder介面的如下3個名為UseDefaultFiles的擴展方法來完成。從如下所示的程式碼片段可以看出,它們與用於註冊DirectoryBrowserMiddleware中間件的UseDirectoryBrowser擴展方法具有一致的定義和實現方式。

public static class DefaultFilesExtensions
{
    public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app) => app.UseMiddleware<DefaultFilesMiddleware>(Array.Empty<object>());

    public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app, DefaultFilesOptions options)
    {
        var args = new object[] {Options.Create<DefaultFilesOptions>(options) };
        return app.UseMiddleware<DefaultFilesMiddleware>(args);
    }

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

下面採用一種易於理解的形式重新定義DefaultFilesMiddleware類型,以便於讀者理解它的處理邏輯。如下面的程式碼片段所示,與前面介紹的DirectoryBrowserMiddleware中間件一樣,DefaultFilesMiddleware中間件會對請求做相應的驗證。如果當前目錄下存在某個默認文件,那麼它會將當前請求的URL修改成指向這個默認文件的URL。值得注意的是,DefaultFiles
Middleware中間件同樣要求訪問目錄的請求路徑必須以「/」作為後綴,否則會在目前的路徑上添加這個後綴並針對最終的路徑發送一個重定向。

public class DefaultFilesMiddleware
{
    private RequestDelegate _next;
    private DefaultFilesOptions _options;

    public DefaultFilesMiddleware(RequestDelegate next, IWebHostEnvironment env, IOptions<DefaultFilesOptions> options)
    {
        _next = next;
        _options = options.Value;
        _options.FileProvider = _options.FileProvider ?? env.WebRootFileProvider;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        //只處理GET請求和HEAD請求
        if (!new string[] { "GET", "HEAD" }.Contains(context.Request.Method, StringComparer.OrdinalIgnoreCase))
        {
            await _next(context);
            return;
        }

        //檢驗當前路徑是否與註冊的請求路徑相匹配
        PathString path = new PathString(context.Request.Path.Value.TrimEnd('/') + "/");
        PathString subpath;
        if (!path.StartsWithSegments(_options.RequestPath, out subpath))
        {
            await _next(context);
            return;
        }

        //檢驗目標目錄是否存在
        if (!_options.FileProvider.GetDirectoryContents(subpath).Exists)
        {
            await _next(context);
            return;
        }

        //檢驗當前目錄是否包含默認文件
        foreach (var fileName in _options.DefaultFileNames)
        {
            if (_options.FileProvider.GetFileInfo($"{subpath}{fileName}").Exists)
            {
                //如果當前路徑不以「/」作為後綴,會響應一個針對「標準」URL的重定向
                if (!context.Request.Path.Value.EndsWith("/"))
                {
                    context.Response.StatusCode = 302;
                    context.Response.GetTypedHeaders().Location = new Uri(path.Value + context.Request.QueryString);
                    return;
                }
                //將針對目錄的URL更新為針對默認文件的URL
                context.Request.Path = new PathString($"{context.Request.Path}{fileName}");
            }
        }
        await _next(context);
    }
}

由於DefaultFilesMiddleware中間件採用URL重寫的方式來響應默認文件,默認文件的內容其實還是通過StaticFileMiddleware中間件予以響應的,所以針對後者的註冊是必需的。也正是這個原因,DefaultFilesMiddleware中間件需要優先註冊,以確保URL重寫發生在StaticFileMiddleware響應文件之前。

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