ASP.NET Core静态文件中间件[5]: DirectoryBrowserMiddleware & DefaultFilesMiddleware

时间:2024-01-27 18:51:38

对于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