如果你写过任何调用外部服务的 .NET 生产代码,你就用过 HttpClient。它是应用发出每一个 HTTP 请求的入口——REST API、webhook、第三方服务、微服务通信,全走它。
问题是 HttpClient 表面看起来极其简单,几行代码就能发 HTTP 请求。真正的麻烦在之后才出现——投产之后——以 socket 耗尽、DNS 缓存过期或者负载下不明 timeout 的形式。这篇指南覆盖你在 .NET 10 中正确使用 HttpClient 需要知道的全部内容。
HttpClient 是什么?
HttpClient 是 .NET 中发送 HTTP 请求和接收 HTTP 响应的主类,位于 System.Net.Http 命名空间,.NET 10 中内置于基础运行时,不需要额外 NuGet 包。
底层来看,HttpClient 是 HttpMessageHandler 的一层薄封装。handler 才是真正管理 TCP 连接、TLS 协商和连接池的组件。.NET 中的默认 handler 是 SocketsHttpHandler,一个全托管的跨平台实现。这个 HttpClient 和 SocketsHttpHandler 的区分非常关键——它是 IHttpClientFactory 解决方案的基础。
最简单的用法:
using System.Net.Http.Json;
var client = new HttpClient
{
BaseAddress = new Uri("https://api.example.com"),
};
// GET 自动 JSON 反序列化 —— .NET 5+ 可用
WeatherForecast? forecast = await client.GetFromJsonAsync<WeatherForecast>("/v1/forecast");
// POST 自动 JSON 序列化
var request = new ForecastRequest("Toronto", Days: 7);
HttpResponseMessage response = await client.PostAsJsonAsync("/v1/forecast", request);
response.EnsureSuccessStatusCode();
GetFromJsonAsync<T> 和 PostAsJsonAsync 扩展方法来自 System.Net.Http.Json(.NET 5 引入),底层使用 System.Text.Json,省去了手动读流和调用 JsonSerializer 的样板代码。
HttpClient 开箱即用处理了很多东西:HTTP keep-alive、HTTP/1.1 管道化、HTTP/2 多路复用、自动解压(gzip, br, deflate)、cookie、重定向跟随和代理支持。日常使用你不需要配置其中大部分。但你确实需要仔细考虑的是生命周期管理——这是大多数开发者踩坑的地方。
三种错误用法
理解 HttpClient 的反模式和正确的模式同样重要。三种常见错误有共同的根因:不理解 HttpClient 和底层 OS socket 资源之间的关系。
反模式一:每次请求新建实例(Socket 耗尽)
// 错了 —— 不要在生产中这样用
public async Task<string> GetDataAsync(string url)
{
using var client = new HttpClient(); // 每次调用新建
return await client.GetStringAsync(url);
}
看起来无害,甚至像是用 using 清理资源的好习惯。但在 OS 层面:当你 Dispose 一个 HttpClient 时,底层 TCP 连接进入 TIME_WAIT 状态。操作系统最长保留这个 socket 240 秒才回收。在任何有意义的请求量下,你会耗尽可用的临时端口(大多数系统约 64,000 个),然后开始看到 SocketException: Address already in use。你的应用停止发出出站连接。
反模式二:静态单例(DNS 过期)
// 比每次新建好 —— 但仍有问题
public class MyService
{
private static readonly HttpClient _client = new();
public async Task<string> GetDataAsync(string url) =>
await _client.GetStringAsync(url);
}
这避免了 socket 耗尽,因为连接被复用了。但引入了另一个问题:DNS 过期。当你永续持有一个 HttpClient(以及它的 SocketsHttpHandler),目标服务的 DNS 解析只在第一次连接时发生一次——然后永远不刷新。如果那个服务在故障转移、蓝绿部署或 CDN 切换期间更新了 IP 地址,你的单例客户端仍然连接旧 IP。从你应用的视角这个服务挂了,但实际上它好好的。唯一的修复是重启。
反模式三:在错误的作用域用 using
// 错了 —— 几乎不应该 Dispose HttpClient
public async Task HandleBatchAsync(IEnumerable<string> urls)
{
using var client = new HttpClient();
foreach (var url in urls)
await client.GetStringAsync(url);
// Dispose 关闭 handler 和所有底层的连接池
}
关键认知:HttpClient 被设计为长期存活和共享的。调用 Dispose() 会关闭 handler 和它持有的池化连接。在应用关闭或测试拆解之外,几乎不应该 Dispose 一个 HttpClient。把它想成连接池而不是一次性资源。
IHttpClientFactory:正确模式
IHttpClientFactory 在 .NET Core 2.1 中引入,专门在框架层面同时解决 socket 耗尽和 DNS 过期。它是有 DI 容器的应用中 HttpClient 的推荐方式。
工作原理:工厂在内部维护一个 HttpMessageHandler 实例池。当你调用 CreateClient() 时,工厂给你一个 HttpClient,它包装了池中的一个 handler。这些 handler 在多个 HttpClient 实例间复用(不会耗尽 socket)。但每个 handler 有一个可配置的过期时间——默认两分钟——过期后从池中退役,创建一个具有新 DNS 解析的新 handler(不会 DNS 过期)。当你 Dispose HttpClient 包装器时,只有包装器被丢弃,底层 handler 在池中存活直到过期。
注册只需一行:
builder.Services.AddHttpClient();
基础用法:
public sealed class ApiService(IHttpClientFactory factory)
{
public async Task<string> GetAsync(string url, CancellationToken ct = default)
{
using var client = factory.CreateClient();
return await client.GetStringAsync(url, ct);
}
}
在实际应用中,你几乎总是用命名客户端或类型化客户端,而不是直接调用 CreateClient()。它们提供集中配置和更好的封装。
三种模式对比
IHttpClientFactory 支持三种使用模式:
| 模式 | 最适合 | 封装性 | 可测试性 |
|---|---|---|---|
| 基础工厂 | 临时请求、工具脚本 | 低 | 中 |
| 命名客户端 | 共享配置、多个调用方 | 中 | 中 |
| 类型化客户端 | 专用服务集成 | 高 | 高 |
类型化客户端把 HttpClient 隐藏在接口后面,调用方只依赖接口抽象,不直接接触 HTTP:
public interface IGitHubClient
{
Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default);
}
public sealed class GitHubClient(HttpClient httpClient) : IGitHubClient
{
public async Task<GitHubUser?> GetUserAsync(string username, CancellationToken ct = default)
=> await httpClient.GetFromJsonAsync<GitHubUser>(
$"/users/{Uri.EscapeDataString(username)}", ct);
}
// 注册
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
});
类型化客户端跟依赖倒置原则自然对齐——调用方依赖 IGitHubClient,不依赖 HttpClient。这让代码更容易测试、更易替换实现、更易推理。
更深入的命名客户端、类型化客户端、handler 生命周期和 DI 集成,参见 IHttpClientFactory 在 .NET 中的使用。
DNS 生命周期与 PooledConnectionLifetime
连接池化和 DNS 新鲜度是直接冲突的:永远池化连接,DNS 变更永远不传播;太激进回收连接,就失去了池化的效率。
IHttpClientFactory 通过在可配置时间表上轮换 handler 实例来化解——默认两分钟。两分钟后,工厂为新请求创建新 handler。已在途的请求在旧 handler 上完成,旧 handler 在所有请求完成后才被释放。新 handler 用新的 DNS 查询建立连接。
如果不用 IHttpClientFactory,直接在 SocketsHttpHandler 上配置 PooledConnectionLifetime:
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
};
var client = new HttpClient(handler);
这个设置在 Kubernetes 部署、有激进 DNS TTL 的环境和负载均衡器背后的服务上有显著影响。
超时、取消与弹性
HttpClient 有内置的 Timeout 属性,默认 100 秒——极其宽松,如果一个依赖挂了会让用户觉得你的应用卡死了。显式设置它:
var client = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
对每次请求的取消,传递 CancellationToken:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
var response = await client.GetAsync("https://api.example.com/data", cts.Token);
区别在于:HttpClient.Timeout 是该客户端每个请求的硬期限。CancellationToken 是每次请求的,可以从外部取消——用户点击”取消”、应用关闭或调用方定义的期限。
但单独的超时不能让应用有弹性。真实 API 会有瞬态故障、网络抖动、依赖重启。你需要带退避的重试策略、防止压垮挣扎中服务的断路器、和对延迟敏感调用的对冲。
从 .NET 8 起,Microsoft.Extensions.Http.Resilience 把 Polly v8 直接集成到 IHttpClientFactory:
builder.Services.AddHttpClient<IGitHubClient, GitHubClient>(client =>
{
client.BaseAddress = new Uri("https://api.github.com");
})
.AddStandardResilienceHandler(); // 一行:重试 + 断路器 + 超时
标准管道包含指数退避重试、断路器和每次尝试的超时,全部可配置。
流式处理大响应
默认情况下,HttpClient 在把控制权还给你的代码之前先缓冲整个响应体。对于处理大负载——文件下载、大 JSON 数组、分页导出、事件流——这种默认缓冲行为同时是内存和延迟问题。
HttpCompletionOption.ResponseHeadersRead 告诉 HttpClient 在响应头到达时立刻返回,让你渐进流式读取响应体:
using var response = await client.GetAsync(
"https://api.example.com/export/large-dataset",
HttpCompletionOption.ResponseHeadersRead,
cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
await using var fileStream = File.OpenWrite("dataset.json");
await stream.CopyToAsync(fileStream, cancellationToken);
对 JSON 响应,结合 JsonSerializer.DeserializeAsyncEnumerable<T> 逐项处理:
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
await foreach (var item in JsonSerializer.DeserializeAsyncEnumerable<DataItem>(
stream, cancellationToken: cancellationToken))
{
await ProcessItemAsync(item, cancellationToken);
}
这个模式对任何超过几 MB 的响应都至关重要,也是服务器推送事件和实时数据流的基础。
HTTP/3 支持
HTTP/3 使用 QUIC 作为传输层而非 TCP。QUIC 运行在 UDP 之上,消除了 TCP 队头阻塞问题,支持通过 0-RTT 更快建立连接,更优雅地处理丢包。对高延迟网络或有大量并发小请求的 API,提升是真实可测的。
在 .NET 10 中,HTTP/3 成熟且可选择加入:
var client = new HttpClient
{
DefaultRequestVersion = HttpVersion.Version30,
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrLower,
};
HttpVersionPolicy.RequestVersionOrLower 确保优雅降级——如果服务器不支持 HTTP/3,请求会使用 HTTP/2 或 HTTP/1.1 成功完成。HTTP/3 需要 TLS 1.3 和通过 Alt-Svc 响应头广告 HTTP/3 支持的服务器。
测试 HttpClient
测试发 HTTP 调用的代码需要拦截这些调用而不碰真的端点。
方案一:Mock HttpMessageHandler。 创建一个返回固定响应的自定义 handler,内建,无额外依赖:
public sealed class MockHttpMessageHandler(
Func<HttpRequestMessage, HttpResponseMessage> handler) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
=> Task.FromResult(handler(request));
}
// 测试中
var mockHandler = new MockHttpMessageHandler(_ =>
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"city":"Toronto","temp":22}""",
Encoding.UTF8, "application/json")
});
var client = new HttpClient(mockHandler)
{
BaseAddress = new Uri("https://api.example.com"),
};
var forecast = await client.GetFromJsonAsync<WeatherForecast>("/v1/forecast");
方案二:Mock 类型化客户端接口。 如果用了类型化客户端和接口(推荐),最隔离的测试方法是直接 mock IWeatherClient,根本不涉及 HTTP。
方案三:用 RichardSzalay.MockHttp。 一个流行的库,提供流畅 API 在 HttpMessageHandler 之上设置预期的请求和响应,适合需要断言特定请求属性的集成风格测试。
你 HttpClient 代码的可测试性跟它的抽象程度成正比。接口背后的类型化客户端在每个层级都最容易测试。
日志与可观测性
IHttpClientFactory 自动集成 Microsoft.Extensions.Logging。命名和类型化客户端为每个请求和响应发出 Debug 级别的日志。开发环境中,把 System.Net.Http.HttpClient 日志类别设为 Debug 就能拿到详细追踪。
生产可观测性方面,DelegatingHandler 是正确的抽象——它是 HttpClient 的中间件管道,跟 ASP.NET Core 中间件一样但针对出站请求:
public sealed class TimingHandler(ILogger<TimingHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
HttpResponseMessage response;
try
{
response = await base.SendAsync(request, ct);
}
catch (Exception ex)
{
logger.LogError(ex, "HTTP {Method} {Url} failed after {ElapsedMs}ms",
request.Method, request.RequestUri, sw.ElapsedMilliseconds);
throw;
}
sw.Stop();
logger.LogInformation("HTTP {Method} {Url} responded {StatusCode} in {ElapsedMs}ms",
request.Method, request.RequestUri, (int)response.StatusCode, sw.ElapsedMilliseconds);
return response;
}
}
// 注册并附加
builder.Services.AddTransient<TimingHandler>();
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>(client => { ... })
.AddHttpMessageHandler<TimingHandler>();
完整 .NET 10 示例
组合了本文所有模式的生产就绪类型化客户端:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Http.Resilience;
using Microsoft.Extensions.Logging;
using System.Net.Http.Json;
// 领域类型
public sealed record WeatherForecast(
string City, DateOnly Date, int TemperatureCelsius, string Summary);
// 类型化客户端接口
public interface IWeatherClient
{
Task<IReadOnlyList<WeatherForecast>> GetForecastAsync(
string city, int days = 7, CancellationToken ct = default);
}
// 类型化客户端实现
public sealed class WeatherClient(
HttpClient httpClient,
ILogger<WeatherClient> logger) : IWeatherClient
{
public async Task<IReadOnlyList<WeatherForecast>> GetForecastAsync(
string city, int days = 7, CancellationToken ct = default)
{
logger.LogInformation("Fetching {Days}-day forecast for {City}", days, city);
var response = await httpClient.GetAsync(
$"/v1/forecast?city={Uri.EscapeDataString(city)}&days={days}", ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("Weather API returned {StatusCode} for {City}",
(int)response.StatusCode, city);
response.EnsureSuccessStatusCode();
}
var forecasts = await response.Content
.ReadFromJsonAsync<WeatherForecast[]>(ct);
return forecasts ?? [];
}
}
// 启动注册
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<IWeatherClient, WeatherClient>(client =>
{
client.BaseAddress = new Uri(
builder.Configuration["WeatherApi:BaseUrl"]
?? "https://api.weather.example.com");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(15);
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 3;
options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30);
});
var host = builder.Build();
var weatherClient = host.Services.GetRequiredService<IWeatherClient>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var forecast = await weatherClient.GetForecastAsync("Toronto", ct: cts.Token);
foreach (var day in forecast)
Console.WriteLine($"{day.Date}: {day.TemperatureCelsius}°C -- {day.Summary}");
场景速查表
| 场景 | 推荐方案 |
|---|---|
| 简单脚本或控制台应用 | 直接 new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(2) }) |
| 单一外部服务、多个调用方 | AddHttpClient("name", ...) 命名客户端 |
| 每个外部服务专用集成 | AddHttpClient<IClient, Client>(...) 类型化客户端 |
| 任何生产级外部服务调用 | 类型化客户端 + .AddStandardResilienceHandler() |
| 响应超过几 MB | 任意模式 + HttpCompletionOption.ResponseHeadersRead |
| 延迟敏感、现代服务器设施 | 命名/类型化客户端 + HttpVersion.Version30 |
| 单元测试 | 类型化客户端接口 + mock HttpMessageHandler |
| 集成测试 | WebApplicationFactory + 真实 handler 拦截 |
一致的线索:对任何生产应用,从类型化客户端和 IHttpClientFactory 开始。给每个调用真实服务的客户端加 AddStandardResilienceHandler()——但对非幂等请求(POST、PATCH、DELETE)要审视重试配置,避免意外的重复操作。流式处理大负载,处处用 CancellationToken。其余都是调优。
常见问题
Q: HttpClient 在哪个命名空间?
System.Net.Http,.NET 10 中内置于基础运行时。JSON 扩展方法需要额外 using System.Net.Http.Json;。
Q: 为什么 IHttpClientFactory 能防止 socket 耗尽?
工厂内部维护 HttpMessageHandler 实例池。CreateClient() 返回包装了池中共享 handler 的 HttpClient。Dispose HttpClient 只释放包装器,handler 留在池中,TCP 连接保持打开和可复用。昂贵的 OS socket 资源由 handler 持有,创建和丢弃许多 HttpClient 实例不会耗尽 socket。
Q: HttpClient.Timeout 和 CancellationToken 有什么区别?
HttpClient.Timeout 是每个客户端的硬期限,应用于该实例的每个请求。CancellationToken 是每次请求的,外部可控——可被用户操作、应用关闭或调用方设定的期限取消。两者一起用:在客户端设合理的 Timeout 作为遗忘请求的安全网,传递每次请求的 CancellationToken 给调用方控制的取消。
Q: 类型化客户端和命名客户端怎么选? 有专门负责集成一个特定外部服务的类时用类型化客户端。需要共享配置但不想创建专用类时用命名客户端——比如应用三个不同部分以相同 header 和 base URL 调用同一个 API。不确定时优先类型化客户端,代码库增长后更易维护。
Q: 怎么给每个请求加认证头?
注册时已知的静态 token 用 DefaultRequestHeaders。运行时变化的 token(OAuth access token、会话凭证)用 DelegatingHandler 在每次请求前获取并注入当前 token,保持 token 刷新逻辑在业务类型化客户端之外,可跨多客户端复用。
Q: HttpClient 线程安全吗?
是。HttpClient 被设计为可跨多线程并发使用——这正是你被期望复用实例并长期持有的核心原因。不线程安全的是 HttpRequestMessage,每个逻辑 HTTP 请求需要自己的 HttpRequestMessage 实例。