一、分片上传的优势
处理大文件上传,一直是Web开发中的经典难题。直接上传整个文件,网络抖动一下就得从头再来,用户体验极差。分片上传的方案,就是把这一个“大包袱”拆成多个“小包裹”,逐个发送。这么做的好处,有几个层面的考量:
- **上传稳定性显著提升**:某个分片上传失败了,只需要重传这个分片即可,而不是把整个文件再传一遍。这就像拼图,哪块丢了补哪块,而不是把整幅画重画。
- **内存占用更友好**:每次只处理文件的一小部分,避免了整个大文件全部加载到内存中。要知道,一个几十GB的文件直接塞进内存,
服务器可能直接就“罢工”了。
- **天然支持断点续传**:如果上传中断了,没关系。系统会记录下哪些分片已经成功上传,下次可以从中断的地方继续,而不是从零开始。
- **并行上传提速**:可以同时发送多个分片,充分利用网络带宽,上传速度自然就上去了。
- **进度提示更精准**:因为分片是逐个上传的,可以精确显示上传了多少个分片,计算出真实的百分比,给用户更直观的体验。
二、.NET 8 分片上传实现
好,概念说完了,咱们直接动手。下面展示一个完整的实现方案,分前端和后端两部分。
2.1 前端实现(Ja vaScript)
前端负责把文件切分,然后逐个上传。这里用原生Ja vaScript演示,逻辑清晰,方便理解核心思想。
```js
// 文件选择处理
document.getElementById('fileInput').addEventListener('change', async function(e) {
const file = e.target.files[0];
if (!file) return;
const chunkSize = 5 * 1024 * 1024; // 5MB分片
const totalChunks = Math.ceil(file.size / chunkSize);
const fileId = generateFileId(file.name, file.size); // 生成唯一文件ID
// 并行上传控制(限制同时上传的分片数)
const parallelLimit = 3;
let currentChunk = 0;
let activeUploads = 0;
let uploadedChunks = 0;
while (currentChunk < totalChunks || activeUploads > 0) {
if (activeUploads < parallelLimit && currentChunk < totalChunks) {
activeUploads++;
const chunkStart = currentChunk * chunkSize;
const chunkEnd = Math.min(file.size, chunkStart + chunkSize);
const chunk = file.slice(chunkStart, chunkEnd);
try {
await uploadChunk(fileId, currentChunk, chunk, totalChunks, file.name);
uploadedChunks++;
updateProgress(uploadedChunks / totalChunks * 100);
} catch (error) {
console.error(`分片 ${currentChunk} 上传失败:`, error);
// 可加入重试逻辑
continue; // 重新尝试当前分片
} finally {
activeUploads--;
}
currentChunk++;
} else {
// 等待有上传完成
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// 所有分片上传完成,通知
服务器合并
await notifyServerToMerge(fileId, file.name, totalChunks);
console.log('文件上传完成');
});
async function uploadChunk(fileId, chunkNumber, chunkData, totalChunks, fileName) {
const formData = new FormData();
formData.append('fileId', fileId);
formData.append('chunkNumber', chunkNumber);
formData.append('totalChunks', totalChunks);
formData.append('fileName', fileName);
formData.append('chunk', chunkData);
const response = await fetch('/api/upload/chunk', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('上传失败');
}
}
function updateProgress(percent) {
console.log(`上传进度: ${percent.toFixed(2)}%`);
// 更新UI进度条
document.getElementById('progressBar').style.width = `${percent}%`;
}
```
2.2 后端实现(.NET 8 Web API)
后端接收分片、临时保存,并在所有分片到达后把它们合并成一个完整的文件。
**控制器代码**
```csharp
[ApiController]
[Route("api/[controller]")]
public class UploadController : ControllerBase
{
private readonly IFileUploadService _uploadService;
private readonly ILogger
_logger;
public UploadController(IFileUploadService uploadService, ILogger logger)
{
_uploadService = uploadService;
_logger = logger;
}
[HttpPost("chunk")]
[DisableRequestSizeLimit] // 禁用请求大小限制
public async Task UploadChunk()
{
try
{
var form = await Request.ReadFormAsync();
var chunk = form.Files["chunk"];
if (chunk == null || chunk.Length == 0)
return BadRequest("无效的分片数据");
var fileId = form["fileId"].ToString();
var chunkNumber = int.Parse(form["chunkNumber"].ToString());
var totalChunks = int.Parse(form["totalChunks"].ToString());
var fileName = form["fileName"].ToString();
await _uploadService.Sa veChunkAsync(fileId, chunkNumber, totalChunks, fileName, chunk);
return Ok(new { chunkNumber, fileId });
}
catch (Exception ex)
{
_logger.LogError(ex, "分片上传失败");
return StatusCode(500, $"分片上传失败: {ex.Message}");
}
}
[HttpPost("merge")]
public async Task MergeChunks([FromBody] MergeRequest request)
{
try
{
var filePath = await _uploadService.MergeChunksAsync(request.FileId, request.FileName, request.TotalChunks);
return Ok(new { filePath });
}
catch (Exception ex)
{
_logger.LogError(ex, "分片合并失败");
return StatusCode(500, $"分片合并失败: {ex.Message}");
}
}
}
public record MergeRequest(string FileId, string FileName, int TotalChunks);
```
**文件上传服务实现**
```csharp
public interface IFileUploadService
{
Task Sa veChunkAsync(string fileId, int chunkNumber, int totalChunks, string fileName, IFormFile chunk);
Task MergeChunksAsync(string fileId, string fileName, int totalChunks);
}
public class FileUploadService : IFileUploadService
{
private readonly string _uploadPath;
private readonly ILogger _logger;
public FileUploadService(IConfiguration configuration, ILogger logger)
{
_uploadPath = configuration["FileUpload:Path"] ?? Path.Combine(Directory.GetCurrentDirectory(), "Uploads");
_logger = logger;
if (!Directory.Exists(_uploadPath))
{
Directory.CreateDirectory(_uploadPath);
}
}
public async Task Sa veChunkAsync(string fileId, int chunkNumber, int totalChunks, string fileName, IFormFile chunk)
{
// 为每个文件创建临时目录
var tempDir = Path.Combine(_uploadPath, fileId);
if (!Directory.Exists(tempDir))
{
Directory.CreateDirectory(tempDir);
}
var chunkPath = Path.Combine(tempDir, $"{chunkNumber}.part");
// 使用文件流写入,避免内存占用过高
await using var stream = new FileStream(chunkPath, FileMode.Create);
await chunk.CopyToAsync(stream);
_logger.LogInformation("保存分片 {ChunkNumber}/{TotalChunks} 成功,文件ID: {FileId}",
chunkNumber, totalChunks, fileId);
}
public async Task MergeChunksAsync(string fileId, string fileName, int totalChunks)
{
var tempDir = Path.Combine(_uploadPath, fileId);
if (!Directory.Exists(tempDir))
{
throw new DirectoryNotFoundException($"临时目录不存在: {tempDir}");
}
// 验证所有分片是否都存在
for (int i = 0; i < totalChunks; i++)
{
var chunkPath = Path.Combine(tempDir, $"{i}.part");
if (!System.IO.File.Exists(chunkPath))
{
throw new FileNotFoundException($"分片 {i} 不存在", chunkPath);
}
}
// 最终文件路径
var filePath = Path.Combine(_uploadPath, $"{fileId}_{fileName}");
// 合并分片
await using var outputStream = new FileStream(filePath, FileMode.Create);
for (int i = 0; i < totalChunks; i++)
{
var chunkPath = Path.Combine(tempDir, $"{i}.part");
await using var chunkStream = new FileStream(chunkPath, FileMode.Open);
await chunkStream.CopyToAsync(outputStream);
_logger.LogDebug("已合并分片 {ChunkNumber}/{TotalChunks}", i, totalChunks);
}
// 删除临时分片
try
{
Directory.Delete(tempDir, true);
_logger.LogInformation("文件合并完成,临时目录已删除: {TempDir}", tempDir);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "删除临时目录失败: {TempDir}", tempDir);
}
return filePath;
}
}
```
2.3 配置与注册服务
在 `Program.cs` 中添加服务注册和配置:
```csharp
var builder = WebApplication.CreateBuilder(args);
// 添加服务
builder.Services.AddScoped();
// 配置上传路径
builder.Services.Configure(builder.Configuration.GetSection("FileUpload"));
var app = builder.Build();
// 启用静态文件服务(如果需要下载)
app.UseStaticFiles(new StaticFileOptions{
FileProvider = new PhysicalFileProvider(
Path.Combine(builder.Environment.ContentRootPath, "Uploads")),
RequestPath = "/uploads"
});
app.MapControllers();
app.Run();
```
在 `appsettings.json` 中添加配置:
```json
{
"FileUpload": {
"Path": "Uploads",
"MaxFileSize": "1073741824" // 1GB
}
}
```
三、高级功能实现
基础版本跑通后,再聊聊几个实用的高级功能。
3.1 断点续传
这个功能让上传更“智能”。在上传之前,先查一下哪些分片已经上传过了,然后跳过它们。
```csharp
[HttpGet("check")]
public IActionResult CheckChunks(string fileId, int totalChunks)
{
var tempDir = Path.Combine(_uploadPath, fileId);
if (!Directory.Exists(tempDir))
{
return Ok(new { uploadedChunks = Array.Empty() });
}
var uploaded = Directory.GetFiles(tempDir)
.Select(f => Path.GetFileNameWithoutExtension(f))
.Where(f => int.TryParse(f, out _))
.Select(int.Parse)
.ToArray();
return Ok(new { uploadedChunks = uploaded });
}
```
**前端相应修改:**
```js
// 在上传前检查已上传的分片
const checkResponse = await fetch(`/api/upload/check?fileId=${fileId}&totalChunks=${totalChunks}`);
const { uploadedChunks } = await checkResponse.json();
// 跳过已上传的分片
while (currentChunk < totalChunks) {
if (uploadedChunks.includes(currentChunk)) {
currentChunk++;
uploadedChunks++;
updateProgress(uploadedChunks / totalChunks * 100);
continue;
}
// ...原有上传逻辑
}
```
3.2 文件校验(MD5/SHA)
为了确保合并后的文件没有损坏,可以在合并完成后计算文件的哈希值,与预期的哈希值进行对比。
```csharp
public async Task CalculateFileHash(string filePath)
{
await using var stream = System.IO.File.OpenRead(filePath);
using var sha256 = SHA256.Create();
var hashBytes = await sha256.ComputeHashAsync(stream);
return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
}
// 在上传完成后验证文件完整性
var calculatedHash = await CalculateFileHash(filePath);
if (calculatedHash != expectedHash)
{
System.IO.File.Delete(filePath);
throw new Exception("文件校验失败,可能在上传过程中损坏");
}
```
3.3 分片大小动态调整
网络环境是千变万化的。如果网络好,分片可以大一些,提高效率;如果网络差,分片应该小一些,避免失败后大量重传。这里提供一个根据以往上传速度动态调整分片大小的思路。
```js
// 动态调整分片大小
let chunkSize = 1 * 1024 * 1024; // 初始1MB
let uploadSpeeds = [];
async function uploadChunk(...) {
const startTime = performance.now();
// ...上传逻辑
const endTime = performance.now();
const duration = (endTime - startTime) / 1000; // 秒
const speed = chunkData.size / duration; // bytes/s
uploadSpeeds.push(speed);
if (uploadSpeeds.length > 5) {
uploadSpeeds.shift();
}
const a vgSpeed = uploadSpeeds.reduce((sum, val) => sum + val, 0) / uploadSpeeds.length;
// 根据平均速度调整分片大小 (目标: 每个分片上传时间在5-15秒之间)
const targetChunkTime = 10; // 10秒
chunkSize = Math.min(
50 * 1024 * 1024, // 最大50MB
Math.max(
1 * 1024 * 1024, // 最小1MB
Math.round(a vgSpeed * targetChunkTime)
)
);
}
```
四、性能优化与安全考虑
一个健壮的生产环境方案,除了功能实现,还得兼顾性能和安全。
- **性能优化**:
- 始终使用流式处理,避免把整个文件缓冲区加载到内存。
- 对并行上传的数量进行合理控制,避免把服务器压垮。
- 动态调整分片大小,适配网络状况。
- 注意内存管理,及时释放不再需要的临时文件。
- **安全考虑**:
- 服务端必须对上传的文件类型进行校验,防止恶意文件上传。
- 设定文件大小上限,避免存储空间被滥用。
- 可以集成病毒扫描程序,对上传文件进行安全检测。
- 实现严格的访问控制和用户权限验证,防止未授权上传。
- 对文件名进行校验和处理,比如过滤特殊字符,防止路径穿越攻击。
- **错误处理**:
- 实现网络中断后的重试机制,最好带有指数退避策略。
- 对上传的分片进行校验(比如使用CRC32),防止数据在传输中间出错。
- 设置合理的超时时间,避免服务器长时间等待一个不存在的请求。
- 处理好并发冲突,比如在合并时加锁。
五、测试建议
代码写完了,测试也不能少。分几个层面来验证:
1. **单元测试**:
- 测试单个分片的保存和合并功能是否正常。
- 测试文件校验(比如MD5/SHA)的逻辑是否正确。
- 覆盖各种异常情况,比如分片丢失、合并时文件不存在等。
2. **集成测试**:
- 模拟一个完整的从上传到合并的流程。
- 模拟上传过程中断,然后恢复,验证断点续传是否生效。
- 模拟网络不稳定的场景,比如丢包、延迟,看能否自动重试。
3. **性能测试**:
- 测试不同大小文件(从几十MB到几个GB)的上传时间,看线性扩展性如何。
- 进行多用户并发上传的压力测试,看服务能否扛住。
- 监控服务的内存占用,确保在并发压力下没有内存泄漏。
六、总结
这篇文章详细介绍了如何在.NET 8中实现一个完整的大文件分片上传方案。从前端的分片处理和并行上传控制,到后端的接收、临时存储和最终合并,再到断点续传、文件校验等高级特性,应该能覆盖大部分实际需求。
这个方案的核心特点在于:**高效稳定**,能从容应对大文件上传场景;**内存友好**,流式处理避免了大量内存开销;**体验优秀**,断点续传让用户不再担心中途失败;**扩展性强**,可以很方便地在此基础上集成文件校验、病毒扫描乃至更复杂的业务逻辑。
通过合理的配置和优化,这套方案完全可以在企业级应用中落地,为用户提供一个流畅、可靠的大文件上传体验。
本文转载于:https://www.jb51.net/aspnet/3401642d8.htm 如有侵犯,请联系zhengruancom@outlook.com删除。
免责声明:正软商城发布此文仅为传递信息,不代表正软商城认同其观点或证实其描述。