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
相关产品推荐
相关产品推荐

