由ASP.NET Core根據路徑下載文件異常引發的探究

前言

    最近在開發新的項目,使用的是ASP.NET Core6.0版本的框架。由於項目中存在文件下載功能,沒有使用類似MinIOOSS之類的分佈式文件系統,而是下載本地文件,也就是根據本地文件路徑進行下載。這其中遇到了一個問題,是關於如何提供文件路徑的,通過本文記錄一下相關總結,希望能幫助更多的同學避免這個問題。

使用方式

由於我們的系統沒有公司內部使用的也沒有做負載均衡之類的,所以文件是存儲在當前服務器中的,所以我們直接使用文件絕對路徑的方式來進行下載的,使用的是ASP.NET Core自帶的File方法,使用的是如下方法(實際上文件的路徑是存儲在數據庫中的)

[HttpGet]
[Produces("application/msword", Type = typeof(FileResult))]
public FileResult Virtual()
{
    // AppContext.BaseDirectory用來獲取當前執行程序的基目錄
    // 結果爲絕對路徑,比如 D:\CodeProject\MyTest.WebApi\bin\Debug\net6.0\
    var filePath = Path.Combine(AppContext.BaseDirectory, "files/疫情防控規範說明.docx");
    return File(filePath, "application/msword", "疫情防控規範說明.docx");
}

這是比較常用的方式沒太在意會有什麼問題,不過,等自測的時候發現報了一個System.InvalidOperationException異常,大致內容如下所示

 System.InvalidOperationException: No file provider has been configured to process the supplied file.
         at Microsoft.AspNetCore.Mvc.Infrastructure.VirtualFileResultExecutor.GetFileInformation(VirtualFileResult result, IWebHostEnvironment hostingEnvironment)
         at Microsoft.AspNetCore.Mvc.Infrastructure.VirtualFileResultExecutor.ExecuteAsync(ActionContext context, VirtualFileResult result)
         at Microsoft.AspNetCore.Mvc.VirtualFileResult.ExecuteResultAsync(ActionContext context)

看異常內容問題是出在VirtualFileResultExecutor.GetFileInformation()方法,它的意思大概是沒有提供文件提供來處理文件,對於文件提供程序如果瞭解過ASP.NET Core靜態文件相關的話應該是瞭解這個的。如果想訪問ASP.NET Core中的靜態文件,默認是不可以直接訪問的,這也是一種安全機制,想使用的話必須開啓靜態文件訪問機制,且默認的靜態文件要存儲在wwwroot路徑下。如果想在其它路徑提供靜態文件則必須要提供文件處理程序,我們常用的方式則是

var fileProvider = new PhysicalFileProvider($"{env.ContentRootPath}/staticfiles");
app.UseStaticFiles(new StaticFileOptions {
    RequestPath="/staticfiles",
    FileProvider = fileProvider
});

同樣的在這裏我們也需要提供IFileProvider實例,因爲我們是使用的本地文件系統,所以要提供PhysicalFileProvider實例,通過下面方法解決了這個問題

[HttpGet]
[Produces("application/msword", Type = typeof(FileResult))]
public FileResult Virtual()
{
    var filePath = "files/疫情防控規範說明.docx";
    return new VirtualFileResult(filePath, "application/msword")
    {
        // 提供指定目錄的文件訪問程序
        FileProvider = new PhysicalFileProvider(AppContext.BaseDirectory),
        FileDownloadName = "疫情防控規範說明.docx"
    };
}

亦或者是通過原始的方式,比如讀取文件的Stream或者byte[]的方式

[HttpGet]
[Produces("application/msword", Type = typeof(FileResult))]
public FileResult Virtual()
{
    //讀取byte[]方式
    var filePath = Path.Combine(AppContext.BaseDirectory, @"files\疫情防控規範說明.docx");
    var fileBytes = System.IO.File.ReadAllBytes(filePath);
    return File(fileBytes, "application/msword", "疫情防控規範說明.docx");

    //讀取Stream的方式
    //var filePath = Path.Combine(AppContext.BaseDirectory, @"files\疫情防控規範說明.docx");
    //var fileStream = System.IO.File.OpenRead(filePath);
    //return File(fileStream, "application/msword", "疫情防控規範說明.docx");
}

通過這些方式雖然可以解決問題,但是看起來不是很優雅,而且如果提供不同路徑的文件還得要有許多的PhysicalFileProvider實例,或者自己封裝方法去解決問題。
當時就想微軟不至於連讀取自定義物理路徑的方法都不提供吧,於是就在ControllerBase基類中查找相關方法,終於看到了一個叫PhysicalFile的方法,看名字就知道是提供物理文件用的,不知道行不行寫代碼試了試,程序如下

[HttpGet]
[Produces("application/msword", Type = typeof(FileResult))]
public FileResult Physical()
{
    var filePath = Path.Combine(AppContext.BaseDirectory, "files/疫情防控規範說明.docx");
    return PhysicalFile(filePath, "application/msword", "疫情防控規範說明.docx");
}

結果還真的是可以,這個方法呢提供的文件路徑可以是文件的絕對路徑,而不需要提供別的文件提供程序。這就勾起了我的好奇心,爲啥兩個操作還不一樣呢,爲啥有這樣的區別?

源碼探究

通過上面遇到的問題知道了如果想提供絕對路徑的文件下載需要使用PhysicalFile方法去下載,而默認的File方法則不能直接下載絕對路徑的文件,懷揣着好奇心,大概看了一下這兩個方法的相關源碼實現。

VirtualFileResult

接下來咱們就來看一下方法的定義在ControllerBase類中,現在來看一下File(string virtualPath, string contentType, string? fileDownloadName)方法的定義[點擊查看源碼👈]

[NonAction]
public virtual VirtualFileResult File(string virtualPath, string contentType, string? fileDownloadName)
    => new VirtualFileResult(virtualPath, contentType) { FileDownloadName = fileDownloadName };

通過virtualPath這個變量我們大概能猜出來,默認提供的是相對目錄,即當前運行程序配置的相關目錄。通過這段代碼我們可以看到它的本質是VirtualFileResult這個類,那我們繼續找到VirtualFileResult類的實現[點擊查看源碼👈]

public class VirtualFileResult : FileResult
{
    public VirtualFileResult(string fileName, string contentType)
        : this(fileName, MediaTypeHeaderValue.Parse(contentType))
    {
    }

    public VirtualFileResult(string fileName, MediaTypeHeaderValue contentType)
        : base(contentType.ToString())
    {
        FileName = fileName ?? throw new ArgumentNullException(nameof(fileName));
    }

    // 返回客戶端的文件名稱
    private string _fileName;
    public string FileName
    {
        get => _fileName;
        [MemberNotNull(nameof(_fileName))]
        set => _fileName = value ?? throw new ArgumentNullException(nameof(value));
    }
    
    //文件提供程序
    public IFileProvider? FileProvider { get; set; }

    /// <summary>
    /// 真正下載執行的方法
    /// </summary>
    public override Task ExecuteResultAsync(ActionContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        //執行了IActionResultExecutor<VirtualFileResult>實例的ExecuteAsync方法
        var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<VirtualFileResult>>();
        return executor.ExecuteAsync(context, this);
    }
}

在ASP.NET Core中Action的結果都是由IActionResultExecutor<>提供相關的實例,具體的ExecuteResultAsync方法只是在執行IActionResultExecutor<>裏的ExecuteAsync實現方法,這個是在AddControllers方法的時候註冊的,可以通過源碼找到IActionResultExecutor<VirtualFileResult>接口註冊的實例[點擊查看源碼👈]

services.TryAddSingleton<IActionResultExecutor<VirtualFileResult>, VirtualFileResultExecutor>();

由此我們可以知道IActionResultExecutor<VirtualFileResult>註冊的是VirtualFileResultExecutor類,所以我們可以直接查看VirtualFileResultExecutor類的ExecuteAsync方法的實現[點擊查看源碼👈]

public virtual Task ExecuteAsync(ActionContext context, VirtualFileResult result)
{
    // 省略部分代碼

    // 獲取文件信息
    var fileInfo = GetFileInformation(result, _hostingEnvironment);
    // 文件不存在則拋出異常
    if (!fileInfo.Exists)
    {
        throw new FileNotFoundException(Resources.FormatFileResult_InvalidPath(result.FileName), result.FileName);
    }

    Logger.ExecutingFileResult(result, result.FileName);

    var lastModified = result.LastModified ?? fileInfo.LastModified;
    var (range, rangeLength, serveBody) = SetHeadersAndLog(
        context,
        result,
        fileInfo.Length,
        result.EnableRangeProcessing,
        lastModified,
        result.EntityTag);

    if (serveBody)
    {
        // 執行下載程序
        return WriteFileAsync(context, result, fileInfo, range, rangeLength);
    }
    return Task.CompletedTask;
}

通過這個方法可以知道判斷文件信息存不存在是在GetFileInformation這個方法,我們上面看到的那個異常也是在這個方法裏報出來的,所以我們看下這個方法的實現的,看一下具體實現[點擊查看源碼👈]

/// <summary>
/// 獲取文件信息
/// </summary>
internal static IFileInfo GetFileInformation(VirtualFileResult result, IWebHostEnvironment hostingEnvironment)
{
    // 獲取文件提供程序
    var fileProvider = GetFileProvider(result, hostingEnvironment);
    //如果是NullFileProvider則直接拋出異常
    if (fileProvider is NullFileProvider)
    {
        throw new InvalidOperationException(Resources.VirtualFileResultExecutor_NoFileProviderConfigured);
    }

    var normalizedPath = result.FileName;
    //特殊開頭處理
    if (normalizedPath.StartsWith("~", StringComparison.Ordinal))
    {
        normalizedPath = normalizedPath.Substring(1);
    }
    //獲取要下載的文件信息
    var fileInfo = fileProvider.GetFileInfo(normalizedPath);
    return fileInfo;
}

/// <summary>
/// 獲取文件提供程序
/// </summary>
internal static IFileProvider GetFileProvider(VirtualFileResult result, IWebHostEnvironment hostingEnvironment)
{
    //判斷是否設置了VirtualFileResult的FileProvider屬性
    if (result.FileProvider != null)
    {
        return result.FileProvider;
    }
    //沒設置則使用WebRootFileProvider,即設置目錄爲wwwroot的文件提供程序
    result.FileProvider = hostingEnvironment.WebRootFileProvider;
    return result.FileProvider;
}

通過這段代碼我們看到了問題的所在,我們上面看到的異常位置的GetFileInformation方法,也就是我們需要找到的位置,這個地方獲取到的IFileInfo是基於FileProvider的因此通過它獲取的文件的物理路徑是當前程序路徑/WebRootFileProvider路徑/傳遞的路徑,比如程序路徑D:\CodeProject\MyTest.WebApi\bin\Debug\net6.0\+WebRootFileProvider路徑wwwroot\+傳遞路徑files\疫情防控規範說明.docx拼接而成,因此在這裏我們可以得到以下結論

  • 下載的文件信息是在IFileProvider實例中獲取到的
  • GetFileProvider方法會判斷是否手動設置了下載文件的IFileProvider,如果沒有則使用IWebHostEnvironmentWebRootFileProvider實例,即框架中wwwroot目錄的文件提供程序
  • 我們提供絕對路徑會拋出異常,本質是我們沒有提供需要下載文件的文件提供程序實例IFileProvider,所以程序使用了wwwroot目錄的文件提供程序

所以可以得出結論,我們如果想直接使用目錄的話,那指定的目錄必須得是基於wwwroot文件夾的目錄進行存儲的,也就是我們文件存儲的文件夾得是在wwwroot裏纔行。比如我們將我們的上傳的文件移動到wwwroot/files/文件夾內,那麼我們在編寫下載程序相關代碼的時候可以直接使用如下方式

[HttpGet]
[Produces("application/msword", Type = typeof(FileResult))]
public FileResult Virtual()
{
    var filePath = "files/疫情防控規範說明.docx";
    return File(filePath, "application/msword", "疫情防控規範說明.docx");
}

這樣的話程序就可以運行成功了,這也就解釋了上面提供的File方法中提到的是virtualPath了。關於wwwroot機制在源碼中可以看到[點擊查看源碼👈]

var webRoot = options.WebRoot;
//判斷配置裏WebRoot是否配置
if (webRoot == null)
{
    var wwwroot = Path.Combine(hostingEnvironment.ContentRootPath, "wwwroot");
    //判斷wwwroot目錄是否存在
    if (Directory.Exists(wwwroot))
    {
        hostingEnvironment.WebRootPath = wwwroot;
    }
}
else
{
    hostingEnvironment.WebRootPath = Path.Combine(hostingEnvironment.ContentRootPath, webRoot);
}
//判斷IWebHostEnvironment中的WebRootPath是否存在
if (!string.IsNullOrEmpty(hostingEnvironment.WebRootPath))
{
    hostingEnvironment.WebRootPath = Path.GetFullPath(hostingEnvironment.WebRootPath);
    //目錄不存在則創建wwwroot目錄
    if (!Directory.Exists(hostingEnvironment.WebRootPath))
    {
        Directory.CreateDirectory(hostingEnvironment.WebRootPath);
    }
    //根據wwwroot路徑創建WebRootFileProvider文件提供程序
    hostingEnvironment.WebRootFileProvider = new PhysicalFileProvider(hostingEnvironment.WebRootPath);
}
else
{
    //如果沒有提供wwwroot文件路徑則賦值NullFileProvider
    hostingEnvironment.WebRootFileProvider = new NullFileProvider();
}

通過這段代碼相信大家對wwwroot默認的機制能有一定的瞭解了,ASP.NET Core會根據程序是否包含wwwroot目錄自行判斷來填充IWebHostEnvironment實例中WebRootPath屬性和WebRootFileProvider屬性的值。所以可以先添加wwwroot文件夾,然後基於這個目錄添加自定義的文件夾,這樣可以直接使用File方法進行下載了。

PhysicalFileResult

上面我們瞭解到了VirtualFileResult的工作機制,使用VirtualFileResult下載的時候默認需要提供虛擬的路徑即基於當前應用的目錄,默認的是wwwroot目錄。亦或者你可以自己提供IFileProvider機制來完成自定義目錄的下載。但是當我們使用PhysicalFile()方法下載的時候卻可以直接下載,這是爲什麼呢?同樣的我們找到方法定義的地方[點擊查看源碼👈]

[NonAction]
public virtual PhysicalFileResult PhysicalFile(
    string physicalPath,
    string contentType,
    string? fileDownloadName)
    => new PhysicalFileResult(physicalPath, contentType) { FileDownloadName = fileDownloadName };

這裏可以看到方法字段的名字是physicalPath也就是物理路徑,方法返回的是PhysicalFileResult類的實例。我們找到PhysicalFileResult類的ExecuteResultAsync方法的相關源碼[點擊查看源碼👈]

public class PhysicalFileResult : FileResult
{
    public PhysicalFileResult(string fileName, string contentType)
        : this(fileName, MediaTypeHeaderValue.Parse(contentType))
    {
        if (fileName == null)
        {
            throw new ArgumentNullException(nameof(fileName));
        }
    }

    public PhysicalFileResult(string fileName, MediaTypeHeaderValue contentType)
        : base(contentType.ToString())
    {
        FileName = fileName ?? throw new ArgumentNullException(nameof(fileName));
    }
    
    // 返回客戶端的文件名稱
    private string _fileName;
    public string FileName
    {
        get => _fileName;
        [MemberNotNull(nameof(_fileName))]
        set => _fileName = value ?? throw new ArgumentNullException(nameof(value));
    }
    
    /// <summary>
    /// 真正下載執行的方法
    /// </summary>
    public override Task ExecuteResultAsync(ActionContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<PhysicalFileResult>>();
        return executor.ExecuteAsync(context, this);
    }
}

通過這個類可以看出相較於VirtualFileResult類少了FileProvider屬性,這也說明了確實是不需要傳遞文件訪問程序。ExecuteResultAsync方法是完全透明的,核心邏輯在IActionResultExecutor<PhysicalFileResult>實例中[點擊查看源碼👈]

services.TryAddSingleton<IActionResultExecutor<PhysicalFileResult>, PhysicalFileResultExecutor>();

照舊我們直接找到我們可以直接查看PhysicalFileResultExecutor類的ExecuteAsync方法的實現[點擊查看源碼👈]

public virtual Task ExecuteAsync(ActionContext context, PhysicalFileResult result)
{
    //省略代碼

    //獲取文件信息
    var fileInfo = GetFileInfo(result.FileName);
    //文件不存在則拋出異常
    if (!fileInfo.Exists)
    {
        throw new FileNotFoundException(
            Resources.FormatFileResult_InvalidPath(result.FileName), result.FileName);
    }

    Logger.ExecutingFileResult(result, result.FileName);

    var lastModified = result.LastModified ?? fileInfo.LastModified;
    var (range, rangeLength, serveBody) = SetHeadersAndLog(
        context,
        result,
        fileInfo.Length,
        result.EnableRangeProcessing,
        lastModified,
        result.EntityTag);
    if (serveBody)
    {
        // 真正執行下載
        return WriteFileAsync(context, result, range, rangeLength);
    }
    return Task.CompletedTask;
}

同樣的是在GetFileInfo方法裏,看一下定義

protected virtual FileMetadata GetFileInfo(string path)
{
    //直接根據傳進來的路徑獲取的文件信息
    var fileInfo = new FileInfo(path);
    return new FileMetadata
    {
        Exists = fileInfo.Exists,
        Length = fileInfo.Length,
        LastModified = fileInfo.LastWriteTimeUtc,
    };
}

區別主要就是在這個方法,這裏是直接根據文件的絕對路徑獲取的文件信息,而不需要藉助FileProvider,因此如果你傳遞的是D:\CodeProject\MyTest.WebApi\bin\Debug\net6.0\wwwroot\files\疫情防控規範說明.docx,那麼獲取到的文件信息也是來自於這個路徑,也就是文件的絕對路徑。

SendFileAsync方法

咱們看到的邏輯都是爲了獲取到文件的真實路徑,而真正執行下載的是WriteFileAsync方法,這個方法的核心邏輯其實就是調用了HttpResponse的擴展方法SendFileAsync方法,所以如果你不想用FileResult類相關方法提供下載的時候,可以使用這個方法,唯一比較麻煩的就是下載的Header信息你得自己填充上去,簡單的示例一下

[HttpGet]
public Task SendFile()
{
    var filePath = Path.Combine(AppContext.BaseDirectory, "files/疫情防控規範說明.docx");
    //獲取fileInfo
    var fileInfo = new FileInfo(filePath);

    ContentDispositionHeaderValue contentDispositionHeaderValue = new ContentDispositionHeaderValue("attachment");
    contentDispositionHeaderValue.FileName= fileInfo.Name;
    // 設計ContentDisposition頭信息
    Response.Headers.ContentDisposition = contentDispositionHeaderValue.ToString();
    //設置ContentLength頭信息
    Response.ContentLength = new long?(fileInfo.Length);
    //調用SendFileAsync方法
    return Response.SendFileAsync(filePath, 0L, null, default);
}

Results.File

從ASP.NET Core6.0開始就提供了MinimalApi寫法,MinimalApi同樣可以執行文件下載,使用的是Results.File方法。這個方法就比較有意思了,比較智能。無論你是傳遞虛擬路徑或者是物理路徑都是可以的,不用你單獨的去操作別的什麼了,比如以下代碼

//相對路徑下載
app.MapGet("/virtual", () => {
    var virtualPath = "files/疫情防控規範說明.docx";
    return Results.File(virtualPath, "application/msword", "疫情防控規範說明.docx");
});

//物理路徑下載
app.MapGet("/physical", () => {
    var physicalPath = Path.Combine(AppContext.BaseDirectory, "files/疫情防控規範說明.docx");
    return Results.File(physicalPath, "application/msword", "疫情防控規範說明.docx");
});

上面的兩種方式都是可以正常執行的,而且不會報錯,相對來說變得更高級一點了,那具體是如何操作的呢,我們來看一下Results.File方法的實現[點擊查看源碼👈]

public static IResult File(
    string path,
    string? contentType = null,
    string? fileDownloadName = null,
    DateTimeOffset? lastModified = null,
    EntityTagHeaderValue? entityTag = null,
    bool enableRangeProcessing = false)
{
    //判斷路徑是否包含根目錄
    if (Path.IsPathRooted(path))
    {
        // 包含根目錄則是絕對路徑,直接使用PhysicalFileResult
        return new PhysicalFileResult(path, contentType)
        {
            FileDownloadName = fileDownloadName,
            LastModified = lastModified,
            EntityTag = entityTag,
            EnableRangeProcessing = enableRangeProcessing,
        };
    }
    else
    {
        // 表示虛擬路徑,也就是當前程序指定的路徑
        return new VirtualFileResult(path, contentType)
        {
            FileDownloadName = fileDownloadName,
            LastModified = lastModified,
            EntityTag = entityTag,
            EnableRangeProcessing = enableRangeProcessing,
        };
    }
}

通過這個方法我們可以看到Results.File已經幫我們在內部進行了判斷,是符合物理路徑還是虛擬路徑。如果是物理路徑則直接返回PhysicalFileResult實例,也就是ControllerBase類中的PhysicalFile方法的類型。如果是相對路徑則返回VirtualFileResult實例,也就是上面ControllerBase類中的File方法。

總結

    本篇文章起源是由筆者在實際開發項目中,根據文件路徑下載文件出現異常,並找到了相應的解決方法。出於好奇的本心,研究了一下相關的實現。在ASP.NET Core中文件路徑分爲兩類。一種是絕對的物理路徑,一種是在程序中設置的相對路徑,不同路徑的文件下載有不同的文件處理方法,大致總結一下

  • VirtualFileResult類型的文件下載,默認路徑是由WebRootFileProvider提供的路徑,即wwwroot文件夾的路徑,所以這個時候提供的文件路徑是相對路徑,真實文件也是存儲在wwwroot路徑下的,你可以可以定義自己的IFileProvider實例來傳遞自己的路徑。
  • PhysicalFileResult類型的文件下載,路徑則是直接傳遞進來的物理路徑,是絕對路徑,也就是當前程序所在服務器或者操作系統的真實路徑。這個路徑程序不經過加工,是確實存在的路徑。
  • MinimalApi的Results.File方法,不需要我們認爲的判斷是相對路徑還是絕對路徑,直接使用即可。因爲方法內已經幫我們做了判斷了。
  • 文件下載則是調用了HttpResponse的擴展方法SendFileAsync方法。

這一部分邏輯整體來說還是比較清晰的,相信大家理解起來也會比較容易。我們學習的過程最核心的就是積累經驗,但是積累的經驗一定是一系列的抽象概念,我們可以稱它爲思維標籤,這是我們可以複用的底層邏輯。借用劉潤的話來說,只有不同之中的相同之處、變化背後不變的東西,纔是底層邏輯。只有底層邏輯,纔是有生命力的。只有底層邏輯,在我們面臨環境變化時,才能被應用到新的變化中,從而產生適應新環境的方法論。只有掌握了底層邏輯,只有探尋到萬變中的不變,才能動態地、持續地看清事物的本質。

👇歡迎掃碼關注我的公衆號👇
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章