A:网络请求

This commit is contained in:
jimmy committed 2025-11-13 19:13:26 +08:00
1 parent c73c6f1380
commit 956169dbb4
16 files changed
+593

No files matched your search

@@ -0,0 +1,10 @@
using System.Threading;
using System.Threading.Tasks;
namespace NetClient.Abstractions;
public interface IAccessTokenProvider
{
Task<string?> GetTokenAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,22 @@
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace NetClient.Abstractions;
public interface IHttpClient
{
Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default);
Task<T?> GetAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default);
Task<string> GetStringAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<byte[]> GetBytesAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default);
Task DeleteAsync(string path, object? query = null, CancellationToken cancellationToken = default);
Task<TResponse?> PostMultipartAsync<TResponse>(string path, System.Action<System.Net.Http.MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,13 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace NetClient.Abstractions;
public interface IHttpContentSerializer
{
string ContentType { get; }
Task SerializeAsync<T>(Stream stream, T? value, CancellationToken cancellationToken = default);
Task<T?> DeserializeAsync<T>(Stream stream, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,7 @@
namespace NetClient.Abstractions;
public interface INetClientFactory
{
IHttpClient Create(string name);
}
+7
View File
@@ -0,0 +1,7 @@
namespace NetClient
{
public class Class1
{
}
}
@@ -0,0 +1,111 @@
using System;
using System.Net;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using NetClient.Abstractions;
using NetClient.Handlers;
using NetClient.Implementations;
using NetClient.Internals.Serialization;
using NetClient.Options;
using Polly;
using Polly.Contrib.WaitAndRetry;
using Polly.Extensions.Http;
namespace NetClient.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddNetClientCore(this IServiceCollection services)
{
services.TryAddTransient<LoggingHandler>();
services.TryAddTransient<BearerTokenAuthenticationHandler>();
services.TryAddSingleton<IHttpContentSerializer>(sp => new SystemTextJsonContentSerializer(new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)));
services.TryAddSingleton<INetClientFactory, NetClientFactory>();
return services;
}
public static IHttpClientBuilder AddNetClient(this IServiceCollection services, Action<NetClientOptions> configure)
{
services.AddNetClientCore();
var options = new NetClientOptions();
configure(options);
var builder = services.AddHttpClient<IHttpClient, DefaultNetClient>(client => ApplyClientOptions(client, options));
if (options.EnableLogging)
builder.AddHttpMessageHandler<LoggingHandler>();
if (options.IncludeCorrelationId)
builder.AddHttpMessageHandler(() => new CorrelationIdHandler(options.CorrelationIdHeaderName));
if (options.UseBearerToken)
builder.AddHttpMessageHandler<BearerTokenAuthenticationHandler>();
if (options.Retry.Enabled)
builder.AddPolicyHandler(CreateRetryPolicy(options));
return builder;
}
public static IHttpClientBuilder AddNamedNetClient(this IServiceCollection services, string name, Action<NetClientOptions> configure)
{
services.AddNetClientCore();
var options = new NetClientOptions();
configure(options);
var builder = services.AddHttpClient(name, client => ApplyClientOptions(client, options));
if (options.EnableLogging)
builder.AddHttpMessageHandler<LoggingHandler>();
if (options.IncludeCorrelationId)
builder.AddHttpMessageHandler(() => new CorrelationIdHandler(options.CorrelationIdHeaderName));
if (options.UseBearerToken)
builder.AddHttpMessageHandler<BearerTokenAuthenticationHandler>();
if (options.Retry.Enabled)
builder.AddPolicyHandler(CreateRetryPolicy(options));
return builder;
}
private static void ApplyClientOptions(HttpClient client, NetClientOptions options)
{
if (options.BaseAddress is not null)
client.BaseAddress = options.BaseAddress;
client.Timeout = options.Timeout;
foreach (var header in options.DefaultRequestHeaders)
{
client.DefaultRequestHeaders.Remove(header.Key);
client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
}
private static IAsyncPolicy<HttpResponseMessage> CreateRetryPolicy(NetClientOptions options)
{
var retry = options.Retry;
var delay = Backoff.ExponentialBackoff(retry.FirstDelay, retry.MaxRetryAttempts, fastFirst: false);
var policyBuilder = HttpPolicyExtensions
.HandleTransientHttpError(); // 5xx + HttpRequestException + 408
if (retry.Handle429)
{
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests);
}
if (retry.HandleRequestTimeout)
{
policyBuilder = policyBuilder.OrResult(msg => msg.StatusCode == HttpStatusCode.RequestTimeout);
}
return policyBuilder.WaitAndRetryAsync(delay);
}
}
@@ -0,0 +1,21 @@
using System;
using System.Net;
using System.Net.Http;
namespace NetClient.Exceptions;
public class ApiException : Exception
{
public HttpStatusCode StatusCode { get; }
public string? ResponseContent { get; }
public HttpResponseMessage Response { get; }
public ApiException(string message, HttpResponseMessage response, string? content = null, Exception? inner = null)
: base(message, inner)
{
Response = response;
StatusCode = response.StatusCode;
ResponseContent = content;
}
}
@@ -0,0 +1,32 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using NetClient.Abstractions;
namespace NetClient.Handlers;
public class BearerTokenAuthenticationHandler : DelegatingHandler
{
private readonly IAccessTokenProvider _tokenProvider;
public BearerTokenAuthenticationHandler(IAccessTokenProvider tokenProvider)
{
_tokenProvider = tokenProvider;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (request.Headers.Authorization is null)
{
var token = await _tokenProvider.GetTokenAsync(cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,26 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace NetClient.Handlers;
public class CorrelationIdHandler : DelegatingHandler
{
private readonly string _headerName;
public CorrelationIdHandler(string headerName)
{
_headerName = headerName;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
if (!request.Headers.Contains(_headerName))
{
request.Headers.Add(_headerName, Guid.NewGuid().ToString("N"));
}
return base.SendAsync(request, cancellationToken);
}
}
@@ -0,0 +1,38 @@
using System;
using System.Diagnostics;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace NetClient.Handlers;
public class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _logger;
public LoggingHandler(ILogger<LoggingHandler> logger)
{
_logger = logger;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var sw = Stopwatch.StartNew();
try
{
_logger.LogDebug("HTTP {Method} {Uri}", request.Method, request.RequestUri);
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
sw.Stop();
_logger.LogDebug("HTTP {Method} {Uri} -> {Status} in {Elapsed} ms", request.Method, request.RequestUri, (int)response.StatusCode, sw.ElapsedMilliseconds);
return response;
}
catch (Exception ex)
{
sw.Stop();
_logger.LogError(ex, "HTTP {Method} {Uri} failed after {Elapsed} ms", request.Method, request.RequestUri, sw.ElapsedMilliseconds);
throw;
}
}
}
@@ -0,0 +1,126 @@
using System;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using NetClient.Abstractions;
using NetClient.Exceptions;
using NetClient.Internals.Url;
namespace NetClient.Implementations;
public class DefaultNetClient : IHttpClient
{
private readonly HttpClient _httpClient;
private readonly IHttpContentSerializer _serializer;
private readonly ILogger<DefaultNetClient> _logger;
public DefaultNetClient(HttpClient httpClient, IHttpContentSerializer serializer, ILogger<DefaultNetClient> logger)
{
_httpClient = httpClient;
_serializer = serializer;
_logger = logger;
}
public Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken = default)
=> _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
public async Task<T?> GetAsync<T>(string path, object? query = null, CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
await using var stream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await _serializer.DeserializeAsync<T>(stream, cancellationToken).ConfigureAwait(false);
}
public async Task<string> GetStringAsync(string path, object? query = null, CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
return await resp.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
public async Task<byte[]> GetBytesAsync(string path, object? query = null, CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(HttpMethod.Get, uri);
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
return await resp.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
}
public Task<TResponse?> PostAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Post, path, body, cancellationToken);
public Task<TResponse?> PutAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Put, path, body, cancellationToken);
public Task<TResponse?> PatchAsync<TRequest, TResponse>(string path, TRequest body, CancellationToken cancellationToken = default)
=> SendWithBodyAsync<TRequest, TResponse>(HttpMethod.Patch, path, body, cancellationToken);
public async Task DeleteAsync(string path, object? query = null, CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, query);
using var req = new HttpRequestMessage(HttpMethod.Delete, uri);
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
}
public async Task<TResponse?> PostMultipartAsync<TResponse>(string path, Action<MultipartFormDataContent> buildContent, CancellationToken cancellationToken = default)
{
var uri = BuildUri(path, null);
using var content = new MultipartFormDataContent();
buildContent(content);
using var req = new HttpRequestMessage(HttpMethod.Post, uri) { Content = content };
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
await using var stream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await _serializer.DeserializeAsync<TResponse>(stream, cancellationToken).ConfigureAwait(false);
}
private string BuildUri(string path, object? query)
{
var q = QueryStringHelper.Build(query);
if (string.IsNullOrWhiteSpace(q)) return path;
return path.Contains('?') ? $"{path}&{q}" : $"{path}?{q}";
}
private async Task EnsureSuccess(HttpResponseMessage response)
{
if (response.IsSuccessStatusCode) return;
string? content = null;
try
{
content = response.Content is null ? null : await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed reading error response content");
}
throw new ApiException($"Request failed with status {(int)response.StatusCode}", response, content);
}
private async Task<TResponse?> SendWithBodyAsync<TRequest, TResponse>(HttpMethod method, string path, TRequest body, CancellationToken cancellationToken)
{
var uri = BuildUri(path, null);
using var ms = new MemoryStream();
await _serializer.SerializeAsync(ms, body, cancellationToken).ConfigureAwait(false);
ms.Position = 0;
using var content = new StreamContent(ms);
content.Headers.ContentType = new MediaTypeHeaderValue(_serializer.ContentType) { CharSet = Encoding.UTF8.WebName };
using var req = new HttpRequestMessage(method, uri) { Content = content };
using var resp = await SendAsync(req, cancellationToken).ConfigureAwait(false);
await EnsureSuccess(resp).ConfigureAwait(false);
await using var stream = await resp.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await _serializer.DeserializeAsync<TResponse>(stream, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,26 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using NetClient.Abstractions;
namespace NetClient.Implementations;
public class NetClientFactory : INetClientFactory
{
private readonly IServiceProvider _services;
public NetClientFactory(IServiceProvider services)
{
_services = services;
}
public IHttpClient Create(string name)
{
var httpFactory = _services.GetRequiredService<System.Net.Http.IHttpClientFactory>();
var client = httpFactory.CreateClient(name);
var serializer = _services.GetRequiredService<IHttpContentSerializer>();
var logger = _services.GetRequiredService<ILogger<DefaultNetClient>>();
return new DefaultNetClient(client, serializer, logger);
}
}
@@ -0,0 +1,31 @@
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using NetClient.Abstractions;
namespace NetClient.Internals.Serialization;
internal class SystemTextJsonContentSerializer : IHttpContentSerializer
{
private readonly JsonSerializerOptions _options;
public SystemTextJsonContentSerializer(JsonSerializerOptions options)
{
_options = options;
}
public string ContentType => "application/json";
public async Task SerializeAsync<T>(Stream stream, T? value, CancellationToken cancellationToken = default)
{
await JsonSerializer.SerializeAsync(stream, value, _options, cancellationToken).ConfigureAwait(false);
}
public async Task<T?> DeserializeAsync<T>(Stream stream, CancellationToken cancellationToken = default)
{
return await JsonSerializer.DeserializeAsync<T>(stream, _options, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,83 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Text;
namespace NetClient.Internals.Url;
internal static class QueryStringHelper
{
public static string Build(object? query)
{
if (query is null) return string.Empty;
if (query is IEnumerable<KeyValuePair<string, object?>> pairs)
{
return BuildFromPairs(pairs);
}
var dict = new Dictionary<string, object?>();
foreach (var prop in query.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!prop.CanRead) continue;
var val = prop.GetValue(query);
dict[prop.Name] = val;
}
return BuildFromPairs(dict);
}
private static string BuildFromPairs(IEnumerable<KeyValuePair<string, object?>> pairs)
{
var sb = new StringBuilder();
void AppendPair(string key, string value)
{
if (sb.Length > 0) sb.Append('&');
sb.Append(Uri.EscapeDataString(key));
sb.Append('=');
sb.Append(Uri.EscapeDataString(value));
}
foreach (var kv in pairs)
{
var key = kv.Key;
var val = kv.Value;
if (val is null) continue;
if (val is string s)
{
AppendPair(key, s);
}
else if (val is IEnumerable enumerable && val is not byte[])
{
foreach (var item in enumerable)
{
if (item is null) continue;
AppendPair(key, ConvertToString(item));
}
}
else
{
AppendPair(key, ConvertToString(val));
}
}
return sb.ToString();
}
private static string ConvertToString(object value)
{
return value switch
{
DateTime dt => dt.ToString("o", CultureInfo.InvariantCulture),
DateTimeOffset dto => dto.ToString("o", CultureInfo.InvariantCulture),
bool b => b ? "true" : "false",
IFormattable f => f.ToString(null, CultureInfo.InvariantCulture) ?? string.Empty,
_ => value.ToString() ?? string.Empty
};
}
}
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace NetClient.Options;
public class NetClientOptions
{
public Uri? BaseAddress { get; set; }
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(100);
public Dictionary<string, string> DefaultRequestHeaders { get; set; } = new();
public bool EnableLogging { get; set; } = true;
public bool IncludeCorrelationId { get; set; } = true;
public string CorrelationIdHeaderName { get; set; } = "X-Correlation-Id";
public bool UseBearerToken { get; set; } = false;
public RetryOptions Retry { get; set; } = new();
public JsonSerializerOptions JsonSerializerOptions { get; set; } = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
WriteIndented = false
};
}
@@ -0,0 +1,14 @@
using System;
namespace NetClient.Options;
public class RetryOptions
{
public bool Enabled { get; set; } = true;
public int MaxRetryAttempts { get; set; } = 3;
public TimeSpan FirstDelay { get; set; } = TimeSpan.FromMilliseconds(500);
public int MaxJitterMs { get; set; } = 100;
public bool Handle429 { get; set; } = true;
public bool HandleRequestTimeout { get; set; } = true; // 408
}