dotnet-http-client
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesedotnet-http-client
.NET HTTP客户端
Best practices for consuming HTTP APIs in .NET applications using . Covers named and typed clients, resilience pipeline integration, chains for cross-cutting concerns, and testing strategies.
IHttpClientFactoryDelegatingHandlerOut of scope: DI container mechanics and service lifetimes -- see [skill:dotnet-csharp-dependency-injection]. Async/await patterns and cancellation token propagation -- see [skill:dotnet-csharp-async-patterns]. Resilience pipeline configuration (Polly v8, retry, circuit breaker, timeout strategies) is owned by [skill:dotnet-resilience]. Integration testing frameworks -- see [skill:dotnet-integration-testing] for WebApplicationFactory and HTTP client testing patterns.
Cross-references: [skill:dotnet-resilience] for resilience pipeline configuration, [skill:dotnet-csharp-dependency-injection] for service registration, [skill:dotnet-csharp-async-patterns] for async HTTP patterns.
在.NET应用中使用消费HTTP API的最佳实践。涵盖命名客户端与类型化客户端、弹性管道集成、用于横切关注点的链,以及测试策略。
IHttpClientFactoryDelegatingHandler超出范围: DI容器机制与服务生命周期——请参考[skill:dotnet-csharp-dependency-injection]。Async/await模式与取消令牌传播——请参考[skill:dotnet-csharp-async-patterns]。弹性管道配置(Polly v8、重试、断路器、超时策略)由[skill:dotnet-resilience]负责。集成测试框架——请参考[skill:dotnet-integration-testing]中的WebApplicationFactory与HTTP客户端测试模式。
交叉引用:弹性管道配置请参考[skill:dotnet-resilience],服务注册请参考[skill:dotnet-csharp-dependency-injection],异步HTTP模式请参考[skill:dotnet-csharp-async-patterns]。
Why IHttpClientFactory
为什么使用IHttpClientFactory
Creating instances directly causes two problems:
HttpClient- Socket exhaustion -- each instance holds its own connection pool. Creating and disposing many instances exhausts available sockets (
HttpClient).SocketException: Address already in use - DNS staleness -- a long-lived singleton caches DNS lookups indefinitely, missing DNS changes during blue-green deployments or failovers.
HttpClient
IHttpClientFactoryHttpMessageHandlercsharp
// 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();直接创建实例会导致两个问题:
HttpClient- 套接字耗尽 -- 每个实例都持有自己的连接池。频繁创建和销毁实例会耗尽可用套接字(
HttpClient)。SocketException: Address already in use - DNS缓存过时 -- 长期存活的单例会无限期缓存DNS查询结果,在蓝绿部署或故障转移期间无法获取DNS变更。
HttpClient
IHttpClientFactoryHttpMessageHandlercsharp
// 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();Named Clients
命名客户端
Register clients by name for scenarios where you consume multiple APIs with different configurations:
csharp
// 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);
}
}按名称注册客户端,适用于使用多个不同配置API的场景:
csharp
// 注册
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");
});
// 使用
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);
}
}Typed Clients
类型化客户端
Typed clients encapsulate HTTP logic behind a strongly-typed interface. Prefer typed clients when a service consumes a single API with multiple operations:
csharp
// 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");
});类型化客户端将HTTP逻辑封装在强类型接口之后。当服务使用单个包含多个操作的API时,优先使用类型化客户端:
csharp
// 类型化客户端类
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))!;
}
}
// 注册
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});Typed Client with Interface
带接口的类型化客户端
For testability, define an interface:
csharp
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");
});为了可测试性,定义一个接口:
csharp
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
{
// 实现如上
}
// 带接口的注册
builder.Services.AddHttpClient<ICatalogApiClient, CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
});Resilience Pipelines
弹性管道
Apply resilience to HTTP clients using . See [skill:dotnet-resilience] for detailed pipeline configuration, strategy options, and migration guidance.
Microsoft.Extensions.Http.Resilience使用为HTTP客户端应用弹性机制。请参考[skill:dotnet-resilience]获取详细的管道配置、策略选项和迁移指南。
Microsoft.Extensions.Http.ResilienceStandard Resilience Handler (Recommended)
标准弹性处理程序(推荐)
The standard handler applies the full pipeline (rate limiter, total timeout, retry, circuit breaker, attempt timeout) with sensible defaults:
csharp
builder.Services
.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
})
.AddStandardResilienceHandler();标准处理程序应用完整的管道(速率限制器、总超时、重试、断路器、尝试超时),并带有合理的默认值:
csharp
builder.Services
.AddHttpClient<CatalogApiClient>(client =>
{
client.BaseAddress = new Uri("https://catalog.internal");
})
.AddStandardResilienceHandler();Standard Handler with Custom Options
带自定义选项的标准处理程序
csharp
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);
});csharp
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);
});Hedging Handler (for Read-Only APIs)
对冲处理程序(适用于只读API)
For idempotent read operations where tail latency matters:
csharp
builder.Services
.AddHttpClient("search-api")
.AddStandardHedgingHandler(options =>
{
options.Hedging.MaxHedgedAttempts = 2;
options.Hedging.Delay = TimeSpan.FromMilliseconds(500);
});See [skill:dotnet-resilience] for when to use hedging vs standard retry.
对于幂等的读取操作,当尾部延迟很重要时使用:
csharp
builder.Services
.AddHttpClient("search-api")
.AddStandardHedgingHandler(options =>
{
options.Hedging.MaxHedgedAttempts = 2;
options.Hedging.Delay = TimeSpan.FromMilliseconds(500);
});请参考[skill:dotnet-resilience]了解何时使用对冲策略与标准重试策略。
DelegatingHandlers
DelegatingHandler
DelegatingHandlerDelegatingHandlerHandler Pipeline Order
处理程序管道顺序
Handlers execute in registration order for requests (outermost to innermost) and reverse order for responses:
Request --> Handler A --> Handler B --> Handler C --> HttpClientHandler --> Server
Response <-- Handler A <-- Handler B <-- Handler C <-- HttpClientHandler <-- Server处理程序按注册顺序执行请求(从最外层到最内层),并按相反顺序执行响应:
Request --> Handler A --> Handler B --> Handler C --> HttpClientHandler --> Server
Response <-- Handler A <-- Handler B <-- Handler C <-- HttpClientHandler <-- ServerCommon Handlers
常见处理程序
Request Logging
请求日志
csharp
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>();csharp
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;
}
}
// 注册
builder.Services.AddTransient<RequestLoggingHandler>();
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.AddHttpMessageHandler<RequestLoggingHandler>();API Key Authentication
API密钥认证
csharp
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>();csharp
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);
}
}
// 注册
builder.Services.AddTransient<ApiKeyHandler>();
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.AddHttpMessageHandler<ApiKeyHandler>();Bearer Token (from Downstream Auth)
Bearer令牌(来自下游认证)
csharp
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);
}
}csharp
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);
}
}Correlation ID Propagation
关联ID传播
csharp
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);
}
}csharp
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);
}
}Chaining Multiple Handlers
链式多个处理程序
Handlers are added in execution order:
csharp
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 pipelineNote: In , handlers registered first are outermost. added last is innermost -- it wraps the actual HTTP call directly. This means retries happen inside the resilience handler without re-executing the outer DelegatingHandlers. This is typically correct: correlation IDs and auth tokens are set once by the outer handlers, and the resilience layer retries the raw HTTP call. If you need per-retry token refresh (e.g., expired bearer tokens), move the token handler inside the resilience boundary or use a custom callback.
IHttpClientFactory.AddStandardResilienceHandler()ResiliencePipelineBuilder处理程序按执行顺序添加:
csharp
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>() // 第一个(最外层):添加关联ID
.AddHttpMessageHandler<BearerTokenHandler>() // 第二个:添加认证令牌
.AddHttpMessageHandler<RequestLoggingHandler>() // 第三个:记录请求/响应
.AddStandardResilienceHandler(); // 第四个(最内层):弹性管道注意: 在中,先注册的处理程序是最外层。最后添加的是最内层——它直接包装实际的HTTP调用。这意味着重试发生在弹性处理程序内部,不会重新执行外层的DelegatingHandler。这通常是正确的:关联ID和认证令牌由外层处理程序设置一次,弹性层重试原始HTTP调用。如果需要每次重试刷新令牌(例如过期的Bearer令牌),请将令牌处理程序移到弹性边界内部,或使用自定义回调。
IHttpClientFactory.AddStandardResilienceHandler()ResiliencePipelineBuilderConfiguration Patterns
配置模式
Base Address from Configuration
从配置中获取基础地址
csharp
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);
});json
{
"Services": {
"CatalogApi": {
"BaseUrl": "https://catalog.internal"
}
}
}csharp
builder.Services.AddHttpClient<CatalogApiClient>(client =>
{
var baseUrl = builder.Configuration["Services:CatalogApi:BaseUrl"]
?? throw new InvalidOperationException(
"CatalogApi基础URL未配置");
client.BaseAddress = new Uri(baseUrl);
});json
{
"Services": {
"CatalogApi": {
"BaseUrl": "https://catalog.internal"
}
}
}Handler Lifetime
处理程序生命周期
The default handler lifetime is 2 minutes. Adjust for services with different DNS characteristics:
csharp
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.SetHandlerLifetime(TimeSpan.FromMinutes(5));Shorter lifetime (1 min): for services behind load balancers with frequent DNS changes.
Longer lifetime (5-10 min): for stable internal services where connection reuse improves performance.
默认处理程序生命周期为2分钟。根据服务的DNS特性调整:
csharp
builder.Services
.AddHttpClient<CatalogApiClient>(/* ... */)
.SetHandlerLifetime(TimeSpan.FromMinutes(5));更短的生命周期(1分钟):适用于负载均衡器后DNS频繁变更的服务。
更长的生命周期(5-10分钟):适用于稳定的内部服务,连接复用可提升性能。
Testing HTTP Clients
测试HTTP客户端
Unit Testing with MockHttpMessageHandler
使用MockHttpMessageHandler进行单元测试
Test typed clients by providing a mock handler that returns controlled responses:
csharp
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);
}
}通过提供返回可控响应的模拟处理程序来测试类型化客户端:
csharp
public sealed class CatalogApiClientTests
{
[Fact]
public async Task GetProductAsync_ReturnsProduct_WhenFound()
{
// 准备
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);
// 执行
var result = await client.GetProductAsync("p1");
// 断言
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);
}
}
// 可复用的模拟处理程序
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);
}
}Testing DelegatingHandlers
测试DelegatingHandler
Test handlers in isolation by providing an inner handler:
csharp
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());
}
}通过提供内部处理程序单独测试处理程序:
csharp
public sealed class ApiKeyHandlerTests
{
[Fact]
public async Task AddsApiKeyHeader()
{
// 准备
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")
};
// 执行
await client.GetAsync("/test");
// 断言
Assert.NotNull(innerHandler.LastRequest);
Assert.Equal(
"test-key-123",
innerHandler.LastRequest.Headers
.GetValues("X-Api-Key").Single());
}
}Integration Testing with WebApplicationFactory
使用WebApplicationFactory进行集成测试
Test the full HTTP client pipeline including DI registration:
csharp
// See [skill:dotnet-integration-testing] for WebApplicationFactory patterns测试完整的HTTP客户端管道,包括DI注册:
csharp
// 请参考[skill:dotnet-integration-testing]了解WebApplicationFactory模式Named vs Typed Clients -- Decision Guide
命名客户端 vs 类型化客户端——决策指南
| Factor | Named Client | Typed Client |
|---|---|---|
| API surface | Simple (1-2 calls) | Rich (multiple operations) |
| Type safety | Requires string name | Strongly typed |
| Encapsulation | HTTP logic in consuming class | HTTP logic in client class |
| Testability | Mock | Mock the client interface |
| Multiple APIs | One name per API | One class per API |
| Recommendation | Ad-hoc or simple calls | Primary pattern for API consumption |
Default to typed clients. Use named clients only for simple, one-off HTTP calls where a full typed client class adds unnecessary ceremony.
| 因素 | 命名客户端 | 类型化客户端 |
|---|---|---|
| API表面 | 简单(1-2个调用) | 丰富(多个操作) |
| 类型安全 | 需要字符串名称 | 强类型 |
| 封装性 | HTTP逻辑在消费类中 | HTTP逻辑在客户端类中 |
| 可测试性 | 模拟 | 模拟客户端接口 |
| 多API场景 | 每个API对应一个名称 | 每个API对应一个类 |
| 推荐场景 | 临时或简单调用 | API消费的主要模式 |
默认使用类型化客户端。仅在简单的一次性HTTP调用中使用命名客户端,此时完整的类型化客户端类会增加不必要的复杂度。
Key Principles
核心原则
- Always use IHttpClientFactory -- never in application code
new HttpClient() - Prefer typed clients -- encapsulate HTTP logic behind a strongly-typed interface
- Apply resilience via pipeline -- use (see [skill:dotnet-resilience]) rather than manual retry loops
AddStandardResilienceHandler() - Keep handlers focused -- each should do one thing (auth, logging, correlation)
DelegatingHandler - Register handlers as Transient -- DelegatingHandlers are created per-client-instance and should not hold state across requests
- Pass CancellationToken everywhere -- from endpoint to typed client to HTTP call
- Use ReadFromJsonAsync / PostAsJsonAsync -- avoid manual serialization with
StringContent
- 始终使用IHttpClientFactory——在应用代码中永远不要使用
new HttpClient() - 优先使用类型化客户端——将HTTP逻辑封装在强类型接口之后
- 通过管道应用弹性机制——使用(请参考[skill:dotnet-resilience])而非手动重试循环
AddStandardResilienceHandler() - 保持处理程序聚焦——每个应只负责一件事(认证、日志、关联)
DelegatingHandler - 将处理程序注册为Transient——DelegatingHandler按客户端实例创建,不应跨请求保存状态
- 始终传递CancellationToken——从端点到类型化客户端再到HTTP调用
- 使用ReadFromJsonAsync / PostAsJsonAsync——避免使用手动序列化
StringContent
Agent Gotchas
注意事项
- Do not create HttpClient with -- always inject
newor a typed client. Direct instantiation causes socket exhaustion.IHttpClientFactory - Do not dispose typed clients -- the factory manages handler lifetimes. Disposing the instance is harmless (it does not close pooled connections), but wrapping it in
HttpClientis misleading.using - Do not set with a trailing path --
BaseAddresswill dropnew Uri("https://api.example.com/v2")when combining with relative URIs. Use/v2(trailing slash) or use absolute URIs in calls.new Uri("https://api.example.com/v2/") - Understand that resilience added last is innermost -- registered after
AddStandardResilienceHandler()calls wraps the HTTP call directly. Retries do not re-execute outer DelegatingHandlers. This is correct for most cases (tokens/correlation IDs set once). If you need per-retry token refresh, place the token handler after the resilience handler or use a custom pipeline callback.AddHttpMessageHandler - Do not register DelegatingHandlers as Singleton -- they are pooled with the pipeline and must be Transient.
HttpMessageHandler
- 不要使用创建HttpClient——始终注入
new或类型化客户端。直接实例化会导致套接字耗尽。IHttpClientFactory - 不要释放类型化客户端——工厂管理处理程序的生命周期。释放实例是无害的(它不会关闭池化连接),但用
HttpClient包装会产生误导。using - 不要设置带有尾随路径的——
BaseAddress在与相对URI组合时会丢失new Uri("https://api.example.com/v2")。使用/v2(带尾随斜杠)或在调用中使用绝对URI。new Uri("https://api.example.com/v2/") - 了解最后添加的弹性机制是最内层——在调用之后注册的
AddHttpMessageHandler直接包装HTTP调用。重试不会重新执行外层的DelegatingHandler。这在大多数情况下是正确的(令牌/关联ID只设置一次)。如果需要每次重试刷新令牌,请将令牌处理程序放在弹性处理程序之后,或使用自定义管道回调。AddStandardResilienceHandler() - 不要将DelegatingHandler注册为Singleton——它们与管道一起池化,必须注册为Transient。
HttpMessageHandler