在调用ReadAsStreamAsync时何时或是否需要处理HttpResponseMessage?

66

我正在使用 System.Net.Http.HttpClient 进行客户端HTTP通信。我将所有HTTP代码都抽象到一个地方,使其与其他代码分离。在某些情况下,我希望将响应内容作为流读取,但是流的使用者与HTTP通信发生的位置以及打开流的具体细节相隔较远。在负责HTTP通信的代码中,我正在销毁所有的HttpClient相关内容。

以下单元测试会在 Assert.IsTrue(stream.CanRead) 处失败:

[TestMethod]
public async Task DebugStreamedContent()
{
    Stream stream = null; // in real life the consumer of the stream is far away 
    var client = new HttpClient();        
    client.BaseAddress = new Uri("https://www.google.com/", UriKind.Absolute);

    using (var request = new HttpRequestMessage(HttpMethod.Get, "/"))
    using (var response = await client.SendAsync(request))
    {
        response.EnsureSuccessStatusCode();
        //here I would return the stream to the caller
        stream = await response.Content.ReadAsStreamAsync();
    }

    Assert.IsTrue(stream.CanRead); // FAIL if response is disposed so is the stream
}

通常,我会尽早处理任何 IDisposable 对象,但在这种情况下,处理 HttpResponseMessage 对象也会处理从 ReadAsStreamAsync 方法返回的 Stream

所以,似乎调用代码需要知道和接管响应消息以及流,或者我将响应消息保持未处理状态,让终结器处理它。两种选择都不对。

这个回答 讨论了不处理 HttpClient 的情况。那么 HttpRequestMessage 和/或 HttpResponseMessage 呢?

我有什么遗漏吗?我希望保持使用代码无需了解 HTTP,但是让所有这些未处理的对象存在与我多年养成的习惯相悖!


1
只是一个小提示 - 并不是所有的 IDisposable 都需要被释放掉。 - T.S.
1
这似乎与async本身没有任何关系。规则都是一样的:在使用完毕之前不要处理该对象。同步版本也适用于相同的事情。所以,在using中使用返回的Stream。如果您需要在创建请求的上下文之外使用Stream,则必须设置一个不同的机制以在正确的时间处置它。 - Peter Duniho
另外,我不建议将处理对象的清理操作留给终结器(finalizer)......但是我注意到你根本不会去释放 client,所以如果你对此感到舒服,就不需要费心去处理其他的东西了。至于你提到的答案,请注意它只适用于你需要重复使用 HttpClient 对象的情况;坦率地说,如果你想重复使用它,你肯定不会释放它。这个指导并没有说明是否可以通过终结器来让对象自动清理(在我看来,这是非常糟糕的做法)。 - Peter Duniho
5个回答

20
所以看起来调用代码需要知道并掌握响应消息和流,否则我会让响应消息未被释放,最后由终结器处理。两种选择都不太合适。
在这种特定情况下,HttpResponseMessageHttpRequestMessage都没有实现终结器(这是好事!)。如果您不释放它们中的任何一个,它们将在GC启动后垃圾回收,并在此发生时收集到其底层流的句柄。
只要使用这些对象,就不要释放。完成后,请释放它们。而不是将它们包装在using语句中,您始终可以在完成时显式调用Dispose。无论如何,消费代码都不需要了解底层http请求。

4
这真的不是什么好事情!不处理HttpMessageRequest和HttpMessageResponse对象意味着它们只能在垃圾回收运行时被处理,而在此之前所有用于调用的资源都将被占用。这会导致端口枯竭、超时(如果有并发HTTP请求限制)等问题。 - Alon Catz
1
@AlonCatz 在我的回答中哪里说不要处理对象了?你读了第二段吗? - Yuval Itzchakov
4
垃圾回收器(GC)从不主动调用.Dispose()方法。只有当存在一个终结器调用.Dispose()时,垃圾回收器才会触发释放操作。 - Enigmativity
1
@Yuval,也许我理解错了,但我的印象是不需要处理这些对象,因为GC会处理它们。根据我的经验,显式地处理它们非常重要。 - Alon Catz
3
不能保证终结器会调用 .Dispose() - 只有在终结器明确编写以执行此操作时才会发生。 - Enigmativity
显示剩余3条评论

14
在.NET中处理可释放对象既容易又困难。毫无疑问。
流也会出现这种荒谬的情况...释放缓冲区是否也会自动释放包装的流?它应该吗?作为使用者,我应该知道它是否这样做吗?
当我处理这些东西时,我遵循一些规则:
1. 如果我认为有非本地资源参与(比如网络连接!),我绝不让垃圾回收“慢慢来”。资源耗尽是真实存在的问题,良好的代码要处理好这个问题。 2. 如果一个可释放对象以可释放对象作为参数,那么确保我的代码释放了它所创建的每个对象永远没有坏处。如果不是我的代码创建的,我可以忽略它。 3. 垃圾回收器调用~Finalize方法,但不能保证Finalize(即自定义析构函数)会调用Dispose方法。与上述观点相反,没有魔法,所以你必须对此负责任。
所以,你有一个HttpClient、一个HttpRequestMessage和一个HttpResponseMessage。它们每个的生命周期以及它们创建的任何可处理对象都必须得到尊重。因此,你的Stream不应该预期在HttpResponseMessage的可处理对象生命周期之外存活,因为没有实例化这个Stream。
在你上面的场景中,我的模式是假装获取那个Stream实际上只是在Static.DoGet(uri)方法中,而你返回的Stream必须是我们自己制作的。这意味着第二个Stream,使用HttpResponseMessage的流.CopyTo到我的新Stream(通过FileStreamMemoryStream或最适合你情况的其他东西进行路由)……或类似的东西,因为:
  • 你没有权利使用 HttpResponseMessage 的流的生命周期。那是他的,不是你的。 :)
  • 在你处理返回的流内容时,阻塞一个像 HttpClient 这样的可释放对象的生命周期是一种疯狂的做法。这就像在解析 DataTable 时保持 SqlConnection (想象一下如果 DataTable 变得很大,我们会多快地耗尽连接池)
  • 暴露获取响应的方式可能违反 SOLID 原则... 你有一个可释放的 Stream,但它来自一个可释放的 HttpResponseMessage,这只是因为我们使用了可释放的 HttpClientHttpRequestMessage,而你只是想从 URI 获取一个流。这些职责感觉有多混乱?
  • 网络仍然是计算机系统中最慢的通道。为了“优化”而阻塞它们仍然是疯狂的。总有更好的方法来处理最慢的组件。

所以要像捕捉和释放一样使用可释放对象... 创建它们,获取结果,尽快释放它们。不要将优化与正确性混淆,特别是对于你自己没有编写的类。


7
CopyTo会枚举流吗?所以我现在不是将HttpResponseMessage挂起,而是将完整的响应加载到内存中,并从中创建一个新的流? - Bouke

13

您也可以将流作为输入参数,这样调用方就可以完全控制流的类型和处理。现在,您还可以在离开方法之前处理 httpResponse。
以下是 HttpClient 的扩展方法:

    public static async Task HttpDownloadStreamAsync(this HttpClient httpClient, string url, Stream output)
    {
        using (var httpResponse = await httpClient.GetAsync(url).ConfigureAwait(false))
        {
            // Ensures OK status
            response.EnsureSuccessStatusCode();

            // Get response stream
            var result = await httpResponse.Content.ReadAsStreamAsync().ConfigureAwait(false);

            await result.CopyToAsync(output).ConfigureAwait(false);
            output.Seek(0L, SeekOrigin.Begin);                
        }
    }

这种方法的问题在于它与函数式/反应式编程不相容。 - SuperJMN

2
不要处理HttpResponseMessage,因为调用此方法的一方有责任处理它。 方法:
public async Task<Stream> GetStreamContentOrNullAsync()
{
    // The response will be disposed when the returned content stream is disposed.
    const string url = "https://myservice.com/file.zip";
    var client = new HttpClient(); //better use => var httpClient = _cliHttpClientFactory.CreateClient();
    var response = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead);
    if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
    {
        return null;
    }

    return await response.Content.ReadAsStreamAsync();
}

用法:

  public async Task<IActionResult> DownloadPackageAsync()
  {
      var stream = await GetStreamContentOrNullAsync();
      if (stream == null)
      {
            return NotFound();
      }

      return File(stream, "application/octet-stream");
  }

你的例子中使用了 ReadAsStringAsync() 而不是问题所询问的 ReadAsStreamAsync() - Taudris
@Taudris你说得对!我更新了我的代码示例。 - Alper Ebicoglu
你说当内容流被处理后,响应也会被处理,但我敢肯定这并不会发生。请再仔细检查一下。 - SuperJMN

2
经过数小时的思考,我得出结论,这种方法是最好的:
一个适配器,它将HttpRequestMessage和其内容流作为依赖项。
就是这样。请特别注意它的静态工厂方法Create。构造函数因为明显的原因是私有的。
public class HttpResponseMessageStream : Stream
{
    private readonly HttpResponseMessage response;

    private readonly Stream inner;

    private HttpResponseMessageStream(Stream stream, HttpResponseMessage response)
    {
        inner = stream;
        this.response = response;
    }

    public override bool CanRead => inner.CanRead;

    public override bool CanSeek => inner.CanSeek;

    public override bool CanWrite => inner.CanWrite;

    public override long Length => inner.Length;

    public override long Position
    {
        get => inner.Position;
        set => inner.Position = value;
    }

    public static async Task<HttpResponseMessageStream> Create(HttpResponseMessage response)
    {
        return new HttpResponseMessageStream(await response.Content.ReadAsStreamAsync(), response);
    }

    public override ValueTask DisposeAsync()
    {
        response.Dispose();
        return base.DisposeAsync();
    }

    public override void Flush()
    {
        inner.Flush();
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        return inner.Read(buffer, offset, count);
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        return inner.Seek(offset, origin);
    }

    public override void SetLength(long value)
    {
        inner.SetLength(value);
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        inner.Write(buffer, offset, count);
    }

    protected override void Dispose(bool disposing)
    {
        response.Dispose();
        base.Dispose(disposing);
    }
}

请查看以下示例用法:

HttpRequestMessage response = // Obtain the message somewhere, like HttpClient.GetAsync()
var wrapperStream = await HttpResponseMessageStream.Create(response);

重要的是,处理此包装器将同时处理响应,能够有效地控制生命周期。

这样,您可以安全地创建通用的消费者,例如此方法,它不关心底层实现的任何内容:

public async Task DoSomething(Func<Task<Stream>> streamFactory) 
{
    using (var stream = await streamFactory())
    {
       ...
    }
}

并像这样使用它:


async Task<Stream> GetFromUri(Uri uri)
{
    var response = ...
    return await HttpResponseMessageStream.Create(response);
}

await DoSomething(() => GetFromUri("http://...");

DoSomething 方法完全忽略了与处理相关的问题。它只是像其他任何方法一样处理流的处理,而且处理是在内部进行的。

希望这可以帮助到您。


1
这应该是答案。这个不会将整个响应体加载到内存中(更不用说重复的缓冲区了)。我想到了一个更糟糕的解决方案,返回一个包装两者的第三类(更干净、更快速的编码),但如果您无法修改API,则适配器更好。 - Luke Vo
为什么您不在dispose方法中处理内部流? - Vlad

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