You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Angular从ASP.NET Core API流式下载大文件时响应体为null问题排查

问题描述

我们有一个Angular应用,允许用户通过数据库中的图片ID列表下载图片。ASP.NET Core Web API使用响应流创建图片压缩包并在添加图片到归档时传输文件。

核心问题:下载大压缩包文件(测试用例为2.5GB、约2500张图片)时,客户端的响应体始终为null,小文件则无此问题。网络控制台显示请求已完全完成,数据传输大小与文件大小一致,但Angular收到HttpEventType.Response事件时,event.body为null。

已尝试方案:

  • 将responseType设置为arraybuffer、blob,结果相同
  • 修改响应Content-Type为application/zip、application/octet-stream,问题依旧
  • 使用file saver库,仍出现响应体null的问题

相关代码

Angular客户端代码

组件触发逻辑

modalInstance.componentInstance.onConfirmEvent.subscribe(res => {
      
        this.downloadInProgress = true;
        // workingList包含图片ID列表
        this.prepareDownloadByObject(workingList, res.createFileTree, res.createMetadata)
    })

下载服务代码

prepareDownloadByObject(workingList, createFileTree, createMetadata) {
    
   this.downloadByWorkingList(workingList, createFileTree, createMetadata).subscribe(
      res => this.handleDownloadProgress(res, workingList),
      async err => this.handleDownloadError(err)
    )
  }

downloadByWorkingList(workingListDto: IWorkingList,createTreeDirectory:boolean, createMetadata: boolean): Observable<any> {
    
   return this.http.post(`${this.baseUrl}WorkingList/DownloadWorkingList/${createTreeDirectory}/${createMetadata}`,workingListDto,
    {
      observe: 'events',
      responseType: "arraybuffer",
      reportProgress: true
    });
  }

handleDownloadProgress(event, workingList?: IWorkingList) {
   
   if (event.type === HttpEventType.DownloadProgress) {
      //[... UI更新 ...]
   } else if (event.type === HttpEventType.Response) {
      this.downloadFile(event, workingList);
      //[... UI更新 ...]
   } else {
      console.log("Unknown event type", event);
   }
}

downloadFile(data: any, workingList?: IWorkingList) {
    // 此处为true
    if (data.body == null) {
      return;
    }

    const downloadedFile = new Blob([data.body], { type: data.body.type });
    const a = document.createElement("a");
    a.setAttribute("style", "display:none;");
    document.body.appendChild(a);    
    a.download = "workingList" +"_"+ this.getFileDateFormat() + ".zip";
    a.href = URL.createObjectURL(downloadedFile);
    a.target = "_blank";
    a.click();
    document.body.removeChild(a);
}

ASP.NET Core API代码

控制器代码

[HttpPost("{createTreeDirectory}/{createMetadata}")]
public async Task<IActionResult> DownloadWorkingList(WorkingListDto workingListDto, bool createTreeDirectory, bool createMetadata, CancellationToken token)
{
    try
    {
        if (workingListDto.ImageIds == null || workingListDto.ImageIds.Count == 0)
        {
            return BadRequest("You have to select images to download");
        }

        Response.ContentType = "application/octet-stream";
        Response.Headers.Append("Content-Disposition", "attachment; filename=workingList.zip");
        await Response.StartAsync(token);
                
        await this._workingListAppService.DownloadWorkingList(workingListDto, createTreeDirectory, createMetadata, token, Response.BodyWriter.AsStream()).ConfigureAwait(false);
               
        await Response.CompleteAsync();

        return new EmptyResult();
    }
    catch (ArgumentException ex)
    {
        return this.BadRequest(new { ex.Message, ex.StackTrace });
    }
    catch (Exception ex)
    {
        return this.Problem(ex.Message, "WorkingList/DownloadAndSaveWorkingList", 500);
    }
}

应用服务代码

public async Task<Stream> CreateImageZipFileStream(List<Image> images, string user, CancellationToken token, Stream stream, bool createTreeDirectory = true, bool createMetadataFile = false)
{
    // 此处stream为Response.BodyWriter.AsStream()
    using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
    {
        foreach (var image in images)
        {
            await AddImageToArchive(archive, createTreeDirectory, createMetadataFile, image, token);
        }
    }

    // 此处无实际作用
    return stream;
}

private async Task AddImageToArchive(ZipArchive archive, bool createTreeDirectory, bool createMetadata, Image imageItem, CancellationToken token)
{
    string entryName = imageItem.UserFriendlyName + Constantes.IMAGE_EXTENSION;

    if (createTreeDirectory)
    {
        // 修改entryName以在归档中添加子文件夹
        [...]
    }
    
    var entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression);

    using (var entryStream = entry.Open())
    {
        try
        {
            // 该方法从Azure文件共享获取文件字节并写入entryStream
            await this._imageFileShareClient.GetFileContent(imageMaterialFolder + "/" + imageItem.ApplicationCamera.SiteRef.SiteName, imageItem.FilePath, entryStream);
        }
        catch
        {
            try
            {
                entryStream.Position = 0;
                // 该方法从Azure文件共享获取备用文件字节并写入entryStream
                await this._imageFileShareClient.GetFileContent(fisheyedImageFolder + "/" + imageItem.ApplicationCamera.SiteRef.SiteName, imageItem.FilePath, entryStream);
            }
            catch
            {
                this._logger.LogError("An image requested for download was missing from fileshare. ImageId: {0}", imageItem.Id);
                // 忽略错误,继续执行
            }
        }
    }
}

解决方案

核心问题:响应体为null的解决

1. 改用浏览器原生下载,绕过Angular HttpClient内存限制

Angular的HttpClient在处理GB级响应时,内存管理机制会因压力自动丢弃响应体。直接通过隐藏表单或<a>标签发起请求,让浏览器原生处理下载,彻底避免该问题:

prepareDownloadByObject(workingList, createFileTree, createMetadata) {
    this.downloadInProgress = true;
    
    // 创建FormData传递JSON参数
    const formData = new FormData();
    formData.append('workingListDto', JSON.stringify(workingList));
    
    // 创建隐藏表单提交POST请求
    const form = document.createElement('form');
    form.style.display = 'none';
    form.method = 'POST';
    form.action = `${this.baseUrl}WorkingList/DownloadWorkingList/${createFileTree}/${createMetadata}`;
    
    // 填充表单参数
    for (const [key, value] of formData.entries()) {
        const input = document.createElement('input');
        input.type = 'hidden';
        input.name = key;
        input.value = value as string;
        form.appendChild(input);
    }
    
    document.body.appendChild(form);
    form.submit();
    document.body.removeChild(form);
    
    // 可通过定时器或监听下载状态更新UI
}

2. 调整API端响应返回逻辑

当前API使用EmptyResult配合手动操作响应流,可能导致Angular无法正确解析响应结构。改为使用FileStreamResult让框架自动处理流的收尾:

[HttpPost("{createTreeDirectory}/{createMetadata}")]
public async Task<IActionResult> DownloadWorkingList(WorkingListDto workingListDto, bool createTreeDirectory, bool createMetadata, CancellationToken token)
{
    try
    {
        if (workingListDto.ImageIds == null || workingListDto.ImageIds.Count == 0)
        {
            return BadRequest("You have to select images to download");
        }

        // 临时内存流存储压缩包(若担心内存占用,可直接写入Response.Body)
        var stream = new MemoryStream();
        var images = await GetImagesByIds(workingListDto.ImageIds); // 补充获取图片逻辑
        await this._workingListAppService.CreateImageZipFileStream(images, "currentUser", token, stream, createTreeDirectory, createMetadata);
        stream.Position = 0;

        return File(stream, "application/octet-stream", $"workingList_{DateTime.Now:yyyyMMddHHmmss}.zip");
    }
    catch (ArgumentException ex)
    {
        return BadRequest(new { ex.Message, ex.StackTrace });
    }
    catch (Exception ex)
    {
        return Problem(ex.Message, "WorkingList/DownloadAndSaveWorkingList", 500);
    }
}

附带问题:避免客户端加载整个文件到内存

1. 优先使用浏览器原生下载(最优方案)

浏览器会直接将文件写入磁盘,不占用应用内存,同时支持断点续传,无需额外代码处理。

2. 使用流式处理API(需HTTPS环境)

若需要保留进度监控,可利用浏览器FileSystemAccessAPI配合ReadableStream分块写入文件:

downloadByWorkingList(workingListDto: IWorkingList, createTreeDirectory: boolean, createMetadata: boolean): Observable<HttpResponse<Blob>> {
    return this.http.post(`${this.baseUrl}WorkingList/DownloadWorkingList/${createTreeDirectory}/${createMetadata}`, workingListDto, {
        responseType: 'blob',
        observe: 'response',
        reportProgress: true
    });
}

async handleDownloadResponse(response: HttpResponse<Blob>) {
    const stream = response.body?.stream();
    if (!stream) return;

    // 请求用户选择保存路径
    const fileHandle = await window.showSaveFilePicker({
        suggestedName: `workingList_${this.getFileDateFormat()}.zip`,
        types: [{ accept: { 'application/zip': ['.zip'] } }]
    });

    const writableStream = await fileHandle.createWritable();
    const reader = stream.getReader();
    const totalSize = Number(response.headers.get('Content-Length'));
    let downloadedSize = 0;

    // 分块写入文件
    const processChunk = async () => {
        const { done, value } = await reader.read();
        if (done) {
            await writableStream.close();
            this.downloadInProgress = false;
            return;
        }

        downloadedSize += value.length;
        // 更新进度:(downloadedSize / totalSize) * 100
        await writableStream.write(value);
        processChunk();
    };

    processChunk();
}

内容的提问来源于stack exchange,提问作者Mephisto

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 22:49:50