使用Blazor WASM實現可取消的多檔案帶校驗併發分片上傳

coredx發表於2023-10-07

前言

上傳大檔案時,原始HTTP檔案上傳功能可能會影響使用體驗,此時使用分片上傳功能可以有效避免原始上傳的弊端。由於分片上傳不是HTTP標準的一部分,所以只能自行開發相互配合的服務端和客戶端。檔案分片上傳在許多情況時都擁有很多好處,除非已知需要上傳的檔案一定非常小。分片上傳可以對上傳的檔案進行快速分片校驗,避免大檔案上傳時長時間等待校驗,當然完整校驗可以在秒傳時使用,有這種需求的情況就只能老實等待校驗了。

Blazr WASM提供了在 .NET環境中使用瀏覽器功能的能力,充分利用C#和 .NET能夠大幅簡化分片上傳功能的開發。本次示例使用HTTP標準上傳作為分片上傳的底層基礎,並提供分片校驗功能保障上傳資料的完整性。

新書宣傳

有關新書的更多介紹歡迎檢視《C#與.NET6 開發從入門到實踐》上市,作者親自來打廣告了!
image

正文

本示例的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
image

歡迎讀者和廣大朋友一起交流,如發現本書錯誤也歡迎透過部落格園、QQ群等方式告知我。

本文地址:https://www.cnblogs.com/coredx/p/17746162.html

相關文章