ASP.NET Core靜態檔案處理原始碼探究

yi念之間發表於2020-07-16

前言

    靜態檔案(如 HTML、CSS、影像和 JavaScript)等是Web程式的重要組成部分。傳統的ASP.NET專案一般都是部署在IIS上,IIS是一個功能非常強大的伺服器平臺,可以直接處理接收到的靜態檔案處理而不需要經過應用程式池處理,所以很多情況下對於靜態檔案的處理程式本身是無感知的。ASP.NET Core則不同,作為Server的Kestrel服務是宿主到程式上的,由宿主執行程式啟動Server然後可以監聽請求,所以通過程式我們直接可以處理靜態檔案相關。靜態檔案預設儲存到專案的wwwroot目錄中,當然我們也可以自定義任意目錄去處理靜態檔案。總之,在ASP.NET Core我們可以處理靜態檔案相關的請求。

StaticFile三劍客

    通常我們在說道靜態檔案相關的時候會涉及到三個話題分別是啟用靜態檔案、預設靜態頁面、靜態檔案目錄瀏覽,在ASP.NET Core分別是通過UseStaticFiles、UseDefaultFiles、UseDirectoryBrowser三個中介軟體去處理。只有配置了相關中介軟體才能去操作對應的處理,相信大家對這種操作已經很熟了。靜態檔案操作相關的原始碼都位於GitHub aspnetcore倉庫中的https://github.com/dotnet/aspnetcore/tree/v3.1.6/src/Middleware/StaticFiles/src目錄。接下來我們分別探究這三個中介軟體的相關程式碼,來揭開靜態檔案處理的神祕面紗。

UseStaticFiles

UseStaticFiles中介軟體使我們處理靜態檔案時最常使用的中介軟體,因為只有開啟了這個中介軟體我們才能使用靜態檔案,比如在使用MVC開發的時候需要私用js css html等檔案都需要用到它,使用的方式也比較簡單

//使用預設路徑,即wwwroot
app.UseStaticFiles();
//或自定義讀取路徑
var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/staticfiles");
app.UseStaticFiles(new StaticFileOptions {
    RequestPath="/staticfiles",
    FileProvider = fileProvider
});

我們直接找到中介軟體的註冊類StaticFileExtensions[點選檢視StaticFileExtensions原始碼]

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));
    }
}

一般我們最常用到的是無參的方式和傳遞自定義StaticFileOptions的方式比較多,StaticFileOptions是自定義使用靜態檔案時的配置資訊類,接下來我們大致看一下具體包含哪些配置項[點選檢視StaticFileOptions原始碼]

public class StaticFileOptions : SharedOptionsBase
{
    public StaticFileOptions() : this(new SharedOptions())
    {
    }

    public StaticFileOptions(SharedOptions sharedOptions) : base(sharedOptions)
    {
        OnPrepareResponse = _ => { };
    }

    /// <summary>
    /// 檔案型別提供程式,也就是我們常用的檔名對應MimeType的對應關係
    /// </summary>
    public IContentTypeProvider ContentTypeProvider { get; set; }

    /// <summary>
    /// 設定該路徑下預設檔案輸出型別
    /// </summary>
    public string DefaultContentType { get; set; }

    public bool ServeUnknownFileTypes { get; set; }
    
    /// <summary>
    /// 檔案壓縮方式
    /// </summary>
    public HttpsCompressionMode HttpsCompression { get; set; } = HttpsCompressionMode.Compress;

    /// <summary>
    /// 準備輸出之前可以做一些自定義操作
    /// </summary>
    public Action<StaticFileResponseContext> OnPrepareResponse { get; set; }
}

public abstract class SharedOptionsBase
{
    protected SharedOptionsBase(SharedOptions sharedOptions)
    {
        SharedOptions = sharedOptions;
    }

    protected SharedOptions SharedOptions { get; private set; }
    
    /// <summary>
    /// 請求路徑
    /// </summary>
    public PathString RequestPath
    {
        get { return SharedOptions.RequestPath; }
        set { SharedOptions.RequestPath = value; }
    }

    /// <summary>
    /// 檔案提供程式,在.NET Core中如果需要訪問檔案相關操作可使用FileProvider檔案提供程式獲取檔案相關資訊
    /// </summary>
    public IFileProvider FileProvider
    {
        get { return SharedOptions.FileProvider; }
        set { SharedOptions.FileProvider = value; }
    }
}

我們自定義靜態檔案訪問時,最常用到的就是RequestPath和FileProvider,一個設定請求路徑資訊,一個設定讀取檔案資訊。如果需要自定義MimeType對映關係可通過ContentTypeProvider自定義設定對映關係

var provider = new FileExtensionContentTypeProvider();
provider.Mappings[".myapp"] = "application/x-msdownload";
provider.Mappings[".htm3"] = "text/html";
app.UseStaticFiles(new StaticFileOptions
{
    ContentTypeProvider = provider,
    //可以在輸出之前設定輸出相關
    OnPrepareResponse = ctx =>
    {
        ctx.Context.Response.Headers.Append("Cache-Control", $"public, max-age=3600");
    }
});

接下來我們步入正題直接檢視StaticFileMiddleware中介軟體的程式碼[點選檢視StaticFileMiddleware原始碼]

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 = options.Value.ContentTypeProvider ?? new FileExtensionContentTypeProvider();
       //檔案提供程式
        _fileProvider = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
       //匹配路徑
        _matchUrl = _options.RequestPath;
        _logger = loggerFactory.CreateLogger<StaticFileMiddleware>();
    }

    public Task Invoke(HttpContext context)
    {
        //判斷是夠獲取到終結點資訊,這也就是為什麼我們使用UseStaticFiles要在UseRouting之前
        if (!ValidateNoEndpoint(context))
        {
        }
        //判斷HttpMethod,只能是Get和Head操作
        else if (!ValidateMethod(context))
        {
        }
        //判斷請求路徑是否存在
        else if (!ValidatePath(context, _matchUrl, out var subPath))
        {
        }
        //根據請求檔名稱判斷是否可以匹配到對應的MimeType,如果匹配到則返回contentType
        else if (!LookupContentType(_contentTypeProvider, _options, subPath, out var contentType))
        {
        }
        else
        {   
            //執行靜態檔案操作
            return TryServeStaticFile(context, contentType, subPath);
        }
        return _next(context);
    }

    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);
    }
}

關於FileExtensionContentTypeProvider這裡就不作講解了,主要是承載副檔名和MimeType的對映關係程式碼不復雜,但是對映關係比較多,有興趣的可以自行檢視FileExtensionContentTypeProvider原始碼,通過上面我們可以看到,最終執行檔案相關操作的是StaticFileContext類[點選檢視StaticFileContext原始碼]

internal struct StaticFileContext
{
    private const int StreamCopyBufferSize = 64 * 1024;

    private readonly HttpContext _context;
    private readonly StaticFileOptions _options;
    private readonly HttpRequest _request;
    private readonly HttpResponse _response;
    private readonly ILogger _logger;
    private readonly IFileProvider _fileProvider;
    private readonly string _method;
    private readonly string _contentType;

    private IFileInfo _fileInfo;
    private EntityTagHeaderValue _etag;
    private RequestHeaders _requestHeaders;
    private ResponseHeaders _responseHeaders;
    private RangeItemHeaderValue _range;

    private long _length;
    private readonly PathString _subPath;
    private DateTimeOffset _lastModified;

    private PreconditionState _ifMatchState;
    private PreconditionState _ifNoneMatchState;
    private PreconditionState _ifModifiedSinceState;
    private PreconditionState _ifUnmodifiedSinceState;

    private RequestType _requestType;

    public StaticFileContext(HttpContext context, StaticFileOptions options, ILogger logger, IFileProvider fileProvider, string contentType, PathString subPath)
    {
        _context = context;
        _options = options;
        _request = context.Request;
        _response = context.Response;
        _logger = logger;
        _fileProvider = fileProvider;
        _method = _request.Method;
        _contentType = contentType;
        _fileInfo = null;
        _etag = null;
        _requestHeaders = null;
        _responseHeaders = null;
        _range = null;

        _length = 0;
        _subPath = subPath;
        _lastModified = new DateTimeOffset();
        _ifMatchState = PreconditionState.Unspecified;
        _ifNoneMatchState = PreconditionState.Unspecified;
        _ifModifiedSinceState = PreconditionState.Unspecified;
        _ifUnmodifiedSinceState = PreconditionState.Unspecified;
        //再次判斷請求HttpMethod
        if (HttpMethods.IsGet(_method))
        {
            _requestType = RequestType.IsGet;
        }
        else if (HttpMethods.IsHead(_method))
        {
            _requestType = RequestType.IsHead;
        }
        else
        {
            _requestType = RequestType.Unspecified;
        }
    }

    /// <summary>
    /// 判斷檔案是否存在
    /// </summary>
    public bool LookupFileInfo()
    {
        //判斷根據請求路徑是否可以獲取到檔案資訊
        _fileInfo = _fileProvider.GetFileInfo(_subPath.Value);
        if (_fileInfo.Exists)
        {
            //獲取檔案長度
            _length = _fileInfo.Length;
            //最後修改日期
            DateTimeOffset last = _fileInfo.LastModified;
            _lastModified = new DateTimeOffset(last.Year, last.Month, last.Day, last.Hour, last.Minute, last.Second, last.Offset).ToUniversalTime();
            //ETag標識
            long etagHash = _lastModified.ToFileTime() ^ _length;
            _etag = new EntityTagHeaderValue('\"' + Convert.ToString(etagHash, 16) + '\"');
        }
        return _fileInfo.Exists;
    }
    
    /// <summary>
    /// 處理檔案輸出
    /// </summary>
    public async Task ServeStaticFile(HttpContext context, RequestDelegate next)
    {
        //1.準備輸出相關Header,主要是獲取和輸出靜態檔案輸出快取相關的內容
        //2.我們之前提到的OnPrepareResponse也是在這裡執行的
        ComprehendRequestHeaders();
        //根據ComprehendRequestHeaders方法獲取到的檔案狀態進行判斷
        switch (GetPreconditionState())
        {
            case PreconditionState.Unspecified:
            //處理檔案輸出
            case PreconditionState.ShouldProcess:
                //判斷是否是Head請求
                if (IsHeadMethod)
                {
                    await SendStatusAsync(Constants.Status200Ok);
                    return;
                }
                try
                {
                    //判斷是否包含range請求,即檔案分段下載的情況
                    if (IsRangeRequest)
                    {
                        await SendRangeAsync();
                        return;
                    }
                    //正常檔案輸出處理
                    await SendAsync();
                    _logger.FileServed(SubPath, PhysicalPath);
                    return;
                }
                catch (FileNotFoundException)
                {
                    context.Response.Clear();
                }
                await next(context);
                return;
            case PreconditionState.NotModified:
                await SendStatusAsync(Constants.Status304NotModified);
                return;
            case PreconditionState.PreconditionFailed:
                await SendStatusAsync(Constants.Status412PreconditionFailed);
                return;
            default:
                var exception = new NotImplementedException(GetPreconditionState().ToString());
                throw exception;
        }
    }

    /// <summary>
    /// 通用檔案檔案返回處理
    /// </summary>
    public async Task SendAsync()
    {
        SetCompressionMode();
        ApplyResponseHeaders(Constants.Status200Ok);
        string physicalPath = _fileInfo.PhysicalPath;
        var sendFile = _context.Features.Get<IHttpResponseBodyFeature>();
        //判斷是否設定過輸出特徵操作相關,比如是否啟動輸出壓縮,或者自定義的輸出處理比如輸出加密等等
        if (sendFile != null && !string.IsNullOrEmpty(physicalPath))
        {
            await sendFile.SendFileAsync(physicalPath, 0, _length, CancellationToken.None);
            return;
        }
        try
        {
            //不存在任何特殊處理的操作作,直接讀取檔案返回
            using (var readStream = _fileInfo.CreateReadStream())
            {
                await StreamCopyOperation.CopyToAsync(readStream, _response.Body, _length, StreamCopyBufferSize, _context.RequestAborted);
            }
        }
        catch (OperationCanceledException ex)
        {
            _context.Abort();
        }
    }

    /// <summary>
    /// 分段請求下載操作處理
    /// </summary>
    internal async Task SendRangeAsync()
    {
        if (_range == null)
        {
            ResponseHeaders.ContentRange = new ContentRangeHeaderValue(_length);
            ApplyResponseHeaders(Constants.Status416RangeNotSatisfiable);
            _logger.RangeNotSatisfiable(SubPath);
            return;
        }
        //計算range相關header資料
        ResponseHeaders.ContentRange = ComputeContentRange(_range, out var start, out var length);
        _response.ContentLength = length;
        //設定輸出壓縮相關header
        SetCompressionMode();
        ApplyResponseHeaders(Constants.Status206PartialContent);

        string physicalPath = _fileInfo.PhysicalPath;
        var sendFile = _context.Features.Get<IHttpResponseBodyFeature>();
        //判斷是否設定過輸出特徵操作相關,比如是否啟動輸出壓縮,或者自定義的輸出處理比如輸出加密等等
        if (sendFile != null && !string.IsNullOrEmpty(physicalPath))
        {
            _logger.SendingFileRange(_response.Headers[HeaderNames.ContentRange], physicalPath);
            await sendFile.SendFileAsync(physicalPath, start, length, CancellationToken.None);
            return;
        }
        try
        {
            using (var readStream = _fileInfo.CreateReadStream())
            {
                readStream.Seek(start, SeekOrigin.Begin); 
                _logger.CopyingFileRange(_response.Headers[HeaderNames.ContentRange], SubPath);
                //設定檔案輸出起始位置和讀取長度
                await StreamCopyOperation.CopyToAsync(readStream, _response.Body, length, _context.RequestAborted);
            }
        }
        catch (OperationCanceledException ex)
        {
            _context.Abort();
        }
    }
}

    由於程式碼較多刪除了處主流程處理以外的其他程式碼,從這裡我們可以看出,首先是針對輸出快取相關的讀取設定和處理,其此次是針對正常返回和分段返回的情況,在返回之前判斷是否有對輸出做特殊處理的情況,比如輸出壓縮或者自定義的其他輸出操作的IHttpResponseBodyFeature,分段返回和正常返回相比主要是多了一部分關於Http頭Content-Range相關的設定,對於讀取本身其實只是讀取的起始位置和讀取長度的差別。

UseDirectoryBrowser

目錄瀏覽允許在指定目錄中列出目錄裡的檔案及子目錄。出於安全方面考慮預設情況下是關閉的可以通過UseDirectoryBrowser中介軟體開啟指定目錄瀏覽功能。通常情況下我們會這樣使用

//啟用預設目錄瀏覽,即wwwroot
app.UseDirectoryBrowser();
//或自定義指定目錄瀏覽
var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/MyImages");
app.UseDirectoryBrowser(new DirectoryBrowserOptions
{
    RequestPath = "/MyImages",
    FileProvider = fileProvider
});

開啟之後當我們訪問https:///MyImages地址的時候將會展示如下效果,通過一個表格展示目錄裡的檔案資訊等
找到中介軟體註冊類[點選檢視DirectoryBrowserExtensions原始碼]

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));
    }
}

這個中介軟體啟用的過載方法和UseStaticFiles類似最終都是在傳遞DirectoryBrowserOptions,接下來我們就看DirectoryBrowserOptions傳遞了哪些資訊[點選檢視DirectoryBrowserOptions原始碼]

public class DirectoryBrowserOptions : SharedOptionsBase
{
    public DirectoryBrowserOptions()
        : this(new SharedOptions())
    {
    }

    public DirectoryBrowserOptions(SharedOptions sharedOptions)
        : base(sharedOptions)
    {
    }

    /// <summary>
    /// 目錄格式化提供,預設是提供表格的形式展示,課自定義
    /// </summary>
    public IDirectoryFormatter Formatter { get; set; }
}

無獨有偶這個類和StaticFileOptions一樣也是整合自SharedOptionsBase類,唯一多了IDirectoryFormatter操作,通過它我們可以自定義展示到頁面的輸出形式,接下來我們就重點看下DirectoryBrowserMiddleware中介軟體的實現

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 = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
       //預設傳遞的是HtmlDirectoryFormatter型別,也就是我們看到的輸出表格的頁面
        _formatter = options.Value.Formatter ?? new HtmlDirectoryFormatter(encoder);
        _matchUrl = _options.RequestPath;
    }

    public Task Invoke(HttpContext context)
    {
        //1.IsGetOrHeadMethod判斷是否為Get或Head請求
        //2.TryMatchPath判斷請求的路徑和設定的路徑是否可以匹配的上
        //3.TryGetDirectoryInfo判斷根據匹配出來的路徑能否查詢到真實的物理路徑
        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 (!Helpers.PathEndsInSlash(context.Request.Path))
            {
                //如果不是以斜線結尾則重定向(個人感覺直接在服務端重定向就可以了,為啥還要返回瀏覽器在請求一次)
                context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
                var request = context.Request;
                var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString);
                context.Response.Headers[HeaderNames.Location] = redirect;
                return Task.CompletedTask;
            }
            //返回展示目錄的內容
            return _formatter.GenerateContentAsync(context, contents);
        }
        return _next(context);
    }
    
    /// <summary>
    /// 根據請求路徑匹配到物理路徑資訊是否存在,存在則返回路徑資訊
    /// </summary>
    private bool TryGetDirectoryInfo(PathString subpath, out IDirectoryContents contents)
    {
        contents = _fileProvider.GetDirectoryContents(subpath.Value);
        return contents.Exists;
    }
}

這個操作相對簡單了許多,主要就是判斷請求路徑能否和預設定的路徑匹配的到,如果匹配到則獲取可以操作當前目錄內容IDirectoryContents然後通過IDirectoryFormatter輸出如何展示目錄內容,關於IDirectoryFormatter的預設實現類HtmlDirectoryFormatter這裡就不展示裡面的程式碼了,邏輯非常的加單就是拼接成table的html程式碼然後輸出,有興趣的同學可自行檢視原始碼[點選檢視HtmlDirectoryFormatter原始碼],如果自定義的話規則也非常簡單,主要看你想輸出啥

public class TreeDirectoryFormatter: IDirectoryFormatter
{
    public Task GenerateContentAsync(HttpContext context, IEnumerable<IFileInfo> contents)
    {
        //遍歷contents實現你想展示的方式
    }
}

然後在UseDirectoryBrowser的時候給Formatter賦值即可

app.UseDirectoryBrowser(new DirectoryBrowserOptions
{
    Formatter = new TreeDirectoryFormatter()
});

UseDefaultFiles

很多時候出於安全考慮或者其他原因我們想在訪問某個目錄的時候返回一個預設的頁面或展示,這個事實我們就需要使用UseDefaultFiles中介軟體,當我們配置了這個中介軟體,如果命中了配置路徑,那麼會直接返回預設的頁面資訊,簡單使用方式如下

//wwwroot目錄訪問展示預設檔案
app.UseDefaultFiles();
//或自定義目錄預設展示檔案
var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/staticfiles");
app.UseDefaultFiles(new DefaultFilesOptions
{
    RequestPath = "/staticfiles",
    FileProvider = fileProvider
});

老規矩,我們檢視下注冊UseDefaultFiles的原始碼[點選檢視DefaultFilesExtensions原始碼]

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));
    }
}

使用方式和UseStaticFiles、UseDirectoryBrowser是一樣,最終都是呼叫傳遞DefaultFilesOptions的方法,我們檢視一下DefaultFilesOptions的大致實現[點選檢視原始碼]

public class DefaultFilesOptions : SharedOptionsBase
{
    public DefaultFilesOptions()
        : this(new SharedOptions())
    {
    }

    public DefaultFilesOptions(SharedOptions sharedOptions)
        : base(sharedOptions)
    {
        //系統提供的預設頁面的名稱
        DefaultFileNames = new List<string>
        {
            "default.htm",
            "default.html",
            "index.htm",
            "index.html",
        };
    }

    /// <summary>
    /// 通過這個屬性可以配置預設檔名稱
    /// </summary>
    public IList<string> DefaultFileNames { get; set; }
}

和之前的方法如出一轍,都是繼承自SharedOptionsBase,通過DefaultFileNames我們可以配置預設檔案的名稱,預設是default.html/htm和index.html/htm。我們直接檢視中介軟體DefaultFilesMiddleware的原始碼[點選檢視原始碼]

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 = _options.FileProvider ?? Helpers.ResolveFileProvider(hostingEnv);
        _matchUrl = _options.RequestPath;
    }

    public Task Invoke(HttpContext context)
    {
        //1.我們使用UseDefaultFiles中介軟體的時候要置於UseRouting之上,否則就會不生效
        //2.IsGetOrHeadMethod判斷請求為Get或Head的情況下才生效
        //3.TryMatchPath判斷請求的路徑和設定的路徑是否可以匹配的上
        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 (!Helpers.PathEndsInSlash(context.Request.Path))
                        {
                            context.Response.StatusCode = StatusCodes.Status301MovedPermanently;
                            var request = context.Request;
                            var redirect = UriHelper.BuildAbsolute(request.Scheme, request.Host, request.PathBase, request.Path + "/", request.QueryString);
                            context.Response.Headers[HeaderNames.Location] = redirect;
                            return Task.CompletedTask;
                        }
                        //如果匹配的上,則將配置的啟用預設檔案的路徑+遍歷到的預設檔名稱的路徑組合成新的Path交給_next(context)
                        //比如將組成類似這種路徑/staticfiles/index.html向下傳遞
                        context.Request.Path = new PathString(context.Request.Path.Value + defaultFile);
                        break;
                    }
                }
            }
        }
        return _next(context);
    }
}

這個中介軟體的實現思路也非常簡單主要的工作就是,匹配配置的啟用預設檔案的路徑+遍歷到的預設檔名稱的路徑是否存在,如果匹配的上,則將配置的啟用預設檔案的路徑+遍歷到的預設檔名稱的路徑組合成新的Path(比如/staticfiles/index.html)交給後續的中介軟體去處理。這裡值得注意的是UseDefaultFiles 必須要配合UseStaticFiles一起使用,而且註冊位置要出現在UseStaticFiles之上。這也是為什麼UseDefaultFiles只需要匹配到預設檔案所在的路徑並重新賦值給context.Request.Path既可的原因。
當然我們也可以自定義預設檔案的名稱,因為只要能匹配的到具體的檔案既可

var defaultFilesOptions = new DefaultFilesOptions
{
    RequestPath = "/staticfiles",
    FileProvider = fileProvider
};
//我們可以清除掉系統預設的預設檔名稱
defaultFilesOptions.DefaultFileNames.Clear();
defaultFilesOptions.DefaultFileNames.Add("mydefault.html");
app.UseDefaultFiles(defaultFilesOptions);

總結

    通過上面的介紹我們已經大致瞭解了靜態檔案處理的大致實現思路,相對於傳統的Asp.Net程式我們可以更方便的處理靜態檔案資訊,但是思路是一致的,IIS會優先處理靜態檔案,如果靜態檔案處理不了的情況才會交給程式去處理。ASP.NET Core也不例外,通過我們檢視中介軟體原始碼裡的context.GetEndpoint()==null判斷可以知道,ASP.NET Core更希望我們優先去處理靜態檔案,而不是任意出現在其他位置去處理。關於ASP.NET Core處理靜態檔案的講解就到這裡,歡迎評論區探討交流。

?歡迎掃碼關注我的公眾號? ASP.NET Core靜態檔案處理原始碼探究

相關文章