我们有一个 Angular 应用程序,允许用户通过数据库中的 ID 下载图像列表。 ASP.NET Core Web API 使用响应流创建图像 zip 文件并传输它,同时将图像附加到存档。
问题是,对于大型存档文件(我的测试是 2.5 GB,包含大约 2500 个图像),响应正文(客户端)始终为空。对于较小的存档文件没有问题。
一些相关代码:
首先,在客户端,这是组件中的触发器:
modalInstance.componentInstance.onConfirmEvent.subscribe(res => {
this.downloadInProgress = true;
// workingList contains a list of image ids
this.prepareDownloadByObject(workingList, res.createFileTree, res.createMetadata)
})
在下载服务中,我们使用
HttpClient
发布一个包含图像ID列表的对象,然后使用handleDownloadProgress
检查下载进度。一旦事件类型为Response
,我们使用主体创建一个对象url并下载文件(这个方法在SO中随处可见):
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 update ...]
} else if (event.type === HttpEventType.Response) {
this.downloadFile(event, workingList);
//[... UI update ...]
} 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);
}
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);
}
}
WorkingListAppService
收集图像文件路径(在 Azure 文件共享上)并将其传输到特定服务。该服务将获取图像字节内容,将其作为条目写入直接构建到响应流中的zipArchive
。此方法用于避免将文件写入磁盘或将其完全加载到内存中:
public async Task<Stream> CreateImageZipFileStream(List<Image> image, string user, CancellationToken token, Stream stream, bool createTreeDirectory = true, bool createMetadataFile = false)
{
// In this case, stream is Response.BodyWriter.AsStream()
using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
foreach (var image in images)
{
await AddImageToArchive(archive, createTreeDirectory, createMetadataFile, image, token);
}
}
// Irrelevant here
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)
{
// Change the entryName to add a subfolder in the archive
[...]
}
var entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression);
using (var entryStream = entry.Open())
{
try
{
// This methods get the file bytes from an azure fileshare and write it to entryStream
await this._imageFileShareClient.GetFileContent(imageMaterialFolder + "/" + imageItem.ApplicationCamera.SiteRef.SiteName, imageItem.FilePath, entryStream);
}
catch
{
try
{
entryStream.Position = 0;
// This methods get a fallback file bytes from an azure fileshare and write it to 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);
// Do not throw on error, Ignore missing image and continue
}
}
}
}
更多信息:请求已完全完成,数据已传输到客户端:在控制台的网络选项卡中,请求大小是文件的大小。
我们尝试了
arraybuffer
、blob
请求responseType,结果相同。
我们尝试了
application/zip
、application/octet-stream
作为 sesponse Content-Type
。
库文件保存程序也遇到同样的问题,事件主体为空。
问题提醒
当 API 到达
Response.CompleteAsync()
时,客户端将获取键入 Response
的事件。因此,Angular 应用程序尝试访问为空的响应正文。这是主要问题。
另一个可以解决该问题的附属问题:有没有办法避免在内存客户端加载整个文件?
感谢@browsermator的指示,我设法让导航器处理下载。
主要问题是身份验证,我使用令牌绕过了身份验证,以允许用户下次下载。
对于 POST,我生成这样的表单:
<form ngNoForm action="{{baseUrl + 'WorkingList/DownloadWorkingList/' +createFileTree + '/'+ createMetadata + '/' + token}}" method="post">
<input *ngFor="let imageId of workingList.imageIds; let index = index" type="hidden" name="imageIds[]" [(ngModel)]="workingList.imageIds[index]"/>
<button type="submit" class="btn btn-primary" rippleEffect>
{{ "DOWNLOAD_MODAL.CONFIRM" | translate }}
</button>
</form>
对于 GET(不是问题的一部分),我以与表单提交 url 相同的方式生成 URL,只需添加工作列表 Id 作为参数。
服务器端我添加了对查询url中添加的令牌的检查,并在对象参数上添加了
[FromForm]
。
这允许导航器将响应作为文件下载来处理,并让他处理资源。
这可能不是最好的(身份验证是通过的,用户身份验证必须手动处理),但它解决了我的问题,我们可以处理这种授权方法。
PS:我没有在控制器中使用 File() 方法,因为它关闭流太快,无法构建存档