大文件上传与断点续传
大约 12 分钟约 3463 字
大文件上传与断点续传
简介
在 Web 应用中,文件上传是常见功能。小文件(< 5MB)可以直接使用 IFormFile 处理,但大文件上传需要考虑内存溢出、网络中断、超时限制和服务器磁盘空间等问题。分块上传(Chunked Upload)和断点续传(Resumable Upload)是解决大文件上传的核心策略。ASP.NET Core 提供了 MultipartReader、IFormFile 和请求大小配置等基础设施,深入理解这些机制有助于构建可靠、高性能的文件上传服务。
特点
上传大小限制配置
双层限制机制
// ASP.NET Core 文件上传有双层大小限制:
// 1. Kestrel 层 — HTTP 请求体总大小限制
// 2. FormOptions 层 — 表单 multipart 体大小限制
// 两处都必须配置,否则会触发 413 (Request Entity Too Large) 或 500
// === Kestrel 配置 ===
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 500 * 1024 * 1024; // 500MB
options.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(5);
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(30);
});
// === FormOptions 配置 ===
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(options =>
{
options.MultipartBodyLengthLimit = 500 * 1024 * 1024; // 500MB
options.MultipartHeadersLengthLimit = 16384; // 头部长度限制 16KB
options.ValueLengthLimit = 1024 * 1024; // 单个值长度限制 1MB
});
// === IIS 配置(如果部署在 IIS 上) ===
// web.config 中添加:
// <security>
// <requestFiltering>
// <requestLimits maxAllowedContentLength="524288000" />
// </requestFiltering>
// </security>
// === Nginx 配置 ===
// client_max_body_size 500M;
// === 针对单个端点覆盖大小限制 ===
app.MapPost("/upload/large", async (HttpContext ctx) =>
{
// 临时覆盖请求体大小限制
ctx.Features.Get<IHttpMaxRequestBodySizeFeature>()!.MaxRequestBodySize = 1024 * 1024 * 1024; // 1GB
// 处理上传...
return Results.Ok();
});小文件上传
IFormFile 方式
// 适用场景:文件 < 30MB,简单上传
// appsettings.json
// {
// "Upload": {
// "MaxFileSize": 30 * 1024 * 1024,
// "AllowedExtensions": [".jpg", ".jpeg", ".png", ".gif", ".pdf", ".doc", ".docx"],
// "UploadDirectory": "uploads"
// }
// }
public class UploadSettings
{
public long MaxFileSize { get; set; } = 30 * 1024 * 1024;
public string[] AllowedExtensions { get; set; } = Array.Empty<string>();
public string UploadDirectory { get; set; } = "uploads";
}
public record UploadResult(string FileName, long Size, string Url);
app.MapPost("/upload", async (
IFormFile file,
IOptions<UploadSettings> settings,
CancellationToken ct) =>
{
// 1. 验证文件大小
if (file.Length > settings.Value.MaxFileSize)
{
return Results.BadRequest(new { error = $"文件大小超过限制 ({settings.Value.MaxFileSize / 1024 / 1024}MB)" });
}
// 2. 验证文件扩展名
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!settings.Value.AllowedExtensions.Contains(extension))
{
return Results.BadRequest(new { error = $"不支持的文件类型: {extension}" });
}
// 3. 生成安全文件名(避免路径遍历攻击)
var safeFileName = $"{Guid.NewGuid:N}{extension}";
var uploadDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.UploadDirectory);
Directory.CreateDirectory(uploadDir);
var filePath = Path.Combine(uploadDir, safeFileName);
// 4. 保存文件
await using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream, ct);
}
// 5. 返回结果
var url = $"/uploads/{safeFileName}";
return Results.Ok(new UploadResult(safeFileName, file.Length, url));
}).DisableAntiforgery();
// 多文件上传
app.MapPost("/upload/batch", async (
IFormFileCollection files,
IOptions<UploadSettings> settings) =>
{
var results = new List<UploadResult>();
foreach (var file in files)
{
if (file.Length > settings.Value.MaxFileSize)
continue;
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!settings.Value.AllowedExtensions.Contains(extension))
continue;
var safeFileName = $"{Guid.NewGuid:N}{extension}";
var uploadDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.UploadDirectory);
Directory.CreateDirectory(uploadDir);
var filePath = Path.Combine(uploadDir, safeFileName);
await using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
results.Add(new UploadResult(safeFileName, file.Length, $"/uploads/{safeFileName}"));
}
return Results.Ok(results);
}).DisableAntiforgery();流式上传(大文件)
MultipartReader 方式
// 适用场景:文件 > 30MB,避免 IFormFile 将整个文件加载到内存
// MultipartReader 流式读取,逐块写入磁盘,内存占用极低
// 配置
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(options =>
{
options.MultipartBodyLengthLimit = long.MaxValue; // 不限制
options.BufferBodyLengthLimit = 0; // 不缓冲请求体
});
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = long.MaxValue;
});
app.MapPost("/upload/stream", async (HttpContext ctx, CancellationToken ct) =>
{
// 禁用请求体缓冲(关键!)
var bufferingFeature = ctx.Features.Get<IHttpResponseBodyFeature>();
if (bufferingFeature != null)
{
bufferingFeature.DisableBuffering();
}
var boundary = HeaderUtilities.RemoveQuotes(
MediaTypeHeaderValue.Parse(ctx.Request.ContentType!).Boundary).Value;
if (string.IsNullOrEmpty(boundary))
{
return Results.BadRequest("无效的 multipart 请求");
}
var reader = new MultipartReader(boundary, ctx.Request.Body);
var section = await reader.ReadNextSectionAsync(ct);
var uploadDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadDir);
var savedFiles = new List<string>();
while (section != null)
{
var hasContentDisposition = section.ContentDisposition != null;
if (!hasContentDisposition)
{
section = await reader.ReadNextSectionAsync(ct);
continue;
}
var contentDisposition = ContentDispositionHeaderValue.Parse(section.ContentDisposition!);
var fileName = contentDisposition.FileName?.Trim('"');
if (string.IsNullOrEmpty(fileName))
{
section = await reader.ReadNextSectionAsync(ct);
continue;
}
// 生成安全文件名
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var safeFileName = $"{Guid.NewGuid:N}{extension}";
var filePath = Path.Combine(uploadDir, safeFileName);
// 流式写入(不缓冲整个文件)
await using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920))
{
// 每次读取 64KB 写入磁盘
var buffer = new byte[64 * 1024];
int bytesRead;
while ((bytesRead = await section.Body.ReadAsync(buffer, ct)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), ct);
}
}
savedFiles.Add(safeFileName);
section = await reader.ReadNextSectionAsync(ct);
}
return Results.Ok(new { files = savedFiles, count = savedFiles.Count });
}).DisableAntiforgery();分块上传
后端分片接口
// 分块上传流程:
// 1. 客户端请求上传凭证(获取 fileId)
// 2. 客户端将文件分成固定大小的块(如 5MB)
// 3. 客户端逐块上传,每块携带 fileId + chunkIndex
// 4. 全部上传完成后,客户端请求合并
// 5. 服务端合并所有分片为一个文件
// 6. 服务端验证文件完整性(MD5/SHA256)
// appsettings.json
// {
// "ChunkedUpload": {
// "ChunkSize": 5 * 1024 * 1024,
// "TempDirectory": "uploads/chunks",
// "FinalDirectory": "uploads/final",
// "MaxFileSize": 2 * 1024 * 1024 * 1024
// }
// }
public class ChunkedUploadSettings
{
public int ChunkSize { get; set; } = 5 * 1024 * 1024;
public string TempDirectory { get; set; } = "uploads/chunks";
public string FinalDirectory { get; set; } = "uploads/final";
public long MaxFileSize { get; set; } = 2L * 1024 * 1024 * 1024;
}
// 1. 初始化上传
app.MapPost("/api/upload/init", (IOptions<ChunkedUploadSettings> settings) =>
{
var fileId = Guid.NewGuid().ToString("N");
var chunkDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.TempDirectory, fileId);
Directory.CreateDirectory(chunkDir);
// 创建元数据文件
var meta = new { fileId, chunkSize = settings.Value.ChunkSize, status = "uploading", createdAt = DateTime.UtcNow };
var metaPath = Path.Combine(chunkDir, "meta.json");
File.WriteAllText(metaPath, JsonSerializer.Serialize(meta));
return Results.Ok(new { fileId, chunkSize = settings.Value.ChunkSize });
});
// 2. 上传分片
app.MapPost("/api/upload/chunk", async (
HttpContext ctx,
IOptions<ChunkedUploadSettings> settings,
CancellationToken ct) =>
{
var form = await ctx.Request.ReadFormAsync(ct);
var fileId = form["fileId"].ToString();
var chunkIndex = int.Parse(form["chunkIndex"].ToString());
var file = form.Files["chunk"];
if (file == null || file.Length == 0)
{
return Results.BadRequest("分片数据为空");
}
// 验证分片大小
if (file.Length > settings.Value.ChunkSize * 1.1) // 允许 10% 误差
{
return Results.BadRequest($"分片大小超过限制 ({settings.Value.ChunkSize / 1024 / 1024}MB)");
}
var chunkDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.TempDirectory, fileId);
if (!Directory.Exists(chunkDir))
{
return Results.NotFound("上传会话不存在或已过期");
}
var chunkPath = Path.Combine(chunkDir, $"{chunkIndex}.part");
await using var stream = new FileStream(chunkPath, FileMode.Create);
await file.CopyToAsync(stream, ct);
return Results.Ok(new { fileId, chunkIndex, received = true, size = file.Length });
}).DisableAntiforgery();
// 3. 查询已上传分片(断点续传)
app.MapGet("/api/upload/status/{fileId}", (string fileId, IOptions<ChunkedUploadSettings> settings) =>
{
var chunkDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.TempDirectory, fileId);
if (!Directory.Exists(chunkDir))
{
return Results.NotFound(new { error = "上传会话不存在" });
}
var uploadedChunks = Directory.GetFiles(chunkDir, "*.part")
.Select(f => int.Parse(Path.GetFileNameWithoutExtension(f)))
.OrderBy(x => x)
.ToList();
var metaPath = Path.Combine(chunkDir, "meta.json");
var meta = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(File.ReadAllText(metaPath));
return Results.Ok(new
{
fileId,
uploadedChunks,
uploadedCount = uploadedChunks.Count,
totalChunks = meta?["totalChunks"]?.GetInt32() ?? 0,
status = meta?["status"]?.GetString() ?? "uploading"
});
});
// 4. 合并分片
app.MapPost("/api/upload/merge", async (
HttpContext ctx,
IOptions<ChunkedUploadSettings> settings,
CancellationToken ct) =>
{
var form = await ctx.Request.ReadFormAsync(ct);
var fileId = form["fileId"].ToString();
var fileName = form["fileName"].ToString();
var totalChunks = int.Parse(form["totalChunks"].ToString());
var expectedMd5 = form["md5"].ToString(); // 客户端计算的文件 MD5
var chunkDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.TempDirectory, fileId);
var finalDir = Path.Combine(Directory.GetCurrentDirectory(), settings.Value.FinalDirectory);
Directory.CreateDirectory(finalDir);
// 验证所有分片是否已上传
var uploadedChunks = Directory.GetFiles(chunkDir, "*.part").Length;
if (uploadedChunks != totalChunks)
{
return Results.BadRequest(new { error = $"分片不完整: {uploadedChunks}/{totalChunks}" });
}
// 生成安全文件名
var extension = Path.GetExtension(fileName).ToLowerInvariant();
var safeFileName = $"{Guid.NewGuid:N}{extension}";
var outputPath = Path.Combine(finalDir, safeFileName);
// 合并分片
await using var outputStream = new FileStream(outputPath, FileMode.Create);
for (int i = 0; i < totalChunks; i++)
{
var chunkPath = Path.Combine(chunkDir, $"{i}.part");
if (!File.Exists(chunkPath))
{
return Results.BadRequest(new { error = $"分片 {i} 不存在" });
}
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
await chunkStream.CopyToAsync(outputStream, ct);
}
// 验证文件完整性
if (!string.IsNullOrEmpty(expectedMd5))
{
await using var fileStream = File.OpenRead(outputPath);
var md5 = Convert.ToHexString(MD5.HashData(fileStream)).ToLowerInvariant();
if (md5 != expectedMd5.ToLowerInvariant())
{
File.Delete(outputPath);
return Results.BadRequest(new { error = "文件校验失败 (MD5 不匹配)", actualMd5 = md5 });
}
}
// 清理临时文件
try
{
Directory.Delete(chunkDir, recursive: true);
}
catch (Exception ex)
{
Console.WriteLine($"清理临时文件失败: {ex.Message}");
}
var fileSize = new FileInfo(outputPath).Length;
return Results.Ok(new UploadResult(safeFileName, fileSize, $"/uploads/final/{safeFileName}"));
}).DisableAntiforgery();上传进度跟踪
SignalR 实时进度推送
// 上传进度 Hub
public class UploadHub : Hub
{
public async Task JoinUploadGroup(string fileId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"upload_{fileId}");
}
public async Task LeaveUploadGroup(string fileId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"upload_{fileId}");
}
}
// 注册 SignalR
builder.Services.AddSignalR();
// 在合并时推送进度
app.MapPost("/api/upload/merge", async (
HttpContext ctx,
IHubContext<UploadHub> hubContext,
IOptions<ChunkedUploadSettings> settings,
CancellationToken ct) =>
{
// ...(前面的合并逻辑)
// 合并过程中推送进度
for (int i = 0; i < totalChunks; i++)
{
var chunkPath = Path.Combine(chunkDir, $"{i}.part");
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
await chunkStream.CopyToAsync(outputStream, ct);
// 推送进度
var progress = (i + 1) * 100 / totalChunks;
await hubContext.Clients.Group($"upload_{fileId}")
.SendAsync("UploadProgress", new { fileId, progress, currentChunk = i + 1, totalChunks }, ct);
}
// ...
});安全考虑
文件验证
public class FileSecurityValidator
{
private static readonly HashSet<string> DangerousExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".exe", ".bat", ".cmd", ".ps1", ".sh", ".dll", ".so", ".php", ".asp", ".aspx",
".jsp", ".cgi", ".py", ".rb", ".pl", ".msi", ".scr", ".hta", ".vbs", ".wsf"
};
// 验证文件扩展名
public static bool IsExtensionAllowed(string fileName, IEnumerable<string> allowedExtensions)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return allowedExtensions.Contains(ext, StringComparer.OrdinalIgnoreCase);
}
// 验证是否为危险文件类型
public static bool IsDangerousFile(string fileName)
{
var ext = Path.GetExtension(fileName).ToLowerInvariant();
return DangerousExtensions.Contains(ext);
}
// 通过 Magic Number(文件头)验证真实文件类型
public static bool ValidateFileSignature(string filePath, string expectedType)
{
using var stream = File.OpenRead(filePath);
var header = new byte[8];
stream.Read(header, 0, 8);
return expectedType.ToLowerInvariant() switch
{
"jpg" or "jpeg" => header[0] == 0xFF && header[1] == 0xD8,
"png" => header[0] == 0x89 && header[1] == 0x50,
"gif" => header[0] == 0x47 && header[1] == 0x49,
"pdf" => header[0] == 0x25 && header[1] == 0x50,
"zip" => header[0] == 0x50 && header[1] == 0x4B,
_ => true // 未知类型不做验证
};
}
}
// 使用
app.MapPost("/upload/secure", async (IFormFile file, CancellationToken ct) =>
{
// 1. 检查危险扩展名
if (FileSecurityValidator.IsDangerousFile(file.FileName))
{
return Results.BadRequest("不允许上传可执行文件");
}
// 2. 限制扩展名
var allowed = new[] { ".jpg", ".jpeg", ".png", ".gif", ".pdf" };
if (!FileSecurityValidator.IsExtensionAllowed(file.FileName, allowed))
{
return Results.BadRequest("不支持的文件类型");
}
// 3. 保存并验证文件头
var filePath = Path.Combine("uploads", Guid.NewGuid().ToString());
await using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream, ct);
}
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!FileSecurityValidator.ValidateFileSignature(filePath, extension))
{
File.Delete(filePath);
return Results.BadRequest("文件内容与扩展名不匹配");
}
return Results.Ok(new { saved = true, path = filePath });
}).DisableAntiforgery();临时文件清理
后台清理服务
// 定时清理未完成的上传会话
public class UploadCleanupService : BackgroundService
{
private readonly ILogger<UploadCleanupService> _logger;
private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1);
private readonly TimeSpan _maxAge = TimeSpan.FromHours(24); // 超过 24 小时未完成的上传清理
public UploadCleanupService(ILogger<UploadCleanupService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(_cleanupInterval, ct);
CleanExpiredUploads();
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "清理上传临时文件失败");
}
}
}
private void CleanExpiredUploads()
{
var chunkDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "chunks");
if (!Directory.Exists(chunkDir)) return;
var cutoffTime = DateTime.UtcNow - _maxAge;
var cleanedCount = 0;
foreach (var dir in Directory.GetDirectories(chunkDir))
{
var dirInfo = new DirectoryInfo(dir);
if (dirInfo.CreationTimeUtc < cutoffTime)
{
try
{
Directory.Delete(dir, recursive: true);
cleanedCount++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "删除临时目录失败: {Dir}", dir);
}
}
}
if (cleanedCount > 0)
{
_logger.LogInformation("清理了 {Count} 个过期的上传临时目录", cleanedCount);
}
}
}
// 注册
builder.Services.AddHostedService<UploadCleanupService>();优点
缺点
总结
小文件直接使用 IFormFile,大文件使用 MultipartReader 流式读取避免内存溢出。分块上传:前端按固定大小(推荐 5MB)切片,后端接收分片存储到临时目录,全部上传后合并。上传大小限制需要在 Kestrel(MaxRequestBodySize)和 FormOptions(MultipartBodyLengthLimit)两处配置。断点续传通过查询已上传分片实现,客户端跳过已上传的分片。安全方面:验证文件扩展名、检查文件头 Magic Number、限制文件大小。定时清理超过 24 小时未完成的临时上传目录。
关键知识点
- 先分清这个主题位于请求链路、后台任务链路还是基础设施链路。
- 服务端主题通常不只关心功能正确,还关心稳定性、性能和可观测性。
- 任何框架能力都要结合配置、生命周期、异常传播和外部依赖一起看。
- 框架能力的真正重点是它在请求链路中的位置和对上下游的影响。
项目落地视角
- 画清请求进入、业务执行、外部调用、日志记录和错误返回的完整路径。
- 为关键链路补齐超时、重试、熔断、追踪和结构化日志。
- 把配置与敏感信息分离,并明确不同环境的差异来源。
- 画清执行顺序、入参来源、失败返回和日志记录点。
常见误区
- 只会堆中间件或组件,不知道它们在链路中的执行顺序。
- 忽略生命周期和线程池、连接池等运行时资源约束。
- 没有监控和测试就对性能或可靠性下结论。
- 知道 API 名称,却不知道它应该放在请求链路的哪个位置。
进阶路线
- 继续向运行时行为、可观测性、发布治理和微服务协同深入。
- 把主题和数据库、缓存、消息队列、认证授权联动起来理解。
- 沉淀团队级模板,包括统一异常处理、配置约定和基础设施封装。
- 继续补齐协议选型、网关治理、端点可观测性和契约演进策略。
适用场景
- 当你准备把《大文件上传与断点续传》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合 API 服务、后台任务、实时通信、认证授权和微服务协作场景。
- 当需求开始涉及稳定性、性能、可观测性和发布流程时,这类主题会成为基础设施能力。
落地建议
- 先定义请求链路与失败路径,再决定中间件、过滤器、服务边界和依赖方式。
- 为关键链路补日志、指标、追踪、超时与重试策略。
- 环境配置与敏感信息分离,避免把生产参数写死在代码或镜像里。
排错清单
- 先确认问题发生在路由、模型绑定、中间件、业务层还是基础设施层。
- 检查 DI 生命周期、配置来源、序列化规则和认证上下文。
- 查看线程池、连接池、缓存命中率和外部依赖超时。
复盘问题
- 如果把《大文件上传与断点续传》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《大文件上传与断点续传》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《大文件上传与断点续传》最大的收益和代价分别是什么?
