广告位 · 页头(后台-模块管理-新建模块后替换此区域)

C# 中如何正确使用 HttpClientFactory?有哪些最佳实践?

在 .NET 应用中发起 HTTP 请求时,HttpClient 是最常用的类型。不过很多人习惯在需要时直接 new HttpClient,或者用 using 包裹,这种用法看似简单,却可能带来套接字资源耗尽和 DNS 更新失效等问题。HttpClientFactory 正是为此设计的,它负责集中管理 HttpClient 实例的生命周期,让开发者可以更安全、更高效地调用外部服务。

C# 中如何正确使用 HttpClientFactory?有哪些最佳实践?

为什么直接 new HttpClient 会埋下隐患

很多人把 HttpClient 当成一个轻量级对象,认为用 using 包住就能释放资源。实际上,HttpClient 底层的 HttpClientHandler 会占用一个网络连接,这些连接不会因为 HttpClient 被 Dispose 而立即归还给操作系统。频繁创建和销毁 HttpClient 会让大量套接字处于 TIME_WAIT 状态,最终导致端口耗尽,新的请求无法建立连接。

另一个更隐蔽的问题是 DNS 变更不生效。直接复用同一个 HttpClient 实例虽然是推荐的,但它的连接会缓存 DNS 结果,如果后端服务发生迁移或者负载均衡地址变化,应用仍然会把请求发送到旧地址。HttpClientFactory 通过定期旋转消息处理器实例来规避这个问题,同时复用底层连接池,兼顾稳定性和灵活性。

下面是一个容易被误用的写法,它在高并发场景下会快速耗尽资源:

// 不推荐:每次请求都创建新的 HttpClient
public async Task<string> GetDataAsync()
{
    using var client = new HttpClient();
    return await client.GetStringAsync("https://api.ipipp.com/data");
}

在 ASP.NET Core 中注册并使用 HttpClientFactory

要在 ASP.NET Core 项目中使用 HttpClientFactory,需要先安装 Microsoft.Extensions.Http 包,不过大多数 Web 应用模板已经默认引用。然后在服务注册阶段调用 AddHttpClient 扩展方法。它会把工厂注册为单例,同时为每个命名的 HttpClient 维护独立的配置和消息处理器。

注册一个命名客户端时,可以在委托中设置 BaseAddress、默认请求头和超时时间。委托仅在消息处理器首次创建时执行,这意味着不能依赖委托来读取每次请求都会变化的配置,例如当前用户的令牌。对于这类场景应该使用自定义 DelegatingHandler 或类型化客户端中注入服务。

// 在 Program.cs 或 Startup.cs 中注册
builder.Services.AddHttpClient("todos", client =>
{
    client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.Timeout = TimeSpan.FromSeconds(30);
});

除了命名客户端,更推荐的做法是定义类型化客户端。类型化客户端把请求逻辑封装在一个类中,注入时直接使用该类型,从而获得更好的可测试性和强类型体验。注册时使用 AddHttpClient<TClient, TImplementation> 方法,框架会根据类型自动创建对应名称并管理生命周期。

builder.Services.AddHttpClient<ITypicodeClient, TypicodeClient>(client =>
{
    client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com");
});

对应的类型化客户端实现如下,构造函数直接注入 HttpClient,业务方法中只关注请求路径即可:

public class TypicodeClient : ITypicodeClient
{
    private readonly HttpClient _httpClient;
    public TypicodeClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }
    public async Task<string> GetTodosAsync()
    {
        return await _httpClient.GetStringAsync("/todos");
    }
}

搭配 Polly 实现策略组合

网络请求天生具有不确定性,仅仅配置超时还不够。HttpClientFactory 与 Polly 策略库可以自然集成,通过在注册管道中添加策略,让每个 HttpClient 请求自动获得重试、熔断和超时能力。这样做的好处是策略代码与业务逻辑分离,开发者不用在每个调用方法里重复写 try-catch 和重试循环。

要使用 Polly,需要安装 Microsoft.Extensions.Http.Polly 包。注册时先定义策略,再调用 AddPolicyHandler 将其附加到客户端。例如定义一个指数退避重试策略,在请求失败后等待 2 秒、4 秒、8 秒再重试,最多重试 3 次。

using Polly;
using Polly.Extensions.Http;
var retryPolicy = HttpPolicyExtensions
    .HandleTransientHttpError()
    .WaitAndRetryAsync(3, retryAttempt =>
        TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
builder.Services.AddHttpClient("resilient", client =>
{
    client.BaseAddress = new Uri("https://api.ipipp.com");
})
.AddPolicyHandler(retryPolicy);

除了重试,熔断策略可以防止下游服务不可用时雪崩效应继续扩大。通过 CircuitBreakerAsync 设置失败阈值和熔断时间,当错误率达到一定比例时,新请求会在熔断期内直接抛出异常,从而快速失败。

var circuitBreakerPolicy = Policy
    .Handle<HttpRequestException>()
    .CircuitBreakerAsync(2, TimeSpan.FromMinutes(1));
builder.Services.AddHttpClient("circuit")
    .AddPolicyHandler(circuitBreakerPolicy);

使用 HttpClientFactory 的实践要点

首先,不要在 ConfigureHttpClient 委托里解析 IServiceProvider 的 scoped 服务。因为委托并不在请求作用域内执行,解析 scoped 服务可能引发对象已释放或生命周期不一致的问题。如果确实需要每次请求使用不同的请求头,应该创建一个 DelegatingHandler,在 SendAsync 方法中注入 IHttpContextAccessor 或其他 scoped 信息提供者。

其次,当默认的 SocketsHttpHandler 行为不够用时,可以通过 ConfigurePrimaryHttpMessageHandler 定制连接行为。例如设置最大连接数、启用 Cookie 容器、忽略证书验证(仅限受信任环境)等。需要注意,一旦使用了 ConfigurePrimaryHttpMessageHandler,就不再享有默认的自动旋转和生命周期管理,需谨慎判断。

builder.Services.AddHttpClient("custom")
    .ConfigurePrimaryHttpMessageHandler(() =>
    {
        return new SocketsHttpHandler
        {
            MaxConnectionsPerServer = 20,
            PooledConnectionLifetime = TimeSpan.FromMinutes(5)
        };
    });

最后,把 HttpClient 当作长生命周期的组件来对待,不要把它放入 using 块,也不要每次调用都通过工厂重新创建。通过依赖注入获取的 HttpClient 已经由工厂管理,它会在合适的时机回收底层资源,直接使用即可。

常见误区与排查思路

一种常见误区是认为 HttpClientFactory 会提高每个请求的响应速度,于是把超时时间设得极短,结果线上频繁触发重试反而拖垮下游。其实工厂解决的是连接管理问题,而不是提升单次请求的绝对性能。设置超时时间要根据下游接口的 p95 延迟和重试次数综合计算,避免超时时间小于重试等待造成的虚假失败。

另一个容易忽略的点是日志。虽然工厂本身不强制输出日志,但可以通过扩展 DelegatingHandler 记录请求耗时、状态码和异常信息。把这些信息与分布式追踪 ID 关联,可以快速定位是哪一个服务调用出现问题。在故障排查时,优先查看线程池、连接池和 DNS 缓存状态,往往能更快发现根因。

广告位 · 文章底部(后台-模块管理替换此区域)