C# 如何在 API 控制器中进行即时压缩和发送以避免大内存缓冲区

C# How to do on-the-fly compression-and-send in a API controller to avoid large memory buffers

我想要实现的是从 API 向请求者发送备份(流),并在此过程中避免占用大量 RAM。我一直在尝试使用 Pipes,但没有成功(可能缺乏知识;))

看看下面的例程:

 [HttpGet]
        [Route("{projectIdentifier}")]
        [SwaggerResponse(StatusCodes.Status200OK)]
        [SwaggerResponse(StatusCodes.Status404NotFound)]
        public async Task<IActionResult> BackupProject(string projectIdentifier, bool zipped = false)
        {
            if (!TryGetProject(projectIdentifier, out var project))
                return this.NotFoundResult(nameof(projectIdentifier), projectIdentifier);

            string filenamePartName = $"Project {project.Identifier}";
            string saveFilename = Path.GetInvalidFileNameChars().Aggregate(filenamePartName, (current, c) => current.Replace(c, '_'));

            MemoryStream backupstream = new();
            await Core.Iapetus.Instance.Projects.BackupAsync(backupstream, project.ID, zipped); 
            backupstream.Position = 0;
            var file = zipped ? File(backupstream, "application/zip", $"{saveFilename}.json.gz") : File(backupstream, "application/json", $"{saveFilename}.json");
            return file;
        }

BackupAsync 例程支持使用 GZipSTream class 进行压缩,并且可以即时运行。不幸的是,压缩(或未压缩)结果仍然落在 backupstream 内存缓冲区中,在某些情况下占用的 space 比我喜欢的多得多。

问题

有没有办法“跳过”内存缓冲区,并将数据直接从(压缩的)流发送到 File 响应?并且 - 当然 - 保留整个 shabang async

TIA

ASP.NET 中没有流式文件结果类型,但它们确实提供了定义您自己的文件类型的支持。

您可以使用类似于 on my blog 的流式文件结果类型,更新为 ASP.NET 6:

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

    public FileCallbackResult(MediaTypeHeaderValue contentType, Func<Stream, ActionContext, Task> callback)
        : base(contentType.ToString())
    {
        _ = callback ?? 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, fileLength: null, enableRangeProcessing: false);
            return result._callback(context.HttpContext.Response.Body, context);
        }
    }
}

它的用法是这样的:

[HttpGet]
[Route("{projectIdentifier}")]
[SwaggerResponse(StatusCodes.Status200OK)]
[SwaggerResponse(StatusCodes.Status404NotFound)]
public IActionResult BackupProject(string projectIdentifier, bool zipped = false)
{
  if (!TryGetProject(projectIdentifier, out var project))
    return this.NotFoundResult(nameof(projectIdentifier), projectIdentifier);

  string filenamePartName = $"Project {project.Identifier}";
  string saveFilename = Path.GetInvalidFileNameChars().Aggregate(filenamePartName, (current, c) => current.Replace(c, '_'));

  return new FileCallbackResult(
      new MediaTypeHeaderValue(zipped ? "application/zip" : "application/json"),
      async (outputStream, _) =>
      {
        await Core.Iapetus.Instance.Projects.BackupAsync(outputStream, project.ID, zipped);
      })
  {
    FileDownloadName = zipped ? $"{saveFilename}.json.gz" : $"{saveFilename}.json",
  };
}

一句警告(也注明 on my blog):错误处理现在更加笨拙。以前,如果 BackupAsync 抛出异常,您会 return 向客户端发送错误代码。现在您正在流式传输结果,headers(包括 200 OK已经发送,因此如果 BackupAsync 抛出异常, ASP.NET 必须通知客户端的唯一方法是终止连接,不同的浏览器可能对此有不同的解释。我相信大多数浏览器会正确显示“下载错误”或一些此类通用消息,但在这种情况下,用户可能会得到一个被截断的文件。