ASP.NET Core Blazor 文件上传

警告

允许用户上传文件时,始终遵循最佳安全做法。 有关详细信息,请参阅 在 ASP.NET Core 中上传文件

使用 InputFile 组件将浏览器文件数据读入 .NET 代码。 InputFile 组件呈现 file 类型的 HTML <input> 元素。 默认情况下,用户选择单个文件。 可添加 multiple 属性以允许用户一次上传多个文件。

发生 OnChange (change) 事件时,以下 InputFile 组件执行 LoadFiles 方法。 InputFileChangeEventArgs 提供对所选文件列表和每个文件的详细信息的访问:

<InputFile OnChange="@LoadFiles" multiple />

@code {
    private void LoadFiles(InputFileChangeEventArgs e)
    {
        ...
    }
}

已呈现 HTML:

<input multiple="" type="file" _bl_2="">

备注

在上一个示例中,<input> 元素的 _bl_2 属性用于 Blazor 的内部处理。

若要从用户选择的文件中读取数据,请对该文件调用 IBrowserFile.OpenReadStream,并从返回的流中读取。 有关详细信息,请参阅文件流部分。

OpenReadStream 强制采用其 Stream 的最大大小(以字节为单位)。 读取一个或多个大于 512,000 字节 (500 KB) 的文件会引发异常。 此限制可防止开发人员意外地将大型文件读入到内存中。 如果需要,可以使用 OpenReadStream 上的 maxAllowedSize 参数指定更大的大小。

避免将传入的文件流直接读入到内存中。 例如,不要将文件字节复制到 MemoryStream,也不要以字节数组的形式进行读取。 这些方法可能会导致性能和安全问题,尤其是在 Blazor Server 中。 请考虑将文件字节复制到外部存储(如 blob 或磁盘上的文件)。 若需要访问表示文件字节的 Stream,请使用 IBrowserFile.OpenReadStream

在以下示例中,broswerFile 表示上传的文件,并实现 IBrowserFile

❌ 不建议使用以下方法,因为会将文件的 Stream 内容读入内存 (reader) 中的 String

var reader = 
    await new StreamReader(browserFile.OpenReadStream()).ReadToEndAsync();

❌ 不建议对 Microsoft Azure Blob 存储使用以下方法,因为在调用 UploadBlobAsync 之前,会将文件的 Stream 内容复制到内存 (memoryStream) 中的 MemoryStream

var memoryStream = new MemoryStream();
browserFile.OpenReadStream().CopyToAsync(memoryStream);
await blobContainerClient.UploadBlobAsync(
    trustedFilename, memoryStream));

✔️ 建议使用以下方法,因为文件的 Stream 是直接提供给使用者的,FileStream 会在提供的路径中创建文件::

await using FileStream fs = new(path, FileMode.Create);
await browserFile.OpenReadStream().CopyToAsync(fs);

✔️ 建议对 Microsoft Azure Blob 存储使用以下方法,因为文件的 Stream 是直接提供给 UploadBlobAsync 的:

await blobContainerClient.UploadBlobAsync(
    trustedFilename, browserFile.OpenReadStream());

接收图像文件的组件可以对文件调用 BrowserFileExtensions.RequestImageFileAsync 便利方法,在图像流式传入应用之前,在浏览器的 JavaScript 运行时内调整图像数据的大小。 调用 RequestImageFileAsync 的用例最适合 Blazor WebAssembly 应用。

以下示例演示在组件中上传多个文件。 通过 InputFileChangeEventArgs.GetMultipleFiles,可以读取多个文件。 请指定最大文件数,以防止恶意用户上传的文件数超过应用的预期值。 如果文件上传不支持多个文件,则可以通过 InputFileChangeEventArgs.File 读取第一个文件,并且只能读取此文件。

备注

InputFileChangeEventArgs 位于 Microsoft.AspNetCore.Components.Forms 命名空间,后者通常是应用的 _Imports.razor 文件中的一个命名空间。 当 _Imports.razor 文件中存在命名空间时,它提供对应用组件的 API 成员访问权限:

using Microsoft.AspNetCore.Components.Forms

_Imports.razor 文件中的命名空间不适用于 C# 文件 (.cs)。 C# 文件需要显式 using 指令。

备注

为了测试文件上传组件,可以使用 PowerShell 创建任意大小的测试文件:

$out = new-object byte[] {SIZE}; (new-object Random).NextBytes($out); [IO.File]::WriteAllBytes('{PATH}', $out)

在上述命令中:

  • {SIZE} 占位符是文件大小(以字节为单位,例如,2 MB 文件为 2097152)。
  • {PATH} 占位符是路径和带有文件扩展名的文件(例如,D:/test_files/testfile2MB.txt)。

Pages/FileUpload1.razor:

@page "/file-upload-1"
@using Microsoft.Extensions.Logging
@inject ILogger<FileUpload1> Logger

<h3>Upload Files</h3>

<p>
    <label>
        Max file size:
        <input type="number" @bind="maxFileSize" />
    </label>
</p>

<p>
    <label>
        Max allowed files:
        <input type="number" @bind="maxAllowedFiles" />
    </label>
</p>

<p>
    <label>
        Upload up to @maxAllowedFiles of up to @maxFileSize bytes:
        <InputFile OnChange="@LoadFiles" multiple />
    </label>
</p>

@if (isLoading)
{
    <p>Uploading...</p>
}
else
{
    <ul>
        @foreach (var file in loadedFiles)
        {
            <li>
                <ul>
                    <li>Name: @file.Name</li>
                    <li>Last modified: @file.LastModified.ToString()</li>
                    <li>Size (bytes): @file.Size</li>
                    <li>Content type: @file.ContentType</li>
                </ul>
            </li>
        }
    </ul>
}

@code {
    private List<IBrowserFile> loadedFiles = new();
    private long maxFileSize = 1024 * 15;
    private int maxAllowedFiles = 3;
    private bool isLoading;

    private void LoadFiles(InputFileChangeEventArgs e)
    {
        isLoading = true;
        loadedFiles.Clear();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            try
            {
                loadedFiles.Add(file);
            }
            catch (Exception ex)
            {
                Logger.LogError("File: {Filename} Error: {Error}", 
                    file.Name, ex.Message);
            }
        }

        isLoading = false;
    }
}
@page "/file-upload-1"
@using System 
@using System.IO
@using Microsoft.AspNetCore.Hosting
@using Microsoft.Extensions.Logging
@inject ILogger<FileUpload1> Logger
@inject IWebHostEnvironment Environment

<h3>Upload Files</h3>

<p>
    <label>
        Max file size:
        <input type="number" @bind="maxFileSize" />
    </label>
</p>

<p>
    <label>
        Max allowed files:
        <input type="number" @bind="maxAllowedFiles" />
    </label>
</p>

<p>
    <label>
        Upload up to @maxAllowedFiles of up to @maxFileSize bytes:
        <InputFile OnChange="@LoadFiles" multiple />
    </label>
</p>

@if (isLoading)
{
    <p>Uploading...</p>
}
else
{
    <ul>
        @foreach (var file in loadedFiles)
        {
            <li>
                <ul>
                    <li>Name: @file.Name</li>
                    <li>Last modified: @file.LastModified.ToString()</li>
                    <li>Size (bytes): @file.Size</li>
                    <li>Content type: @file.ContentType</li>
                </ul>
            </li>
        }
    </ul>
}

@code {
    private List<IBrowserFile> loadedFiles = new();
    private long maxFileSize = 1024 * 15;
    private int maxAllowedFiles = 3;
    private bool isLoading;

    private async Task LoadFiles(InputFileChangeEventArgs e)
    {
        isLoading = true;
        loadedFiles.Clear();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            try
            {
                loadedFiles.Add(file);

                var trustedFileNameForFileStorage = Path.GetRandomFileName();
                var path = Path.Combine(Environment.ContentRootPath,
                        Environment.EnvironmentName, "unsafe_uploads",
                        trustedFileNameForFileStorage);

                await using FileStream fs = new(path, FileMode.Create);
                await file.OpenReadStream(maxFileSize).CopyToAsync(fs);
            }
            catch (Exception ex)
            {
                Logger.LogError("File: {Filename} Error: {Error}", 
                    file.Name, ex.Message);
            }
        }

        isLoading = false;
    }
}

IBrowserFile 会以属性形式返回浏览器公开的元数据。 使用此元数据进行初步验证。

警告

决不要信任以下属性的值,特别是在 UI 中显示的 Name 属性。 将所有用户提供的数据视为对应用、服务器和网络的重大安全风险。 有关详细信息,请参阅 在 ASP.NET Core 中上传文件

将文件上传到服务器

以下示例演示了将文件上传到托管的 Blazor WebAssembly 解决方案的 Server 应用中的 Web API 控制器。

以下示例演示了将文件从 Blazor Server 应用上传到单独的应用(可能位于单独的服务器上)中的后端 Web API 控制器。

在 Blazor Server 应用中,添加 IHttpClientFactory 和允许应用创建 HttpClient 实例的相关服务。

Startup.csStartup.ConfigureServices 中:

services.AddHttpClient();

有关详细信息,请参阅 在 ASP.NET Core 中使用 IHttpClientFactory 发出 HTTP 请求

对于本部分中的示例:

  • Web API 在 URL https://localhost:5001 上运行
  • Blazor Server 应用在 URL https://localhost:5003 上运行

出于测试目的,上述 URL 在项目的 Properties/launchSettings.json 文件中进行配置。

上传结果类

Shared 项目中的以下 UploadResult 类维护已上传文件的结果。 如果文件无法上传到服务器上,ErrorCode 中会返回错误代码以向用户显示。 服务器上会针对每个文件生成安全的文件名,并在 StoredFileName 中返回给客户端以供显示。 客户端和服务器之间会使用 FileName 中的不安全/不受信任的文件名对文件进行键控。 为与 Shared 项目的程序集名称匹配的类提供命名空间。 在下面的示例中,项目的命名空间为 BlazorSample.Shared

托管的 Blazor WebAssembly 解决方案的 Shared 项目中的 UploadResult.cs

namespace BlazorSample.Shared
{
    public class UploadResult
    {
        public bool Uploaded { get; set; }
        public string FileName { get; set; }
        public string StoredFileName { get; set; }
        public int ErrorCode { get; set; }
    }
}

以下 UploadResult 类置于客户端项目和 Web API 项目中,以维护上传文件的结果。 如果文件无法上传到服务器上,ErrorCode 中会返回错误代码以向用户显示。 服务器上会针对每个文件生成安全的文件名,并在 StoredFileName 中返回给客户端以供显示。 客户端和服务器之间会使用 FileName 中的不安全/不受信任的文件名对文件进行键控。

UploadResult.cs:

public class UploadResult
{
    public bool Uploaded { get; set; }
    public string FileName { get; set; }
    public string StoredFileName { get; set; }
    public int ErrorCode { get; set; }
}

备注

生产应用的最佳安全做法是避免向客户端发送错误消息,这些错误消息可能会披露有关应用、服务器或网络的敏感信息。 提供详细的错误消息会有利于恶意用户针对应用、服务器或网络设计攻击方案。 本部分中的示例代码只发送回错误代码号 (int),以便发生服务器端错误时供组件客户端显示。 如果用户需要在文件上传方面的帮助,他们会向支持人员提供错误代码以解决支持票证问题,而无需知道错误的确切原因。

上传组件

以下 FileUpload2 组件:

  • 允许用户从客户端上传文件。
  • 在 UI 中显示由客户端提供的不受信任/不安全的文件名。 不受信任的/不安全的文件名由 Razor 自动进行 HTML 编码,以在 UI 中安全显示。

警告

在以下情况下,不要信任客户端提供的文件名:

  • 将文件保存到文件系统或服务中。
  • 在不自动或通过开发人员代码对文件名进行编码的 UI 中显示。

有关将文件上传到服务器时的安全注意事项的更多信息,请参阅“在 ASP.NET Core 中上传文件”。

Client 项目中的 Pages/FileUpload2.razor

@page "/file-upload-2"
@using System.Linq
@using System.Net.Http.Headers
@using Microsoft.Extensions.Logging
@inject HttpClient Http
@inject ILogger<FileUpload2> Logger

<h1>Upload Files</h1>

<p>
    <label>
        Upload up to @maxAllowedFiles files:
        <InputFile OnChange="@OnInputFileChange" multiple />
    </label>
</p>

@if (files.Count > 0)
{
    <div class="card">
        <div class="card-body">
            <ul>
                @foreach (var file in files)
                {
                    <li>
                        File: @file.Name
                        <br>
                        @if (FileUpload(uploadResults, file.Name, Logger,
                           out var result))
                        {
                            <span>
                                Stored File Name: @result.StoredFileName
                            </span>
                        }
                        else
                        {
                            <span>
                                There was an error uploading the file
                                (Error: @result.ErrorCode).
                            </span>
                        }
                    </li>
                }
            </ul>
        </div>
    </div>
}

@code {
    private List<File> files = new();
    private List<UploadResult> uploadResults = new();
    private int maxAllowedFiles = 3;
    private bool shouldRender;

    protected override bool ShouldRender() => shouldRender;

    private async Task OnInputFileChange(InputFileChangeEventArgs e)
    {
        shouldRender = false;
        long maxFileSize = 1024 * 1024 * 15;
        var upload = false;

        using var content = new MultipartFormDataContent();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            if (uploadResults.SingleOrDefault(
                f => f.FileName == file.Name) is null)
            {
                try
                {
                    var fileContent = 
                        new StreamContent(file.OpenReadStream(maxFileSize));

                    fileContent.Headers.ContentType = 
                        new MediaTypeHeaderValue(file.ContentType);

                    files.Add(new() { Name = file.Name });

                    content.Add(
                        content: fileContent,
                        name: "\"files\"",
                        fileName: file.Name);

                    upload = true;
                }
                catch (Exception ex)
                {
                    Logger.LogInformation(
                        "{FileName} not uploaded (Err: 6): {Message}", 
                        file.Name, ex.Message);

                    uploadResults.Add(
                        new()
                        {
                            FileName = file.Name, 
                            ErrorCode = 6, 
                            Uploaded = false
                        });
                }
            }
        }

        if (upload)
        {
            var response = await Http.PostAsync("/Filesave", content);

            var newUploadResults = await response.Content
                .ReadFromJsonAsync<IList<UploadResult>>();

            uploadResults = uploadResults.Concat(newUploadResults).ToList();
        }

        shouldRender = true;
    }

    private static bool FileUpload(IList<UploadResult> uploadResults,
        string fileName, ILogger<FileUpload2> logger, out UploadResult result)
    {
        result = uploadResults.SingleOrDefault(f => f.FileName == fileName);

        if (result is null)
        {
            logger.LogInformation("{FileName} not uploaded (Err: 5)", fileName);
            result = new();
            result.ErrorCode = 5;
        }

        return result.Uploaded;
    }

    private class File
    {
        public string Name { get; set; }
    }
}

Blazor Server 应用中的 Pages/FileUpload2.razor

@page "/file-upload-2"
@using System.Linq
@using System.Net.Http.Headers
@using System.Text.Json
@using Microsoft.Extensions.Logging
@inject IHttpClientFactory ClientFactory
@inject ILogger<FileUpload2> Logger

<h1>Upload Files</h1>

<p>
    <label>
        Upload up to @maxAllowedFiles files:
        <InputFile OnChange="@OnInputFileChange" multiple />
    </label>
</p>

@if (files.Count > 0)
{
    <div class="card">
        <div class="card-body">
            <ul>
                @foreach (var file in files)
                {
                    <li>
                        File: @file.Name
                        <br>
                        @if (FileUpload(uploadResults, file.Name, Logger,
                           out var result))
                        {
                            <span>
                                Stored File Name: @result.StoredFileName
                            </span>
                        }
                        else
                        {
                            <span>
                                There was an error uploading the file
                                (Error: @result.ErrorCode).
                            </span>
                        }
                    </li>
                }
            </ul>
        </div>
    </div>
}

@code {
    private List<File> files = new();
    private List<UploadResult> uploadResults = new();
    private int maxAllowedFiles = 3;
    private bool shouldRender;

    protected override bool ShouldRender() => shouldRender;

    private async Task OnInputFileChange(InputFileChangeEventArgs e)
    {
        shouldRender = false;
        long maxFileSize = 1024 * 1024 * 15;
        var upload = false;

        using var content = new MultipartFormDataContent();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            if (uploadResults.SingleOrDefault(
                f => f.FileName == file.Name) is null)
            {
                try
                {
                    var fileContent = 
                        new StreamContent(file.OpenReadStream(maxFileSize));

                    fileContent.Headers.ContentType = 
                        new MediaTypeHeaderValue(file.ContentType);

                    files.Add(new() { Name = file.Name });

                    content.Add(
                        content: fileContent,
                        name: "\"files\"",
                        fileName: file.Name);

                    upload = true;
                }
                catch (Exception ex)
                {
                    Logger.LogInformation(
                        "{FileName} not uploaded (Err: 6): {Message}", 
                        file.Name, ex.Message);

                    uploadResults.Add(
                        new()
                        {
                            FileName = file.Name, 
                            ErrorCode = 6, 
                            Uploaded = false
                        });
                }
            }
        }

        if (upload)
        {
            var client = ClientFactory.CreateClient();

            var response = 
                await client.PostAsync("https://localhost:5001/Filesave", 
                content);

            if (response.IsSuccessStatusCode)
            {
                var options = new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true,
                };

                using var responseStream =
                    await response.Content.ReadAsStreamAsync();

                var newUploadResults = await JsonSerializer
                    .DeserializeAsync<IList<UploadResult>>(responseStream, options);

                uploadResults = uploadResults.Concat(newUploadResults).ToList();
            }
        }

        shouldRender = true;
    }

    private static bool FileUpload(IList<UploadResult> uploadResults,
        string fileName, ILogger<FileUpload2> logger, out UploadResult result)
    {
        result = uploadResults.SingleOrDefault(f => f.FileName == fileName);

        if (result is null)
        {
            logger.LogInformation("{FileName} not uploaded (Err: 5)", fileName);
            result = new();
            result.ErrorCode = 5;
        }

        return result.Uploaded;
    }

    private class File
    {
        public string Name { get; set; }
    }
}

上传控制器

Server 项目中的以下控制器保存从客户端上传的文件。

若要使用以下代码,请为于 Development 环境中运行的应用在 Server 项目的根目录下创建一个 Development/unsafe_uploads 文件夹。 由于该示例使用应用的环境作为保存文件的路径的一部分,因此,如果在测试和生产中使用其他环境,则还需要其他文件夹。 例如,为 Staging 环境创建 Staging/unsafe_uploads 文件夹。 为 Production 环境创建 Production/unsafe_uploads 文件夹。

Web API 项目中的以下控制器保存从客户端上传的文件。

若要使用以下代码,请为于 Development 环境中运行的应用在 Web API 项目的根上创建 Development/unsafe_uploads 文件夹。 由于该示例使用应用的环境作为保存文件的路径的一部分,因此,如果在测试和生产中使用其他环境,则还需要其他文件夹。 例如,为 Staging 环境创建 Staging/unsafe_uploads 文件夹。 为 Production 环境创建 Production/unsafe_uploads 文件夹。

警告

该示例直接保存文件而没有扫描其内容。 在生产情景中,会对上传的文件使用防病毒/反恶意软件扫描程序 API,然后文件才可供下载或供其他系统使用。 有关将文件上传到服务器时的安全注意事项的更多信息,请参阅“在 ASP.NET Core 中上传文件”。

Controllers/FilesaveController.cs:

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

[ApiController]
[Route("[controller]")]
public class FilesaveController : ControllerBase
{
    private readonly IWebHostEnvironment env;
    private readonly ILogger<FilesaveController> logger;

    public FilesaveController(IWebHostEnvironment env,
        ILogger<FilesaveController> logger)
    {
        this.env = env;
        this.logger = logger;
    }

    [HttpPost]
    public async Task<ActionResult<IList<UploadResult>>> PostFile(
        [FromForm] IEnumerable<IFormFile> files)
    {
        var maxAllowedFiles = 3;
        long maxFileSize = 1024 * 1024 * 15;
        var filesProcessed = 0;
        var resourcePath = new Uri($"{Request.Scheme}://{Request.Host}/");
        List<UploadResult> uploadResults = new();

        foreach (var file in files)
        {
            var uploadResult = new UploadResult();
            string trustedFileNameForFileStorage;
            var untrustedFileName = file.FileName;
            uploadResult.FileName = untrustedFileName;
            var trustedFileNameForDisplay =
                WebUtility.HtmlEncode(untrustedFileName);

            if (filesProcessed < maxAllowedFiles)
            {
                if (file.Length == 0)
                {
                    logger.LogInformation("{FileName} length is 0 (Err: 1)",
                        trustedFileNameForDisplay);
                    uploadResult.ErrorCode = 1;
                }
                else if (file.Length > maxFileSize)
                {
                    logger.LogInformation("{FileName} of {Length} bytes is " +
                        "larger than the limit of {Limit} bytes (Err: 2)",
                        trustedFileNameForDisplay, file.Length, maxFileSize);
                    uploadResult.ErrorCode = 2;
                }
                else
                {
                    try
                    {
                        trustedFileNameForFileStorage = Path.GetRandomFileName();
                        var path = Path.Combine(env.ContentRootPath,
                            env.EnvironmentName, "unsafe_uploads",
                            trustedFileNameForFileStorage);

                        await using FileStream fs = new(path, FileMode.Create);
                        await file.CopyToAsync(fs);

                        logger.LogInformation("{FileName} saved at {Path}",
                            trustedFileNameForDisplay, path);
                        uploadResult.Uploaded = true;
                        uploadResult.StoredFileName = trustedFileNameForFileStorage;
                    }
                    catch (IOException ex)
                    {
                        logger.LogError("{FileName} error on upload (Err: 3): {Message}",
                            trustedFileNameForDisplay, ex.Message);
                        uploadResult.ErrorCode = 3;
                    }
                }

                filesProcessed++;
            }
            else
            {
                logger.LogInformation("{FileName} not uploaded because the " +
                    "request exceeded the allowed {Count} of files (Err: 4)",
                    trustedFileNameForDisplay, maxAllowedFiles);
                uploadResult.ErrorCode = 4;
            }

            uploadResults.Add(uploadResult);
        }

        return new CreatedResult(resourcePath, uploadResults);
    }
}

文件流

在 Blazor WebAssembly 中,文件数据直接流式传入浏览器中的 .NET 代码。

在 Blazor Server 中,读取文件时,文件数据通过 SignalR 连接流式传入服务器上的 .NET 代码。

其他资源

警告

允许用户上传文件时,始终遵循最佳安全做法。 有关详细信息,请参阅 在 ASP.NET Core 中上传文件

使用 InputFile 组件将浏览器文件数据读入 .NET 代码。 InputFile 组件呈现 file 类型的 HTML <input> 元素。 默认情况下,用户选择单个文件。 可添加 multiple 属性以允许用户一次上传多个文件。

发生 OnChange (change) 事件时,以下 InputFile 组件执行 LoadFiles 方法。 InputFileChangeEventArgs 提供对所选文件列表和每个文件的详细信息的访问:

<InputFile OnChange="@LoadFiles" multiple />

@code {
    private void LoadFiles(InputFileChangeEventArgs e)
    {
        ...
    }
}

已呈现 HTML:

<input multiple="" type="file" _bl_2="">

备注

在上一个示例中,<input> 元素的 _bl_2 属性用于 Blazor 的内部处理。

若要从用户选择的文件中读取数据,请对该文件调用 IBrowserFile.OpenReadStream,并从返回的流中读取。 有关详细信息,请参阅文件流部分。

OpenReadStream 强制采用其 Stream 的最大大小(以字节为单位)。 读取一个或多个大于 512,000 字节 (500 KB) 的文件会引发异常。 此限制可防止开发人员意外地将大型文件读入到内存中。 如果需要,可以使用 OpenReadStream 上的 maxAllowedSize 参数指定更大的大小。

避免将传入的文件流直接读入到内存中。 例如,不要将文件字节复制到 MemoryStream,也不要以字节数组的形式进行读取。 这些方法可能会导致性能和安全问题,尤其是在 Blazor Server 中。 请考虑将文件字节复制到外部存储(如 blob 或磁盘上的文件)。 若需要访问表示文件字节的 Stream,请使用 IBrowserFile.OpenReadStream

在以下示例中,broswerFile 表示上传的文件,并实现 IBrowserFile

❌ 不建议使用以下方法,因为会将文件的 Stream 内容读入内存 (reader) 中的 String

var reader = 
    await new StreamReader(browserFile.OpenReadStream()).ReadToEndAsync();

❌ 不建议对 Microsoft Azure Blob 存储使用以下方法,因为在调用 UploadBlobAsync 之前,会将文件的 Stream 内容复制到内存 (memoryStream) 中的 MemoryStream

var memoryStream = new MemoryStream();
browserFile.OpenReadStream().CopyToAsync(memoryStream);
await blobContainerClient.UploadBlobAsync(
    trustedFilename, memoryStream));

✔️ 建议使用以下方法,因为文件的 Stream 是直接提供给使用者的,FileStream 会在提供的路径中创建文件::

await using FileStream fs = new(path, FileMode.Create);
await browserFile.OpenReadStream().CopyToAsync(fs);

✔️ 建议对 Microsoft Azure Blob 存储使用以下方法,因为文件的 Stream 是直接提供给 UploadBlobAsync 的:

await blobContainerClient.UploadBlobAsync(
    trustedFilename, browserFile.OpenReadStream());

接收图像文件的组件可以对文件调用 BrowserFileExtensions.RequestImageFileAsync 便利方法,在图像流式传入应用之前,在浏览器的 JavaScript 运行时内调整图像数据的大小。 调用 RequestImageFileAsync 的用例最适合 Blazor WebAssembly 应用。

以下示例演示在组件中上传多个文件。 通过 InputFileChangeEventArgs.GetMultipleFiles,可以读取多个文件。 请指定最大文件数,以防止恶意用户上传的文件数超过应用的预期值。 如果文件上传不支持多个文件,则可以通过 InputFileChangeEventArgs.File 读取第一个文件,并且只能读取此文件。

备注

InputFileChangeEventArgs 位于 Microsoft.AspNetCore.Components.Forms 命名空间,后者通常是应用的 _Imports.razor 文件中的一个命名空间。 当 _Imports.razor 文件中存在命名空间时,它提供对应用组件的 API 成员访问权限:

using Microsoft.AspNetCore.Components.Forms

_Imports.razor 文件中的命名空间不适用于 C# 文件 (.cs)。 C# 文件需要显式 using 指令。

备注

为了测试文件上传组件,可以使用 PowerShell 创建任意大小的测试文件:

$out = new-object byte[] {SIZE}; (new-object Random).NextBytes($out); [IO.File]::WriteAllBytes('{PATH}', $out)

在上述命令中:

  • {SIZE} 占位符是文件大小(以字节为单位,例如,2 MB 文件为 2097152)。
  • {PATH} 占位符是路径和带有文件扩展名的文件(例如,D:/test_files/testfile2MB.txt)。

Pages/FileUpload1.razor:

@page "/file-upload-1"
@using Microsoft.Extensions.Logging
@inject ILogger<FileUpload1> Logger

<h3>Upload Files</h3>

<p>
    <label>
        Max file size:
        <input type="number" @bind="maxFileSize" />
    </label>
</p>

<p>
    <label>
        Max allowed files:
        <input type="number" @bind="maxAllowedFiles" />
    </label>
</p>

<p>
    <label>
        Upload up to @maxAllowedFiles of up to @maxFileSize bytes:
        <InputFile OnChange="@LoadFiles" multiple />
    </label>
</p>

@if (isLoading)
{
    <p>Uploading...</p>
}
else
{
    <ul>
        @foreach (var file in loadedFiles)
        {
            <li>
                <ul>
                    <li>Name: @file.Name</li>
                    <li>Last modified: @file.LastModified.ToString()</li>
                    <li>Size (bytes): @file.Size</li>
                    <li>Content type: @file.ContentType</li>
                </ul>
            </li>
        }
    </ul>
}

@code {
    private List<IBrowserFile> loadedFiles = new();
    private long maxFileSize = 1024 * 15;
    private int maxAllowedFiles = 3;
    private bool isLoading;

    private void LoadFiles(InputFileChangeEventArgs e)
    {
        isLoading = true;
        loadedFiles.Clear();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            try
            {
                loadedFiles.Add(file);
            }
            catch (Exception ex)
            {
                Logger.LogError("File: {Filename} Error: {Error}", 
                    file.Name, ex.Message);
            }
        }

        isLoading = false;
    }
}
@page "/file-upload-1"
@using System 
@using System.IO
@using Microsoft.AspNetCore.Hosting
@using Microsoft.Extensions.Logging
@inject ILogger<FileUpload1> Logger
@inject IWebHostEnvironment Environment

<h3>Upload Files</h3>

<p>
    <label>
        Max file size:
        <input type="number" @bind="maxFileSize" />
    </label>
</p>

<p>
    <label>
        Max allowed files:
        <input type="number" @bind="maxAllowedFiles" />
    </label>
</p>

<p>
    <label>
        Upload up to @maxAllowedFiles of up to @maxFileSize bytes:
        <InputFile OnChange="@LoadFiles" multiple />
    </label>
</p>

@if (isLoading)
{
    <p>Uploading...</p>
}
else
{
    <ul>
        @foreach (var file in loadedFiles)
        {
            <li>
                <ul>
                    <li>Name: @file.Name</li>
                    <li>Last modified: @file.LastModified.ToString()</li>
                    <li>Size (bytes): @file.Size</li>
                    <li>Content type: @file.ContentType</li>
                </ul>
            </li>
        }
    </ul>
}

@code {
    private List<IBrowserFile> loadedFiles = new();
    private long maxFileSize = 1024 * 15;
    private int maxAllowedFiles = 3;
    private bool isLoading;

    private async Task LoadFiles(InputFileChangeEventArgs e)
    {
        isLoading = true;
        loadedFiles.Clear();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            try
            {
                loadedFiles.Add(file);

                var trustedFileNameForFileStorage = Path.GetRandomFileName();
                var path = Path.Combine(Environment.ContentRootPath,
                        Environment.EnvironmentName, "unsafe_uploads",
                        trustedFileNameForFileStorage);

                await using FileStream fs = new(path, FileMode.Create);
                await file.OpenReadStream(maxFileSize).CopyToAsync(fs);
            }
            catch (Exception ex)
            {
                Logger.LogError("File: {Filename} Error: {Error}", 
                    file.Name, ex.Message);
            }
        }

        isLoading = false;
    }
}

IBrowserFile 会以属性形式返回浏览器公开的元数据。 使用此元数据进行初步验证。

警告

决不要信任以下属性的值,特别是在 UI 中显示的 Name 属性。 将所有用户提供的数据视为对应用、服务器和网络的重大安全风险。 有关详细信息,请参阅 在 ASP.NET Core 中上传文件

将文件上传到服务器

以下示例演示了将文件上传到托管的 Blazor WebAssembly 解决方案的 Server 应用中的 Web API 控制器。

以下示例演示了将文件从 Blazor Server 应用上传到单独的应用(可能位于单独的服务器上)中的后端 Web API 控制器。

在 Blazor Server 应用中,添加 IHttpClientFactory 和允许应用创建 HttpClient 实例的相关服务。

Startup.csStartup.ConfigureServices 中:

services.AddHttpClient();

有关详细信息,请参阅 在 ASP.NET Core 中使用 IHttpClientFactory 发出 HTTP 请求

对于本部分中的示例:

  • Web API 在 URL https://localhost:5001 上运行
  • Blazor Server 应用在 URL https://localhost:5003 上运行

出于测试目的,上述 URL 在项目的 Properties/launchSettings.json 文件中进行配置。

上传结果类

Shared 项目中的以下 UploadResult 类维护已上传文件的结果。 如果文件无法上传到服务器上,ErrorCode 中会返回错误代码以向用户显示。 服务器上会针对每个文件生成安全的文件名,并在 StoredFileName 中返回给客户端以供显示。 客户端和服务器之间会使用 FileName 中的不安全/不受信任的文件名对文件进行键控。 为与 Shared 项目的程序集名称匹配的类提供命名空间。 在下面的示例中,项目的命名空间为 BlazorSample.Shared

托管的 Blazor WebAssembly 解决方案的 Shared 项目中的 UploadResult.cs

namespace BlazorSample.Shared
{
    public class UploadResult
    {
        public bool Uploaded { get; set; }
        public string FileName { get; set; }
        public string StoredFileName { get; set; }
        public int ErrorCode { get; set; }
    }
}

以下 UploadResult 类置于客户端项目和 Web API 项目中,以维护上传文件的结果。 如果文件无法上传到服务器上,ErrorCode 中会返回错误代码以向用户显示。 服务器上会针对每个文件生成安全的文件名,并在 StoredFileName 中返回给客户端以供显示。 客户端和服务器之间会使用 FileName 中的不安全/不受信任的文件名对文件进行键控。

UploadResult.cs:

public class UploadResult
{
    public bool Uploaded { get; set; }
    public string FileName { get; set; }
    public string StoredFileName { get; set; }
    public int ErrorCode { get; set; }
}

备注

生产应用的最佳安全做法是避免向客户端发送错误消息,这些错误消息可能会披露有关应用、服务器或网络的敏感信息。 提供详细的错误消息会有利于恶意用户针对应用、服务器或网络设计攻击方案。 本部分中的示例代码只发送回错误代码号 (int),以便发生服务器端错误时供组件客户端显示。 如果用户需要在文件上传方面的帮助,他们会向支持人员提供错误代码以解决支持票证问题,而无需知道错误的确切原因。

上传组件

以下 FileUpload2 组件:

  • 允许用户从客户端上传文件。
  • 在 UI 中显示由客户端提供的不受信任/不安全的文件名。 不受信任的/不安全的文件名由 Razor 自动进行 HTML 编码,以在 UI 中安全显示。

警告

在以下情况下,不要信任客户端提供的文件名:

  • 将文件保存到文件系统或服务中。
  • 在不自动或通过开发人员代码对文件名进行编码的 UI 中显示。

有关将文件上传到服务器时的安全注意事项的更多信息,请参阅“在 ASP.NET Core 中上传文件”。

Client 项目中的 Pages/FileUpload2.razor

@page "/file-upload-2"
@using System.Linq
@using System.Net.Http.Headers
@using Microsoft.Extensions.Logging
@inject HttpClient Http
@inject ILogger<FileUpload2> Logger

<h1>Upload Files</h1>

<p>
    <label>
        Upload up to @maxAllowedFiles files:
        <InputFile OnChange="@OnInputFileChange" multiple />
    </label>
</p>

@if (files.Count > 0)
{
    <div class="card">
        <div class="card-body">
            <ul>
                @foreach (var file in files)
                {
                    <li>
                        File: @file.Name
                        <br>
                        @if (FileUpload(uploadResults, file.Name, Logger,
                           out var result))
                        {
                            <span>
                                Stored File Name: @result.StoredFileName
                            </span>
                        }
                        else
                        {
                            <span>
                                There was an error uploading the file
                                (Error: @result.ErrorCode).
                            </span>
                        }
                    </li>
                }
            </ul>
        </div>
    </div>
}

@code {
    private List<File> files = new();
    private List<UploadResult> uploadResults = new();
    private int maxAllowedFiles = 3;
    private bool shouldRender;

    protected override bool ShouldRender() => shouldRender;

    private async Task OnInputFileChange(InputFileChangeEventArgs e)
    {
        shouldRender = false;
        long maxFileSize = 1024 * 1024 * 15;
        var upload = false;

        using var content = new MultipartFormDataContent();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            if (uploadResults.SingleOrDefault(
                f => f.FileName == file.Name) is null)
            {
                try
                {
                    var fileContent = 
                        new StreamContent(file.OpenReadStream(maxFileSize));

                    fileContent.Headers.ContentType = 
                        new MediaTypeHeaderValue(file.ContentType);

                    files.Add(new() { Name = file.Name });

                    content.Add(
                        content: fileContent,
                        name: "\"files\"",
                        fileName: file.Name);

                    upload = true;
                }
                catch (Exception ex)
                {
                    Logger.LogInformation(
                        "{FileName} not uploaded (Err: 6): {Message}", 
                        file.Name, ex.Message);

                    uploadResults.Add(
                        new()
                        {
                            FileName = file.Name, 
                            ErrorCode = 6, 
                            Uploaded = false
                        });
                }
            }
        }

        if (upload)
        {
            var response = await Http.PostAsync("/Filesave", content);

            var newUploadResults = await response.Content
                .ReadFromJsonAsync<IList<UploadResult>>();

            uploadResults = uploadResults.Concat(newUploadResults).ToList();
        }

        shouldRender = true;
    }

    private static bool FileUpload(IList<UploadResult> uploadResults,
        string fileName, ILogger<FileUpload2> logger, out UploadResult result)
    {
        result = uploadResults.SingleOrDefault(f => f.FileName == fileName);

        if (result is null)
        {
            logger.LogInformation("{FileName} not uploaded (Err: 5)", fileName);
            result = new();
            result.ErrorCode = 5;
        }

        return result.Uploaded;
    }

    private class File
    {
        public string Name { get; set; }
    }
}

Blazor Server 应用中的 Pages/FileUpload2.razor

@page "/file-upload-2"
@using System.Linq
@using System.Net.Http.Headers
@using System.Text.Json
@using Microsoft.Extensions.Logging
@inject IHttpClientFactory ClientFactory
@inject ILogger<FileUpload2> Logger

<h1>Upload Files</h1>

<p>
    <label>
        Upload up to @maxAllowedFiles files:
        <InputFile OnChange="@OnInputFileChange" multiple />
    </label>
</p>

@if (files.Count > 0)
{
    <div class="card">
        <div class="card-body">
            <ul>
                @foreach (var file in files)
                {
                    <li>
                        File: @file.Name
                        <br>
                        @if (FileUpload(uploadResults, file.Name, Logger,
                           out var result))
                        {
                            <span>
                                Stored File Name: @result.StoredFileName
                            </span>
                        }
                        else
                        {
                            <span>
                                There was an error uploading the file
                                (Error: @result.ErrorCode).
                            </span>
                        }
                    </li>
                }
            </ul>
        </div>
    </div>
}

@code {
    private List<File> files = new();
    private List<UploadResult> uploadResults = new();
    private int maxAllowedFiles = 3;
    private bool shouldRender;

    protected override bool ShouldRender() => shouldRender;

    private async Task OnInputFileChange(InputFileChangeEventArgs e)
    {
        shouldRender = false;
        long maxFileSize = 1024 * 1024 * 15;
        var upload = false;

        using var content = new MultipartFormDataContent();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            if (uploadResults.SingleOrDefault(
                f => f.FileName == file.Name) is null)
            {
                try
                {
                    var fileContent = 
                        new StreamContent(file.OpenReadStream(maxFileSize));

                    fileContent.Headers.ContentType = 
                        new MediaTypeHeaderValue(file.ContentType);

                    files.Add(new() { Name = file.Name });

                    content.Add(
                        content: fileContent,
                        name: "\"files\"",
                        fileName: file.Name);

                    upload = true;
                }
                catch (Exception ex)
                {
                    Logger.LogInformation(
                        "{FileName} not uploaded (Err: 6): {Message}", 
                        file.Name, ex.Message);

                    uploadResults.Add(
                        new()
                        {
                            FileName = file.Name, 
                            ErrorCode = 6, 
                            Uploaded = false
                        });
                }
            }
        }

        if (upload)
        {
            var client = ClientFactory.CreateClient();

            var response = 
                await client.PostAsync("https://localhost:5001/Filesave", 
                content);

            if (response.IsSuccessStatusCode)
            {
                var options = new JsonSerializerOptions
                {
                    PropertyNameCaseInsensitive = true,
                };

                using var responseStream =
                    await response.Content.ReadAsStreamAsync();

                var newUploadResults = await JsonSerializer
                    .DeserializeAsync<IList<UploadResult>>(responseStream, options);

                uploadResults = uploadResults.Concat(newUploadResults).ToList();
            }
        }

        shouldRender = true;
    }

    private static bool FileUpload(IList<UploadResult> uploadResults,
        string fileName, ILogger<FileUpload2> logger, out UploadResult result)
    {
        result = uploadResults.SingleOrDefault(f => f.FileName == fileName);

        if (result is null)
        {
            logger.LogInformation("{FileName} not uploaded (Err: 5)", fileName);
            result = new();
            result.ErrorCode = 5;
        }

        return result.Uploaded;
    }

    private class File
    {
        public string Name { get; set; }
    }
}

上传控制器

Server 项目中的以下控制器保存从客户端上传的文件。

若要使用以下代码,请为于 Development 环境中运行的应用在 Server 项目的根目录下创建一个 Development/unsafe_uploads 文件夹。 由于该示例使用应用的环境作为保存文件的路径的一部分,因此,如果在测试和生产中使用其他环境,则还需要其他文件夹。 例如,为 Staging 环境创建 Staging/unsafe_uploads 文件夹。 为 Production 环境创建 Production/unsafe_uploads 文件夹。

Web API 项目中的以下控制器保存从客户端上传的文件。

若要使用以下代码,请为于 Development 环境中运行的应用在 Web API 项目的根上创建 Development/unsafe_uploads 文件夹。 由于该示例使用应用的环境作为保存文件的路径的一部分,因此,如果在测试和生产中使用其他环境,则还需要其他文件夹。 例如,为 Staging 环境创建 Staging/unsafe_uploads 文件夹。 为 Production 环境创建 Production/unsafe_uploads 文件夹。

警告

该示例直接保存文件而没有扫描其内容。 在生产情景中,会对上传的文件使用防病毒/反恶意软件扫描程序 API,然后文件才可供下载或供其他系统使用。 有关将文件上传到服务器时的安全注意事项的更多信息,请参阅“在 ASP.NET Core 中上传文件”。

Controllers/FilesaveController.cs:

using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;

[ApiController]
[Route("[controller]")]
public class FilesaveController : ControllerBase
{
    private readonly IWebHostEnvironment env;
    private readonly ILogger<FilesaveController> logger;

    public FilesaveController(IWebHostEnvironment env,
        ILogger<FilesaveController> logger)
    {
        this.env = env;
        this.logger = logger;
    }

    [HttpPost]
    public async Task<ActionResult<IList<UploadResult>>> PostFile(
        [FromForm] IEnumerable<IFormFile> files)
    {
        var maxAllowedFiles = 3;
        long maxFileSize = 1024 * 1024 * 15;
        var filesProcessed = 0;
        var resourcePath = new Uri($"{Request.Scheme}://{Request.Host}/");
        List<UploadResult> uploadResults = new();

        foreach (var file in files)
        {
            var uploadResult = new UploadResult();
            string trustedFileNameForFileStorage;
            var untrustedFileName = file.FileName;
            uploadResult.FileName = untrustedFileName;
            var trustedFileNameForDisplay =
                WebUtility.HtmlEncode(untrustedFileName);

            if (filesProcessed < maxAllowedFiles)
            {
                if (file.Length == 0)
                {
                    logger.LogInformation("{FileName} length is 0 (Err: 1)",
                        trustedFileNameForDisplay);
                    uploadResult.ErrorCode = 1;
                }
                else if (file.Length > maxFileSize)
                {
                    logger.LogInformation("{FileName} of {Length} bytes is " +
                        "larger than the limit of {Limit} bytes (Err: 2)",
                        trustedFileNameForDisplay, file.Length, maxFileSize);
                    uploadResult.ErrorCode = 2;
                }
                else
                {
                    try
                    {
                        trustedFileNameForFileStorage = Path.GetRandomFileName();
                        var path = Path.Combine(env.ContentRootPath,
                            env.EnvironmentName, "unsafe_uploads",
                            trustedFileNameForFileStorage);

                        await using FileStream fs = new(path, FileMode.Create);
                        await file.CopyToAsync(fs);

                        logger.LogInformation("{FileName} saved at {Path}",
                            trustedFileNameForDisplay, path);
                        uploadResult.Uploaded = true;
                        uploadResult.StoredFileName = trustedFileNameForFileStorage;
                    }
                    catch (IOException ex)
                    {
                        logger.LogError("{FileName} error on upload (Err: 3): {Message}",
                            trustedFileNameForDisplay, ex.Message);
                        uploadResult.ErrorCode = 3;
                    }
                }

                filesProcessed++;
            }
            else
            {
                logger.LogInformation("{FileName} not uploaded because the " +
                    "request exceeded the allowed {Count} of files (Err: 4)",
                    trustedFileNameForDisplay, maxAllowedFiles);
                uploadResult.ErrorCode = 4;
            }

            uploadResults.Add(uploadResult);
        }

        return new CreatedResult(resourcePath, uploadResults);
    }
}

文件流

在 Blazor WebAssembly 中,文件数据直接流式传入浏览器中的 .NET 代码。

在 Blazor Server 中,从流中读取文件时,文件数据通过 SignalR 连接流式传入服务器上的 .NET 代码。 通过 RemoteBrowserFileStreamOptions,可以配置 Blazor Server 的文件上传特性。

其他资源