C#异步ApiController过早关闭输出流

8
今天的问题是,在使用WebApi 2和异步ApiController的Get方法时,返回文件内容。当我将Get方法更改为同步时,它可以正常工作,但是一旦我将其转换回异步,它会过早关闭流(Fiddler报告连接中止)。可工作的同步代码如下:
 public void Get(int id)
    {
        try
        {
            FileInfo fileInfo = logic.GetFileInfoSync(id);
            HttpResponse response = HttpContext.Current.Response;
            response.Clear();
            response.ClearContent();
            response.Buffer = true;
            response.AddHeader("Content-Disposition", "attachment; filename=\"" + fileInfo.Node.Name + fileInfo.Ext + "\"");
            response.AddHeader("Content-Length", fileInfo.SizeInBytes.ToString());
            response.ContentType = "application/octet-stream";
            logic.GetDownloadStreamSync(id, response.OutputStream);
            response.StatusCode = (int)HttpStatusCode.OK;
            //HttpContext.Current.ApplicationInstance.CompleteRequest();
             response.End();
        }
        catch(Exception ex)
        {
            Console.WriteLine(ex.ToString());
        }
    }

GetDownloadStreamSync的代码如下:

public async Task GetDownloadStream(string fileIdentifier, Stream streamToCopyTo)
{
    string filePath = Path.Combine(fileIdentifierFolder, fileIdentifier);
    using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None, BufferSize, false))
    {
        fs.CopyTo(streamToCopyTo);
    }
 }

--------异步代码 ----------

异步版本与同步版本完全相同,唯一不同之处在于:

public async Task Get(int id)
{
    FileInfo fileInfo = await logic.GetFileInfoSync(id); // database opp
            HttpResponse response = HttpContext.Current.Response;
            response.Clear();
            response.ClearContent();
            response.Buffer = true;
            response.AddHeader("Content-Disposition", "attachment; filename=\"" + fileInfo.Node.Name + fileInfo.Ext + "\"");
            response.AddHeader("Content-Length", fileInfo.SizeInBytes.ToString());
            response.ContentType = "application/octet-stream";
            await logic.GetDownloadStreamSync(id, response.OutputStream); 
                           //database opp + file I/O
            response.StatusCode = (int)HttpStatusCode.OK;
             //HttpContext.Current.ApplicationInstance.CompleteRequest();
             response.End();
}

通过以下异步实现的GetDownloadStream:(streamToCopyTo是来自response.OutputStream的OutputStream)
    public async Task GetDownloadStream(string fileIdentifier, Stream streamToCopyTo)
{
    using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None, BufferSize, true))
    {
        await fs.CopyToAsync(streamToCopyTo);
    }
}

我们正在尝试从前端到后端采用异步/等待模式,希望有人知道为什么会失败?我也尝试过不调用Response.End(),Response.Flush()和HttpContext.Current.ApplicationInstance.CompleteRequest()。另外,回应下面的问题/评论,我在response.End()上放了一个断点,结果它没有被触发,以致于GetDownloadStream方法已经完成。也许OutputStream不是异步的?欢迎任何想法!谢谢。
************************** 最终解决方案 ***************************
非常感谢所有发表评论的人,特别是@Noseratio对FileOptions.DeleteOnClose的建议。
[HttpGet]
public async Task<HttpResponseMessage> Get(long id)
{
        HttpResponseMessage result = new HttpResponseMessage(HttpStatusCode.OK);
        Node node = await logic.GetFileInfoForNodeAsync(id);

        result.Content = new StreamContent(await logic.GetDownloadStreamAsync(id));
        result.Content.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
        result.Content.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment")
        {
            FileName = node.Name + node.FileInfo.Extension
        };
        result.Content.Headers.ContentLength = node.FileInfo.SizeInBytes;
        return result
}

GetDownloadStreamAsync的代码如下:

 FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None, BufferSize, FileOptions.DeleteOnClose | FileOptions.Asynchronous);

我之前遗漏了一点,那就是我在实时解密文件流,这个方法是可行的,所以对于那些感兴趣的人...

FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.None, BufferSize, FileOptions.DeleteOnClose | FileOptions.Asynchronous);
RijndaelManaged rm = new RijndaelManaged();
return new CryptoStream(fs, GetDecryptor(rm, password), CryptoStreamMode.Read);

这里是MSDN的解释:“await表达式不会阻塞它执行的线程。相反,它会导致编译器将异步方法的其余部分作为等待任务的继续项注册。然后控制权返回到异步方法的调用者。当任务完成时,它会调用它的继续项,并在异步方法离开的地方恢复执行。 await表达式只能出现在立即封闭方法、lambda表达式或匿名方法的主体中,并标有async修饰符。否则,它将被解释为标识符。” - user1789573
在异步情况下,方法的签名是什么?(我猜测为 Task Get...,但最好确定一下)。 - Alexei Levenkov
如果您在问题中遗漏了某些内容,能否发布完整的异步版本? - EZI
我已经编辑了附加的代码片段。 - TChadwick
为什么你必须自己指定内容长度?这似乎很奇怪,你必须做这么多工作来完成这样简单的事情。如果你只是复制流会发生什么?你的异步代码听起来不错。 - albertjan
@aldertjan,手动设置内容长度是使用response.End()的副作用,这是唯一让浏览器弹出下载对话框的方法。 - TChadwick
2个回答

1
需要一个完整的重现案例才能回答你的确切问题,但我认为你根本不需要使用 async/await。我还认为在可能的情况下应避免直接使用 HttpContext.Current.Response,特别是在异步 WebAPI 控制器方法中。在这种特殊情况下,你可以使用 HttpResponseMessage
[HttpGet]
public HttpResponseMessage Get(int id)
{
    HttpResponseMessage result = new HttpResponseMessage(HttpStatusCode.OK);
    FileInfo fileInfo = logic.GetFileInfoSync(id);

    FileStream fs = new FileStream(
        filePath, FileMode.Open, FileAccess.Read, FileShare.None, BufferSize, false);

    result.Content = new StreamContent(fs);
    result.Content.Headers.ContentType = 
        new MediaTypeHeaderValue("application/octet-stream");
    result.Content.Headers.ContentDisposition = 
        new ContentDispositionHeaderValue("attachment") 
        {
            FileName = fileInfo.Node.Name + fileInfo.Ext
        };
    result.Content.Headers.ContentLength = fileInfo.SizeInBytes;

    return result;
}

这里没有明确的异步操作,所以该方法不是async。但是,如果你仍然需要引入一些await,该方法将会像这样:

[HttpGet]
public async Task<HttpResponseMessage> Get(int id)
{
    HttpResponseMessage result = new HttpResponseMessage(HttpStatusCode.OK);
    // ...
    await fs.CopyToAsync(streamToCopyTo)
    // ...
    return result;
}

关于第一个解决方案,有一个问题,它是否保持了FileStream的打开状态?我上面的解决方案有一个很好的using语句来确保它被正确关闭和处理,而在第一个解决方案中,我稍微担心FileStream会被保持打开状态,这是真的吗?我正在尝试将FileStream包装在using语句中,并在文件下载完成后从本地驱动器中删除该文件,如果这更有意义的话? - TChadwick
1
@TChadwick,不会保持打开状态,WebAPI运行时将在使用完流后调用Close。如果您需要在此时删除它,只需将FileOptions.DeleteOnClose | FileOptions.Asynchronous作为new FileStream的最后一个参数即可。 - noseratio - open to work
@TChadwick,最终你把这种方法搞定了吗? - noseratio - open to work
1
我已经让它工作了,最终的实现在我最近的帖子编辑中。请查看最终解决方案部分,感谢FileOptions.DeleteOnClose,这是让我想到的东西能够工作的魔法酱汁。 :D - TChadwick

1
你的问题实际上在于使用了Response.End()。当你运行异步时,它会在完成流式传输文件内容之前执行Response.End()。而同步版本中不会出现这种情况,因为Response.End()直到完成流式传输文件内容后才会被调用。 Response.End()是表示处理完成的极其糟糕的方法,因为它会抛出TreadAbortException异常。相反,你应该使用HttpContext.Current.ApplicationInstance.CompleteRequest()
有关更多信息,请参见此文章 Response.End, Response.Close, and How Customer Feedback Helps Us Improve MSDN Documentation

感谢您的回复,Matthew。无论是否使用Response.End(),问题都是一样的,我也尝试过CompleteRequest()。 - TChadwick
@TChadwick,这是我最近编写的一个内容助手,用于通过HTTP响应将文件流传输到客户端。http://pastebin.com/5B29Pgdp 它不是异步的,但应该可以在异步上下文中使用。试一试,看看是否有效。 - Matthew Brubaker
再次感谢您的回复,我已经深入研究了这个问题,当我在同步模式下运行这些调用时,它们可以正常工作。我发现调用CompleteRequest()实际上将响应代码更改为204(无内容),而不是200,导致浏览器无法下载文件,并且我必须调用Response.End()才能返回200代码并使其正常工作。我想采用异步读取文件并将其写入OutputStream的方式,但目前看来这似乎不切实际... - TChadwick

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