从数据库流式传输数据 - ASP.NET Core & SqlDataReader.GetStream()

Streaming data from the database - ASP.NET Core & SqlDataReader.GetStream()

在从 ASP.NET 核心站点发送大型对象时,我尽量减少将它们从数据库加载到内存中,因为我偶尔会遇到 OutOfMemoryException

我想我会流式传输它。现在根据我的研究 SQL 只要您在命令中指定 CommandBehavior.SequentialAccess 服务器就支持它。我想如果我要流式传输它,我最好尽可能直接流式传输它,所以我几乎是直接从 DataReader 流式传输它到 ASP.NET MVC ActionResult.

但是一旦 FileStreamResult(隐藏在对 File() 的调用下)完成执行,我该如何清理我的 reader/command?连接由 DI 提供,所以这不是问题,但我在 GetDocumentStream().

的调用中创建了 reader/command

我在 MVC 中注册了一个 ActionFilterAttribute 的子类,所以这给了我一个可以调用 ActionFilterAttribute.OnResultExecuted() 的入口点,但我完全不知道该放什么处理清理我的数据库事务和 commit/rollback 东西的当前逻辑(不包括在内,因为它不是真正相关的)。

有没有办法在我的 DataReader/Command 之后进行清理并仍然提供 StreamFile()

public class DocumentsController : Controller
{
    private DocumentService documentService;

    public FilesController(DocumentService documentService)
    {
        this.documentService = documentService;
    }

    public IActionResult Stream(Guid id, string contentType = "application/octet-stream") // Defaults to octet-stream when unspecified
    {
        // Simple lookup by Id so that I can use it for the Name and ContentType below
        if(!(documentService.GetDocument(id)) is Document document) 
            return NotFound();

        var cd = new System.Net.Http.Headers.ContentDispositionHeaderValue("inline") {FileNameStar = document.DocumentName};
        Response.Headers.Add(Microsoft.Net.Http.Headers.HeaderNames.ContentDisposition, cd.ToString());

        return File(documentService.GetDocumentStream(id), document.ContentType ?? contentType);
    }

    /*
    public class Document
    {
        public Guid Id { get; set; }
        public string DocumentName { get; set; }
        public string ContentType { get; set; }
    }
    */
}

public class DocumentService
{
    private readonly DbConnection connection;

    public DocumentService(DbConnection connection)
    {
        this.connection = connection;
    }

    /* Other content omitted for brevity */

    public Stream GetDocumentStream(Guid documentId)
    {
        //Source table definition
        /*
            CREATE TABLE [dbo].[tblDocuments]
            (
                [DocumentId] [uniqueidentifier] NOT NULL,
                [ContentType] [varchar](100) NULL,
                [DocumentName] [varchar](100) NULL,
                [DocumentData] [varbinary](max) NULL
            CONSTRAINT [PK_DocumentId] PRIMARY KEY NONCLUSTERED ([DocumentID] ASC)
            )
            GO
         */

        const string query = "SELECT DocumentData FROM tblDocuments WHERE DocumentId = @documentId";

        //build up the command
        var command = connection.CreateCommand();
        command.CommandText = query;
        var parameter = command.CreateParameter();
        parameter.DbType = System.Data.DbType.Guid;
        parameter.ParameterName = "@documentId";
        parameter.Value = documentId;
        command.Parameters.Add(parameter);

        //Execute commmand with SequentialAccess to support streaming the data
        var reader = command.ExecuteReader(System.Data.CommandBehavior.SequentialAccess);

        if(reader.Read())
            return reader.GetStream(0);
        else
            return Stream.Null;
    }
}

发表评论后我也想起了一个很少用的API。

您可以注册任何一次性 classes 以在请求结束时处理(当响应已写入时):

HttpContext.Response.RegisterForDispose(reader);

或者挂接到 OnCompleted 响应回调并在那里进行清理(即,如果对象不是一次性的,或者如果您需要调用特殊方法作为清理的一部分)

HttpContext.Response.OnCompleted(() =>
{
    reader.Dispose();
    return Task.CompletedTask;
});

最后但并非最不重要的一点,如果用很多方法完成,拥有自己的 SqlSequencialStreamingResult class 可能是您最好的选择

所以我按照@Tseng 的所有建议,先是简单的建议,然后是更复杂的建议。最终我无法使用为我注册要处理的对象的方法,因为在这个过程中发生得太晚了,我正在 ActionFilterOnResultExecuted 方法中清理我的数据库事务。所以我选择了自定义 ActionResult class.

StreamableDisposable(及其接口)只是为了简化我需要 return 两件事的事实,流和数据 reader,而不想要仅通过 returning reader.

来公开 reader 的底层 GetStream(0) 方法
public interface IStreamableDisposible : IDisposable
{
    public System.IO.Stream Stream { get; }
}
public class StreamableDisposible : IStreamableDisposible
{
    private readonly IDisposable toDisposeOf;

    public StreamableDisposible(System.IO.Stream stream, System.Data.Common.DbDataReader toDisposeOf)
    {
        Stream = stream ?? throw new ArgumentNullException(nameof(stream));
        this.toDisposeOf = toDisposeOf;
    }

    public System.IO.Stream Stream { get; set; }

    public void Dispose()
    {
        toDisposeOf?.Dispose();
    }
}

这是新的 ActionResult class,这样我可以确保在流完成用于执行结果后立即清理我的一次性用品。

public class DisposingFileStreamResult : FileStreamResult
{
    private readonly IStreamableDisposible streamableDisposible;

    public DisposingFileStreamResult(IStreamableDisposible streamableDisposible, string contentType)
        : base(streamableDisposible.Stream, contentType)
    {
        this.streamableDisposible = streamableDisposible ?? throw new ArgumentNullException(nameof(streamableDisposible));
    }

    public override void ExecuteResult(ActionContext context)
    {
        base.ExecuteResult(context);
        streamableDisposible.Dispose();
    }

    public override Task ExecuteResultAsync(ActionContext context)
    {
        return base.ExecuteResultAsync(context).ContinueWith(x => streamableDisposible.Dispose());
    }
}

这让我更新我的 GetDocumentStream() 方法如下

public StreamableDisposible GetDatabaseStream(Guid documentId)
{
    const string query = "SELECT DocumentData FROM tblDocuments WHERE DocumentId = @documentId AND DocumentData IS NOT NULL AND DATALENGTH(DocumentData) > 0";
    using var command = ((NHibernateData)Data).ManualCommand();

    command.CommandText = query;
    var parameter = command.CreateParameter();
    parameter.DbType = System.Data.DbType.Guid;
    parameter.ParameterName = "@documentId";
    parameter.Value = documentId;
    command.Parameters.Add(parameter);

    //Execute commmand with SequentialAccess to support streaming the data
    var reader = command.ExecuteReader(System.Data.CommandBehavior.SequentialAccess);
    if(reader.Read())
        return new StreamableDisposible(reader.GetStream(0), reader);
    else
    {
        reader.Dispose();
        return null;
    }
}

现在我的动作是这样的

public IActionResult Stream(Guid id, string contentType = "application/octet-stream") // Defaults to octet-stream when unspecified
{
    // Simple lookup by Id so that I can use it for the Name and ContentType below
    if(!(documentService.GetDocument(id)) is Document document) 
        return NotFound();

    var cd = new System.Net.Http.Headers.ContentDispositionHeaderValue("inline") {FileNameStar = document.DocumentName};
    Response.Headers.Add(Microsoft.Net.Http.Headers.HeaderNames.ContentDisposition, cd.ToString());

    var sd = var sd = documentService.GetDocumentStream(id);
    return new DisposingFileStreamResult(sd, document.ContentType ?? contentType);
}

我在 SELECT 语句中添加了检查以说明空数据列,或者只是空数据长度以消除必须检查 StreamableDisposableStream本身为空,或者可能没有数据等

这几乎是我最终使用的所有代码。