最近在开发一个基于.NET Core的文件管理系统时,遇到了一个大文件上传的需求。客户需要上传单个超过5GB的设计图纸文件,但直接上传经常因为网络波动或超时而失败。经过调研,我决定采用分块上传方案来解决这个问题。
分块上传的核心思想是将大文件切割成多个小块(chunks),然后逐个上传到服务器,最后在服务器端将这些块重新组合成完整文件。这种方式有三大优势:
前端采用Vue.js + Axios实现分块上传界面,关键代码如下:
javascript复制// 文件分片处理
function createFileChunks(file, chunkSize = 5 * 1024 * 1024) {
const chunks = []
let cur = 0
while (cur < file.size) {
chunks.push(file.slice(cur, cur + chunkSize))
cur += chunkSize
}
return chunks
}
// 上传分片
async function uploadChunk(chunk, index, fileHash) {
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('hash', fileHash)
formData.append('index', index)
return axios.post('/api/upload/chunk', formData)
}
提示:前端需要生成文件唯一hash值作为标识,可以使用spark-md5库计算文件指纹,确保断点续传时能正确识别文件。
后端采用ASP.NET Core WebAPI,主要设计三个端点:
/api/upload/init - 初始化上传,验证文件是否存在/api/upload/chunk - 接收文件分块/api/upload/complete - 合并所有分块csharp复制// 分块上传控制器
[ApiController]
[Route("api/upload")]
public class UploadController : ControllerBase
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<UploadController> _logger;
public UploadController(IWebHostEnvironment env, ILogger<UploadController> logger)
{
_env = env;
_logger = logger;
}
[HttpPost("init")]
public IActionResult InitUpload([FromBody] FileInfoDto fileInfo)
{
// 验证逻辑...
}
[HttpPost("chunk")]
public async Task<IActionResult> UploadChunk(IFormFile chunk, string hash, int index)
{
// 分块处理逻辑...
}
[HttpPost("complete")]
public IActionResult CompleteUpload([FromBody] MergeRequestDto request)
{
// 合并逻辑...
}
}
在服务器端,我们采用临时文件夹+最终合并的策略:
code复制Uploads/
├── temp/ // 临时分块存储
│ ├── file1_hash1/
│ ├── file1_hash2/
│ └── ...
└── final/ // 最终合并文件
每个分块以[文件hash]_[分块索引].part的格式存储,例如abc123_0.part。合并时按照索引顺序读取并拼接这些文件。
实现断点续传需要三个关键步骤:
csharp复制// 检查已上传分块的示例代码
public IEnumerable<int> GetUploadedChunks(string fileHash)
{
var tempDir = Path.Combine(_env.WebRootPath, "Uploads/temp", fileHash);
if (!Directory.Exists(tempDir))
return Enumerable.Empty<int>();
return Directory.GetFiles(tempDir)
.Select(f => int.Parse(Path.GetFileName(f).Split('_')[1].Split('.')[0]));
}
合并分块时需要注意文件顺序和流处理效率:
csharp复制public async Task MergeChunksAsync(string fileHash, string fileName)
{
var tempDir = Path.Combine(_env.WebRootPath, "Uploads/temp", fileHash);
var finalPath = Path.Combine(_env.WebRootPath, "Uploads/final", fileName);
// 确保目录存在
Directory.CreateDirectory(Path.GetDirectoryName(finalPath));
// 按分块索引排序
var chunkFiles = Directory.GetFiles(tempDir)
.OrderBy(f => int.Parse(Path.GetFileName(f).Split('_')[1].Split('.')[0]));
// 使用FileStream合并
using (var finalStream = new FileStream(finalPath, FileMode.Create))
{
foreach (var chunkFile in chunkFiles)
{
using (var chunkStream = new FileStream(chunkFile, FileMode.Open))
{
await chunkStream.CopyToAsync(finalStream);
}
System.IO.File.Delete(chunkFile); // 删除已合并的分块
}
}
Directory.Delete(tempDir); // 删除临时目录
}
虽然可以并行上传多个分块,但需要注意浏览器并发限制(通常6个)。建议实现一个上传队列:
javascript复制class UploadQueue {
constructor(maxConcurrent = 3) {
this.maxConcurrent = maxConcurrent
this.queue = []
this.activeCount = 0
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push({ task, resolve, reject })
this.run()
})
}
run() {
while (this.activeCount < this.maxConcurrent && this.queue.length) {
const { task, resolve, reject } = this.queue.shift()
this.activeCount++
task()
.then(resolve)
.catch(reject)
.finally(() => {
this.activeCount--
this.run()
})
}
}
}
处理大文件时需要注意内存使用:
csharp复制// 优化的分块保存方法
public async Task SaveChunkAsync(IFormFile chunk, string fileHash, int index)
{
var tempDir = Path.Combine(_env.WebRootPath, "Uploads/temp", fileHash);
Directory.CreateDirectory(tempDir);
var chunkPath = Path.Combine(tempDir, $"{fileHash}_{index}.part");
using (var stream = new FileStream(chunkPath, FileMode.Create))
{
await chunk.CopyToAsync(stream);
}
}
问题现象:部分分块上传失败导致最终文件损坏
解决方案:
csharp复制// 分块校验示例
public bool VerifyChunk(string fileHash, int index, string md5)
{
var chunkPath = Path.Combine(_env.WebRootPath, "Uploads/temp", fileHash, $"{fileHash}_{index}.part");
if (!System.IO.File.Exists(chunkPath)) return false;
using (var md5Provider = System.Security.Cryptography.MD5.Create())
{
using (var stream = System.IO.File.OpenRead(chunkPath))
{
var hashBytes = md5Provider.ComputeHash(stream);
var computedMd5 = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
return computedMd5 == md5;
}
}
}
问题现象:合并超过2GB的文件时请求超时
解决方案:
csharp复制// Program.cs
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxRequestBodySize = 5_368_709_120; // 5GB
options.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(10);
});
csharp复制// 使用IHostedService处理长时间任务
public class MergeService : IHostedService
{
public Task StartAsync(CancellationToken cancellationToken)
{
// 启动合并任务
return Task.CompletedTask;
}
// 实现细节...
}
问题现象:前端上传时出现CORS错误
解决方案:
csharp复制// Program.cs
builder.Services.AddCors(options =>
{
options.AddPolicy("UploadPolicy", policy =>
{
policy.WithOrigins("https://example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.SetPreflightMaxAge(TimeSpan.FromHours(1));
});
});
app.UseCors("UploadPolicy");
csharp复制private static readonly Dictionary<string, byte[]> _fileSignature = new()
{
{ ".png", new byte[] { 0x89, 0x50, 0x4E, 0x47 } },
// 其他文件签名...
};
public bool IsValidFileType(IFormFile file)
{
using var stream = file.OpenReadStream();
var extension = Path.GetExtension(file.FileName).ToLower();
if (!_fileSignature.ContainsKey(extension))
return false;
var signature = _fileSignature[extension];
var buffer = new byte[signature.Length];
stream.Read(buffer, 0, signature.Length);
return buffer.SequenceEqual(signature);
}
csharp复制[RequestSizeLimit(5_368_709_120)] // 5GB
[HttpPost("chunk")]
public async Task<IActionResult> UploadChunk(IFormFile chunk)
{
if (chunk.Length > 5 * 1024 * 1024) // 5MB per chunk
return BadRequest("Chunk size exceeds limit");
// 处理逻辑...
}
csharp复制public string SanitizeFileName(string fileName)
{
var invalidChars = Path.GetInvalidFileNameChars();
var sanitized = new string(fileName
.Where(c => !invalidChars.Contains(c))
.ToArray());
return Path.GetFileName(sanitized); // 防止路径遍历
}
csharp复制// Azure Blob Storage集成示例
public async Task UploadToBlobStorageAsync(string filePath, string containerName)
{
var blobServiceClient = new BlobServiceClient(connectionString);
var containerClient = blobServiceClient.GetBlobContainerClient(containerName);
var blobClient = containerClient.GetBlobClient(Path.GetFileName(filePath));
await blobClient.UploadAsync(filePath, true);
}
csharp复制// 使用RabbitMQ分发任务
public async Task PublishMergeTaskAsync(string fileHash, string fileName)
{
var factory = new ConnectionFactory() { HostName = "localhost" };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
channel.QueueDeclare(queue: "merge_tasks",
durable: true,
exclusive: false,
autoDelete: false,
arguments: null);
var message = new { FileHash = fileHash, FileName = fileName };
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message));
channel.BasicPublish(exchange: "",
routingKey: "merge_tasks",
basicProperties: null,
body: body);
}
csharp复制_logger.LogInformation("Starting merge for {FileHash} with {ChunkCount} chunks",
fileHash, chunkCount);
// 使用Serilog等库记录到文件/数据库
Log.Logger = new LoggerConfiguration()
.WriteTo.File("logs/upload-.txt", rollingInterval: RollingInterval.Day)
.CreateLogger();
在实际项目中,我发现分块大小设置对性能影响很大。经过多次测试,2-5MB的分块在大多数网络环境下表现最佳。太小的分块会增加请求开销,太大的分块则失去了分块上传的优势。