Skip to content

31. 虚拟文件系统

📝 模块更新日志 新特性*

+ 新增 `.m3u8` 和 `.ts` 文件类型 `MIME` 支持 4\.8\.7\.5 ⏱️2023\.03\.07 [\#I6KKEM](https://gitee.com/dotnetchina/Furion/issues/I6KKEM)
+ 新增 `*.bcmap` 和 `.properties` 文件类型 `MIME` 支持 4\.8\.4\.9 ⏱️2023\.01\.06 [!694](https://gitee.com/dotnetchina/Furion/pulls/694)

版本说明以下内容仅限 Furion 2.5.0 + 版本使用。

31.1 关于文件系统

本章所谓的 文件系统 有点名不副实,其实根本算不上一个系统,它仅仅是利用一个抽象化的 IFileProvider 以统一的方式提供所需的文件而已。通过该 文件系统 可以读取物理文件和嵌入资源文件,包括目录结果读取,文件内容读取,文件内容监听等等。

31.1.1 文件系统类型

Furion 提供了两种文件系统类型:

  • Physical:物理文件系统类型,也就是物理机中实际存在的文件
  • Embedded:嵌入资源文件系统类型,也就是资源文件嵌入到了程序集中,常用于模块化开发

31.2 注册虚拟文件系统服务

services.AddVirtualFileServer();  

31.3 获取文件系统 IFileProvider 实例

31.3.1 Func<FileProviderTypes, object, IFileProvider> 方式

Furion 框架提供了 Func<FileProviderTypes, object, IFileProvider> 委托供构造函数注入或解析服务,如:

public class PersonServices  
{  
    private readonly IFileProvider _physicalFileProvider;  
    private readonly IFileProvider _embeddedFileProvider;  

    public PersonServices(Func<FileProviderTypes, object, IFileProvider> fileProviderResolve)  
    {  
        // 解析物理文件系统  
        _physicalFileProvider = fileProviderResolve(FileProviderTypes.Physical, @"c:/test");  

        // 解析嵌入资源文件系统  
        _embeddedFileProvider = fileProviderResolve(FileProviderTypes.Embedded, Assembly.GetEntryAssembly());  
    }  
}  

31.3.2 FS 静态类方式

Furion 框架也提供了 FS 静态类方式创建,如:

// 解析物理文件系统  
var physicalFileProvider = FS.GetPhysicalFileProvider(@"c:/test");  

// 解析嵌入资源文件系统  
var embeddedFileProvider = FS.GetEmbeddedFileProvider(Assembly.GetEntryAssembly());  

31.4 IFileProvider 常见操作

31.4.1 读取文件内容

byte[] buffer;  
using (Stream readStream = _fileProvider.GetFileInfo("你的文件路径").CreateReadStream())  
{  
    buffer = new byte[readStream.Length];  
    await readStream.ReadAsync(buffer.AsMemory(0, buffer.Length));  
}  

// 读取文件内容  
var content = Encoding.UTF8.GetString(buffer);  

31.4.2 获取文件目录内容(需递归查找)

var rootPath = "当前目录路径";  
var fileinfos = _fileProvider.GetDirectoryContents(rootPath);  
foreach (var fileinfo in fileinfos)  
{  
    if(fileinfo.IsDirectory)  
    {  
        // 这里递归。。。  
    }  
}  

31.4.3 监听文件变化

ChangeToken.OnChange(() => _fileProvider.Watch("监听的文件"), () =>  
{  
    // 这里写你的逻辑  
});  

31.5 模块化静态资源配置

通常我们采用模块化开发,静态资源都是嵌入进程序集中,这时候我们需要通过配置 UseFileServer 指定模块静态资源路径,如:

// 默认静态资源调用,wwwroot  
app.UseStaticFiles();  

// 配置模块化静态资源  
app.UseFileServer(new FileServerOptions  
{  
    FileProvider = new EmbeddedFileProvider(模块程序集),  
    RequestPath = "/模块名称",  // 后续所有资源都是通过 /模块名称/xxx.css 调用  
    EnableDirectoryBrowsing = true  
});  

31.6 文件上传下载

在应用开发中,文件上传下载属于非常常用的功能,这里贴出常见的文件上传下载示例。

31.6.1 文件下载

  • 文件路径的方式
[HttpGet, NonUnify]  
public IActionResult FileDownload()  
{  
    string filePath = "这里获取完整的文件下载路径";  
    return new FileStreamResult(new FileStream(filePath, FileMode.Open), "application/octet-stream")  
    {  
        FileDownloadName = fileName // 配置文件下载显示名  
    };  
}  

  • byte[] 方式
[HttpGet, NonUnify]  
public async Task<IActionResult> FileDownload()  
{  
    var bytes = await File.ReadAllBytesAsync("文件路径");  
    return new FileContentResult(bytes, "application/octet-stream")   
    {  
        FileDownloadName = fileName // 配置文件下载显示名  
    };  
}  

  • stream 方式
[HttpGet, NonUnify]  
public async Task<IActionResult> FileDownload()  
{  
    var (stream, _) = await "https://furion.net/img/rm1.png".GetAsStreamAsync();  
    // var (stream, _, _) = await "https://furion.net/img/rm1.png".GetAsStreamAsync(); // Furion 4.9.1.44+  

    // 将 stream 转 byte[]  
    byte[] bytes = new byte[stream.Length];  
    stream.Seek(0, SeekOrigin.Begin);  
    await stream.ReadAsync(bytes);  

    return new FileContentResult(bytes, "application/octet-stream")  
    {  
        FileDownloadName = fileName // 配置文件下载显示名  
    };  
}  

关于前端获取文件名如果前端获取不到文件,可添加以下配置:

_httpContextAccessor.HttpContext.Response.Headers.Add("Content-Disposition", $"attachment; filename={文件名}");  
_httpContextAccessor.HttpContext.Response.Headers.Add("Access-Control-Expose-Headers", "Content-Disposition");  

如果依然不能解决问题可尝试添加以下配置:

{  
  "CorsAccessorSettings": {  
    "WithExposedHeaders": [  
      "Content-Disposition",  
      "Access-Control-Expose-Headersx-access-token"  
    ]  
  }  
}  

31.6.2 文件下载(前端)

  • JavaScript 方式
var xhr = new XMLHttpRequest();  
xhr.responseType = "blob";  
xhr.onload = function () {  
  if (xhr.status === 200) {  
    var data = xhr.response;  
    var url = window.URL.createObjectURL(new Blob([data] ,{ type: 'application/octet-stream'}));  
    var link = document.createElement("a");  
    link.href = url;  
    link.setAttribute("download", "monksoul.png"); // 可以设置为你想要的任何文件名  
    document.body.appendChild(link);  
    link.click();  
    document.body.removeChild(link);  
  }  
};  

xhr.open("GET", "https://localhost:5001/api/user-manage/download", true);  
xhr.send();  

  • jQuery 方式
$.ajax({  
  url: "https://localhost:5001/api/user-manage/download",  
  type: "GET", // 任何请求都可以  
  xhrFields: { responseType: "blob" },  
  success: function (data) {  
    var url = window.URL.createObjectURL(new Blob([data] ,{ type: 'application/octet-stream'}));  
    var link = document.createElement("a");  
    link.href = url;  
    link.setAttribute("download", "monksoul.png"); // 可以设置为你想要的任何文件名  
    document.body.appendChild(link);  
    link.click();  
    document.body.removeChild(link);  
  },  
  error: function (jqXHR, textStatus, errorThrown) {  
    console.log("Error: " + errorThrown);  
  },  
});  

  • Fetch 方式
fetch("https://localhost:5001/api/user-manage/download", {  
  method: "GET",  
})  
  .then((response) => response.blob()) // 将响应转换为Blob对象  
  .then((data) => {  
    var url = window.URL.createObjectURL(new Blob([data] ,{ type: 'application/octet-stream'}));  
    var link = document.createElement("a");  
    link.href = url;  
    link.setAttribute("download", "monksoul.png"); // 可以设置为你想要的任何文件名  
    document.body.appendChild(link);  
    link.click();  
    document.body.removeChild(link);  
  })  
  .catch((error) => {});  

  • Axios 方式
axios({  
  method: "get",  
  url: "https://localhost:5001/api/user-manage/download",  
  responseType: "blob", // 设置为 blob 二进制流的方式  
})  
  .then((response) => {  
    const data = response.data;  
    var url = window.URL.createObjectURL(new Blob([data] ,{ type: 'application/octet-stream'}));  
    var link = document.createElement("a");  
    link.href = url;  
    link.setAttribute("download", "monksoul.png"); // 可以设置为你想要的任何文件名  
    document.body.appendChild(link);  
    link.click();  
    document.body.removeChild(link);  
  })  
  .catch((error) => {});  

31.6.3 文件上传

小提醒IFormFile 类型对应前端的 Content-Type 为: multipart/form-data

  • 单文件 IFormFile 类型参数(存储到硬盘)
[HttpPost]  
public async Task<string> UploadFileAsync(IFormFile file)  
{  
    // 如:保存到网站根目录下的 uploads 目录  
    var savePath = Path.Combine(App.HostEnvironment.ContentRootPath, "uploads");  
    if (!Directory.Exists(savePath)) Directory.CreateDirectory(savePath);  

    //// 这里还可以获取文件的信息  
    // var size = file.Length / 1024.0;  // 文件大小 KB  
    // var clientFileName = file.FileName; // 客户端上传的文件名  
    // var contentType = file.ContentType; // 获取文件 ContentType 或解析 MIME 类型  

    // 避免文件名重复,采用 GUID 生成  
    var fileName = Guid.NewGuid().ToString("N") + Path.GetExtension(file.FileName);  
    var filePath = Path.Combine(savePath, fileName);  

    // 保存到指定路径  
    using (var stream = System.IO.File.Create(filePath))  
    {  
        await file.CopyToAsync(stream);  
    }  

    // 返回文件名(这里可以自由返回更多信息)  
    return fileName;  
}  

  • 单文件 Base64 类型参数(存储到硬盘)
[HttpPost]  
public async Task<string> UploadFileAsync([FromBody] string fileBase64, string clientFileName)  
{  
    // 如:保存到网站根目录下的 uploads 目录  
    var savePath = Path.Combine(App.HostEnvironment.ContentRootPath, "uploads");  
    if (!Directory.Exists(savePath)) Directory.CreateDirectory(savePath);  

    // 将 base64 字符串转 byte[]  
    var bytes = Convert.FromBase64String(fileBase64);  

    // 这里还可以获取文件的信息  
    // var size = bytes.Length / 1024.0;  // 文件大小 KB  

    // 避免文件名重复,采用 GUID 生成  
    var fileName = Guid.NewGuid().ToString("N") + Path.GetExtension(clientFileName);  
    var filePath = Path.Combine(savePath, fileName);  

    // 保存到指定路径  
    using (var fs = new FileStream(filePath, FileMode.Create))  
    {  
        await fs.WriteAsync(bytes);  
    }  

    // 返回文件名(这里可以自由返回更多信息)  
    return filename;  
}  

特别注意文件 Base64 字符串如果带 data:text/plain;base64, 开头则,需要手动去掉 , 之前(含逗号)的字符串。

  • 多文件 List<IFormFile> 类型参数(存储到硬盘)

参数提示通常多文件上传用的最多的是 List<IFormFile> files 参数,但 .NET5+ 更推荐使用 IFormFileCollection files

代码和 单文件处理一致,只需 foreach 即可。

[HttpPost]  
public async Task<object> UploadFileAsync(List<IFormFile> files)    // 可改为 IFormFileCollection files  
{  
    // 保存到网站根目录下的 uploads 目录  
    var savePath = Path.Combine(App.HostEnvironment.ContentRootPath, "uploads");  
    if(!Directory.Exists(savePath)) Directory.CreateDirectory(savePath);  

    // 总上传大小  
    long size = files.Sum(f => f.Length);  

    // 遍历所有文件逐一上传  
    foreach (var formFile in files)  
    {  
        if (formFile.Length > 0)  
        {  
            // 避免文件名重复,采用 GUID 生成  
            var fileName = Guid.NewGuid().ToString("N") + Path.GetExtension(formFile.FileName);  
            var filePath = Path.Combine(savePath, fileName);  

            // 保存到指定路径  
            using (var stream = System.IO.File.Create(filePath))  
            {  
                await formFile.CopyToAsync(stream);  
            }  
        }  
    }  

    // 这里可自行返回更多信息  
    return new { count = files.Count, size };  
}  

  • 多文件 List<string> Base64 类型参数(存储到硬盘)

代码和 单文件处理一致,只需 foreach 即可(参上)。

31.6.4 文件上传(多表单名)

在一些复杂的表单中,我们可能包含多个上传输入框,如 file1file2

<form asp-action="Upload" enctype="multipart/form-data" method="post">  
  <input type="file" name="file1" />  
  <input type="file" name="file2" />  

  <!-- 其他表单代码 -->  
  <input type="submit" value="Upload" />  
</form>  

那么可以通过下列三种方式进行接收 file1file2 的值,注意,参数名需对应上。

  • 方式一:使用单独的 IFormFile 参数

你可以为每个文件输入字段创建一个单独的 IFormFile 参数:

[HttpPost]  
public async Task<object> Upload(IFormFile file1, IFormFile file2)  
{  
    if (file1 != null && file1.Length > 0)  
    {  
        // 处理 model.File1  
    }  

    if (file2 != null && file2.Length > 0)  
    {  
        // 处理 model.File1  
    }  

    // 这里可自行返回更多信息  
    return new { message = "上传成功" };  
}  

  • 方式二:使用 IFormFileCollection

你也可以使用 IFormFileCollection 来接收所有上传的文件,然后根据 Name 属性来处理它们:

[HttpPost]  
public async Task<object> Upload(IFormFileCollection files)  
{  
    foreach (var file in files)  
    {  
        if (file.Length > 0)  
        {  
            switch (file.Name)  
            {  
                case "file1":  
                    // 处理 file1  
                    break;  
                case "file2":  
                    // 处理 file2  
                    break;  
                // 可以添加更多 case 来处理其他文件输入字段  
                default:  
                    // 处理未知的文件输入字段或忽略它们  
                    break;  
            }  
        }  
    }  

    // 这里可自行返回更多信息  
    return new { message = "上传成功" };  
}  

  • 方式三:使用自定义模型绑定

你还可以创建一个自定义模型,该模型包含 IFormFile 属性,并使用模型绑定来处理上传的文件。这通常在你需要更复杂的验证或处理逻辑时很有用。

public class FileUploadModel  
{  
    public IFormFile File1 { get; set; }  
    public IFormFile File2 { get; set; }  

    // 其他属性定义  
}  

[HttpPost]  
public async Task<object> Upload([FromForm] FileUploadModel model)  
{  
    if (model.File1 != null && model.File1.Length > 0)  
    {  
        // 处理 model.File1  
    }  

    if (model.File2 != null && model.File2.Length > 0)  
    {  
        // 处理 model.File2  
    }  

    // 这里可自行返回更多信息  
    return new { message = "上传成功" };  
}  

31.6.5 将 IFormFilebyte[]

有时候我们需要将文件转换成 byte[] 存储到数据库,而不是存储到硬盘中。

[HttpPost]  
public async Task<IActionResult> UploadFileAsync(IFormFile file)  
{  
    var fileLength = file.Length;  
    using var stream = file.OpenReadStream();  
    var bytes = new byte[fileLength];  

    stream.Read(bytes, 0, (int)fileLength);  

    // 这里将 bytes 存储到你想要的介质中即可  
}  

便捷拓展方法在 Furion v3.2.0 新增了 IFormFileToByteArray 拓展,如:

[HttpPost]  
public async Task<IActionResult> UploadFileAsync(IFormFile file)  
{  
    var bytes = file.ToByteArray();  

    // 这里将 bytes 存储到你想要的介质中即可  
}  

31.6.6 将 byte[] 输出为 Url 地址

由于一些项目直接将文件二进制存储在数据库中,读取到内存的时候都是 byte[] 数组,比如我们将图片文件存储在数据库中,然后前端通过 Url 链接进行访问,这个时候就需要将 byte[] 转换为有效的资源路径格式,如:

[NonUnify, HttpGet, AllowAnonymous]  
public async Task<IActionResult> attachment(string resourceId)  
{  
    // 根据 resourceId 查询 byte[] 字节数组和 content-type  

    // 返回 FileContentResult 类型  
    return new FileContentResult(字节数组,content-type);  
}  

之后我们就可以通过 https://localhost/attachment/资源id 访问文件或图片了。

31.6.7 配置上传文件目录

很多时候我们会将文件存储在特定的目录中,如 uploads 中,这时候只需要添加以下配置即可:

  • 编辑启动层 YourPoject.Web.Entry.csproj 项目,添加以下代码:
<ItemGroup>  
    <Content Include="uploads\**\*">  
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>  
    </Content>  
</ItemGroup>  

  • 配置文件服务中间件
app.UseStaticFiles();  

// 如果目录不存在则创建  
var staticRoot = Path.Combine(AppContext.BaseDirectory, "uploads"); // 注意这里不要添加 /  
if (!Directory.Exists(staticRoot)) Directory.CreateDirectory(staticRoot);  

app.UseFileServer(new FileServerOptions  
{  
    RequestPath = "/uploads",   // 配置访问地址,需以 / 开头,通常和目录名称一致,也可以不同  
    FileProvider = new PhysicalFileProvider(staticRoot)  
});  

代码位置上述代码放在 Startup.cs 中的 Configure 的位置 app.UseHttpsRedirection();app.UseRouting(); 之间。

  • 通过浏览器访问文件

之后就可以通过浏览器访问 http://地址:端口/uploads/xxx.png 了。

31.7 请求大小控制(上传文件大小控制)

Web 项目中,KestrelHttpSys 都强制实施 30M (~28.6MiB) 的最大请求正文大小限制,如果请求正文大小超过配置的最大请求正文大小限制,则引发 Request body too large. The max request body size is xxxxx 异常,状态码为 413500

31.7.1 对特定的接口进行控制

可通过 [RequestSizeLimit] 特性进行特定限制

[HttpPost]  
[RequestSizeLimit(100_000_000)] // 通常设置为 [RequestSizeLimit(long.MaxValue)]  
[RequestFormLimits(MultipartBodyLengthLimit = long.MaxValue)]  
public IActionResult MyAction([FromBody] MyViewModel data)  
{  
}  

相关 Issuehttps://gitee.com/dotnetchina/Furion/issues/I8669C

31.7.2 对特定接口取消限制

如果不需要对请求大小进行限制,也就是支持提交无限大小,则贴 [DisableRequestSizeLimit] 特性即可。

31.7.3 通用中间件进行控制(不推荐,推荐使用全局)

我们也可以通过中间件的方式在 Startup.cs 中进行配置:

app.Use(async (context, next) =>  
{  
    context.Features.Get<IHttpMaxRequestBodySizeFeature>().MaxRequestBodySize = 100_000_000; // 设置 null 就是不限制,具体值就是限制最大多少 M  
    await next.Invoke();  
}  

如果设置 MaxRequestBodySizenull ,则等同于取消限制,也就是 [DisableRequestSizeLimit] 的效果。

小注意有时候配置了中间件效果发现没起作用,很有可能和中间件顺序有关,可以通过 .IsReadOnly 属性判断,如果为 true ,说明你的配置无效,只有 false 才有效。

31.7.4 全局配置

  • IIS 方式:

  • 开发环境(IISExpress)

Web 启动层(通常是 XXX.Web.Entry)根目录下创建 web.config 文件,内容如下:

<?xml version="1.0" encoding="utf-8"?>  
<configuration>  
    <system.webServer>  
        <security>  
            <requestFiltering>  
                <requestLimits maxAllowedContentLength="1073741824" />  
            </requestFiltering>  
        </security>  
    </system.webServer>  
</configuration>  

  1. 生产环境

通常生产环境 IIS 自动项目添加了 web.config 文件,这时候只需要在 <configuration> 节点下添加下面内容即可:

<system.webServer>  
    <security>  
        <requestFiltering>  
            <requestLimits maxAllowedContentLength="1073741824" />  
        </requestFiltering>  
    </security>  
</system.webServer>  

特别注意如果前端也使用的是 IIS 部署,那么前端的站点也要添加 web.config 进行配置。

  • Kestrel 方式:

小知识未使用 IIS 托管时,ASP.NET Core 默认使用 Kestrel 方式。

// .NET5 方式,在 .ConfigureWebHostDefaults 里面配置  
.UseStartup<Startup>()  
.UseKestrel(options =>  
{  
    options.Limits.MaxRequestBodySize = null;   // 设置 null 就是不限制,具体值就是限制最大多少 M  
}  

// .NET6 方式,在 progame.cs 文件 var app = builder.Build(); 之后配置  
app.Configuration.Get<WebHostBuilder>().ConfigureKestrel(options =>  
{  
    options.Limits.MaxRequestBodySize = null;   // 设置 null 就是不限制,具体值就是限制最大多少 M  
});  

  • HttpSys 方式:

小知识HTTP.sys 是仅在 Windows 上运行的适用于 ASP.NET CoreWeb 服务器。 HTTP.sysKestrel 服务器的替代选择,提供了一些 Kestrel 不提供的功能。

// .NET5 方式同上  
.UseHttpSys(options =>  
{  
    options.MaxRequestBodySize = 100_000_000;   // 设置 null 就是不限制,具体值就是限制最大多少 M  
}  

// .NET6 方式同上  

  • Nginx 部署方式:

默认情况下,Nginx 代理工具会限制客户端请求体大小,可通过以下方式设置(支持三种方式):

http {  
    ...  
    # 在 http 块内全局设定  
    # client_max_body_size 50m;  

    server {  
        listen 80;  
        server_name yourdomain.com;  

        # 在 server 块内设定  
        client_max_body_size 50m;  

        location / {  
            # 在 location 块内针对特定路径设定  
            # client_max_body_size 50m;  

            # 其他 location 设置...  
        }  
    }  
}  

注意:更改 Nginx 配置后需重载配置或重启 Nginx 服务。

31.8 特定文件类型(文件后缀)处理

有时我们在服务器静态资源目录下存放如 .apk.m3u8 等文件时,通过 URL 请求返回 404 Not Found,那是因为默认情况下框架未配置此类文件类型 MIME 类型。

Furion 框架提供了 400 多个常见的文件类型 MIME,只需要在 Startup.csProgame.cs 中注册即可,如:

// 为 UseStaticFiles 添加 StaticFileOptions 参数并指定 ContentTypeProvider 属性  
app.UseStaticFiles(new StaticFileOptions {  
  ContentTypeProvider = FS.GetFileExtensionContentTypeProvider()  
});  

如果配置了还是出现 404,那么可能访问的文件类型的 MIMEFurion 框架中未被包含,这时可自行添加,如:

var contentTypeProvider = FS.GetFileExtensionContentTypeProvider();  
contentTypeProvider.Mappings[".文件后缀"] = "MIME 类型";    // 注意文件后缀以  . 开头  
// 可多个....  

app.UseStaticFiles(new StaticFileOptions {  
  ContentTypeProvider = contentTypeProvider;  
});  

31.9 反馈与建议

与我们交流给 Furion 提 Issue