从 Blazor Webassembly 流式传输大文件?

Streaming Large Files from Blazor Webassembly?

我想从 Blazor Webassembly 应用程序上传视频(最多 2GB)到我的 asp net core / 5 服务器。

我成功地将 IformFile 用于较小的文件并且工作正常。

我研究了各种来源: 这个 post 很有帮助 这个也很好解释and gives a method of getting progress of uploading - which I want

我还使用了来自 Microsoft which uses streaming 的示例,我构建了 MVC 应用程序使其工作并在 Blazor 应用程序中进行了尝试,两者都使用 Javasript Form(工作正常,但我不知道'看不到如何取得进展)并使用适用于较小文件的代码,但是当我尝试一个巨大的 1.4GB 文件时,它会耗尽内存。

服务器控制器非常简单,来自 .Net 示例:

    [HttpPost]
    [DisableFormValueModelBinding]
    [RequestSizeLimit(4294967296)]
    [RequestFormLimits(MultipartBodyLengthLimit = 4294967296)]

    //[ValidateAntiForgeryToken] (just for now..)
    [Route("UploadFile")]
    public async Task<IActionResult> UploadFile()
    {
        if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
        {
            ModelState.AddModelError("File", 
                $"The request couldn't be processed (Error 1).");
            // Log error

            return BadRequest(ModelState);
        }

        var boundary = MultipartRequestHelper.GetBoundary(
            MediaTypeHeaderValue.Parse(Request.ContentType),
            _defaultFormOptions.MultipartBoundaryLengthLimit);
        var reader = new MultipartReader(boundary, HttpContext.Request.Body);
        var section = await reader.ReadNextSectionAsync();

        while (section != null)
        {
            var hasContentDispositionHeader = 
                ContentDispositionHeaderValue.TryParse(
                    section.ContentDisposition, out var contentDisposition);

            if (hasContentDispositionHeader)
            {
  ....

            }

            // Drain any remaining section body that hasn't been consumed and
            // read the headers for the next section.
            section = await reader.ReadNextSectionAsync();
        }

        return Created(nameof(StreamingController), null);
    }

在我的 Blazor 测试页中,我有几个试用输入。 这个:

                <h1>Upload Files</h1>

                <p>
                    <label>
                        Upload up to @maxAllowedFiles files:
                        <InputFile OnChange="@OnInputFileChange" multiple />
                    </label>
                </p>

                @if (files.Count > 0)
                {
              .....
                }

来自 Microsoft 的示例使用此代码:

    private async Task OnInputFileChange(InputFileChangeEventArgs e)
    {
        shouldRender = false;
        var upload = false;

        using var content = new MultipartFormDataContent();

        foreach (var file in e.GetMultipleFiles(maxAllowedFiles))
        {
            try
            {


                if (uploadResults.SingleOrDefault(
                    f => f.FileName == file.Name) is null)
                {
                    var fileContent = new StreamContent(file.OpenReadStream(maxFileSize),BufferSize);

                    files.Add(
                        new File()
                        {
                            Name = file.Name,
                        });

                    if (file.Size < maxFileSize)
                    {
                        content.Add(
                            content: fileContent,
                            name: "\"files\"",
                            fileName: file.Name);

                        upload = true;
                    }
                    else
                    {
                        //ILogger.LogInformation("{FileName} not uploaded", file.Name);

                        uploadResults.Add(
                            new UploadResult()
                            {
                                FileName = file.Name,
                                ErrorCode = 6,
                                Uploaded = false,
                            });
                    }
                }
            }
            catch(Exception ex)
            {
                Console.WriteLine($"Error: {ex.Message}");
            }
        }
        if (upload)
        {
            var response = await Http.PostAsync(Routes.UploadFileRoute, content);
            var newUploadResults = await response.Content
                .ReadFromJsonAsync<IList<UploadResult>>();
            uploadResults = uploadResults.Concat(newUploadResults).ToList();
        }
        shouldRender = true;
    }

我也有示例应用程序中的此表单:

        <h3>From SampleApp</h3>
    <form id="uploadForm" action="@UploadRoute" method="post"
          enctype="multipart/form-data" onsubmit="AJAXSubmit(this);return false;">
        <dl>
            <dt>
                <label for="file">File</label>
            </dt>
            <dd>
                <input id="file" type="file" name="file" />
            </dd>
        </dl>

        <input class="btn" type="submit" value="Upload" />

        <div style="margin-top:15px">
            <output form="uploadForm" name="result"></output>
        </div>
    </form>

及其关联的 Javascript 直接加载到 wwwroot index.html(注意这是直接的 html/javascript...这里没有 Blazor JSInterop...

        <script>
              "use strict";
              async function AJAXSubmit(oFormElement)
                  {
                      const formData = new FormData(oFormElement);
                      try
                      {
                          const response = await fetch(oFormElement.action, {
                          method: 'POST',
                    headers:
                              {
                                  'RequestVerificationToken': getCookie('RequestVerificationToken')
                    },
                    body: formData
                          });

                          oFormElement.elements.namedItem("result").value =
                            'Result: ' + response.status + ' ' + response.statusText;
                      }
                      catch (error)
                      {
                          console.error('Error:', error);
                      }
                  }
                  function getCookie(name)
                  {
                      var value = "; " + document.cookie;
                      var parts = value.split("; " + name + "=");
                      if (parts.length == 2) return parts.pop().split(";").shift();
                  }
</script>

这是直接来自微软的样本。

当我使用 Blazor Inputfile 方法上传一个 1.4GB 的文件时(尽管应该是流式传输的)我可以观察任务管理器并看到 Chrome 的内存使用(和 Visual Studios)正在增加直到应用程序因内存不足错误而终止。此外,在控制器入口点设置了一个断点,它永远不会到达它,中断。只是延迟,因为客户端正在进行一些缓冲(我猜?)然后繁荣。 (我的笔记本电脑中只有 8G Ram 过期了,如果我有更多可能会起作用,但这不是问题,问题在于它完全在浏览器中构建)...

如果我使用表单和 JS 做同样的事情,那么断点会立即命中,并且在继续时 chrome 的内存使用量只会略微增加,然后才趋于平稳。 VS 内存仍在增长,但不会太多,我可以看到缓冲工作。

这是我误解了 StreamContent 的实际工作原理,还是 Blazorizm?我想我在某处看到 fetch 使用了一些 JS Interop 或阻止它正确缓冲的东西?但那是在一个旧的 post pre net core 3.1 中,我现在使用的是 5,所以希望它已被排序。

我也尝试过 Blazorise,因为它似乎有一些允许进度监控的事件,但我无法找到如何使用这些事件,因为它们似乎在客户端建立缓冲区并结束时触发在任何东西到达服务器之前。哪一种支持上述理论..

提前致谢....

我一直想回答这个问题,但现在我有一个具体的要求,这里是...

我按如下方式解决了这个问题。 我正在为我的数据使用 Azure 存储,因此您需要针对您自己的解决方案改变策略。它还处理进度条和多个文件的提供。 Azure 存储库有一组 blockblob 函数,可以实现这种场景。如果你想自己滚动 'your own' 存储,你可能想检查它们...

处理此问题的客户端 'FileService' 函数在这里:

        public async Task<List<ExternalFileDTO>> HandleFilesUpload(FileChangedEventArgs e, IProgress<Tuple<int, int, string>> progressHandler,
        IProgress<Tuple<int,string>> fileCountHandler, ExternalFileDTO fileTemplate, CancellationToken cancellationToken = default)
    {

        int FileCount =0;
        fileTemplate.CreatedBy = _userservice.CurrentUser;
        Tuple<int, string> reportfile;
        Tuple<int, int, string> CountProgressName;
        List<ExternalFileDTO> NewFilesList = new List<ExternalFileDTO>();
        foreach (var file in e.Files)
        {
            FileCount++;
            reportfile = Tuple.Create(FileCount, file.Name);
            ExternalFileDTO currentfile = new ExternalFileDTO(); // Set up a current file in case of cancel

            fileCountHandler.Report(reportfile);
            try
            {

                // A bit clumsy, but in the event of an error, the Newfilelist gets a single ExtFileDTO added with a status
                // and error message. This because the caller gets a list of the files added or not with status.
                // if it fails partway through then it will be the list up to that point and the error
                if (file == null)
                {
                    fileTemplate.Status= new InfoBool(false, "File is null");
                    NewFilesList.Add(new ExternalFileDTO(fileTemplate));
                    return NewFilesList;

                }
                long filesize = file.Size;
                if (filesize > maxFileSize)
                {
                    fileTemplate.Status = new InfoBool(false, "File exceeds Max Size");
                    NewFilesList.Add(new ExternalFileDTO(fileTemplate));
                    return NewFilesList;

                }
                fileTemplate.OriginalFileName = file.Name;
                fileTemplate.FileType = file.Type;

                var sendfile = await _azureservice.GetAzureUploadURLFile(fileTemplate);
                if (!sendfile.Status.Success) // There was an error so return the details
                {

                    NewFilesList.Add(sendfile);
                    return NewFilesList;
                }
                currentfile = sendfile; // This allows the current file to be passed out of the loop if cancelled

                BlockBlobClient blockBlobclient = new BlockBlobClient(sendfile.CloudURI);

                // BlobHttpHeaders blobheader = new BlobHttpHeaders { ContentType = fileTemplate.FileType };

                // blockBlobclient.SetHttpHeaders(blobheader);

                byte[] buffer = new byte[BufferSize];
                using (var bufferedStream = new BufferedStream(file.OpenReadStream(maxFileSize), BufferSize))
                {
                    int readCount = 0;
                    int bytesRead;
                    long TotalBytesSent = 0;
                    // track the current block number as the code iterates through the file
                    int blockNumber = 0;

                    // Create list to track blockIds, it will be needed after the loop
                    List<string> blockList = new List<string>();

                    while ((bytesRead = await bufferedStream.ReadAsync(buffer, 0, BufferSize)) > 0)
                    {
                        blockNumber++;
                        // set block ID as a string and convert it to Base64 which is the required format
                        string blockId = $"{blockNumber:0000000}";
                        string base64BlockId = Convert.ToBase64String(Encoding.UTF8.GetBytes(blockId));

                        Console.WriteLine($"Read:{readCount++} {bytesRead / (double)BufferSize} MB");

                        // Do work on the block of data
                        await blockBlobclient.StageBlockAsync(base64BlockId, new MemoryStream(buffer, 0, bytesRead), null, null, null, cancellationToken);
                        // add the current blockId into our list
                        blockList.Add(base64BlockId);
                        TotalBytesSent += bytesRead;
                        int PercentageSent = (int)(TotalBytesSent * 100 / filesize);
                        CountProgressName = Tuple.Create(FileCount, PercentageSent, file.Name);
                        //
                        // I'm a bit confused as I don't know how the OperationCancelledException is thrown
                        // But it is!
                        // 
                        progressHandler.Report(CountProgressName);
                    }

                    await blockBlobclient.CommitBlockListAsync(blockList, null, cancellationToken);

                    // make sure to dispose the stream once your are done
                    bufferedStream.Dispose();   // Belt and braces
                }
                //
                // Now make a server API call to verify the file upload was successful
                //
                sendfile.Status = new InfoBool(false, "File upload succeeded");
                currentfile = await _azureservice.VerifyFileAsync(sendfile);
                if (currentfile.Status.Success)
                {
                    // On;y add the file into the Current Live files list if the result was successful
                    CurrentFiles.Add(currentfile); // Add the returned sendfile object to the list
                }
                NewFilesList.Add(currentfile); // Add to this anyway since the result is diplayerd to the user

            }
            catch (OperationCanceledException cancelEx)
            {
                Console.WriteLine(cancelEx.Message);
                currentfile.Status = new InfoBool(true, cancelEx.Message); // Set the file status as cancelled
                await _azureservice.VerifyFileAsync(currentfile);
            }
            catch (Exception exc)
            {
                Console.WriteLine(exc.Message);
            }
            finally
            {
               
            }
        }
        return NewFilesList; // Return the list of files uploaded or not.. 

    }

UI 的这一点允许用户创建要上传的文件列表:

@if (IsNewItem)
            {
                @if (fileArgs != null && fileArgs.Files.Count() > 0)
                {
                    <div>Selected FIles:</div>
                    <ul>
                        @foreach (var file in @fileArgs.Files)
                        {
                            <li>@file.Name</li>
                        }
                    </ul>
                    if (!IsUploading)
                    {
                        <Button Color="Color.Primary" Clicked="@UploadFiles">Upload Files</Button>
                    }
                }
                @if (IsUploading)
                {
                    <div>
                        <div> Uploading @FileNameUploading</div>
                        <div> @FileCount of @TotalFilesUploading files - File Progress % = @ProgressPercent %</div>
                    </div>

                    <div>
                        <Progress>
                            <ProgressBar Value="@ProgressPercent" Color="Color.Success" />
                        </Progress>
                    </div>

                    <Button Color="Color.Danger" Clicked="@CancelUpload">Cancel</Button>
                }
                else
                {
                    <Span>Uploading is FALSE</Span>
                }
                <div >
                    <Blazorise.FileEdit @ref="fileedit" Changed="@BLOnFilesChanged"
                        Filter=@FileService.AllowedFileTypes Multiple="true" />
                </div>

            }

我最终使用 Blazorise 'FileEdit' 是为了 UI 的一致性而不是它的功能。

代码部分中的 'UploadFiles' 函数如下所示:

    async Task UploadFiles()
    {

            TotalFilesUploading = fileArgs.Files.Count();
            IsUploading = true;
            // throw the Fileupload selection to the File Service
            List<ExternalFileDTO> sentfiles = await FileService.HandleFilesUpload(fileArgs, progressHandler, fileCountHandler, editUserFile, cts.Token);

            IsUploading = false;

            StringBuilder bob = new StringBuilder();
            bob.Append("File Upload Status:<br />");
            foreach (ExternalFileDTO file in sentfiles)
            {
                bob.Append($"FIle:{file.OriginalFileName} - {file.Status.Reason}</div><br />");
            }
            if (sentfiles.Count > 0)
            {
                ShowStatusMsg(bob.ToString());
            }
            TotalFilesUploading = 0;    //Clear down the vars
            HideModal();    //File has finished uploading close and refresh
            await OnUserFilesUploaded.InvokeAsync();
            this.StateHasChanged();     // Refresh the display

    }

...最后,进度回调最终还是很简单的。这是页面(在我的例子中是表单)用于设置的一些内务处理代码。

        protected override void OnInitialized()
    
    {
        progressHandler = new Progress<Tuple<int, int, string>>(UploadProgressChanged);
        fileCountHandler = new Progress<Tuple<int,string>>(FileCountChanged);
        FileService.CurrentFilesChanged += Refresh; // Refresh the page on the change of files
    }

    private void FileCountChanged(Tuple<int,string> FileNoAndNameSending)
    {
        Console.WriteLine($"FileCount Changed  = {FileNoAndNameSending}");
        FileCount = FileNoAndNameSending.Item1;
        FileNameUploading = FileNoAndNameSending.Item2;
        this.StateHasChanged();
    }

    private void UploadProgressChanged(Tuple<int, int, string> CountProgressName)
    {
        Console.WriteLine($"File Name: {CountProgressName.Item3} /n Fileno: {CountProgressName.Item1}, Upload Progress Changed Percentage = {CountProgressName.Item2}");
        FileCount = CountProgressName.Item1;
        ProgressPercent = CountProgressName.Item2;
        FileNameUploading = CountProgressName.Item3;
        if (FileCount >= TotalFilesUploading && ProgressPercent >=100)
        {
            // This is the last file and it is complete
            Console.WriteLine($"Last File reached at 100%");
        }
        Refresh(); // Update the display
    }

我确信这并不理想,但它确实有效并且看起来非常稳健。 我必须注意的一件事是确保 VeryFile 服务做得很好。我仍然有一个挥之不去的疑问,如果 Azure 因某种错误而取消,捕获是否会正确发生。不过那是以后的事了。

希望对您有所帮助。