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

来源:https://www.cnblogs.com/coredx/archive/2023/10/07/17746162.html
-Advertisement-
Play Games

前言 上傳大文件時,原始HTTP文件上傳功能可能會影響使用體驗,此時使用分片上傳功能可以有效避免原始上傳的弊端。由於分片上傳不是HTTP標準的一部分,所以只能自行開發相互配合的服務端和客戶端。文件分片上傳在許多情況時都擁有很多好處,除非已知需要上傳的文件一定非常小。分片上傳可以對上傳的文件進行快速分 ...


前言

上傳大文件時,原始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


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 早在C++11就在STL中引入了原子操作支持了。大部分時候,我使用C++11的atomic僅僅是為了原子地操作特定的一個變數,比如`load`、`store`、`fetch_add`等等。然而實際上,C++11的原子操作帶著的memory order還能起到memory barrier的作用。本文會... ...
  • 本教程中,我們將學習如何在 Spring Boot 中整合使用 Log4j2 日誌框架。 Log4j2 介紹 Spring Boot 中預設使用 Logback 作為日誌框架,接下來我們將學習如何在 Spring Boot 中集成與配置 Log4j2。在配置之前,我們需要知道的是 Log4j2 是 ...
  • 柱狀圖,是一種使用矩形條,對不同類別進行數值比較的統計圖表。在柱狀圖上,分類變數的每個實體都被表示為一個矩形(通俗講即為“柱子”),而數值則決定了柱子的高度。 1. 主要元素 柱狀圖是一種用長方形柱子表示數據的圖表。它包含三個主要元素: 橫軸(x軸):表示數據的類別或時間。 縱軸(y軸):表示數據的 ...
  • 歡迎訪問我的GitHub 這裡分類和彙總了欣宸的全部原創(含配套源碼):https://github.com/zq2599/blog_demos 本篇概覽 本文是《Strimzi Kafka Bridge(橋接)實戰》的第三篇,前文咱們掌握了Strimzi Kafka Bridge的基本功能:基於h ...
  • 當你需要為你的 Go 項目創建一個強大的命令行工具時,你可能會遇到許多挑戰,比如如何定義命令、標誌和參數,如何生成詳細的幫助文檔,如何支持子命令等等。為瞭解決這些問題,github.com/spf13/cobra 就可以派上用場。 github.com/spf13/cobra 是一個用於構建強大的命 ...
  • 1、功能介紹 海量數據操作ORM性能瓶頸在實體轉換上面,並且不能使用常規的Sql去實現 當列越多轉換越慢,SqlSugar將轉換性能做到極致,並且採用資料庫最佳API 操作資料庫達到極限性能,當然你如果不用sqlsugar瞭解一下原理也可以使用其他ORM實現 BulkCopy BulkCopy是一種 ...
  • 支持.Net Core(2.0及以上)與.Net Framework(4.0及以上)(註意:升級了,可以覆蓋到早期的.Net Framework4.0了,而且修複了資料庫欄位為Null時報錯的問題,無敵了!!) 此工具在IDataAccess介面中提供。 已被.Net圈內多家大廠採用! IDataA ...
  • 一:背景 1. 講故事 中秋國慶長假結束,哈哈,在老家拍了很多的短視頻,有興趣的可以上B站觀看:https://space.bilibili.com/409524162 ,今天繼續給大家分享各種奇奇怪怪的.NET生產事故,希望能幫助大家在未來的編程之路上少踩坑。 話不多說,這篇看一個.NET程式集泄 ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...