HttpClient 是连接器与第三方 API 通信的唯一通道。错误使用 HttpClient(直接 new、Singleton 捕获)会导致 socket 耗尽和 DNS 不刷新。本节约定全库统一的 HttpClient 使用模式、审计日志接入方式,以及超时与重试的处理职责。
核心原则:永远用 IHttpClientFactory
禁止 new HttpClient()。 所有 HttpClient 实例必须通过 IHttpClientFactory 获取,由框架管理底层 HttpMessageHandler 的生命周期和连接池复用。
// ✗ 禁止——socket 耗尽 + DNS 不刷新private static readonly HttpClient _client = new HttpClient();
// ✓ 正确——通过 IHttpClientFactory 获取命名客户端internal sealed class StripeHttpClient{ private readonly HttpClient _httpClient;
public StripeHttpClient(IHttpClientFactory httpClientFactory, IStripeConfigProvider config) { // ① 按名字匹配注册时配置的客户端 _httpClient = httpClientFactory.CreateClient(config.HttpClientName); _httpClient.Timeout = options.Timeout; }}三种客户端模式
全库存在三种 HttpClient 接入方式,按使用场景区分。
模式一:命名客户端(Payment 推荐)
在 DI 注册时用 AddHttpClient(name) 配置命名客户端,Internal 封装类通过 IHttpClientFactory.CreateClient(name) 获取。Payment 域采用此模式。
// DI 注册——命名客户端 + 审计日志services.AddHttpClient(options.HttpClientName).AddRequestLogging(options.HttpClientName);
// Internal 封装internal sealed class StripeHttpClient{ private readonly HttpClient _httpClient;
public StripeHttpClient(IHttpClientFactory httpClientFactory, IStripeConfigProvider config) { _httpClient = httpClientFactory.CreateClient(config.HttpClientName); _httpClient.Timeout = config.Current.Timeout; // ① 超时从 Options 应用 }
public async Task<JsonElement> PostFormAsync(string url, IDictionary<string, string> form, CancellationToken ct) { using var response = await _httpClient.PostAsync(url, new FormUrlEncodedContent(form), ct); // ② 非 2xx → 抛 PaymentException(在边界转 Result) if (!response.IsSuccessStatusCode) throw new PaymentException("Stripe", response.StatusCode.ToString(), ...); return JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)).RootElement; }}模式二:Typed 客户端(Sms / TeamWork)
Typed 客户端把 HttpClient 直接注入封装类构造函数,框架自动构造并配置。TeamWork.DingTalk 采用此模式,配合 DelegatingHandler 做令牌注入。
// DI 注册——typed 客户端 + DelegatingHandler + 审计日志services.AddHttpClient<DingTalkHttpClient>() // ① typed 客户端 .AddRequestLogging<DingTalkHttpClient>() // ② 审计日志 .AddHttpMessageHandler(sp => // ③ DelegatingHandler 注入 access_token { var options = sp.GetRequiredService<IOptions<DingTalkOptions>>(); var logger = sp.GetRequiredService<ILogger<DingTalkAuthHandler>>(); var factory = sp.GetRequiredService<IHttpClientFactory>(); return new DingTalkAuthHandler(options, logger, factory); });
// Internal 封装——HttpClient 由框架注入internal sealed class DingTalkHttpClient{ private readonly HttpClient _httpClient;
public DingTalkHttpClient(HttpClient httpClient, ...) // ① 直接注入 HttpClient { _httpClient = httpClient; }}模式三:工厂按名解析(FileStorage)
FileStorage 一个 Provider 需要根据文件类型动态选择不同的底层客户端(上传、下载、预签名 URL),用工厂按名解析:
// FileStorage 注册时只注册 HttpClientFactory 能力,不绑定具体客户端services.AddHttpClient(); // ① 仅注册 IHttpClientFactory,不命名
// Provider 内部按需创建internal sealed class AwsS3FileStore{ private readonly IHttpClientFactory _httpClientFactory;
// ② 按场景用不同名字创建客户端 private HttpClient CreateFetchClient() => _httpClientFactory.CreateClient("AwsFetch");}请求审计日志
AddRequestLogging 注册
所有 HttpClient 注册时都链式调用 AddRequestLogging(),挂载审计 DelegatingHandler。该 Handler 拦截请求/响应,脱敏后非阻塞写入日志队列。
// 命名客户端审计services.AddHttpClient(options.HttpClientName) .AddRequestLogging(options.HttpClientName); // ① 按客户端名记录
// Typed 客户端审计services.AddHttpClient<DingTalkHttpClient>() .AddRequestLogging<DingTalkHttpClient>(); // ② 按类型记录AddRequestLogging 有两个重载:一个接受 string 名字(命名客户端),一个接受泛型类型(typed 客户端)。两者底层都注册 RequestLogHandler 作为 DelegatingHandler。
审计基础设施注册
AddRequestLogging() 同时幂等注册审计基础设施(IRequestLogRecorder、日志存储)。重复调用不会重复注册:
// 幂等——多个 Provider 调用时只注册一次基础设施services.AddRequestLogging(); // 在 Sms 等历史域中显式调用厂商 SDK 例外
部分厂商(阿里云短信、腾讯云短信)使用自带 HTTP 管道而非 IHttpClientFactory。这些 SDK 的请求无法通过 DelegatingHandler 拦截,审计日志改用 IRequestLogRecorder.Record() 回调写入。
// Sms 域——阿里云 SDK 自带 HTTP 管道public static IServiceCollection AddAliyunSms( this IServiceCollection services, Action<AliyunSmsOptions> configure){ services.Configure(configure);
// ① SDK 自带管道,无法用 DelegatingHandler,但仍注册审计基础设施 services.AddRequestLogging();
// ② 审计通过 IRequestLogRecorder.Record() 回调在 SDK 调用处手动写入 services.AddTransient<ISMS, AliyunSmsService>(); return services;}超时约定
全库没有统一的 HttpClient 全局超时。每个域在 Options 中定义自己的超时字段,在构造 HttpClient 封装时应用。
| 域 | 超时字段 | 类型 | 默认值 |
|---|---|---|---|
| Payment | StripeOptions.Timeout | TimeSpan | 30 秒 |
| TeamWork | DingTalkOptions 内部定义 | — | — |
| Beisen | BeisenOptions.TimeoutMs | int(毫秒) | 30000(30 秒) |
// Payment 应用超时public StripeHttpClient(IHttpClientFactory httpClientFactory, IStripeConfigProvider config){ _httpClient = httpClientFactory.CreateClient(config.HttpClientName); _httpClient.Timeout = config.Current.Timeout; // ① 从 Options 应用}重试约定
全库不内置 Polly,不提供自动重试。第三方 API 失败的重试策略由消费者负责。
设计理由
- 幂等性是消费者职责:支付退款等操作的重试必须配合幂等键,库无法判断某次失败是否可安全重试。
IntegrationException.IsTransient提供判断依据:消费者可据此区分瞬时错误(网络、限流)和永久错误(认证失败、参数错误)。- 避免隐藏的重复调用:自动重试可能导致意外的重复扣款或重复发送,显式重试更安全。
CancellationToken 约定
所有 HTTP 方法必须接受并传递 CancellationToken,绝不吞掉取消:
public async Task<JsonElement> PostFormAsync( string url, IDictionary<string, string> form, CancellationToken ct){ // ① ct 一直传到底层 HttpClient using var response = await _httpClient.PostAsync(url, new FormUrlEncodedContent(form), ct); // ...}取消是控制流语义,不是失败。详见 错误处理约定。