关于 dotnet-http-client
面向 .NET 开发者的 HTTP API 调用指南,介绍如何使用 `IHttpClientFactory`。内容涵盖客户端模式、弹性处理、消息处理器配置与测试。
主要功能
- 01命名和类型化 HTTP 客户端
- 02集成弹性处理器
- 03配置 DelegatingHandler 管道
- 04客户端配置与生命周期
- 05测试客户端和处理器
适用场景
- 01为 API 选择命名或类型化客户端
- 02添加身份验证或请求日志处理器
- 03为外发 HTTP 请求应用弹性机制
- 04使用可控响应测试 HTTP 客户端
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-http-client安装后在 Claude Code 中输入/dotnet-http-client确认已加载。
详细介绍
dotnet-http-client
使用 IHttpClientFactory 在 .NET 应用程序中调用 HTTP API 的最佳实践。涵盖命名客户端和类型化客户端、韧性管道集成、用于横切关注点的 DelegatingHandler 链,以及测试策略。
不在本指南范围内: DI 容器机制和服务生存期——请参阅 [skill:dotnet-csharp-dependency-injection]。Async/await 模式和取消令牌传播——请参阅 [skill:dotnet-csharp-async-patterns]。韧性管道配置(Polly v8、重试、断路器、超时策略)由 [skill:dotnet-resilience] 负责。集成测试框架——有关 WebApplicationFactory 和 HTTP 客户端测试模式,请参阅 [skill:dotnet-integration-testing]。
交叉引用:[skill:dotnet-resilience](韧性管道配置)、[skill:dotnet-csharp-dependency-injection](服务注册)、[skill:dotnet-csharp-async-patterns](异步 HTTP 模式)。
为什么使用 IHttpClientFactory
直接创建 HttpClient 实例会导致两个问题:
- 套接字耗尽——每个
HttpClient实例都有自己的连接池。创建并释放大量实例会耗尽可用套接字(SocketException: Address already in use)。 - DNS 过时——长时间存活的单例
HttpClient会无限期缓存 DNS 查询结果,无法及时获取蓝绿部署或故障转移期间的 DNS 变更。
IHttpClientFactory 通过管理 HttpMessageHandler 的生存期,并自动进行池化和轮换(默认:处理程序生存期为 2 分钟),解决了这两个问题。
// Do not do this
var client = new HttpClient(); // Socket exhaustion risk
// Do not do this either
static readonly HttpClient _client = new(); // DNS staleness risk
// Do this -- use IHttpClientFactory
builder.Services.AddHttpClient();
命名客户端
如果需要调用多个配置不同的 API,可按名称注册客户端:
// Registration
builder.Services.AddHttpClient("catalog-api", client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.Timeout = TimeSpan.FromSeconds(30);
});
builder.Services.AddHttpClient("payment-api", client =>
{
client.BaseAddress = new Uri("https://payments.internal");
client.DefaultRequestHeaders.Add("X-Api-Version", "2");
});
// Usage
public sealed class OrderService(IHttpClientFactory clientFactory)
{
public async Task<Product?> GetProductAsync(
string productId, CancellationToken ct)
{
var client = clientFactory.CreateClient("catalog-api");
var response = await client.GetAsync($"/products/{productId}", ct);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<Product>(ct);
}
}
类型化客户端
类型化客户端将 HTTP 逻辑封装在强类型接口之后。当服务需要调用同一个 API 的多个操作时,优先使用类型化客户端:
// Typed client class
public sealed class CatalogApiClient(HttpClient httpClient)
{
public async Task<Product?> GetProductAsync(
string productId, CancellationToken ct = default)
{
var response = await httpClient.GetAsync(
$"/products/{productId}", ct);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content
.ReadFromJsonAsync<Product>(ct);
}
public async Task<PagedResult<Product>> ListProductsAsync(
int page = 1,
int pageSize = 20,
CancellationToken ct = default)
{
var response = await httpClient.GetAsync(
$"/products?page={page}&pageSize={pageSize}", ct);
response.EnsureSuccessStatusCode();
return (await response.Content
.ReadFromJsonAsync<PagedResult<Product>>(ct))!;
}
public async Task<Product> CreateProductAsync(
CreateProductRequest request,
CancellationToken ct = default)
{
var response = await httpClient.PostAsJsonAsync(
"/products", request, ct);
response.EnsureSuccessStatusCode();
return (await response.Content
.ReadFromJsonAsync<Product>(ct))!;
}
}
// Registration
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
带接口的类型化客户端
为了便于测试,请定义一个接口:
public interface ICatalogApiClient
{
Task<Product?> GetProductAsync(string productId, CancellationToken ct = default);
Task<PagedResult<Product>> ListProductsAsync(int page = 1, int pageSize = 20, CancellationToken ct = default);
}
public sealed class CatalogApiClient(HttpClient httpClient) : ICatalogApiClient
{
// Implementation as above
}
// Registration with interface
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
});
韧性管道
使用 Microsoft.Extensions.Http.Resilience 为 HTTP 客户端应用韧性策略。有关管道配置、策略选项和迁移指南的详细信息,请参阅 [skill:dotnet-resilience]。
标准韧性处理程序(推荐)
标准处理程序会应用完整管道(速率限制器、总超时、重试、断路器、单次尝试超时),并采用合理的默认值:
builder.Services
.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
})
.AddStandardResilienceHandler();
自定义选项的标准处理程序
builder.Services
.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
})
.AddStandardResilienceHandler(options =>
{
options.Retry.MaxRetryAttempts = 5;
options.Retry.Delay = TimeSpan.FromSeconds(1);
options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(15);
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(60);
});
对冲处理程序(用于只读 API)
对于对尾延迟敏感的幂等读取操作:
builder.Services
.AddHttpClient("search-api")
.AddStandardHedgingHandler(options =>
{
options.Hedging.MaxHedgedAttempts = 2;
options.Hedging.Delay = TimeSpan.FromMilliseconds(500);
});
有关何时使用对冲而不是标准重试,请参阅 [skill:dotnet-resilience]。
DelegatingHandlers
DelegatingHandler 提供消息处理程序管道,用于处理传出的请求和传入的响应。可将其用于适用于 HTTP 流量的横切关注点。
处理程序管道顺序
处理程序处理请求时按注册顺序执行(从最外层到最内层),处理响应时则按相反顺序执行:
Request --> Handler A --> Handler B --> Handler C --> HttpClientHandler --> Server
Response <-- Handler A <-- Handler B <-- Handler C <-- HttpClientHandler <-- Server
常见处理程序
请求日志记录
public sealed class RequestLoggingHandler(
ILogger<RequestLoggingHandler> logger) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
logger.LogInformation(
"HTTP {Method} {Uri}",
request.Method,
request.RequestUri);
var response = await base.SendAsync(request, cancellationToken);
stopwatch.Stop();
logger.LogInformation(
"HTTP {Method} {Uri} responded {StatusCode} in {ElapsedMs}ms",
request.Method,
request.RequestUri,
(int)response.StatusCode,
stopwatch.ElapsedMilliseconds);
return response;
}
}
// Registration
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.AddHttpMessageHandler<RequestLoggingHandler>();
API 密钥身份验证
public sealed class ApiKeyHandler(IConfiguration config) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var apiKey = config["ExternalApi:ApiKey"]
?? throw new InvalidOperationException("API key not configured");
request.Headers.Add("X-Api-Key", apiKey);
return base.SendAsync(request, cancellationToken);
}
}
// Registration
builder.Services.AddTransient<ApiKeyHandler>();
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.AddHttpMessageHandler<ApiKeyHandler>();
Bearer 令牌(来自下游身份验证)
public sealed class BearerTokenHandler(
IHttpContextAccessor httpContextAccessor) : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
var token = httpContextAccessor.HttpContext?
.Request.Headers.Authorization
.ToString()
.Replace("Bearer ", "");
if (!string.IsNullOrEmpty(token))
{
request.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
return base.SendAsync(request, cancellationToken);
}
}
关联 ID 传播
public sealed class CorrelationIdHandler : DelegatingHandler
{
private const string HeaderName = "X-Correlation-Id";
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
if (!request.Headers.Contains(HeaderName))
{
var correlationId = Activity.Current?.Id
?? Guid.NewGuid().ToString();
request.Headers.Add(HeaderName, correlationId);
}
return base.SendAsync(request, cancellationToken);
}
}
链接多个处理程序
处理程序按执行顺序添加:
builder.Services.AddTransient<CorrelationIdHandler>();
builder.Services.AddTransient<BearerTokenHandler>();
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services
.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
})
.AddHttpMessageHandler<CorrelationIdHandler>() // 1st (outermost): add correlation ID
.AddHttpMessageHandler<BearerTokenHandler>() // 2nd: add auth token
.AddHttpMessageHandler<RequestLoggingHandler>() // 3rd: log request/response
.AddStandardResilienceHandler(); // 4th (innermost): resilience pipeline
**注意:**在 IHttpClientFactory 中,先注册的处理程序位于最外层。最后添加的 .AddStandardResilienceHandler() 位于最内层——它会直接包装实际的 HTTP 调用。这意味着重试发生在韧性处理程序内部,不会重新执行外层的 DelegatingHandlers。这通常是正确的做法:关联 ID 和身份验证令牌由外层处理程序设置一次,韧性层则重试原始 HTTP 调用。如果需要在每次重试时刷新令牌(例如,Bearer 令牌已过期),请将令牌处理程序移到韧性边界内,或使用自定义 ResiliencePipelineBuilder 回调。
配置模式
从配置获取基址
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
var baseUrl = builder.Configuration["Services:CatalogApi:BaseUrl"]
?? throw new InvalidOperationException(
"CatalogApi base URL not configured");
client.BaseAddress = new Uri(baseUrl);
});
{
"Services": {
"CatalogApi": {
"BaseUrl": "https://catalog.internal"
}
}
}
处理程序生命周期
默认处理程序生命周期为 2 分钟。对于 DNS 特性不同的服务,请进行相应调整:
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.SetHandlerLifetime(TimeSpan.FromMinutes(5));
较短的生命周期(1 分钟):适用于位于负载均衡器之后且 DNS 频繁变化的服务。 较长的生命周期(5-10 分钟):适用于连接复用可提升性能的稳定内部服务。
测试 HTTP 客户端
使用 MockHttpMessageHandler 进行单元测试
通过提供一个返回受控响应的模拟处理程序来测试类型化客户端:
public sealed class CatalogApiClientTests
{
[Fact]
public async Task GetProductAsync_ReturnsProduct_WhenFound()
{
// Arrange
var expectedProduct = new Product { Id = "p1", Name = "Widget" };
var handler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK)
{
Content = JsonContent.Create(expectedProduct)
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://test.local")
};
var client = new CatalogApiClient(httpClient);
// Act
var result = await client.GetProductAsync("p1");
// Assert
Assert.NotNull(result);
Assert.Equal("Widget", result.Name);
}
[Fact]
public async Task GetProductAsync_ReturnsNull_WhenNotFound()
{
var handler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.NotFound));
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://test.local")
};
var client = new CatalogApiClient(httpClient);
var result = await client.GetProductAsync("missing");
Assert.Null(result);
}
}
// Reusable mock handler
public sealed class MockHttpMessageHandler(
HttpResponseMessage response) : HttpMessageHandler
{
private HttpRequestMessage? _lastRequest;
public HttpRequestMessage? LastRequest => _lastRequest;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
_lastRequest = request;
return Task.FromResult(response);
}
}
测试 DelegatingHandlers
通过提供一个内部处理程序,单独测试处理程序:
public sealed class ApiKeyHandlerTests
{
[Fact]
public async Task AddsApiKeyHeader()
{
// Arrange
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["ExternalApi:ApiKey"] = "test-key-123"
})
.Build();
var innerHandler = new MockHttpMessageHandler(
new HttpResponseMessage(HttpStatusCode.OK));
var handler = new ApiKeyHandler(config)
{
InnerHandler = innerHandler
};
var client = new HttpClient(handler)
{
BaseAddress = new Uri("https://test.local")
};
// Act
await client.GetAsync("/test");
// Assert
Assert.NotNull(innerHandler.LastRequest);
Assert.Equal(
"test-key-123",
innerHandler.LastRequest.Headers
.GetValues("X-Api-Key").Single());
}
}
使用 WebApplicationFactory 进行集成测试
测试完整的 HTTP 客户端管道,包括 DI 注册:
// See [skill:dotnet-integration-testing] for WebApplicationFactory patterns
命名客户端与类型化客户端——选择指南
| 因素 | 命名客户端 | 类型化客户端 |
|---|---|---|
| API 表面 | 简单(1-2 次调用) | 丰富(多个操作) |
| 类型安全 | 需要字符串名称 | 强类型 |
| 封装 | HTTP 逻辑位于使用它的类中 | HTTP 逻辑位于客户端类中 |
| 可测试性 | 模拟 IHttpClientFactory | 模拟客户端接口 |
| 多个 API | 每个 API 使用一个名称 | 每个 API 对应一个类 |
| 建议 | 临时或简单调用 | API 调用的首选模式 |
默认使用类型化客户端。 仅当简单、一次性的 HTTP 调用不需要完整类型化客户端类时,才使用命名客户端。
核心原则
- 始终使用 IHttpClientFactory——切勿在应用程序代码中使用
new HttpClient() - 优先使用类型化客户端——通过强类型接口封装 HTTP 逻辑
- 通过管道应用弹性处理——使用
AddStandardResilienceHandler()(参见 [skill:dotnet-resilience]),而非手动编写重试循环 - 保持处理程序专注——每个
DelegatingHandler只负责一项工作(身份验证、日志记录、关联信息) - 将处理程序注册为 Transient——DelegatingHandlers 按客户端实例创建,不应在请求之间保留状态
- 在所有地方传递 CancellationToken——从端点传递到类型化客户端,再传递到 HTTP 调用
- 使用 ReadFromJsonAsync / PostAsJsonAsync——避免使用
StringContent手动序列化
Agent 常见陷阱
- 不要使用
new创建 HttpClient——始终注入IHttpClientFactory或类型化客户端。直接实例化会导致套接字耗尽。 - 不要释放类型化客户端——工厂会管理处理程序的生命周期。释放
HttpClient实例没有危害(不会关闭池化连接),但用using包装会造成误解。 - 不要为
BaseAddress设置带尾部路径的值——使用相对 URI 进行组合时,new Uri("https://api.example.com/v2")会移除/v2。请使用new Uri("https://api.example.com/v2/")(带尾部斜杠),或在调用中使用绝对 URI。 - 请注意,最后添加的弹性处理程序位于最内层——在
AddHttpMessageHandler调用后注册AddStandardResilienceHandler(),会使其直接包装 HTTP 调用。重试不会重新执行外层 DelegatingHandlers。对于大多数情况(令牌/关联 ID 只设置一次),这都是正确的。如果需要每次重试都刷新令牌,请将令牌处理程序放在弹性处理程序之后,或使用自定义管道回调。 - 不要将 DelegatingHandlers 注册为 Singleton——它们会与
HttpMessageHandler管道一起池化,因此必须设为 Transient。