引言

在现代Web开发中,文件上传和下载是极为常见的需求。无论是用户头像上传、文档存储,还是数据导入导出,.NET平台(特别是ASP.NET Core)提供了强大且灵活的机制来处理这些任务。然而,文件处理也伴随着诸多挑战,如安全性、性能、大文件支持以及跨平台兼容性等。

本文将深入探讨.NET平台下文件上传与接收的完整流程,涵盖从基础实现到高级优化的各个方面,并提供常见问题的解决方案和最佳实践。

1. 基础文件上传实现

1.1 简单的单文件上传

在ASP.NET Core中,处理文件上传最核心的对象是 IFormFile。控制器方法通常接收一个或多个 IFormFile 参数来代表上传的文件。

控制器代码示例:

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.IO;
using System.Threading.Tasks;

namespace FileUploadDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class UploadController : ControllerBase
    {
        [HttpPost("simple-upload")]
        public async Task<IActionResult> SimpleUpload(IFormFile file)
        {
            if (file == null || file.Length == 0)
            {
                return BadRequest("未选择文件或文件为空");
            }

            // 定义保存路径(建议将路径配置在appsettings.json中)
            var uploadPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");

            // 确保目录存在
            if (!Directory.Exists(uploadPath))
            {
                Directory.CreateDirectory(uploadPath);
            }

            // 生成唯一文件名以防止冲突
            var uniqueFileName = $"{Guid.NewGuid()}_{file.FileName}";
            var filePath = Path.Combine(uploadPath, uniqueFileName);

            // 使用流将文件写入磁盘
            using (var stream = new FileStream(filePath, FileMode.Create))
            {
                await file.CopyToAsync(stream);
            }

            return Ok(new { 
                Message = "文件上传成功", 
                FileName = uniqueFileName,
                FilePath = filePath
            });
        }
    }
}

详细说明:

  1. IFormFile接口:这是ASP.NET Core用于表示上传文件的抽象。它提供了OpenReadStream()CopyToAsync()等方法来访问文件内容。
  2. 文件大小检查file.Length属性可以获取文件大小(字节)。在实际应用中,应在前端和后端都进行大小限制。
  3. 路径安全:使用Path.Combine拼接路径,避免手动拼接字符串带来的错误。Guid生成的唯一文件名可以有效防止文件名冲突和路径遍历攻击(虽然ASP.NET Core已内置防护,但这是良好的实践)。
  4. 异步操作:使用CopyToAsync可以避免在文件上传过程中阻塞线程,提高服务器的并发处理能力。

1.2 多文件上传

处理多个文件非常简单,只需将参数类型改为 IFormFileCollectionList<IFormFile>

控制器代码示例:

[HttpPost("multi-upload")]
public async Task<IActionResult> MultiUpload(List<IFormFile> files)
{
    if (files == null || files.Count == 0)
    {
        return BadRequest("未选择任何文件");
    }

    var uploadPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
    if (!Directory.Exists(uploadPath))
    {
        Directory.CreateDirectory(uploadPath);
    }

    var uploadResults = new List<string>();

    foreach (var file in files)
    {
        if (file.Length > 0)
        {
            var uniqueFileName = $"{Guid.NewGuid()}_{file.FileName}";
            var filePath = Path.Combine(uploadPath, uniqueFileName);

            using (var stream = new FileStream(filePath, FileMode.Create))
            {
                await file.CopyToAsync(stream);
            }
            
            uploadResults.Add(uniqueFileName);
        }
    }

    return Ok(new { 
        Message = $"成功上传 {uploadResults.Count} 个文件", 
        FileNames = uploadResults 
    });
}

2. 高级配置与优化

2.1 配置文件大小限制

默认情况下,ASP.NET Core对上传文件大小有限制(大约28.6MB)。我们可以通过修改 Program.cs (或 Startup.cs 在旧版本中) 来调整这些限制。

配置代码示例:

var builder = WebApplication.CreateBuilder(args);

// 添加控制器服务
builder.Services.AddControllers();

// 配置 Kestrel 服务器的最大请求体大小
builder.WebHost.ConfigureKestrel(serverOptions =>
{
    serverOptions.Limits.MaxRequestBodySize = 50 * 1024 * 1024; // 50 MB
});

// 或者使用 IIS 的配置(如果部署在 IIS 后面)
builder.Services.Configure<IISServerOptions>(options =>
{
    options.MaxRequestBodySize = 50 * 1024 * 1024; // 50 MB
});

// 在 MVC/Razor Pages 中配置模型绑定大小限制
builder.Services.AddControllersWithViews(options =>
{
    options.MaxModelBindingCollectionSize = 100; // 影响集合大小
    // 注意:对于文件上传,主要限制在 Kestrel/IIS 和 [RequestSizeLimit] 属性
});

var app = builder.Build();

// ... 其他中间件配置
app.MapControllers();
app.Run();

注意: 除了全局配置,还可以在具体的 Action 方法上使用 [RequestSizeLimit(bytes)] 特性进行细粒度控制。

[HttpPost("large-upload")]
[RequestSizeLimit(100 * 1024 * 1024)] // 100 MB
public async Task<IActionResult> LargeFileUpload(IFormFile file)
{
    // ...
}

2.2 流式上传与内存缓冲

默认情况下,ASP.NET Core 会将小文件缓冲到内存中。对于大文件,这会导致内存压力过大。虽然 IFormFile.CopyToAsync 已经处理了流,但了解其背后的机制很重要。

对于超大文件(GB级别),推荐使用 流式处理,避免一次性加载整个文件到内存。实际上,IFormFile 本身就是基于流的,只要不使用 file.OpenReadStream() 一次性读取全部内容到 byte[],通常就是流式的。

优化的大文件处理示例:

[HttpPost("streaming-upload")]
public async Task<IActionResult> StreamingUpload(IFormFile file)
{
    if (file == null) return BadRequest();

    var uploadPath = Path.Combine(Directory.GetCurrentDirectory(), "LargeUploads");
    Directory.CreateDirectory(uploadPath);

    var filePath = Path.Combine(uploadPath, file.FileName);

    // 这里的 CopyToAsync 内部使用了流式传输,不会将整个文件加载到内存
    using (var targetStream = File.Create(filePath))
    {
        await file.CopyToAsync(targetStream);
    }

    return Ok(new { Size = file.Length });
}

关键点: CopyToAsync 内部使用了 80KB 的缓冲区进行流式复制,这是非常高效的。

2.3 内存缓冲区大小调整

在某些情况下,你可能需要调整内部缓冲区大小以优化特定场景的性能。

// 在 Program.cs 中配置
builder.Services.Configure<FormOptions>(options =>
{
    // 设置内存缓冲区阈值,超过此大小的文件将被写入磁盘临时文件
    options.MemoryBufferThreshold = 1024 * 1024; // 1 MB
    // 设置最大内存缓冲区大小
    options.ValueLengthLimit = 1024 * 1024 * 10; // 10 MB
});

3. 文件类型处理与验证

3.1 基于扩展名的验证

这是最简单但最不安全的方法。仅检查文件名后缀。

private bool IsAllowedExtension(string fileName)
{
    var allowedExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".pdf" };
    var ext = Path.GetExtension(fileName).ToLowerInvariant();
    return allowedExtensions.Contains(ext);
}

3.2 基于 MIME 类型的验证

IFormFile.ContentType 属性提供了客户端声明的 MIME 类型。这比扩展名更可靠,但仍然可以被伪造。

[HttpPost("upload-image")]
public async Task<IActionResult> UploadImage(IFormFile file)
{
    if (file == null) return BadRequest();

    // 检查 MIME 类型
    var allowedMimeTypes = new[] { "image/jpeg", "image/png", "image/gif" };
    if (!allowedMimeTypes.Contains(file.ContentType))
    {
        return BadRequest("不支持的文件类型");
    }

    // ... 保存逻辑
}

3.3 基于文件签名(Magic Numbers)的验证

这是最安全的方法。文件的开头几个字节(Magic Numbers)通常标识了文件的真实类型。我们需要读取文件头部的几个字节来判断。

代码示例:检查图片文件签名

public async Task<bool> IsFileSignatureValid(IFormFile file)
{
    // 定义支持的文件类型及其签名
    var signatures = new Dictionary<string, List<byte[]>>
    {
        { ".jpeg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF } } },
        { ".jpg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF } } },
        { ".png", new List<byte[]> { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
        { ".gif", new List<byte[]> { new byte[] { 0x47, 0x49, 0x46, 0x38 } } },
        { ".pdf", new List<byte[]> { new byte[] { 0x25, 0x50, 0x44, 0x46, 0x2D } } }
    };

    // 读取文件头部的前几个字节(通常是最大签名长度)
    using (var stream = file.OpenReadStream())
    {
        var header = new byte[8]; // 读取前8个字节通常足够
        await stream.ReadAsync(header, 0, header.Length);

        // 遍历所有支持的签名进行匹配
        foreach (var signature in signatures.Values.SelectMany(x => x))
        {
            if (header.Take(signature.Length).SequenceEqual(signature))
            {
                return true;
            }
        }
    }

    return false;
}

使用方法:

[HttpPost("secure-upload")]
public async Task<IActionResult> SecureUpload(IFormFile file)
{
    if (file == null) return BadRequest();

    // 1. 检查扩展名(快速失败)
    var ext = Path.GetExtension(file.FileName).ToLower();
    if (!new[] { ".jpg", ".png", ".pdf" }.Contains(ext)) return BadRequest("扩展名不支持");

    // 2. 检查文件签名(深度验证)
    if (!await IsFileSignatureValid(file))
    {
        return BadRequest("文件内容与扩展名不符,可能被篡改");
    }

    // ... 保存逻辑
}

4. 大文件上传与断点续传

4.1 分块上传(Chunked Upload)

对于大文件,分块上传是标准解决方案。它允许将大文件切分成小块逐个上传,并支持断点续传。

前端逻辑(伪代码):

  1. 读取文件,计算分块大小(例如 1MB)。
  2. 循环发送每个分块到服务器,携带 index(当前块索引)和 total(总块数)以及一个 fileId(唯一标识符)。

后端控制器:

[HttpPost("chunked-upload")]
public async Task<IActionResult> ChunkedUpload([FromForm] IFormFile file, [FromForm] int chunkIndex, [FromForm] string fileId)
{
    if (file == null) return BadRequest();

    var tempDir = Path.Combine(Directory.GetCurrentDirectory(), "TempUploads", fileId);
    Directory.CreateDirectory(tempDir);

    var chunkPath = Path.Combine(tempDir, chunkIndex.ToString());
    
    // 保存分块
    using (var stream = new FileStream(chunkPath, FileMode.Create))
    {
        await file.CopyToAsync(stream);
    }

    // 检查是否所有分块都上传完成(这里简化处理,实际应记录总块数)
    // 假设前端会发送一个 "final" 请求来合并
    return Ok(new { chunkIndex });
}

[HttpPost("merge-upload")]
public IActionResult MergeUpload([FromForm] string fileId, [FromForm] string fileName, [FromForm] int totalChunks)
{
    var tempDir = Path.Combine(Directory.GetCurrentDirectory(), "TempUploads", fileId);
    var finalPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads", fileName);

    using (var finalStream = File.Create(finalPath))
    {
        for (int i = 0; i < totalChunks; i++)
        {
            var chunkPath = Path.Combine(tempDir, i.ToString());
            if (File.Exists(chunkPath))
            {
                var chunkBytes = File.ReadAllBytes(chunkPath);
                finalStream.Write(chunkBytes, 0, chunkBytes.Length);
                File.Delete(chunkPath); // 删除临时分块
            }
        }
    }

    // 删除临时目录
    Directory.Delete(tempDir);

    return Ok(new { Message = "文件合并完成" });
}

4.2 使用第三方库

对于生产环境,建议使用成熟的库,如 Azure Storage SDK (用于云存储) 或 Tus Protocol (基于标准的断点续传协议)。

5. 文件接收与流式处理

5.1 接收文件并直接处理(不保存到磁盘)

有时我们不需要保存文件,而是直接读取其内容(例如解析CSV)。

[HttpPost("process-csv")]
public async Task<IActionResult> ProcessCsv(IFormFile file)
{
    if (file == null || file.Length == 0) return BadRequest();

    // 使用 StreamReader 读取内容
    using (var reader = new StreamReader(file.OpenReadStream()))
    {
        while (!reader.EndOfStream)
        {
            var line = await reader.ReadLineAsync();
            // 处理每一行数据
            // 例如:解析 CSV,存入数据库等
        }
    }

    return Ok(new { Message = "CSV 数据处理完成" });
}

5.2 接收 Base64 编码的文件

除了 multipart/form-data,有时前端会发送 Base64 字符串。

模型定义:

public class FileUploadModel
{
    public string FileName { get; set; }
    public string Base64Data { get; set; }
}

控制器:

[HttpPost("upload-base64")]
public IActionResult UploadBase64([FromBody] FileUploadModel model)
{
    if (string.IsNullOrEmpty(model.Base64Data)) return BadRequest();

    // 移除 Data URI 前缀(如果存在)
    var base64 = model.Base64Data.Split(',')[1];
    var bytes = Convert.FromBase64String(base64);

    var uploadPath = Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
    Directory.CreateDirectory(uploadPath);

    var filePath = Path.Combine(uploadPath, model.FileName);
    File.WriteAllBytes(filePath, bytes);

    return Ok(new { Message = "Base64 文件保存成功" });
}

6. 文件下载与导出

6.1 返回物理文件

[HttpGet("download/{fileName}")]
public IActionResult DownloadFile(string fileName)
{
    // 安全检查:防止路径遍历攻击
    if (string.IsNullOrEmpty(fileName) || fileName.Contains(".."))
    {
        return BadRequest("无效的文件名");
    }

    var path = Path.Combine(Directory.GetCurrentDirectory(), "Uploads", fileName);
    if (!File.Exists(path))
    {
        return NotFound();
    }

    // 获取 MIME 类型(可选,可使用 MimeTypesMap 库)
    var mime = "application/octet-stream"; 

    // 返回文件流
    return File(System.IO.File.OpenRead(path), mime, fileName);
}

6.2 返回内存流(动态生成文件)

例如,导出 Excel 或 PDF。

[HttpGet("export-excel")]
public IActionResult ExportExcel()
{
    // 模拟生成 Excel 数据(这里使用简单的内存流)
    using (var stream = new MemoryStream())
    {
        // 使用第三方库如 EPPlus, NPOI 或 ClosedXML 填充 stream
        // 这里仅作演示,写入一些文本
        using (var writer = new StreamWriter(stream))
        {
            writer.WriteLine("ID,Name,Date");
            writer.WriteLine("1,Item A,2023-01-01");
            writer.WriteLine("2,Item B,2023-01-02");
        }
        
        stream.Position = 0; // 重要:重置流位置
        
        return File(stream.ToArray(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "export.xlsx");
    }
}

7. 常见问题与解决方案

7.1 问题:IIS 反向代理下 404.13 错误(请求超大)

原因:IIS 默认限制请求大小(通常为 30MB)。 解决方案

  1. 打开 IIS 管理器。
  2. 选择站点 -> 请求筛选 -> 编辑功能设置。
  3. 将“最大允许内容长度”修改为所需的值(例如 1073741824 字节,即 1GB)。
  4. 或者在 web.config 中添加:
    
    <system.webServer>
     <security>
       <requestFiltering>
         <requestLimits maxAllowedContentLength="1073741824" />
       </requestFiltering>
     </security>
    </system.webServer>
    

7.2 问题:上传进度条不动或上传失败

原因

  1. 未配置 Kestrel 或 IIS 的请求大小限制。
  2. 网络不稳定或超时。
  3. 前端未正确使用 FormData

解决方案

  • 确保后端配置了 MaxRequestBodySize
  • 前端检查 Content-Type 是否为 multipart/form-data
  • 如果是大文件,务必实现分块上传。

7.3 问题:中文文件名乱码

原因:HTTP 头对非 ASCII 字符的处理不一致。 解决方案: 在下载文件时,使用 ContentDisposition 辅助类进行编码。

[HttpGet("download-chinese")]
public IActionResult DownloadChinese()
{
    var fileName = "中文文件名.pdf";
    var path = Path.Combine(Directory.GetCurrentDirectory(), "Uploads", "test.pdf");
    
    // 使用 ContentDispositionHeaderValue 进行编码
    var contentDisposition = new System.Net.Http.Headers.ContentDispositionHeaderValue("attachment");
    contentDisposition.FileNameStar = fileName; // RFC 5987 编码
    
    Response.Headers.Add("Content-Disposition", contentDisposition.ToString());
    
    return File(System.IO.File.OpenRead(path), "application/pdf");
}

7.4 问题:临时文件占满磁盘

原因:ASP.NET Core 在处理 multipart 请求时,如果文件超过内存缓冲区阈值,会写入临时文件。如果请求异常中断,这些临时文件可能未被清理。 解决方案

  • 确保系统定期清理 %ASPNETCORE_TEMP% 目录下的文件。
  • 在代码中尽量使用流式处理,减少临时文件的产生。
  • 对于分块上传,务必在合并完成后删除临时分块。

7.5 问题:跨域 (CORS) 限制

原因:前端和后端不在同一个域。 解决方案: 在 Program.cs 中配置 CORS。

builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowSpecificOrigins",
        builder =>
        {
            builder.WithOrigins("http://localhost:3000")
                   .AllowAnyHeader()
                   .AllowAnyMethod()
                   .AllowCredentials(); // 如果需要发送 Cookie
        });
});

// ...
app.UseCors("AllowSpecificOrigins");

8. 安全最佳实践总结

  1. 永远不要信任客户端数据:验证文件大小、类型(通过签名)、文件名。
  2. 存储位置:不要将上传文件存储在 Web 根目录(wwwroot)下,除非你确定不需要执行权限。最好存储在非 Web 可访问的目录或云存储(如 Azure Blob, AWS S3)。
  3. 文件名处理:始终使用随机生成的文件名存储,仅在下载时使用原始文件名。
  4. 扫描病毒:在生产环境中,上传的文件应经过病毒扫描。
  5. 限制并发:对于大文件上传,考虑使用信号量限制并发数,防止服务器资源耗尽。

9. 总结

.NET 平台提供了非常完善的文件上传和接收机制。从简单的 IFormFile 使用,到复杂的分块上传和安全验证,开发者需要根据具体场景选择合适的方案。通过本文的详细指南和代码示例,你应该能够构建出安全、高效、健壮的文件处理系统。记住,安全性始终是文件处理中的首要考虑因素。