常见IHttpClientFactory使用问题

在本文中,您将学习使用时可能遇到的一些最常见的问题IHttpClientFactory创建HttpClient实例。

IHttpClientFactory是一种方便的方式来设置多个HttpClient在 DI 容器中配置,配置日志记录,建立弹性策略等。 IHttpClientFactory还封装了生存期管理和HttpClientHttpMessageHandler实例,以防止出现套接字耗尽和 DNS 更改丢失等问题。 有关如何在您的 .NET 应用IHttpClientFactory 程序中使用的概述,请参阅 .NET 中的 IHttpClientFactory

由于与依赖注入 DI 集成的复杂性IHttpClientFactory ,您可能会遇到一些难以发现和排除的问题。 本文中列出的场景还包含建议,您可以主动应用这些建议以避免潜在问题。

HttpClient不尊重Scoped生命周期

如果您需要访问任何作用域服务,例如,您可能会遇到问题HttpMessageHandler,或从内部访问某些作用域缓存HttpContext 那里保存的数据可能会“消失”,或者相反,在不应该的情况下“持久化”。 这是由依赖注入(DI)引起的范围不匹配在应用程序上下文和处理程序实例之间,这是一个已知的限制IHttpClientFactory

IHttpClientFactory 为每个 HttpMessageHandler 实例创建单独的 DI 范围。 这些处理程序范围与应用程序上下文范围是不同的(例如,ASP.NET Core 的传入请求范围或用户创建的手动 DI 范围),因此它们不会共享作用域服务实例。

由于这一限制:

  • 在作用域服务中“外部”缓存的任何数据在内部将不可用HttpMessageHandler
  • 在内部缓存的任何数据或其作用域依赖项HttpMessageHandler可以从多个应用程序 DI 范围(例如,来自不同的传入请求)中观察到,因为它们可以共享同一个处理程序。

考虑以下建议,以帮助缓解这一已知限制:

❌请勿在实例或其依赖项内部缓存任何与范围相关的信息HttpContext(例如来自)HttpMessageHandler以避免泄露敏感信息。

❌请勿使用 cookie,因为CookieContainer它们将与处理程序一起共享。

✔️ 考虑不存储该信息,或仅在实例HttpRequestMessage内传递它。

要在处理程序旁边传递任意信息HttpRequestMessage,您可以使用HttpRequestMessage.Options属性。

考虑将所有与范围相关的(例如,身份验证)逻辑封DelegatingHandler装在一个不由发送处理程序创IHttpClientFactory建的单独组件中,并使用IHttpClientFactory它来包装由发送处理程序创建的处理程序。

要创建一个HttpMessageHandler没有HttpClient任何上下IHttpMessageHandlerFactory.CreateHandler文的实例,请调用任何注册的命名客户端。 在该案例中,您将需要使用HttpClient组合处理程序自己创建一个实例。 您可以在 GitHub 上找到此解决方法的完整可运行示例

有关更多信息,请参阅指南中的 IHttpClientFactory 中的消息处理程序范围IHttpClientFactory部分。

HttpClient不尊重 DNS 更改

即使使用了 DNS,IHttpClientFactory仍然可能遇到过时的 DNS 问题。 如果一个HttpClient实例被捕获在服务中,或者一般Singleton来说,被存储在某个地方的时间超过了指定的时间,这种情况通常会发生HandlerLifetimeHttpClient如果相应的类型化客户端被单例捕获,它也会被捕获。

❌请不要长HttpClient时间IHttpClientFactory缓存实例。

❌请勿将类型化客户Singleton端实例注入到服务中。

✔️ 考虑及时或每次需要时IHttpClientFactory从请求一个客户端。 由工厂创建的客户端是安全可以处理的。

HttpClient 创建的 IHttpClientFactory 实例是短期的。

  • 回收和重新创建 HttpMessageHandler 在其生存期到期时对于 IHttpClientFactory 至关重要,可确保处理程序对 DNS 更改做出反应。 HttpClient 在创建特定处理程序实例时与之绑定,因此应及时请求新的 HttpClient 实例,以确保客户端将获取更新后的处理程序。

  • 处理由工HttpClient厂创建的此类实例不会导致套接字耗尽,因为其处理不会触发的处理HttpMessageHandlerIHttpClientFactory 跟踪和释放用于创建 HttpClient 实例的资源,特别是 HttpMessageHandler 实例,只要其生存期到期,并且 HttpClient 不再使用这些实例。

类型化客户端旨在短生命周期,因为实例HttpClient被注入到构造函数中,因此它将共享类型化客户端的生命周期。

有关更多信息,请参阅指南中的HttpClient生命周期管理避免在单例服务中使用类型化客户端IHttpClientFactory部分。

HttpClient 使用了过多的套接字。

即使使用了IHttpClientFactory套接字,仍然可能在特定使用场景中遇到套接字耗尽的问题。 默认情况下,HttpClient未限制并发请求的数量。 如果同时启动大量 HTTP/1.1 请求,每个请求都会触发一个新的 HTTP 连接尝试,因为连接池中没有可用连接且没有设置限制。

❌ 请勿在未指定限制的情况下同时启动大量 HTTP/1.1 请求。

✔️ 考虑将(或者如果HttpClientHandler.MaxConnectionsPerServer您将其用作主SocketsHttpHandler.MaxConnectionsPerServer处理程序)设置为合理的值。 请注意,这些限制仅适用于特定的处理程序实例。

✔️ 考虑使用 HTTP/2,它允许通过单个 TCP 连接多路复用请求。

类型化客户端HttpClient注入错误

在某些情况下,HttpClient可能会意外地注入到类型化客户端中。 大多数情况下,根本原因在于错误的配置,因为根据依赖注入的设计,对服务的任何后续注册都会覆盖先前的注册。

类型化客户端 使用 命名客户端 “在后台”:添加 类型化客户端 隐式注册并将其链接到 命名客户端。 客户端名称,除非明确提供,否则将设置为的类型名称TClient。 如果使用AddHttpClient<TClient,TImplementation>重载,则这是对中的第一TClient,TImplementation重载。

因此,注册一个类型化客户端会执行两个独立的操作:

  1. 注册命名客户端(在简单的默认情况下,名称为 typeof(TClient).Name)。
  2. Transient使用TClientTClient,TImplementation提供注册服务。

以下两个语句在技术上是相同的:

services.AddHttpClient<ExampleClient>(c => c.BaseAddress = new Uri("http://example.com"));

// -OR-

services.AddHttpClient(nameof(ExampleClient), c => c.BaseAddress = new Uri("http://example.com")) // register named client
    .AddTypedClient<ExampleClient>(); // link the named client to a typed client

在一个简单的案例中,它也将类似于以下内容:

services.AddHttpClient(nameof(ExampleClient), c => c.BaseAddress = new Uri("http://example.com")); // register named client

// register plain Transient service and link it to the named client
services.AddTransient<ExampleClient>(s =>
    new ExampleClient(
        s.GetRequiredService<IHttpClientFactory>().CreateClient(nameof(ExampleClient))));

请考虑以下示例,了解如何断开类型化客户端和命名客户端之间的链接。

类型化客户端被第二次注册

❌ 请勿单独注册 类型化客户端 — 调用已自动注册该 AddHttpClient<T> 客户端。

如果类型化客户端第二次错误地注册为普通暂时性服务,则会覆盖该客户端HttpClientFactory添加的注册,从而中断指向命名客户端的链接。 它将像丢失配置一样 HttpClient,因为未配置的 HttpClient 配置将改为注入 到类型化的客户端 中。

可能会令人困惑的是,使用了一个“错误”的,HttpClient而不是抛出异常。 发生这种情况是因为 “default” 未配置HttpClient(名称Options.DefaultName 为(string.Empty)的客户端注册为普通暂时性服务,以启用最基本的HttpClientFactory使用方案。 这就是链接断开且类型化客户端仅成为普通服务的原因,此“默认”HttpClient自然会注入到相应的构造函数参数中。

不同类型客户端在通用接口上注册

如果两个不同的类型客户端在公共接口上注册,它们将重复使用同一命名的客户端。 这似乎是第一个类型化的客户端,第二个命名客户端“错误”注入。

❌ 请勿在单个接口上注册多个类型化客户端,而无需显式指定名称。

✔️ 请考虑单独注册和配置命名客户端,然后将其链接到一个或多个类型化客户端,方法是在调用中AddHttpClient<T>指定名称或在命名客户端设置期间调用AddTypedClient

根据设计,使用相同名称多次注册和配置命名客户端只是将配置操作附加到现有操作的列表中。 这种行为HttpClientFactory可能并不明显,但与选项模式和配置 API 使用Configure的方法相同。

这主要对于高级处理程序配置非常有用,例如,向外部定义的命名客户端添加自定义处理程序,HttpClient或为测试模拟主处理程序,但它也适用于实例配置。 例如,以下示例中的三个HttpClient示例将采用相同的方式进行配置(同时BaseAddress设置和DefaultRequestHeaders设置):

// one configuration callback
services.AddHttpClient("example", c =>
    {
        c.BaseAddress = new Uri("http://example.com");
        c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0");
    });

// -OR-

// two configuration callbacks
services.AddHttpClient("example", c => c.BaseAddress = new Uri("http://example.com"))
    .ConfigureHttpClient(c => c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0"));

// -OR-

// two configuration callbacks in separate AddHttpClient calls
services.AddHttpClient("example", c => c.BaseAddress = new Uri("http://example.com"));
services.AddHttpClient("example")
    .ConfigureHttpClient(c => c.DefaultRequestHeaders.UserAgent.ParseAdd("HttpClient/8.0"));

这允许将类型化客户端链接到已定义的命名客户端,并将多个类型化客户端链接到单个命名客户端。 当使用带参数的重载时,name这一点更加明显:

services.AddHttpClient("LogClient", c => c.BaseAddress = new Uri(LogServerAddress));

services.AddHttpClient<FooLogger>("LogClient");
services.AddHttpClient<BarLogger>("LogClient");

在命名客户端配置期间调用AddTypedClient也可以实现相同的操作:

services.AddHttpClient("LogClient", c => c.BaseAddress = new Uri(LogServerAddress))
    .AddTypedClient<FooLogger>()
    .AddTypedClient<BarLogger>();

但是,如果不想重复使用同一命名客户端,但仍希望在同一接口上注册客户端,可以通过显式指定不同的名称来执行此操作:

services.AddHttpClient<ITypedClient, ExampleClient>(nameof(ExampleClient),
    c => c.BaseAddress = new Uri("http://example.com"));
services.AddHttpClient<ITypedClient, GithubClient>(nameof(GithubClient),
    c => c.BaseAddress = new Uri("https://github.com"));

另请参阅