前言
上傳大檔案時,原始HTTP檔案上傳功能可能會影響使用體驗,此時使用分片上傳功能可以有效避免原始上傳的弊端。由於分片上傳不是HTTP標準的一部分,所以只能自行開發相互配合的服務端和客戶端。檔案分片上傳在許多情況時都擁有很多好處,除非已知需要上傳的檔案一定非常小。分片上傳可以對上傳的檔案進行快速分片校驗,避免大檔案上傳時長時間等待校驗,當然完整校驗可以在秒傳時使用,有這種需求的情況就只能老實等待校驗了。
Blazr WASM提供了在 .NET環境中使用瀏覽器功能的能力,充分利用C#和 .NET能夠大幅簡化分片上傳功能的開發。本次示例使用HTTP標準上傳作為分片上傳的底層基礎,並提供分片校驗功能保障上傳資料的完整性。
新書宣傳
有關新書的更多介紹歡迎檢視《C#與.NET6 開發從入門到實踐》上市,作者親自來打廣告了!
正文
本示例的Blazor程式碼位於預設ASP.NET Core託管的Blazor WASM應用模板的Index頁面。
在Shared專案新增公共資料模型
/// <summary>
/// 檔案分片上傳輸入模型
/// </summary>
public class FileChunkUploadInput
{
/// <summary>
/// 上傳任務程式碼
/// </summary>
public string? UploadTaskCode { get; set; }
/// <summary>
/// 上傳請求型別
/// </summary>
public string UploadType { get; set; } = null!;
/// <summary>
/// 檔名
/// </summary>
public string FileName { get; set; } = null!;
/// <summary>
/// 檔案大小
/// </summary>
public long? FileSize { get; set; }
/// <summary>
/// 支援的Hash演算法,優選演算法請靠前
/// </summary>
public List<string>? AllowedHashAlgorithm { get; set; }
/// <summary>
/// 使用的Hash演算法
/// </summary>
public string? HashAlgorithm { get; set; }
/// <summary>
/// Hash值
/// </summary>
public string? HashValue { get; set; }
/// <summary>
/// 檔案分片數量
/// </summary>
public int FileChunkCount { get; set; }
/// <summary>
/// 檔案片段大小
/// </summary>
public int? FileChunkSize { get; set; }
/// <summary>
/// 檔案片段偏移量(相對於整個檔案)
/// </summary>
public long? FileChunkOffset { get; set; }
/// <summary>
/// 檔案片段索引
/// </summary>
public int? FileChunkIndex { get; set; }
/// <summary>
/// 取消上傳的原因
/// </summary>
public string? CancelReason { get; set; }
}
/// <summary>
/// 檔案分片上傳開始結果
/// </summary>
public class FileChunkUploadStartReault
{
/// <summary>
/// 上傳任務程式碼
/// </summary>
public string UploadTaskCode { get; set; } = null!;
/// <summary>
/// 選中的Hash演算法
/// </summary>
public string SelectedHashAlgorithm { get; set; } = null!;
}
/// <summary>
/// Hash助手
/// </summary>
public static class HashHelper
{
/// <summary>
/// 把Hash的位元組陣列轉換為16進位制字串表示
/// </summary>
/// <param name="bytes">原始Hash值</param>
/// <returns>Hash值的16進位制文字表示(大寫)</returns>
public static string ToHexString(this byte[] bytes)
{
StringBuilder sb = new(bytes.Length * 2);
foreach (var @byte in bytes)
{
sb.Append(@byte.ToString("X2"));
}
return sb.ToString();
}
}
服務端控制器
[ApiController]
[Route("[controller]")]
public class UploadController : ControllerBase
{
/// <summary>
/// 支援的Hash演算法,優選演算法請靠前
/// </summary>
private static string[] supportedHashAlgorithm = new[] { "MD5", "SHA1", "SHA256" };
/// <summary>
/// 檔案寫入鎖的執行緒安全字典,每個上傳任務對應一把鎖
/// </summary>
private static readonly ConcurrentDictionary<string, AsyncLock> fileWriteLockerDict = new();
private readonly ILogger<UploadController> _logger;
private readonly IWebHostEnvironment _env;
public UploadController(ILogger<UploadController> logger, IWebHostEnvironment env)
{
_logger = logger;
_env = env;
}
/// <summary>
/// 分片上傳動作
/// </summary>
/// <param name="input">上傳表單</param>
/// <param name="fileChunkData">檔案片段資料</param>
/// <param name="requestAborted">請求取消令牌</param>
/// <returns>片段上傳結果</returns>
[HttpPost, RequestSizeLimit(1024 * 1024 * 11)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesDefaultResponseType]
public async Task<IActionResult> Upload(
[FromForm]FileChunkUploadInput input,
[FromForm]IFormFile? fileChunkData,
CancellationToken requestAborted)
{
switch (input.UploadType)
{
// 請求開始一個新的上傳任務,協商上傳引數
case "startUpload":
{
//var trustedFileNameForDisplay =
// WebUtility.HtmlEncode(fileChunkData?.FileName ?? input.FileName);
// 選擇雙方都支援的優選Hash演算法
var selectedHashAlgorithm = supportedHashAlgorithm
.Intersect(input.AllowedHashAlgorithm ?? Enumerable.Empty<string>())
.FirstOrDefault();
// 驗證必要的表單資料
if (selectedHashAlgorithm is null or "")
{
ModelState.AddModelError<FileChunkUploadInput>(x => x.AllowedHashAlgorithm, "can not select hash algorithm");
}
if (input.FileSize is null)
{
ModelState.AddModelError<FileChunkUploadInput>(x => x.FileSize, "must have value for start、upload and complete");
}
if (ModelState.ErrorCount > 0)
{
return ValidationProblem(ModelState);
}
// 使用隨機檔名提高安全性,並把檔名作為任務程式碼使用
var trustedFileNameForFileStorage = Path.GetRandomFileName();
var savePath = Path.Combine(
_env.ContentRootPath,
_env.EnvironmentName,
"unsafe_uploads",
trustedFileNameForFileStorage);
var savePathWithFile = Path.Combine(
savePath,
$"{input.FileName}.tmp");
if (!Directory.Exists(savePath))
{
Directory.CreateDirectory(savePath);
}
// 根據表單建立對應大小的檔案
await using (var fs = new FileStream(savePathWithFile, FileMode.Create))
{
fs.SetLength(input.FileSize!.Value);
await fs.FlushAsync();
}
// 設定鎖
fileWriteLockerDict.TryAdd(trustedFileNameForFileStorage, new());
// 返回協商結果
return Ok(new FileChunkUploadStartReault
{
UploadTaskCode = trustedFileNameForFileStorage,
SelectedHashAlgorithm = selectedHashAlgorithm!
});
}
// 上傳檔案片段
case "uploadChunk":
// 驗證表單
if (!fileWriteLockerDict.TryGetValue(input.UploadTaskCode!, out var _))
{
ModelState.AddModelError<FileChunkUploadInput>(x => x.UploadTaskCode, $"file upload task with code {input.UploadTaskCode} is not exists");
return ValidationProblem(ModelState);
}
// 使用記憶體池緩衝資料,注意使用using釋放記憶體
using (var pooledMemory = MemoryPool<byte>.Shared.Rent((int)fileChunkData!.Length))
{
// 使用切片語法獲取精準大小的記憶體緩衝區裝載上傳的資料
var buffer = pooledMemory.Memory[..(int)fileChunkData!.Length];
var readBytes = await fileChunkData.OpenReadStream().ReadAsync(buffer, requestAborted);
var readBuffer = buffer[..readBytes];
Debug.Assert(readBytes == fileChunkData!.Length);
// 校驗Hash
var hash = input.HashAlgorithm switch
{
"SHA1" => SHA1.HashData(readBuffer.Span),
"SHA256" => SHA256.HashData(readBuffer.Span),
"MD5" => MD5.HashData(readBuffer.Span),
_ => Array.Empty<byte>()
};
if (hash.ToHexString() != input.HashValue)
{
ModelState.AddModelError<FileChunkUploadInput>(x => x.HashValue, "hash does not match");
return ValidationProblem(ModelState);
}
var savePath = Path.Combine(
_env.ContentRootPath,
_env.EnvironmentName,
"unsafe_uploads",
input.UploadTaskCode!);
var savePathWithFile = Path.Combine(
savePath,
$"{input.FileName}.tmp");
// 使用鎖寫入資料,檔案流不支援寫共享,必須序列化
if(fileWriteLockerDict.TryGetValue(input.UploadTaskCode!, out var locker))
{
using (await locker.LockAsync())
{
await using (var fs = new FileStream(savePathWithFile, FileMode.Open, FileAccess.Write))
{
// 定位檔案流
fs.Seek(input.FileChunkOffset!.Value, SeekOrigin.Begin);
await fs.WriteAsync(readBuffer, requestAborted);
await fs.FlushAsync();
}
}
}
}
return Ok();
// 取消上傳
case "cancelUpload":
// 驗證表單
if (!fileWriteLockerDict.TryGetValue(input.UploadTaskCode!, out var _))
{
ModelState.AddModelError<FileChunkUploadInput>(x => x.UploadTaskCode, $"file upload task with code {input.UploadTaskCode} is not exists");
return ValidationProblem(ModelState);
}
{
var deletePath = Path.Combine(
_env.ContentRootPath,
_env.EnvironmentName,
"unsafe_uploads",
input.UploadTaskCode!);
// 刪除檔案,清除鎖
if (fileWriteLockerDict.TryGetValue(input.UploadTaskCode!, out var locker))
{
using (await locker.LockAsync())
{
if (Directory.Exists(deletePath))
{
var dir = new DirectoryInfo(deletePath);
dir.Delete(true);
}
fileWriteLockerDict.TryRemove(input.UploadTaskCode!, out _);
}
}
}
return Ok();
// 完成上傳
case "completeUpload":
// 驗證表單
if (!fileWriteLockerDict.TryGetValue(input.UploadTaskCode!, out var _))
{
ModelState.AddModelError<FileChunkUploadInput>(x => x.UploadTaskCode, $"file upload task with code {input.UploadTaskCode} is not exists");
return ValidationProblem(ModelState);
}
{
var savePath = Path.Combine(
_env.ContentRootPath,
_env.EnvironmentName,
"unsafe_uploads",
input.UploadTaskCode!);
// 去除檔案的臨時副檔名,清除鎖
var savePathWithFile = Path.Combine(savePath, $"{input.FileName}.tmp");
var fi = new FileInfo(savePathWithFile);
fi.MoveTo(Path.Combine(savePath, input.FileName));
fileWriteLockerDict.TryRemove(input.UploadTaskCode!, out _);
}
return Ok();
default:
return BadRequest();
}
}
}
服務端使用三段式上傳模式,開始上傳,上傳資料,完成(取消)上傳。開始上傳負責協商Hash演算法和分配任務程式碼;上傳資料負責具體的傳輸,並透過表單提供附加資訊方便服務端操作。完成上傳負責善後和資源清理。其中檔案寫入的非同步鎖使用Nito.AsyncEx
代替不支援在非同步中使用的lock
語句。
頁面程式碼(Index.razor),在結尾追加
<p>支援隨時取消的多檔案並行分片上傳,示例同時上傳2個檔案,每個檔案同時上傳2個分片,合計同時上傳4個分片</p>
<InputFile OnChange="UploadFile" multiple></InputFile>
<button @onclick="async (MouseEventArgs e) => uploadCancelSource?.Cancel()">取消上傳</button>
@code{
[Inject] private HttpClient _http { get; init; } = null!;
[Inject] private ILogger<Index> _logger { get; init; } = null!;
private CancellationTokenSource? uploadCancelSource;
/// <summary>
/// 上傳檔案
/// </summary>
/// <param name="args">上傳檔案的事件引數</param>
/// <returns></returns>
private async Task UploadFile(InputFileChangeEventArgs args)
{
// 設定檔案併發選項
var parallelCts = new CancellationTokenSource();
uploadCancelSource = parallelCts;
var parallelOption = new ParallelOptions
{
MaxDegreeOfParallelism = 2,
CancellationToken = parallelCts.Token
};
// 併發上傳所有檔案
await Parallel.ForEachAsync(
args.GetMultipleFiles(int.MaxValue),
parallelOption,
async (file, cancellation) =>
{
// 這裡的取消令牌是併發方法建立的,和併發選項裡的令牌不是一個
if (cancellation.IsCancellationRequested)
{
parallelCts.Cancel();
return;
}
// 使用連結令牌確保外部取消能傳遞到內部
var chunkUploadResult = await UploadChunkedFile(
file,
CancellationTokenSource.CreateLinkedTokenSource(
parallelCts.Token,
cancellation
).Token
);
// 如果上傳不成功則取消後續上傳
if (chunkUploadResult != FileUploadResult.Success)
{
parallelCts.Cancel();
return;
}
}
);
}
/// <summary>
/// 分片上傳檔案
/// </summary>
/// <param name="file">要上傳的檔案</param>
/// <param name="cancellation">取消令牌</param>
/// <returns>上傳結果</returns>
private async Task<FileUploadResult> UploadChunkedFile(IBrowserFile file, CancellationToken cancellation = default)
{
if (cancellation.IsCancellationRequested) return FileUploadResult.Canceled;
_logger.LogInformation("開始上傳檔案:{0}", file.Name);
// 計算分片大小,檔案小於10MB分片1MB,大於100MB分片10MB,在其間則使用不超過10片時的所需大小
var coefficient = file.Size switch
{
<= 1024 * 1024 * 10 => 1,
> 1024 * 1024 * 10 and <= 1024 * 1024 *100 => (int)Math.Ceiling(file.Size / (1024.0 * 1024) / 10),
_ => 10
};
// 初始化分片引數,準備字串格式的資料供表單使用
var bufferSize = 1024 * 1024 * coefficient; // MB
var stringBufferSize = bufferSize.ToString();
var chunkCount = (int)Math.Ceiling(file.Size / (double)bufferSize);
var stringChunkCount = chunkCount.ToString();
var stringFileSize = file.Size.ToString();
// 發起分片上傳,協商Hash演算法,獲取任務程式碼
var uploadStartContent = new List<KeyValuePair<string, string>>
{
new("uploadType", "startUpload"),
new("fileName", file.Name),
new("fileSize", stringFileSize),
new("allowedHashAlgorithm", "SHA1"),
new("allowedHashAlgorithm", "SHA256"),
new("fileChunkCount", stringChunkCount),
new("fileChunkSize", stringBufferSize),
};
var uploadStartForm = new FormUrlEncodedContent(uploadStartContent);
HttpResponseMessage? uploadStartResponse = null;
try
{
uploadStartResponse = await _http.PostAsync("/upload", uploadStartForm, cancellation);
}
catch(TaskCanceledException e)
{
_logger.LogWarning(e, "外部取消上傳,已停止檔案:{0} 的上傳", file.Name);
return FileUploadResult.Canceled;
}
catch(Exception e)
{
_logger.LogError(e, "檔案:{0} 的上傳引數協商失敗", file.Name);
return FileUploadResult.Fail;
}
// 如果伺服器響應失敗,結束上傳
if (uploadStartResponse?.IsSuccessStatusCode is null or false)
{
_logger.LogError("檔案:{0} 的上傳引數協商失敗", file.Name);
return FileUploadResult.Fail;
}
// 解析協商的引數
var uploadStartReault = await uploadStartResponse.Content.ReadFromJsonAsync<FileChunkUploadStartReault>();
var uploadTaskCode = uploadStartReault!.UploadTaskCode;
var selectedHashAlgorithm = uploadStartReault!.SelectedHashAlgorithm;
_logger.LogInformation("檔案:{0} 的上傳引數協商成功", file.Name);
// 設定分片併發選項
var parallelOption = new ParallelOptions
{
MaxDegreeOfParallelism = 2,
};
var fileUploadCancelSource = new CancellationTokenSource();
var sliceEnumeratorCancelSource = CancellationTokenSource
.CreateLinkedTokenSource(
cancellation,
fileUploadCancelSource.Token
);
// 各個分片的上傳結果
var sliceUploadResults = new FileUploadResult?[chunkCount];
// 併發上傳各個分片,併發迴圈本身不能用併發選項的取消令牌取消,可能會導致記憶體洩漏,應該透過切片迴圈的取消使併發迴圈因沒有可用元素自然結束
await Parallel.ForEachAsync(
SliceFileAsync(
file,
bufferSize,
sliceEnumeratorCancelSource.Token
),
parallelOption,
async (fileSlice, sliceUploadCancel) =>
{
// 解構引數
var (memory, sliceIndex, readBytes, fileOffset) = fileSlice;
// 使用using確保結束後把租用的記憶體歸還給記憶體池
using (memory)
{
var stringSliceIndex = sliceIndex.ToString();
// 主動取消上傳,傳送取消請求,通知服務端清理資源
if (sliceUploadCancel.IsCancellationRequested)
{
_logger.LogWarning("外部取消上傳,已停止檔案:{0} 的上傳", file.Name);
fileUploadCancelSource.Cancel();
sliceUploadResults[sliceIndex] = FileUploadResult.Canceled;
var uploadCancelContent = new Dictionary<string, string>()
{
{"uploadType", "cancelUpload"},
{"uploadTaskCode", uploadTaskCode!},
{"fileName", file.Name},
{"hashAlgorithm", selectedHashAlgorithm},
{"fileChunkCount", stringChunkCount},
{"fileChunkIndex", stringSliceIndex},
{"cancelReason", "呼叫方要求取消上傳。"},
};
var uploadCancelForm = new FormUrlEncodedContent(uploadCancelContent);
var uploadCancelResponse = await _http.PostAsync("/upload", uploadCancelForm);
return;
}
// 當前上傳分片索引應當小於預計的分片數
Debug.Assert(sliceIndex < chunkCount);
// 獲取準確大小的緩衝區,從記憶體池租用時得到的容量可能大於申請的大小,使用C#的新集合切片語法
var readBuffer = memory.Memory[..readBytes];
var sw = Stopwatch.StartNew();
// 根據協商的演算法計算Hash,wasm環境不支援MD5和全部非對稱加密演算法
var hash = selectedHashAlgorithm switch
{
"SHA1" => SHA1.HashData(readBuffer.Span),
"SHA256" => SHA256.HashData(readBuffer.Span),
_ => Array.Empty<byte>()
};
sw.Stop();
_logger.LogInformation("檔案:{0} 的片段 {1}({2} Bytes) 計算Hash用時 {3}", file.Name, sliceIndex, readBytes, sw.Elapsed);
var stringReadBytes = readBytes.ToString();
var stringFileOffset = fileOffset.ToString();
// 上傳當前分片
MultipartFormDataContent uploadFileForm = new();
uploadFileForm.Add(new StringContent(uploadTaskCode!), "uploadTaskCode");
uploadFileForm.Add(new StringContent("uploadChunk"), "uploadType");
uploadFileForm.Add(new StringContent(file.Name), "fileName");
uploadFileForm.Add(new StringContent(stringFileSize), "fileSize");
uploadFileForm.Add(new StringContent(selectedHashAlgorithm!), "hashAlgorithm");
uploadFileForm.Add(new StringContent(hash.ToHexString()), "hashValue");
uploadFileForm.Add(new StringContent(stringChunkCount), "fileChunkCount");
uploadFileForm.Add(new StringContent(stringReadBytes), "fileChunkSize");
uploadFileForm.Add(new StringContent(stringFileOffset), "fileChunkOffset");
uploadFileForm.Add(new StringContent(stringSliceIndex), "fileChunkIndex");
// 如果是未知的檔案型別,設定為普通二進位制流的MIME型別
var fileChunk = new ReadOnlyMemoryContent(readBuffer);
fileChunk.Headers.ContentType = new MediaTypeHeaderValue(string.IsNullOrEmpty(file.ContentType) ? "application/octet-stream" : file.ContentType);
uploadFileForm.Add(fileChunk, "fileChunkData", file.Name);
HttpResponseMessage? uploadResponse = null;
try
{
var uploadTaskCancel = CancellationTokenSource
.CreateLinkedTokenSource(
sliceUploadCancel,
sliceEnumeratorCancelSource.Token
);
_logger.LogInformation("檔案:{0} 的片段 {1}({2} Bytes) 開始上傳", file.Name, sliceIndex, readBytes);
sw.Restart();
uploadResponse = await _http.PostAsync("/upload", uploadFileForm, uploadTaskCancel.Token);
}
catch (TaskCanceledException e)
{
_logger.LogWarning(e, "外部取消上傳,已停止檔案:{0} 的上傳", file.Name);
fileUploadCancelSource.Cancel();
sliceUploadResults[sliceIndex] = FileUploadResult.Canceled;
var uploadCancelContent = new Dictionary<string, string>()
{
{"uploadType", "cancelUpload"},
{"uploadTaskCode", uploadTaskCode!},
{"fileName", file.Name},
{"hashAlgorithm", selectedHashAlgorithm},
{"fileChunkCount", stringChunkCount},
{"fileChunkIndex", stringSliceIndex},
{"cancelReason", "呼叫方要求取消上傳。"},
};
var uploadCancelForm = new FormUrlEncodedContent(uploadCancelContent);
var uploadCancelResponse = await _http.PostAsync("/upload", uploadCancelForm);
return;
}
catch (Exception e)
{
_logger.LogError(e, "上傳發生錯誤,已停止檔案:{0} 的上傳", file.Name);
fileUploadCancelSource.Cancel();
sliceUploadResults[sliceIndex] = FileUploadResult.Fail;
var uploadCancelContent = new Dictionary<string, string>()
{
{"uploadType", "cancelUpload"},
{"uploadTaskCode", uploadTaskCode!},
{"fileName", file.Name},
{"hashAlgorithm", selectedHashAlgorithm},
{"fileChunkCount", stringChunkCount},
{"fileChunkSize", stringReadBytes},
{"fileChunkOffset", stringFileOffset},
{"fileChunkIndex", stringSliceIndex},
{"cancelReason", "上傳過程中發生錯誤。"},
};
var uploadCancelForm = new FormUrlEncodedContent(uploadCancelContent);
var uploadCancelResponse = await _http.PostAsync("/upload", uploadCancelForm);
return;
}
finally
{
sw.Stop();
}
// 上傳發生錯誤,傳送取消請求,通知服務端清理資源
if (uploadResponse?.IsSuccessStatusCode is null or false)
{
_logger.LogError("上傳發生錯誤,已停止檔案:{0} 的上傳", file.Name);
fileUploadCancelSource.Cancel();
sliceUploadResults[sliceIndex] = FileUploadResult.Fail;
var uploadCancelContent = new Dictionary<string, string>()
{
{"uploadType", "cancelUpload"},
{"uploadTaskCode", uploadTaskCode!},
{"fileName", file.Name},
{"hashAlgorithm", selectedHashAlgorithm},
{"fileChunkCount", stringChunkCount},
{"fileChunkSize", stringReadBytes},
{"fileChunkOffset", stringFileOffset},
{"fileChunkIndex", stringSliceIndex},
{"cancelReason", "上傳過程中發生錯誤。"},
};
var uploadCancelForm = new FormUrlEncodedContent(uploadCancelContent);
var uploadCancelResponse = await _http.PostAsync("/upload", uploadCancelForm);
return;
}
_logger.LogInformation("檔案:{0} 的片段 {1}({2} Bytes) 上傳成功,用時 {3}", file.Name, sliceIndex, readBytes, sw.Elapsed);
sliceUploadResults[sliceIndex] = FileUploadResult.Success;
}
}
);
// 如果所有分片都上傳成功,則傳送完成請求完成上傳
if (sliceUploadResults.All(success => success is FileUploadResult.Success))
{
var uploadCompleteContent = new Dictionary<string, string>()
{
{"uploadType", "completeUpload"},
{"uploadTaskCode", uploadTaskCode!},
{"fileName", file.Name},
{"fileSize", stringFileSize},
{"hashAlgorithm", selectedHashAlgorithm},
{"fileChunkCount", stringChunkCount},
{"fileChunkSize", stringBufferSize},
};
var uploadCompleteForm = new FormUrlEncodedContent(uploadCompleteContent);
var uploadCompleteResponse = await _http.PostAsync("/upload", uploadCompleteForm);
if (uploadCompleteResponse.IsSuccessStatusCode)
{
_logger.LogInformation("檔案:{0} 上傳成功,共 {1} 個片段", file.Name, chunkCount);
return FileUploadResult.Success;
}
else
{
_logger.LogError("上傳發生錯誤,已停止檔案:{0} 的上傳", file.Name);
var uploadCancelContent = new Dictionary<string, string>()
{
{"uploadType", "cancelUpload"},
{"uploadTaskCode", uploadTaskCode!},
{"fileName", file.Name},
{"hashAlgorithm", selectedHashAlgorithm},
{"fileChunkCount", stringChunkCount},
{"cancelReason", "上傳過程中發生錯誤。"},
};
var uploadCancelForm = new FormUrlEncodedContent(uploadCancelContent);
var uploadCancelResponse = await _http.PostAsync("/upload", uploadCancelForm);
return FileUploadResult.Fail;
}
}
else if (sliceUploadResults.Any(success => success is FileUploadResult.Fail))
{
return FileUploadResult.Fail;
}
else
{
return FileUploadResult.Canceled;
}
}
/// <summary>
/// 非同步切分要上傳的檔案
/// <br/>如果想中途結束切分,不要在呼叫此方法的foreach塊中使用break,請使用取消令牌,否則會出現記憶體洩漏
/// </summary>
/// <param name="file">要分片的檔案</param>
/// <param name="sliceSize">分片大小</param>
/// <param name="cancellation">取消令牌</param>
/// <returns>已切分的檔案片段資料,用完切記釋放其中的記憶體緩衝</returns>
private static async IAsyncEnumerable<(IMemoryOwner<byte> memory, int sliceIndex, int readBytes, long fileOffset)> SliceFileAsync(
IBrowserFile file,
int sliceSize,
[EnumeratorCancellation] CancellationToken cancellation = default)
{
if (cancellation.IsCancellationRequested) yield break;
int fileSliceIndex;
long fileOffset;
IMemoryOwner<byte> memory;
await using var fileStream = file.OpenReadStream(long.MaxValue);
for (fileSliceIndex = 0, fileOffset = 0, memory = MemoryPool<byte>.Shared.Rent(sliceSize);
(await fileStream.ReadAsync(memory.Memory[..sliceSize], cancellation)) is int readBytes and > 0;
fileSliceIndex++, fileOffset += readBytes, memory = MemoryPool<byte>.Shared.Rent(sliceSize)
)
{
if(cancellation.IsCancellationRequested)
{
// 如果取消切分,緩衝不會返回到外部,只能在內部釋放
memory.Dispose();
yield break;
}
yield return (memory, fileSliceIndex, readBytes, fileOffset);
}
// 切分結束後會多出一個沒用的緩衝,只能在內部釋放
memory.Dispose();
}
/// <summary>
/// 上傳結果
/// </summary>
public enum FileUploadResult
{
/// <summary>
/// 失敗
/// </summary>
Fail = -2,
/// <summary>
/// 取消
/// </summary>
Canceled = -1,
/// <summary>
/// 沒有結果,未知結果
/// </summary>
None = 0,
/// <summary>
/// 成功
/// </summary>
Success = 1
}
}
示例使用Parallel.ForEachAsync
方法並行啟動多個檔案和每個檔案的多個片段的上傳,併發量由方法的引數控制。UploadChunkedFile
方法負責單個檔案的上傳,其中的IBrowserFile
型別是.NET 6新增的檔案選擇框選中項的包裝,可以使用其中的OpenReadStream
方法流式讀取檔案資料,確保大檔案上傳不會在記憶體中緩衝所有資料導致記憶體佔用問題。
UploadChunkedFile
方法內部使用自適應分片大小演算法,規則為片段最小1MB,最大10MB,儘可能平均分為10份。得出片段大小後向服務端請求開始上傳檔案,服務端成功返回後開始檔案切分、校驗和上傳。
SliceFileAsync
負責切分檔案並流式返回每個片段,切分方法是惰性的,所以不用擔心佔用大量記憶體,但是這個方法只能使用取消令牌中斷切分,如果在呼叫該方法的await foreach
塊中使用break
中斷會產生記憶體洩漏。切分完成後會返回包含片段資料的記憶體緩衝和其他附加資訊。OpenReadStream
需要使用引數控制允許讀取的最大位元組數(預設512KB),因為這裡是分片上傳,直接設定為long.MaxValue
即可。for
迴圈頭使用逗號表示式定義多個迴圈操作,使迴圈體的程式碼清晰簡潔。
UploadChunkedFile
方法使用Parallel.ForEachAsync
並行啟動多個片段的校驗和上傳,WASM中不支援MD5和所有非對稱加密演算法,需要注意。完成檔案的並行上傳或發生錯誤後會檢查所有片段的上傳情況,如果所有片段都上傳成功,就傳送完成上傳請求通知服務端收尾善後,否則刪除臨時檔案。
結語
這應該是一個比較清晰易懂的分片上傳示例。示例使用Blazor 和C#以非常流暢的非同步程式碼實現了併發分片上傳。但是本示例依然有許多可最佳化的點,例如實現斷點續傳,服務端如果沒有收到結束請求時的兜底處理等,這些就留給朋友們思考了。
又是很久沒有寫文章了,一直沒有找到什麼好選題,難得找到一個,經過將近1周的研究開發終於搞定了。
QQ群
讀者交流QQ群:540719365
歡迎讀者和廣大朋友一起交流,如發現本書錯誤也歡迎透過部落格園、QQ群等方式告知我。