在ASP.NET Core中流式传输在内存中生成的文件

8

在浏览互联网数小时后,我仍然摸不着头脑,不知道如何解决我的ASP.NET Core 2.x问题。

我正在动态生成CSV文件(可能需要几分钟时间),然后尝试将其发送回客户端。很多客户端在我开始发送响应之前就已经超时了,因此我试图向他们流式传输文件(并立即返回200响应),并异步写入该流。在以前的ASP中,似乎可以使用PushStreamContent实现这一点,但我不确定如何构建我的代码,以使CSV生成过程是异步完成并立即返回HTTP响应。

[HttpGet("csv")]
public async Task<FileStreamResult> GetCSV(long id)
{
    // this stage can take 2+ mins, which obviously blocks the response
    var data = await GetData(id);
    var records = _csvGenerator.GenerateRecords(data); 

    // using the CsvHelper Nuget package
    var stream = new MemoryStream();
    var writer = new StreamWriter(stream);
    var csv = new CsvWriter(writer);

    csv.WriteRecords(stream, records);
    await writer.FlushAsync();

    return new FileStreamResult(stream, new MediaTypeHeaderValue("text/csv))
    {
        FileDownloadName = "results.csv"
    };
 }

如果你请求这个控制器方法,除非整个CSV生成完毕并且最终得到响应,否则你将什么都得不到。此时,大多数客户端请求已经超时。
我尝试过使用Task.Run()来包装CSV生成代码,但这并没有解决我的问题。

要向流中写入内容,只需使用 Response.Body,例如 new StreamWriter(Response.Body)。但这并不能使缓慢的查询变快,您仍然会遇到超时问题。GetData 是做什么的?为什么需要 2 分钟?修复错误的查询可能比后台生成 CSV 文件更有效(也更快)。 - Panagiotis Kanavos
1
它加载了多少数据?如果加载100行需要2分钟,那么编写它的人应该修复它。 - Panagiotis Kanavos
100K行意味着背景执行,而不是流式传输。该操作可能足够长,以至于请求本身会中止。尝试为长时间运行的作业使用流式传输或SSE就像使用锤子驱动Torx螺丝一样。虽然可以实现,但使用Torx螺丝刀更好。 - Panagiotis Kanavos
无论如何,PushStreamContent不执行任何复杂的操作。您可以创建自己基于FileResult的类,在其ExecuteResultAsync调用中执行整个读取-转换-流操作。如果您检查FileStreamResult的实现,您会发现您可以在该方法中自行完成任务,或者调用一个DI注册处理程序来完成它。 - Panagiotis Kanavos
默认的FileStreamResultExecutor会先写入头部,然后将内容写入输出。早期版本在FileStreamResult中完成所有工作,但将结果与执行器分离允许您通过依赖注入更改整个应用程序的行为。 - Panagiotis Kanavos
显示剩余9条评论
2个回答

13

ASP.NET Core中没有内置的PushStreamContext类型。不过,您可以构建自己的FileCallbackResult来实现相同的功能。这个示例代码应该能够解决问题:

public class FileCallbackResult : FileResult
{
    private Func<Stream, ActionContext, Task> _callback;

    public FileCallbackResult(MediaTypeHeaderValue contentType, Func<Stream, ActionContext, Task> callback)
        : base(contentType?.ToString())
    {
        if (callback == null)
            throw new ArgumentNullException(nameof(callback));
        _callback = callback;
    }

    public override Task ExecuteResultAsync(ActionContext context)
    {
        if (context == null)
            throw new ArgumentNullException(nameof(context));
        var executor = new FileCallbackResultExecutor(context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>());
        return executor.ExecuteAsync(context, this);
    }

    private sealed class FileCallbackResultExecutor : FileResultExecutorBase
    {
        public FileCallbackResultExecutor(ILoggerFactory loggerFactory)
            : base(CreateLogger<FileCallbackResultExecutor>(loggerFactory))
        {
        }

        public Task ExecuteAsync(ActionContext context, FileCallbackResult result)
        {
            SetHeadersAndLog(context, result, null);
            return result._callback(context.HttpContext.Response.Body, context);
        }
    }
}

使用方法:

[HttpGet("csv")]
public IActionResult GetCSV(long id)
{
  return new FileCallbackResult(new MediaTypeHeaderValue("text/csv"), async (outputStream, _) =>
  {
    var data = await GetData(id);
    var records = _csvGenerator.GenerateRecords(data); 
    var writer = new StreamWriter(outputStream);
    var csv = new CsvWriter(writer);
    csv.WriteRecords(stream, records);
    await writer.FlushAsync();
  })
  {
    FileDownloadName = "results.csv"
  };
}

注意,FileCallbackResultPushStreamContext 具有相同的限制:如果回调函数中发生错误,则Web服务器无法很好地通知客户端该错误。您可以做的就是传播异常,这将导致ASP.NET提前关闭连接,因此客户端会收到“连接意外关闭”或“下载中止”的错误。这是因为HTTP在流式传输开始之前,首先发送标题中的错误代码。


1
非常感谢你,Stephen。正是你原始的关于PushStreamContent的教程,我才能够构建出我的解决方案! - Alex
谢谢提供示例,但似乎至少需要发送一个字节到浏览器才能显示“保存文件”对话框。 - Lonli-Lokli

3
如果文档生成需要2分钟或更长时间,应该使用异步方式。操作步骤如下:
1.客户端发送生成文档的请求。 2.您接受请求,在后台开始生成,并回复消息,如“已经开始生成,我们会通知您”。 3.在客户端上,定期检查文档是否准备好,并最终获取链接。
您也可以使用 signalr完成此操作。步骤相同,但客户端无需检查文档状态。文档完成时,您可以推送链接。

谢谢,我认为你可能是对的,应该将生成过程设置为异步。在将您的答案标记为正确之前,我会再等待一段时间。 - Alex

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接