mcpField
D
dotnet-http-client

dotnet-http-client

github.com/wshaddix开发8684 次安装35 周前更新
代码仓库

关于 dotnet-http-client

面向 .NET 开发者的 HTTP API 调用指南,介绍如何使用 `IHttpClientFactory`。内容涵盖客户端模式、弹性处理、消息处理器配置与测试。

主要功能

  1. 01命名和类型化 HTTP 客户端
  2. 02集成弹性处理器
  3. 03配置 DelegatingHandler 管道
  4. 04客户端配置与生命周期
  5. 05测试客户端和处理器

适用场景

  1. 01为 API 选择命名或类型化客户端
  2. 02添加身份验证或请求日志处理器
  3. 03为外发 HTTP 请求应用弹性机制
  4. 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 实例会导致两个问题:

  1. 套接字耗尽——每个 HttpClient 实例都有自己的连接池。创建并释放大量实例会耗尽可用套接字(SocketException: Address already in use)。
  2. 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 常见陷阱

  1. 不要使用 new 创建 HttpClient——始终注入 IHttpClientFactory 或类型化客户端。直接实例化会导致套接字耗尽。
  2. 不要释放类型化客户端——工厂会管理处理程序的生命周期。释放 HttpClient 实例没有危害(不会关闭池化连接),但用 using 包装会造成误解。
  3. 不要为 BaseAddress 设置带尾部路径的值——使用相对 URI 进行组合时,new Uri("https://api.example.com/v2") 会移除 /v2。请使用 new Uri("https://api.example.com/v2/")(带尾部斜杠),或在调用中使用绝对 URI。
  4. 请注意,最后添加的弹性处理程序位于最内层——在 AddHttpMessageHandler 调用后注册 AddStandardResilienceHandler(),会使其直接包装 HTTP 调用。重试不会重新执行外层 DelegatingHandlers。对于大多数情况(令牌/关联 ID 只设置一次),这都是正确的。如果需要每次重试都刷新令牌,请将令牌处理程序放在弹性处理程序之后,或使用自定义管道回调。
  5. 不要将 DelegatingHandlers 注册为 Singleton——它们会与 HttpMessageHandler 管道一起池化,因此必须设为 Transient。

参考资料

评论

支持 Markdown