理解ASP.NET Core - 檔案伺服器(File Server)

xiaoxiaotank發表於2021-11-02

注:本文隸屬於《理解ASP.NET Core》系列文章,請檢視置頂部落格或點選此處檢視全文目錄

提供靜態檔案

靜態檔案預設存放在 Web根目錄(Web Root) 中,路徑為 專案根目錄(Content Root) 下的wwwroot資料夾,也就是{Content Root}/wwwroot

如果你呼叫了Host.CreateDefaultBuilder方法,那麼在該方法中,會通過UseContentRoot方法,將程式當前工作目錄(Directory.GetCurrentDirectory())設定為專案根目錄。具體可以檢視主機一節。

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            webBuilder.UseStartup<Startup>();
        });

當然,你也可以通過UseWebRoot擴充套件方法將預設的路徑{Content Root}/wwwroot修改為自定義目錄(不過,你改它幹啥捏?)

public static IHostBuilder CreateHostBuilder(string[] args) =>
    Host.CreateDefaultBuilder(args)
        .ConfigureWebHostDefaults(webBuilder =>
        {
            // 配置靜態資源的根目錄為 mywwwroot, 預設為 wwwroot
            webBuilder.UseWebRoot("mywwwroot");

            webBuilder.UseStartup<Startup>();
        });

為了方便,後面均使用 wwwroot 來表示Web根目錄

首先,我們先在 wwwroot 資料夾下建立一個名為 config.json 的檔案,內容隨便填寫

注意,確保 wwwroot 下的檔案的屬性為“如果較新則複製”或“始終複製”。

接著,我們通過UseStaticFiles擴充套件方法,來註冊靜態檔案中介軟體StaticFileMiddleware

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseStaticFiles();
}

現在,嘗試一下通過 http://localhost:5000/config.json 來獲取 wwwroot/config.json 的檔案內容吧

如果你的專案中啟用SwaggerUI,那麼你會發現,即使你沒有手動通過呼叫UseStaticFiles()新增中介軟體,你也可以訪問 wwwroot 檔案下的檔案,這是因為 SwaggerUIMiddleware 中使用了 StaticFileMiddleware

提供Web根目錄之外的檔案

上面我們已經能夠提供 wwwroot 資料夾內的靜態檔案了,那如果我們的檔案不在 wwwroot 資料夾內,那如何提供呢?

很簡單,我們可以針對StaticFileMiddleware中介軟體進行一些額外的配置,瞭解一下配置項:

public abstract class SharedOptionsBase
{
    // 用於自定義靜態檔案的相對請求路徑
    public PathString RequestPath { get; set; }

    // 檔案提供程式
    public IFileProvider FileProvider { get; set; }

    // 是否補全路徑末尾斜槓“/”,並重定向
    public bool RedirectToAppendTrailingSlash { get; set; }
}

public class StaticFileOptions : SharedOptionsBase
{
    // ContentType提供程式
    public IContentTypeProvider ContentTypeProvider { get; set; }
    
    // 如果 ContentTypeProvider 無法識別檔案型別,是否仍作為預設檔案型別提供
    public bool ServeUnknownFileTypes { get; set; }
    
    // 當 ServeUnknownFileTypes = true 時,若出現無法識別的檔案型別,則將該屬性的值作為此檔案的型別
    // 當 ServeUnknownFileTypes = true 時,必須賦值該屬性,才會生效
    public string DefaultContentType { get; set; }
    
    // 當註冊了HTTP響應壓縮中介軟體時,是否對檔案進行壓縮
    public HttpsCompressionMode HttpsCompression { get; set; } = HttpsCompressionMode.Compress;
    
    // 在HTTP響應的 Status Code 和 Headers 設定完畢之後,Body 寫入之前進行呼叫
    // 用於新增或更改 Headers
    public Action<StaticFileResponseContext> OnPrepareResponse { get; set; }
}

假設我們現在有這樣一個檔案目錄結構:

  • wwwroot
    • config.json
  • files
    • file.json

然後,除了用於提供 wwwroot 靜態檔案的中介軟體外,我們還要註冊一個用於提供 files 靜態檔案的中介軟體:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // 提供 wwwroot 靜態檔案
    app.UseStaticFiles();

    // 提供 files 靜態檔案
    app.UseStaticFiles(new StaticFileOptions
    {
        FileProvider = new PhysicalFileProvider(Path.Combine(env.ContentRootPath, "files")),
        // 指定檔案的訪問路徑,允許與 FileProvider 中的資料夾不同名
        // 如果不指定,則可通過 http://localhost:5000/file.json 獲取,
        // 如果指定,則需要通過 http://localhost:5000/files/file.json 獲取
        RequestPath = "/files",
        OnPrepareResponse = ctx =>
        {
            // 配置前端快取 600s(為了後續示例的良好執行,建議先不要配置該Header)
            ctx.Context.Response.Headers.Add(HeaderNames.CacheControl, "public,max-age=600");
        }
    });
}

建議將公開訪問的檔案放置到 wwwroot 目錄下,而將需要授權訪問的檔案放置到其他目錄下(在呼叫UseAuthorization之後呼叫UseStaticFiles並指定檔案目錄)

提供目錄瀏覽

上面,我們可以通過Url訪問某一個檔案的內容,而通過UseDirectoryBrowser,註冊DirectoryBrowserMiddleware中介軟體,可以讓我們在瀏覽器中以目錄的形式來訪問檔案列表。

另外,DirectoryBrowserMiddleware中介軟體的可配置項除了SharedOptionsBase中的之外,還有一個Formatter,用於自定義目錄檢視。

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

示例如下:

public void ConfigureServices(IServiceCollection services)
{
    services.AddDirectoryBrowser();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // 通過 http://localhost:5000,即可訪問 wwwroot 目錄
    app.UseDirectoryBrowser();
    
    // 通過 http://localhost:5000/files,即可訪問 files 目錄
    app.UseDirectoryBrowser(new DirectoryBrowserOptions
    {
        // 如果指定了沒有在 UseStaticFiles 中提供的檔案目錄,雖然可以瀏覽檔案列表,但是無法訪問檔案內容
        FileProvider = new PhysicalFileProvider(Path.Combine(env.ContentRootPath, "files")),
        // 這裡一定要和 StaticFileOptions 中的 RequestPath 一致,否則會無法訪問檔案
        RequestPath = "/files"
    });
}

提供預設頁

通過UseDefaultFiles,註冊DefaultFilesMiddleware中介軟體,允許在訪問靜態檔案、但未提供檔名的情況下(即傳入的是一個目錄的路徑),提供預設頁的展示。

注意:UseDefaultFiles必須在UseStaticFiles之前進行呼叫。因為DefaultFilesMiddleware僅僅負責重寫Url,實際上預設頁檔案,仍然是通過StaticFilesMiddleware來提供的。

預設情況下,該中介軟體會按照順序搜尋檔案目錄下的HTML頁面檔案:

  • default.htm
  • default.html
  • index.htm
  • index.html

另外,DefaultFilesMiddleware中介軟體的可配置項除了SharedOptionsBase中的之外,還有一個DefaultFileNames,是個列表,用於自定義預設頁的檔名,裡面的預設值就是上面提到的4個檔名。

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

示例如下:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // 會去 wwwroot 尋找 default.htm 、default.html 、index.htm 或 index.html 檔案作為預設頁
    app.UseDefaultFiles();

    // 設定 files 目錄的預設頁
    var defaultFilesOptions = new DefaultFilesOptions();
    defaultFilesOptions.DefaultFileNames.Clear();
    // 指定預設頁名稱
    defaultFilesOptions.DefaultFileNames.Add("index1.html");
    // 指定請求路徑
    defaultFilesOptions.RequestPath = "/files";
    // 指定預設頁所在的目錄
    defaultFilesOptions.FileProvider = new PhysicalFileProvider(Path.Combine(env.ContentRootPath, "files"));

    app.UseDefaultFiles(defaultFilesOptions);
}

UseFileServer

UseFileServer整合了UseStaticFilesUseDefaultFilesUseDirectoryBrowser的功能,用起來方便一些,也是我們專案中使用的首選擴充套件方法。

先看一下FileServerOptions

public class FileServerOptions : SharedOptionsBase
{
    public FileServerOptions()
        : base(new SharedOptions())
    {
        StaticFileOptions = new StaticFileOptions(SharedOptions);
        DirectoryBrowserOptions = new DirectoryBrowserOptions(SharedOptions);
        DefaultFilesOptions = new DefaultFilesOptions(SharedOptions);
        EnableDefaultFiles = true;
    }

    public StaticFileOptions StaticFileOptions { get; private set; }

    public DirectoryBrowserOptions DirectoryBrowserOptions { get; private set; }

    public DefaultFilesOptions DefaultFilesOptions { get; private set; }

    // 預設禁用目錄瀏覽
    public bool EnableDirectoryBrowsing { get; set; }

    // 預設啟用預設頁(在建構函式中初始化的)
    public bool EnableDefaultFiles { get; set; }
}

可以看到,FileServerOptions包含了StaticFileOptionsDirectoryBrowserOptionsDefaultFilesOptions三個選項,可以針對StaticFileMiddlewareDirectoryBrowserMiddlewareDefaultFilesMiddleware進行自定義配置。另外,其預設啟用了靜態檔案和預設頁,禁用了目錄瀏覽。

下面舉個例子熟悉一下:

假設檔案目錄:

  • files
    • images
      • 1.jpg
    • file.json
    • myindex.html
public void ConfigureServices(IServiceCollection services)
{
    // 如果將 EnableDirectoryBrowsing 設為 true,記得註冊服務
    services.AddDirectoryBrowser();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{   
    // 啟用 StaticFileMiddleware
    // 啟用 DefaultFilesMiddleware
    // 禁用 DirectoryBrowserMiddleware
    // 預設指向 wwwroot
    app.UseFileServer();
    
    // 針對 files 資料夾配置
    var fileServerOptions = new FileServerOptions
    {
        FileProvider = new PhysicalFileProvider(Path.Combine(env.ContentRootPath, "files")),
        RequestPath = "/files",
        EnableDirectoryBrowsing = true
    };
    fileServerOptions.StaticFileOptions.OnPrepareResponse = ctx =>
    {
        // 配置快取600s
        ctx.Context.Response.Headers.Add(HeaderNames.CacheControl, "public,max-age=600");
    };
    fileServerOptions.DefaultFilesOptions.DefaultFileNames.Clear();
    fileServerOptions.DefaultFilesOptions.DefaultFileNames.Add("myindex.html");
    app.UseFileServer(fileServerOptions);
}

當訪問 http://localhost:5000/files 時,由於在DefaultFilesOptions.DefaultFileNames中新增了檔名myindex.html,所以可以找到預設頁,此時會顯示預設頁的內容。

假如我們沒有在DefaultFilesOptions.DefaultFileNames中新增檔名myindex.html,那麼便找不到預設頁,但由於啟用了DirectoryBrowsing,所以此時會展示檔案列表。

核心配置項

FileProvider

上面我們已經見過PhysicalFileProvider了,它僅僅是眾多檔案提供程式中的一種。所有的檔案提供程式均實現了IFileProvider介面:

public interface IFileProvider
{
    // 獲取給定路徑的目錄資訊,可列舉該目錄中的所有檔案
    IDirectoryContents GetDirectoryContents(string subpath);

    // 獲取給定路徑的檔案資訊
    IFileInfo GetFileInfo(string subpath);

    // 建立指定 filter 的 ChangeToken
    IChangeToken Watch(string filter);
}

public interface IDirectoryContents : IEnumerable<IFileInfo>, IEnumerable
{
    bool Exists { get; }
}

public interface IFileInfo
{
    bool Exists { get; }

    bool IsDirectory { get; } 

    DateTimeOffset LastModified { get; }

    // 位元組(bytes)長度
    // 如果是目錄或檔案不存在,則是 -1
    long Length { get; }

    // 目錄或檔名,純檔名,不包括路徑
    string Name { get; }

    // 檔案路徑,包含檔名
    // 如果檔案無法直接訪問,則返回 null
    string PhysicalPath { get; }

    // 建立該檔案只讀流
    Stream CreateReadStream();
}

常用的檔案提供程式有以下三種:

  • PhysicalFileProvider
  • ManifestEmbeddedFileProvider
  • CompositeFileProvider

glob模式

在介紹這三種檔案提供程式之前,先說一下glob模式,即萬用字元模式。兩個萬用字元分別是***

  • *:匹配當前目錄層級(不包含子目錄)下的任何內容、任何檔名或任何副檔名,可以通過/\.進行分隔。
  • **:匹配目錄多層級(包含子目錄)的任何內容,用於遞迴匹配多層級目錄的多個檔案。

PhysicalFileProvider

PhysicalFileProvider用於提供物理檔案系統的訪問。該提供程式需要將檔案路徑範圍限定在一個目錄及其子目錄中,不能訪問目錄外部的內容。

當例項化該檔案提供程式時,需要提供一個絕對的目錄路徑,作為檔案目錄的root。

PhysicalFileProvider目錄或檔案路徑不支援glob(萬用字元)模式。

ManifestEmbeddedFileProvider

ManifestEmbeddedFileProvider用於提供嵌入在程式集中的檔案的訪問。

可能你對這個嵌入檔案比較陌生,沒關係,請按照下面的步驟來:

  • 安裝Nuget包:Install-Package Microsoft.Extensions.FileProviders.Embedded
  • 編輯.csproj檔案:
    • 新增<GenerateEmbeddedFilesManifest>,並設定為true
    • 使用<EmbeddedResource>新增要嵌入的檔案

以下是 .csproj 檔案的示例:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.FileProviders.Embedded" Version="5.0.11" />
  </ItemGroup>

  <ItemGroup>
    <EmbeddedResource Include="files\**" />
  </ItemGroup>
</Project>

現在我們通過ManifestEmbeddedFileProvider來提供嵌入到程式集的 files 目錄下檔案的訪問:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
     var fileServerOptions = new FileServerOptions();
    fileServerOptions.StaticFileOptions.FileProvider = new ManifestEmbeddedFileProvider(Assembly.GetExecutingAssembly(), "/files");
    fileServerOptions.StaticFileOptions.RequestPath = "/files";

    app.UseFileServer(fileServerOptions);
}

現在,你可以通過 http://localhost:5000/files/file.json 來訪問檔案了。

CompositeFileProvider

CompositeFileProvider用於將多種檔案提供程式進行整合。

如:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    var fileServerOptions = new FileServerOptions();
    var fileProvider = new CompositeFileProvider(
        env.WebRootFileProvider,
        new ManifestEmbeddedFileProvider(Assembly.GetExecutingAssembly(), "/files")
    );
    fileServerOptions.StaticFileOptions.FileProvider = fileProvider;
    fileServerOptions.StaticFileOptions.RequestPath = "/composite";

    app.UseFileServer(fileServerOptions);
}

現在,你可以通過 http://localhost:5000/composite/file.json 來訪問檔案了。

ContentTypeProvider

Http請求頭中的Content-Type大家一定很熟悉,ContentTypeProvider就是用來提供副檔名和MIME型別對映關係的。

若我們沒有顯示指定ContentTypeProvider,則框架預設使用FileExtensionContentTypeProvider,其實現了介面IContentTypeProvider

public interface IContentTypeProvider
{
    // 嘗試根據檔案路徑,獲取對應的 MIME 型別
    bool TryGetContentType(string subpath, out string contentType);
}

public class FileExtensionContentTypeProvider : IContentTypeProvider
{
    public FileExtensionContentTypeProvider()
        : this(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
        {
            // ...此處省略一萬字
        }
    {
    }

    public FileExtensionContentTypeProvider(IDictionary<string, string> mapping)
    {
        Mappings = mapping;
    }

    public IDictionary<string, string> Mappings { get; private set; }

    public bool TryGetContentType(string subpath, out string contentType)
    {
        string extension = GetExtension(subpath);
        if (extension == null)
        {
            contentType = null;
            return false;
        }
        return Mappings.TryGetValue(extension, out contentType);
    }

    private static string GetExtension(string path)
    {
        // 沒有使用 Path.GetExtension() 的原因是:當路徑中存在無效字元時,其會丟擲異常,而這裡不應丟擲異常。

        if (string.IsNullOrWhiteSpace(path))
        {
            return null;
        }

        int index = path.LastIndexOf('.');
        if (index < 0)
        {
            return null;
        }

        return path.Substring(index);
    }
}

FileExtensionContentTypeProvider的無參建構函式中,預設新增了380種已知的副檔名和MIME型別的對映,存放在Mappings屬性中。你也可以新增自定義的對映,或移除不想要的對映。

核心中介軟體

StaticFileMiddleware

通過UseStaticFiles擴充套件方法,可以方便的註冊StaticFileMiddleware中介軟體:

public static class StaticFileExtensions
{
    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app)
    {
        return app.UseMiddleware<StaticFileMiddleware>();
    }
    
    public static IApplicationBuilder UseStaticFiles(this IApplicationBuilder app, string requestPath)
    {
        return app.UseStaticFiles(new StaticFileOptions
        {
            RequestPath = new PathString(requestPath)
        });
    }

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

緊接著檢視StaticFileMiddlewareInvoke方法:

public class StaticFileMiddleware
{
    private readonly StaticFileOptions _options;
    private readonly PathString _matchUrl;
    private readonly RequestDelegate _next;
    private readonly ILogger _logger;
    private readonly IFileProvider _fileProvider;
    private readonly IContentTypeProvider _contentTypeProvider;

    public StaticFileMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptions<StaticFileOptions> options, ILoggerFactory loggerFactory)
    {
        _next = next;
        _options = options.Value;
        // 若未指定 ContentTypeProvider,則預設使用 FileExtensionContentTypeProvider
        _contentTypeProvider = _options.ContentTypeProvider ?? new FileExtensionContentTypeProvider();
        // 若未指定 FileProvider,則預設使用 hostingEnv.WebRootFileProvider
        _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
        _matchUrl = _options.RequestPath;
        _logger = loggerFactory.CreateLogger<StaticFileMiddleware>();
    }

    public Task Invoke(HttpContext context)
    {
        // 若已匹配到 Endpoint,則跳過
        if (!ValidateNoEndpoint(context))
        {
            _logger.EndpointMatched();
        }
        // 若HTTP請求方法不是 Get,也不是 Head,則跳過
        else if (!ValidateMethod(context))
        {
            _logger.RequestMethodNotSupported(context.Request.Method);
        }
        // 如果請求路徑不匹配,則跳過
        else if (!ValidatePath(context, _matchUrl, out var subPath))
        {
            _logger.PathMismatch(subPath);
        }
        // 如果 ContentType 不受支援,則跳過
        else if (!LookupContentType(_contentTypeProvider, _options, subPath, out var contentType))
        {
            _logger.FileTypeNotSupported(subPath);
        }
        else
        {
            // 嘗試提供靜態檔案
            return TryServeStaticFile(context, contentType, subPath);
        }

        return _next(context);
    }

    private static bool ValidateNoEndpoint(HttpContext context) => context.GetEndpoint() == null;

    private static bool ValidateMethod(HttpContext context) => Helpers.IsGetOrHeadMethod(context.Request.Method);

    internal static bool ValidatePath(HttpContext context, PathString matchUrl, out PathString subPath) => Helpers.TryMatchPath(context, matchUrl, forDirectory: false, out subPath);

    internal static bool LookupContentType(IContentTypeProvider contentTypeProvider, StaticFileOptions options, PathString subPath, out string contentType)
    {
        // 檢視 Provider 中是否支援該 ContentType
        if (contentTypeProvider.TryGetContentType(subPath.Value, out contentType))
        {
            return true;
        }

        // 如果提供未知檔案型別,則將其設定為預設 ContentType
        if (options.ServeUnknownFileTypes)
        {
            contentType = options.DefaultContentType;
            return true;
        }

        return false;
    }

    private Task TryServeStaticFile(HttpContext context, string contentType, PathString subPath)
    {
        var fileContext = new StaticFileContext(context, _options, _logger, _fileProvider, contentType, subPath);

        // 如果檔案不存在,則跳過
        if (!fileContext.LookupFileInfo())
        {
            _logger.FileNotFound(fileContext.SubPath);
        }
        else
        {
            // 若檔案存在,則提供該靜態檔案
            return fileContext.ServeStaticFile(context, _next);
        }

        return _next(context);
    }
}

DirectoryBrowserMiddleware

通過UseDirectoryBrowser擴充套件方法,可以方便的註冊DirectoryBrowserMiddleware中介軟體:

public static class DirectoryBrowserExtensions
{
    public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app)
    {
        return app.UseMiddleware<DirectoryBrowserMiddleware>();
    }

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

    public static IApplicationBuilder UseDirectoryBrowser(this IApplicationBuilder app, DirectoryBrowserOptions options)
    {
        return app.UseMiddleware<DirectoryBrowserMiddleware>(Options.Create(options));
    }
}

緊接著檢視DirectoryBrowserMiddlewareInvoke方法:

public class DirectoryBrowserMiddleware
{
    private readonly DirectoryBrowserOptions _options;
    private readonly PathString _matchUrl;
    private readonly RequestDelegate _next;
    private readonly IDirectoryFormatter _formatter;
    private readonly IFileProvider _fileProvider;

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

    public DirectoryBrowserMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, HtmlEncoder encoder, IOptions<DirectoryBrowserOptions> options)
    {
        _next = next;
        _options = options.Value;
        // 若未指定 FileProvider,則預設使用 hostingEnv.WebRootFileProvider
        _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
        _formatter = _options.Formatter ?? new HtmlDirectoryFormatter(encoder);
        _matchUrl = _options.RequestPath;
    }

    public Task Invoke(HttpContext context)
    {
        // 若已匹配到 Endpoint,則跳過
        // 若HTTP請求方法不是 Get,也不是 Head,則跳過
        // 如果請求路徑不匹配,則跳過
        // 若檔案目錄不存在,則跳過
        if (context.GetEndpoint() == null
            && Helpers.IsGetOrHeadMethod(context.Request.Method)
            && Helpers.TryMatchPath(context, _matchUrl, forDirectory: true, subpath: out var subpath)
            && TryGetDirectoryInfo(subpath, out var contents))
        {
            if (_options.RedirectToAppendTrailingSlash && !Helpers.PathEndsInSlash(context.Request.Path))
            {
                Helpers.RedirectToPathWithSlash(context);
                return Task.CompletedTask;
            }

            // 生成檔案瀏覽檢視
            return _formatter.GenerateContentAsync(context, contents);
        }

        return _next(context);
    }

    private bool TryGetDirectoryInfo(PathString subpath, out IDirectoryContents contents)
    {
        contents = _fileProvider.GetDirectoryContents(subpath.Value);
        return contents.Exists;
    }
}

DefaultFilesMiddleware

通過UseDefaultFiles擴充套件方法,可以方便的註冊DefaultFilesMiddleware中介軟體:

public static class DefaultFilesExtensions
{
    public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app)
    {
        return app.UseMiddleware<DefaultFilesMiddleware>();
    }

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

    public static IApplicationBuilder UseDefaultFiles(this IApplicationBuilder app, DefaultFilesOptions options)
    {
        return app.UseMiddleware<DefaultFilesMiddleware>(Options.Create(options));
    }
}

緊接著檢視DefaultFilesMiddlewareInvoke方法:

public class DefaultFilesMiddleware
{
    private readonly DefaultFilesOptions _options;
    private readonly PathString _matchUrl;
    private readonly RequestDelegate _next;
    private readonly IFileProvider _fileProvider;

    public DefaultFilesMiddleware(RequestDelegate next, IWebHostEnvironment hostingEnv, IOptions<DefaultFilesOptions> options)
    {
        _next = next;
        _options = options.Value;
        // 若未指定 FileProvider,則預設使用 hostingEnv.WebRootFileProvider
        _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
        _matchUrl = _options.RequestPath;
    }
    
    public Task Invoke(HttpContext context)
    {
        // 若已匹配到 Endpoint,則跳過
        // 若HTTP請求方法不是 Get,也不是 Head,則跳過
        // 如果請求路徑不匹配,則跳過
        if (context.GetEndpoint() == null
            && Helpers.IsGetOrHeadMethod(context.Request.Method)
            && Helpers.TryMatchPath(context, _matchUrl, forDirectory: true, subpath: out var subpath))
        {
            var dirContents = _fileProvider.GetDirectoryContents(subpath.Value);
            if (dirContents.Exists)
            {
                for (int matchIndex = 0; matchIndex < _options.DefaultFileNames.Count; matchIndex++)
                {
                    string defaultFile = _options.DefaultFileNames[matchIndex];
                    var file = _fileProvider.GetFileInfo(subpath.Value + defaultFile);
                    
                    // 找到了預設頁
                    if (file.Exists)
                    {
                        if (_options.RedirectToAppendTrailingSlash && !Helpers.PathEndsInSlash(context.Request.Path))
                        {
                            Helpers.RedirectToPathWithSlash(context);
                            return Task.CompletedTask;
                        }
                        
                        // 重寫為預設頁的Url,後續通過 StaticFileMiddleware 提供該頁面
                        context.Request.Path = new PathString(Helpers.GetPathValueWithSlash(context.Request.Path) + defaultFile);
                        break;
                    }
                }
            }
        }

        return _next(context);
    }
}

FileServer

FileServer並不是某個具體的中介軟體,它的實現還是依賴了StaticFileMiddlewareDirectoryBrowserMiddlewareDefaultFilesMiddleware這3箇中介軟體。不過,我們可以看一下UseFileServer裡的邏輯:

public static class FileServerExtensions
{
    public static IApplicationBuilder UseFileServer(this IApplicationBuilder app)
    {
        return app.UseFileServer(new FileServerOptions());
    }

    public static IApplicationBuilder UseFileServer(this IApplicationBuilder app, bool enableDirectoryBrowsing)
    {
        return app.UseFileServer(new FileServerOptions
        {
            EnableDirectoryBrowsing = enableDirectoryBrowsing
        });
    }

    public static IApplicationBuilder UseFileServer(this IApplicationBuilder app, string requestPath)
    {
        return app.UseFileServer(new FileServerOptions
        {
            RequestPath = new PathString(requestPath)
        });
    }

    public static IApplicationBuilder UseFileServer(this IApplicationBuilder app, FileServerOptions options)
    {
        // 啟用預設頁
        if (options.EnableDefaultFiles)
        {
            app.UseDefaultFiles(options.DefaultFilesOptions);
        }

        // 啟用目錄瀏覽
        if (options.EnableDirectoryBrowsing)
        {
            app.UseDirectoryBrowser(options.DirectoryBrowserOptions);
        }

        return app.UseStaticFiles(options.StaticFileOptions);
    }
}

FileProvider in IWebHostingEnvironment

在介面IHostingEnvironment中,包含ContentRootFileProviderWebRootFileProvider兩個檔案提供程式。下面我們就看一下他們是如何被初始化的。

internal class GenericWebHostBuilder : IWebHostBuilder, ISupportsStartup, ISupportsUseDefaultServiceProvider
{
    private WebHostBuilderContext GetWebHostBuilderContext(HostBuilderContext context)
    {
        if (!context.Properties.TryGetValue(typeof(WebHostBuilderContext), out var contextVal))
        {
            var options = new WebHostOptions(context.Configuration, Assembly.GetEntryAssembly()?.GetName().Name);
            var webHostBuilderContext = new WebHostBuilderContext
            {
                Configuration = context.Configuration,
                HostingEnvironment = new HostingEnvironment(),
            };
            
            // 重點在這裡,看這個 Initialize 方法
            webHostBuilderContext.HostingEnvironment.Initialize(context.HostingEnvironment.ContentRootPath, options);
            context.Properties[typeof(WebHostBuilderContext)] = webHostBuilderContext;
            context.Properties[typeof(WebHostOptions)] = options;
            return webHostBuilderContext;
        }

        var webHostContext = (WebHostBuilderContext)contextVal;
        webHostContext.Configuration = context.Configuration;
        return webHostContext;
    }
}

internal static class HostingEnvironmentExtensions
{
    internal static void Initialize(this IWebHostEnvironment hostingEnvironment, string contentRootPath, WebHostOptions options)
    {
        hostingEnvironment.ApplicationName = options.ApplicationName;
        hostingEnvironment.ContentRootPath = contentRootPath;
        // 初始化 ContentRootFileProvider
        hostingEnvironment.ContentRootFileProvider = new PhysicalFileProvider(hostingEnvironment.ContentRootPath);

        var webRoot = options.WebRoot;
        if (webRoot == null)
        {
            // 如果 /wwwroot 目錄存在,則設定為Web根目錄
            var wwwroot = Path.Combine(hostingEnvironment.ContentRootPath, "wwwroot");
            if (Directory.Exists(wwwroot))
            {
                hostingEnvironment.WebRootPath = wwwroot;
            }
        }
        else
        {
            hostingEnvironment.WebRootPath = Path.Combine(hostingEnvironment.ContentRootPath, webRoot);
        }

        if (!string.IsNullOrEmpty(hostingEnvironment.WebRootPath))
        {
            hostingEnvironment.WebRootPath = Path.GetFullPath(hostingEnvironment.WebRootPath);
            if (!Directory.Exists(hostingEnvironment.WebRootPath))
            {
                Directory.CreateDirectory(hostingEnvironment.WebRootPath);
            }
            
            // 初始化 WebRootFileProvider
            hostingEnvironment.WebRootFileProvider = new PhysicalFileProvider(hostingEnvironment.WebRootPath);
        }
        else
        {
            hostingEnvironment.WebRootFileProvider = new NullFileProvider();
        }

        hostingEnvironment.EnvironmentName =
            options.Environment ??
            hostingEnvironment.EnvironmentName;
    }
}

注意

  • 使用UseDirectoryBrowserUseStaticFiles提供檔案瀏覽和訪問時,URL 受大小寫和基礎檔案系統字元的限制。例如,Windows 不區分大小寫,但 macOS 和 Linux 區分大小寫。
  • 如果使用 IIS 託管應用,那麼 IIS 自帶的靜態檔案處理器是不工作的,均是使用 ASP.NET Core Module 進行處理的,包括靜態檔案處理。

小結

  • 使用UseFileServer擴充套件方法提供檔案瀏覽和訪問,其整合了UseStaticFilesUseDirectoryBrowserUseDefaultFiles三個中介軟體的功能。
    • UseStaticFiles:註冊StaticFilesMiddleware,提供檔案訪問
    • UseDirectoryBrowser:註冊DirectoryBrowserMiddleware,提供檔案目錄瀏覽
    • UseDefaultFiles:註冊DefaultFilesMiddleware,當Url未指定訪問的檔名時,提供預設頁。
  • 檔案提供程式均實現了介面IFileProvider,常用的檔案提供程式有以下三種:
    • PhysicalFileProvider:提供物理檔案系統的訪問
    • ManifestEmbeddedFileProvider:提供嵌入在程式集中的檔案的訪問
    • CompositeFileProvider:用於將多種檔案提供程式進行整合。
  • 可通過IWebHostingEnvironment獲取ContentRootFileProvider(預設目錄為專案根目錄)和WebRootFileProvider(預設目錄為Web根目錄)。

相關文章