Skip to content
Go back

HttpClient in C# 完全指南:.NET 开发者的正确用法

如果你写过任何调用外部服务的 .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 包。

底层来看,HttpClientHttpMessageHandler 的一层薄封装。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 实例。

参考


Tags


Previous

多智能体 A2A 系统中的上下文传递:三种方案与选择标准

Next

30 道 LINQ 面试题:2026 年真实会被问到的那些