初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
using PCL.Core.IO.Storage.Cache;
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP 缓存处理器
|
||||
/// </summary>
|
||||
public class HttpCacheHandler : DelegatingHandler
|
||||
{
|
||||
private ICacheService _cacheService;
|
||||
public HttpCacheHandler(HttpMessageHandler invoker, ICacheService cacheService)
|
||||
{
|
||||
InnerHandler = invoker;
|
||||
_cacheService = cacheService;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||
{
|
||||
var uri = request.RequestUri?.ToString();
|
||||
if (string.IsNullOrEmpty(uri))
|
||||
{
|
||||
return await base.SendAsync(request, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// seek cache
|
||||
var cacheKey = CacheKeys.ApiResponse("http", uri);
|
||||
var cached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
|
||||
if (cached.Found)
|
||||
{
|
||||
var cachedResponse = _DeserializeResponse(cached.Value!);
|
||||
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
|
||||
cachedResponse.RequestMessage = request;
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// seek metadata
|
||||
var metaKey = CacheKeys.ApiResponseMeta("http", uri);
|
||||
var metaCached = await _cacheService.GetAsync<HttpCacheMetadata>(metaKey, ct).ConfigureAwait(false);
|
||||
if (metaCached.Found)
|
||||
{
|
||||
if (metaCached.Value!.ETag is not null)
|
||||
{
|
||||
request.Headers.IfNoneMatch.Add(new EntityTagHeaderValue(metaCached.Value!.ETag));
|
||||
}
|
||||
|
||||
if (metaCached.Value!.LastModified is not null)
|
||||
{
|
||||
request.Headers.IfModifiedSince = DateTimeOffset.Parse(metaCached.Value!.LastModified);
|
||||
}
|
||||
}
|
||||
|
||||
// send request
|
||||
var response = await base.SendAsync(request, ct).ConfigureAwait(false);
|
||||
if (response.Headers.CacheControl?.NoStore ?? false)
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
// not modified, return cached response if exists
|
||||
if (response.StatusCode is HttpStatusCode.NotModified)
|
||||
{
|
||||
var reCached = await _cacheService.GetAsync<byte[]>(cacheKey, ct).ConfigureAwait(false);
|
||||
if (reCached.Found)
|
||||
{
|
||||
var cachedResponse = _DeserializeResponse(reCached.Value!);
|
||||
cachedResponse.Headers.Add("X-Cache-Hit", "HIT");
|
||||
cachedResponse.RequestMessage = request;
|
||||
return cachedResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// cache response
|
||||
var body = await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
var ttl = _ComputeTtl(response);
|
||||
|
||||
await _cacheService.SetAsync(cacheKey, body, new CachePolicy
|
||||
{
|
||||
AbsoluteExpiration = ttl,
|
||||
Group = "http",
|
||||
Tags = "http-response"
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
await _cacheService.SetAsync(metaKey, new HttpCacheMetadata
|
||||
{
|
||||
ETag = response.Headers.ETag?.Tag,
|
||||
LastModified = response.Content.Headers.LastModified?.ToString("O"),
|
||||
StatusCode = (int)response.StatusCode
|
||||
}, CachePolicy.NeverExpire, ct).ConfigureAwait(false);
|
||||
|
||||
// not matched, return original response
|
||||
response.Headers.Add("X-Cache-Hit", "MISS");
|
||||
return response;
|
||||
}
|
||||
|
||||
private static TimeSpan? _ComputeTtl(HttpResponseMessage response)
|
||||
{
|
||||
var cc = response.Headers.CacheControl;
|
||||
if (cc?.MaxAge is not null)
|
||||
{
|
||||
return cc.MaxAge;
|
||||
}
|
||||
|
||||
if (cc?.SharedMaxAge is not null)
|
||||
{
|
||||
return cc.SharedMaxAge;
|
||||
}
|
||||
|
||||
return TimeSpan.FromMinutes(5);
|
||||
}
|
||||
|
||||
private static HttpResponseMessage _DeserializeResponse(byte[] data)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new ByteArrayContent(data)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace PCL.Core.IO.Net.Http.Cache;
|
||||
|
||||
public record HttpCacheMetadata
|
||||
{
|
||||
public string? ETag { get; init; }
|
||||
public string? LastModified { get; init; }
|
||||
public int StatusCode { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.IO.Net.Dns;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HostConnectionHandler
|
||||
{
|
||||
public static HostConnectionHandler Instance { get; } = new();
|
||||
private const string ModuleName = "HostConnectionHandler";
|
||||
|
||||
private readonly DnsQuery _dnsQuery = DnsQuery.Instance;
|
||||
private static readonly TimeSpan _CacheDuration = TimeSpan.FromMinutes(10); // 10 分钟缓存(RFC 建议值)
|
||||
private const int WaitTasks = 2;
|
||||
|
||||
// 缓存结构: (host, port) -> (IPAddress -> LastSuccessUtc)
|
||||
private readonly ConcurrentDictionary<(string host, int port), ConcurrentDictionary<IPAddress, DateTime>>
|
||||
_connectionCache = new();
|
||||
|
||||
public async ValueTask<Stream> GetConnectionAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var host = context.DnsEndPoint.Host;
|
||||
var port = context.DnsEndPoint.Port;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 代理地址或者直连 IP 直接返回结果
|
||||
if (IPAddress.TryParse(host, out var directIp))
|
||||
{
|
||||
return await _ConnectToAddressAsync(directIp, port, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var addresses = await _dnsQuery.QueryForIpAsync(host, cancellationToken).ConfigureAwait(false);
|
||||
if (addresses is null || addresses.Length == 0)
|
||||
{
|
||||
throw new HttpRequestException($"DNS resolution failed for {host}");
|
||||
}
|
||||
|
||||
// 对待连接地址进行排序 (缓存成功+IPv6优先)
|
||||
var sortedAddresses = _SortAddresses(host, port, addresses, now);
|
||||
|
||||
// Happy Eyeballs 连接逻辑,优先 IPv6 稍后
|
||||
var connectionTasks = new List<Task<NetworkStream>>(WaitTasks);
|
||||
var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var connectCancellationToken = cancellationSource.Token;
|
||||
|
||||
try
|
||||
{
|
||||
for (int i = 0; i < sortedAddresses.Length; i++)
|
||||
{
|
||||
var address = sortedAddresses[i];
|
||||
var delayMs = i * (address.AddressFamily.Equals(AddressFamily.InterNetwork) ? 150 : 80); // 偏向使用 v6
|
||||
|
||||
connectionTasks.Add(_DelayedConnectAsync(address, port, delayMs, connectCancellationToken));
|
||||
}
|
||||
|
||||
// 等待首个成功连接
|
||||
var winner = await connectionTasks
|
||||
.ToArray()
|
||||
.WhenAnySuccessAsync()
|
||||
.ConfigureAwait(false);
|
||||
var stream = await winner.ConfigureAwait(false);
|
||||
|
||||
// 更新缓存 + 记录成功
|
||||
var remoteIp = ((IPEndPoint)stream.Socket.RemoteEndPoint!).Address;
|
||||
_UpdateConnectionCache(host, port, remoteIp, now);
|
||||
LogWrapper.Debug(ModuleName, $"Connected to {host} via {remoteIp}");
|
||||
|
||||
// 取消其他连接
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
cancellationSource.Cancel();
|
||||
_ = _CleanupUnusedConnectionsAsync(connectionTasks, winner).ConfigureAwait(false);
|
||||
|
||||
return stream;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 清理所有连接
|
||||
// ReSharper disable once MethodHasAsyncOverload
|
||||
cancellationSource.Cancel();
|
||||
_ = _CleanupUnusedConnectionsAsync(connectionTasks, null).ConfigureAwait(false);
|
||||
|
||||
LogWrapper.Error(ex, ModuleName, $"All connection attempts failed for {host}");
|
||||
throw new HttpRequestException($"Connection failed for {host}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
cancellationSource.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private IPAddress[] _SortAddresses(string host, int port, IPAddress[] addresses, DateTime now)
|
||||
{
|
||||
var cacheKey = (host, port);
|
||||
var addressCache = _connectionCache.GetValueOrDefault(cacheKey);
|
||||
|
||||
var cachedAddresses = new List<IPAddress>();
|
||||
var uncachedAddresses = new List<IPAddress>();
|
||||
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
if (addressCache is not null &&
|
||||
addressCache.TryGetValue(ip, out var successTime) &&
|
||||
now - successTime <= _CacheDuration)
|
||||
{
|
||||
cachedAddresses.Add(ip);
|
||||
}
|
||||
else
|
||||
{
|
||||
uncachedAddresses.Add(ip);
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存地址: 按成功时间倒序 (最近成功的优先)
|
||||
cachedAddresses.Sort((a, b) =>
|
||||
addressCache![b].CompareTo(addressCache[a]));
|
||||
|
||||
// 非缓存地址: IPv6 优先 + 保持原始顺序
|
||||
var ipv6List = uncachedAddresses
|
||||
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
.OrderBy(ip => Array.IndexOf(addresses, ip))
|
||||
.ToList();
|
||||
|
||||
var ipv4List = uncachedAddresses
|
||||
.Where(static ip => ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
.OrderBy(ip => Array.IndexOf(addresses, ip))
|
||||
.ToList();
|
||||
|
||||
// 交错合并,确保有一个 v6 和一个 v4
|
||||
var sortedUncached = new List<IPAddress>();
|
||||
int i = 0, j = 0;
|
||||
while (i < ipv6List.Count || j < ipv4List.Count)
|
||||
{
|
||||
if (i < ipv6List.Count)
|
||||
sortedUncached.Add(ipv6List[i++]);
|
||||
if (j < ipv4List.Count)
|
||||
sortedUncached.Add(ipv4List[j++]);
|
||||
}
|
||||
|
||||
return cachedAddresses
|
||||
.Concat(sortedUncached)
|
||||
.Take(WaitTasks)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private void _UpdateConnectionCache(string host, int port, IPAddress ip, DateTime now)
|
||||
{
|
||||
var cacheKey = (host, port);
|
||||
var addressCache = _connectionCache.GetOrAdd(cacheKey, static _ =>
|
||||
new ConcurrentDictionary<IPAddress, DateTime>());
|
||||
|
||||
// 移除过期条目 (懒清理)
|
||||
foreach (var entry in addressCache)
|
||||
{
|
||||
if (now - entry.Value > _CacheDuration)
|
||||
{
|
||||
addressCache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新当前IP
|
||||
addressCache[ip] = now;
|
||||
}
|
||||
|
||||
private static async Task<NetworkStream> _DelayedConnectAsync(IPAddress ip, int port, int delay, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
return await _ConnectToAddressAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<NetworkStream> _ConnectToAddressAsync(IPAddress ip, int port, CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
|
||||
{
|
||||
NoDelay = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
socket.Dispose();
|
||||
throw new HttpRequestException($"Connection to {ip}:{port} failed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _CleanupUnusedConnectionsAsync(
|
||||
List<Task<NetworkStream>> allTasks,
|
||||
Task<NetworkStream>? winnerTask)
|
||||
{
|
||||
foreach (var task in allTasks.Where(t => t != winnerTask))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (task.IsCompletedSuccessfully)
|
||||
{
|
||||
var stream = await task.ConfigureAwait(false);
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
else if (task.IsCompleted)
|
||||
{
|
||||
_ = task.Exception;
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = task.ContinueWith(static t =>
|
||||
{
|
||||
if (t.IsCompletedSuccessfully)
|
||||
{
|
||||
t.Result.Dispose();
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = t.Exception;
|
||||
}
|
||||
}, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, ModuleName, "Dispose connection with an error");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpBasicExtension
|
||||
{
|
||||
extension(HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithHttpVersionOption(Version httpVersion)
|
||||
{
|
||||
requestMessage.Version = httpVersion;
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpContentExtension
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithContent(HttpContent content, string? contentType = null)
|
||||
{
|
||||
requestMessage.Content = content;
|
||||
if (contentType is not null) requestMessage.WithHeader("Content-Type", contentType);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithContent(string content, string? contentType = null)
|
||||
{
|
||||
requestMessage.Content = contentType is null
|
||||
? new StringContent(content, Encoding.UTF8)
|
||||
: new StringContent(content, Encoding.UTF8, contentType);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithBinaryContent(byte[] content)
|
||||
{
|
||||
requestMessage.Content = new ByteArrayContent(content);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithJsonContent<T>(T content, string? contentType = null, JsonSerializerOptions? options = null)
|
||||
{
|
||||
requestMessage.Content = new StringContent(
|
||||
JsonSerializer.Serialize(content, options ?? JsonCompat.SerializerOptions),
|
||||
Encoding.UTF8,
|
||||
contentType ?? "application/json");
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithFormContent(string form)
|
||||
{
|
||||
return requestMessage.WithContent(
|
||||
new ByteArrayContent(Encoding.UTF8.GetBytes(form)),
|
||||
"application/x-www-form-urlencoded");
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithFormContent(IEnumerable<KeyValuePair<string, string>> pairs)
|
||||
{
|
||||
requestMessage.Content = new FormUrlEncodedContent(pairs);
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpCookieExtension
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithCookie(string name, string value)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(requestMessage);
|
||||
ArgumentNullException.ThrowIfNullOrEmpty(value);
|
||||
ArgumentNullException.ThrowIfNull(value);
|
||||
|
||||
var newCookie = $"{name}={_GetSafeCookieValue(value)}";
|
||||
|
||||
if (requestMessage.Headers.TryGetValues("Cookie", out var existingValues))
|
||||
{
|
||||
var existingCookie = string.Join("; ", existingValues);
|
||||
requestMessage.Headers.Remove("Cookie");
|
||||
requestMessage.Headers.Add("Cookie", $"{existingCookie}; {newCookie}");
|
||||
}
|
||||
else
|
||||
{
|
||||
requestMessage.Headers.Add("Cookie", newCookie);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
}
|
||||
|
||||
private static string _GetSafeCookieValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return value;
|
||||
var needsEncoding = value.Any(c => _ForbiddenCookieValueChar.Contains(c) || char.IsControl(c));
|
||||
return needsEncoding ? Uri.EscapeDataString(value) : value;
|
||||
}
|
||||
|
||||
private static readonly char[] _ForbiddenCookieValueChar = [';', ',', ' ', '\r', '\n', '\t', '\0', '=', '"', '\'', '\\', '<', '>'];
|
||||
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpHeaderHandler
|
||||
{
|
||||
extension (HttpRequestMessage requestMessage)
|
||||
{
|
||||
public HttpRequestMessage WithHeader(string key, string value)
|
||||
{
|
||||
if (key.StartsWith("Content-", StringComparison.OrdinalIgnoreCase) && requestMessage.Content is not null)
|
||||
requestMessage.Content.Headers.TryAddWithoutValidation(key, value);
|
||||
else
|
||||
requestMessage.Headers.TryAddWithoutValidation(key, value);
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithHeaders(IDictionary<string, string> pairs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pairs);
|
||||
|
||||
foreach (var item in pairs)
|
||||
{
|
||||
requestMessage.WithHeader(item.Key, item.Value);
|
||||
}
|
||||
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithHeader(KeyValuePair<string, string> pair) =>
|
||||
requestMessage.WithHeader(pair.Key, pair.Value);
|
||||
|
||||
public HttpRequestMessage WithAuthentication(string scheme, string token)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(scheme);
|
||||
ArgumentException.ThrowIfNullOrEmpty(token);
|
||||
|
||||
requestMessage.Headers.Authorization = new AuthenticationHeaderValue(scheme, token);
|
||||
return requestMessage;
|
||||
}
|
||||
|
||||
public HttpRequestMessage WithBearerToken(string token) =>
|
||||
requestMessage.WithAuthentication("Bearer", token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using Microsoft.Win32;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using PCL.Core.Utils.OS;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpProxyManager : IWebProxy, IDisposable
|
||||
{
|
||||
public static readonly HttpProxyManager Instance = new();
|
||||
|
||||
public enum ProxyMode
|
||||
{
|
||||
NoProxy,
|
||||
SystemProxy,
|
||||
CustomProxy
|
||||
}
|
||||
|
||||
private readonly object _lock = new();
|
||||
private ProxyMode _mode = ProxyMode.SystemProxy;
|
||||
private readonly WebProxy _customWebProxy = new() { BypassProxyOnLocal = true };
|
||||
private readonly WebProxy _systemWebProxy = new() { BypassProxyOnLocal = true };
|
||||
private const string ProxyRegPathFull = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
private const string ProxyRegPath = @"Software\Microsoft\Windows\CurrentVersion\Internet Settings";
|
||||
private readonly RegistryChangeMonitor _proxyMonitor = new(ProxyRegPath);
|
||||
|
||||
private HttpProxyManager()
|
||||
{
|
||||
RefreshSystemProxy(); // 初始化系统代理
|
||||
_proxyMonitor.Changed += _OnSystemProxyChanged;
|
||||
}
|
||||
|
||||
private void _OnSystemProxyChanged(object? sender, EventArgs e)
|
||||
{
|
||||
RefreshSystemProxy();
|
||||
}
|
||||
|
||||
private enum ProxyProtocol
|
||||
{
|
||||
Http,
|
||||
Socks
|
||||
}
|
||||
|
||||
private record ProxyItem
|
||||
{
|
||||
public ProxyProtocol Protocol;
|
||||
public required string Address;
|
||||
}
|
||||
|
||||
private static ProxyItem[] _GetProxyFromString(string? proxyString)
|
||||
{
|
||||
if (proxyString.IsNullOrWhiteSpace()) return [];
|
||||
|
||||
var ret = new List<ProxyItem>();
|
||||
|
||||
// 形式:http=192.168.1.100:8080;socks=192.168.1.100:1080
|
||||
if (proxyString.Contains('='))
|
||||
{
|
||||
foreach (var segment in proxyString.Split(';', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eqIndex = segment.IndexOf('=');
|
||||
if (eqIndex <= 0 || eqIndex >= segment.Length - 1)
|
||||
continue;
|
||||
|
||||
var protocolStr = segment[..eqIndex].Trim();
|
||||
var address = segment[(eqIndex + 1)..].Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(address))
|
||||
continue;
|
||||
|
||||
ret.Add(new ProxyItem { Protocol = _ParseProtocol(protocolStr), Address = address });
|
||||
}
|
||||
|
||||
return ret.Count > 0 ? [.. ret] : [];
|
||||
}
|
||||
|
||||
// 形式:http://127.0.0.1:1145/ 或者单纯 127.0.0.1:1145
|
||||
if (Uri.TryCreate(proxyString, new UriCreationOptions(), out var proxyAddr))
|
||||
{
|
||||
var address = proxyAddr.Port > 0
|
||||
? $"{proxyAddr.Host}:{proxyAddr.Port}"
|
||||
: proxyAddr.Host;
|
||||
ret.Add(new ProxyItem { Protocol = _ParseProtocol(proxyAddr.Scheme), Address = address });
|
||||
}
|
||||
else
|
||||
{
|
||||
ret.Add(new ProxyItem { Protocol = ProxyProtocol.Http, Address = proxyString.Trim() });
|
||||
}
|
||||
|
||||
return [.. ret];
|
||||
}
|
||||
|
||||
private static ProxyProtocol _ParseProtocol(string scheme)
|
||||
{
|
||||
return scheme.ToLowerInvariant() switch
|
||||
{
|
||||
"socks" or "socks4" or "socks5" => ProxyProtocol.Socks,
|
||||
_ => ProxyProtocol.Http
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>刷新系统代理设置</summary>
|
||||
public void RefreshSystemProxy()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
try
|
||||
{
|
||||
// read from reg
|
||||
var isSystemProxyEnabled = (int)(Registry.GetValue(ProxyRegPathFull, "ProxyEnable", 0) ?? 0);
|
||||
var systemProxyString = Registry.GetValue(ProxyRegPathFull, "ProxyServer", string.Empty) as string;
|
||||
|
||||
// parse
|
||||
var proxies = _GetProxyFromString(systemProxyString);
|
||||
|
||||
// filter
|
||||
if (proxies.Length == 0 || !proxies.Any(static x => x.Protocol.Equals(ProxyProtocol.Http))) isSystemProxyEnabled = 0;
|
||||
var selectedProxy = proxies.FirstOrDefault(static x => x.Protocol.Equals(ProxyProtocol.Http));
|
||||
|
||||
// apply
|
||||
_systemWebProxy.Address = (isSystemProxyEnabled == 0 || selectedProxy!.Address.IsNullOrEmpty())
|
||||
? null
|
||||
: new Uri($"http://{selectedProxy.Address}");
|
||||
|
||||
LogWrapper.Info("Proxy",
|
||||
$"已从操作系统更新代理设置,系统代理状态:{isSystemProxyEnabled}|{systemProxyString}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Error(ex, "Proxy", "获取系统代理时出现异常");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ProxyMode Mode
|
||||
{
|
||||
get { lock (_lock) return _mode; }
|
||||
set { lock (_lock) _mode = value; }
|
||||
}
|
||||
|
||||
public Uri? CustomProxyAddress
|
||||
{
|
||||
get { lock (_lock) return _customWebProxy.Address; }
|
||||
set { lock (_lock) _customWebProxy.Address = value; }
|
||||
}
|
||||
|
||||
public ICredentials? CustomProxyCredentials
|
||||
{
|
||||
get { lock (_lock) return _customWebProxy.Credentials; }
|
||||
set { lock (_lock) _customWebProxy.Credentials = value; }
|
||||
}
|
||||
|
||||
public bool BypassOnLocal
|
||||
{
|
||||
get { lock (_lock) return field; }
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
field = value;
|
||||
_systemWebProxy.BypassProxyOnLocal = value;
|
||||
}
|
||||
}
|
||||
} = true;
|
||||
|
||||
public Uri? GetProxy(Uri destination)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _mode switch
|
||||
{
|
||||
ProxyMode.NoProxy => null, // 返回 null 表明没有代理
|
||||
ProxyMode.SystemProxy => _systemWebProxy.GetProxy(destination),
|
||||
ProxyMode.CustomProxy => _customWebProxy.GetProxy(destination),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsBypassed(Uri host)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _mode switch
|
||||
{
|
||||
ProxyMode.NoProxy => true,
|
||||
ProxyMode.SystemProxy => _systemWebProxy.IsBypassed(host),
|
||||
ProxyMode.CustomProxy => _customWebProxy.IsBypassed(host),
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public ICredentials? Credentials
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// 仅 CustomProxy 模式返回凭据
|
||||
return _mode == ProxyMode.CustomProxy
|
||||
? _customWebProxy.Credentials
|
||||
: null;
|
||||
}
|
||||
}
|
||||
set
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_customWebProxy.Credentials = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_proxyMonitor.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpRequest
|
||||
{
|
||||
public static HttpRequestMessage Create(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Get, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreateHead(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Head, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreatePost(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Post, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreatePut(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Put, new Uri(url));
|
||||
}
|
||||
public static HttpRequestMessage CreateDelete(string url)
|
||||
{
|
||||
return new HttpRequestMessage(HttpMethod.Delete, new Uri(url));
|
||||
}
|
||||
|
||||
public static async Task<string> GetStringAsync(string url)
|
||||
{
|
||||
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
|
||||
return await resp.AsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<T?> GetJsonAsync<T>(string url)
|
||||
{
|
||||
using var resp = await Create(url).SendAsync().ConfigureAwait(false);
|
||||
return await resp.AsJsonAsync<T>().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public static async Task<HttpResponseMessage> PostJsonAsync<T>(string url, T data, string? contentType = null)
|
||||
{
|
||||
return await CreatePost(url)
|
||||
.WithJsonContent(data, contentType)
|
||||
.SendAsync()
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpResponseException:HttpRequestException,IDisposable
|
||||
{
|
||||
public HttpResponseMessage? Response { get; init; }
|
||||
|
||||
public HttpResponseException()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(string message) : base(message)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(string message, Exception? inner) : base(message, inner)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public HttpResponseException(HttpResponseMessage? response) : this(
|
||||
$"{(int?)response?.StatusCode} {response?.ReasonPhrase ?? (response is null ? "undefined" : Enum.GetName(response.StatusCode))})")
|
||||
{
|
||||
Response = response;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Response?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
~HttpResponseException()
|
||||
{
|
||||
try
|
||||
{
|
||||
Response?.Dispose();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Suppress Exception
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpResponseExtension
|
||||
{
|
||||
extension(HttpResponseMessage responseMessage)
|
||||
{
|
||||
public bool IsSuccess => responseMessage.IsSuccessStatusCode;
|
||||
|
||||
public string AsString() { return responseMessage.AsStringAsync().GetAwaiter().GetResult(); }
|
||||
|
||||
public async Task<string> AsStringAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Stream> AsStreamAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<byte[]> AsByteArrayAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await responseMessage.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
|
||||
} catch(TaskCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The request was canceled due to a timeout.");
|
||||
} catch(OperationCanceledException)
|
||||
{
|
||||
throw new TimeoutException("The operation was canceled.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T?> AsJsonAsync<T>(
|
||||
JsonSerializerOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var stream = await responseMessage.AsStreamAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await JsonSerializer.DeserializeAsync<T>(stream, options ?? JsonCompat.SerializerOptions, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
} catch(JsonException ex)
|
||||
{
|
||||
throw new InvalidDataException("Failed to deserialize JSON response.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, string[]> GetHeaders()
|
||||
{
|
||||
return responseMessage.Headers
|
||||
.Concat(responseMessage.Content.Headers)
|
||||
.ToDictionary(k => k.Key, v => v.Value.ToArray());
|
||||
}
|
||||
|
||||
public Dictionary<string, string[]> GetContentHeaders()
|
||||
{ return responseMessage.Content.Headers.ToDictionary(k => k.Key, v => v.Value.ToArray()); }
|
||||
|
||||
public string[] GetHeader(string name)
|
||||
{
|
||||
if(responseMessage.Headers.TryGetValues(name, out var values) ||
|
||||
responseMessage.Content.Headers.TryGetValues(name, out values))
|
||||
return values.ToArray();
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public bool TryGetHeader(string name, out string[] values)
|
||||
{
|
||||
if(responseMessage.Headers.TryGetValues(name, out var headerValues) ||
|
||||
responseMessage.Content.Headers.TryGetValues(name, out headerValues))
|
||||
{
|
||||
values = headerValues.ToArray();
|
||||
return true;
|
||||
}
|
||||
|
||||
values = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
public string? GetFirstHeaderValue(string name) { return responseMessage.GetHeader(name).FirstOrDefault(); }
|
||||
|
||||
public bool TryGetFirstHeaderValue(string name, out string value)
|
||||
{
|
||||
var values = responseMessage.GetHeader(name);
|
||||
if(values.Length == 0)
|
||||
{
|
||||
value = string.Empty;
|
||||
return false;
|
||||
}
|
||||
value = values.First();
|
||||
return true;
|
||||
}
|
||||
|
||||
public void EnsureSuccessStatusCode()
|
||||
{
|
||||
if(!responseMessage.IsSuccess)
|
||||
throw new HttpRequestException(
|
||||
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}");
|
||||
}
|
||||
|
||||
public async Task EnsureSuccessStatusCodeWithContentAsync(CancellationToken ct = default)
|
||||
{
|
||||
if(!responseMessage.IsSuccess)
|
||||
{
|
||||
var content = await responseMessage
|
||||
.AsStringAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
throw new HttpRequestException(
|
||||
$"HTTP request failed with status code {responseMessage.StatusCode}: {responseMessage.ReasonPhrase}. Response content: {content}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public class HttpRoute(HttpMethod method, string path) : Attribute
|
||||
{
|
||||
public HttpMethod Method { get; } = method;
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using PCL.Core.Utils;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
/// <summary>
|
||||
/// 用于 <see cref="HttpServer"/> 响应客户端请求的服务端响应结构。
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class HttpRouteResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP 状态码
|
||||
/// </summary>
|
||||
public HttpStatusCode? StatusCode = null;
|
||||
|
||||
/// <summary>
|
||||
/// 此响应 <see cref="InputStream"/> 使用的字符编码
|
||||
/// </summary>
|
||||
public Encoding? ContentEncoding = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 内容 MIME 类型
|
||||
/// </summary>
|
||||
public string? ContentType = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 重定向目标 URL 或路径。
|
||||
/// </summary>
|
||||
public string? RedirectLocation = null;
|
||||
|
||||
/// <summary>
|
||||
/// [Header] 是否使用分块传输编码。
|
||||
/// </summary>
|
||||
public bool? SendChunked = null;
|
||||
|
||||
/// <summary>
|
||||
/// 随响应添加的 Cookies。
|
||||
/// </summary>
|
||||
public CookieCollection? Cookies = null;
|
||||
|
||||
/// <summary>
|
||||
/// 用于传输响应内容的输入流,若非空值,该流将被直接 <c>CopyTo</c> 到实际响应的 <c>OutputStream</c> 中。
|
||||
/// </summary>
|
||||
public Stream? InputStream = null;
|
||||
|
||||
/// <summary>
|
||||
/// 向标准 <see cref="HttpListener"/> 的响应对象写入数据。
|
||||
/// </summary>
|
||||
/// <param name="target">目标对象</param>
|
||||
public void Pour(HttpListenerResponse target)
|
||||
{
|
||||
target.StatusCode = (int)(StatusCode ?? HttpStatusCode.OK);
|
||||
if (ContentType is {} contentType) target.ContentType = contentType;
|
||||
if (ContentEncoding is {} contentEncoding) target.ContentEncoding = contentEncoding;
|
||||
if (RedirectLocation is {} redirectLocation) target.RedirectLocation = redirectLocation;
|
||||
if (SendChunked is {} sendChunked) target.SendChunked = sendChunked;
|
||||
if (Cookies is {} cookies) target.Cookies = cookies;
|
||||
if (InputStream is {} inputStream) inputStream.CopyTo(target.OutputStream);
|
||||
}
|
||||
|
||||
public Task<HttpRouteResponse> AsTask() => Task.FromResult(this);
|
||||
|
||||
/// <summary>
|
||||
/// 返回指定 HTTP 状态码的空响应
|
||||
/// </summary>
|
||||
/// <param name="statusCode">HTTP 状态码</param>
|
||||
public static HttpRouteResponse Empty(HttpStatusCode statusCode) => new() { StatusCode = statusCode };
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 204 (No Content) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse NoContent = Empty(HttpStatusCode.NoContent);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 400 (Bad Request) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse BadRequest = Empty(HttpStatusCode.BadRequest);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 403 (Forbidden) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse Forbidden = Empty(HttpStatusCode.Forbidden);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 404 (Not Found) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse NotFound = Empty(HttpStatusCode.NotFound);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 500 (Internal Server Error) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse InternalServerError = Empty(HttpStatusCode.InternalServerError);
|
||||
|
||||
/// <summary>
|
||||
/// 默认的 502 (Bad Gateway) 响应。
|
||||
/// </summary>
|
||||
public static readonly HttpRouteResponse BadGateway = Empty(HttpStatusCode.BadGateway);
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定输入流的内容。
|
||||
/// </summary>
|
||||
/// <param name="stream">输入流</param>
|
||||
/// <param name="contentType">内容 MIME 类型</param>
|
||||
/// <param name="encoding">输入流内容使用的字符编码,默认为 UTF-8</param>
|
||||
public static HttpRouteResponse Input(Stream stream, string contentType = "application/octet-stream", Encoding? encoding = null) =>
|
||||
new() { InputStream = stream, ContentType = contentType, ContentEncoding = encoding ?? Encoding.UTF8 };
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定文本内容。
|
||||
/// </summary>
|
||||
/// <param name="text">文本内容</param>
|
||||
/// <param name="contentType">内容 MIME 类型</param>
|
||||
/// <param name="encoding">响应流使用的字符编码,默认为 UTF-8</param>
|
||||
public static HttpRouteResponse Text(string text, string contentType = "text/plain", Encoding? encoding = null) =>
|
||||
Input(new StringStream(text, encoding), contentType, encoding);
|
||||
|
||||
/// <summary>
|
||||
/// 响应重定向。
|
||||
/// </summary>
|
||||
/// <param name="location">重定向目标 URL 或路径</param>
|
||||
/// <param name="statusCode">重定向状态码</param>
|
||||
public static HttpRouteResponse Redirect(string location, HttpStatusCode statusCode = HttpStatusCode.Found) =>
|
||||
new() { StatusCode = statusCode, RedirectLocation = location };
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
|
||||
/// </summary>
|
||||
/// <param name="obj">用于序列化的对象</param>
|
||||
/// <param name="options">JSON 序列化选项</param>
|
||||
public static HttpRouteResponse Json(object obj, JsonSerializerOptions? options)
|
||||
{
|
||||
var stream = new MemoryStream();
|
||||
JsonSerializer.Serialize(stream, obj, options ?? JsonCompat.SerializerOptions);
|
||||
stream.Position = 0;
|
||||
return new HttpRouteResponse
|
||||
{
|
||||
ContentEncoding = Encoding.UTF8,
|
||||
ContentType = "application/json, charset=utf-8",
|
||||
InputStream = stream
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 响应指定对象序列化得到的 JSON 内容,固定使用 UTF-8 编码
|
||||
/// </summary>
|
||||
/// <param name="obj">用于序列化的对象</param>
|
||||
public static HttpRouteResponse Json(object obj) => Json(obj, JsonCompat.SerializerOptions);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using PCL.Core.App;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Utils.Exts;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public static class HttpSenderExtension
|
||||
{
|
||||
extension(HttpRequestMessage requestMessage)
|
||||
{
|
||||
public async Task<HttpResponseMessage> SendAsync(
|
||||
HttpClient? httpClient = null,
|
||||
bool addMetedata = true,
|
||||
bool enableLogging = true,
|
||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead,
|
||||
int retryTimes = 3,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var request = requestMessage;
|
||||
httpClient ??= NetworkService.GetClient();
|
||||
|
||||
if(addMetedata)
|
||||
{
|
||||
request
|
||||
.WithHeader("User-Agent", $"PCL-Community/PCL2-CE/{Basics.VersionName} (pclc.cc)")
|
||||
.WithHeader("Referer", $"https://{Basics.VersionCode}.ce.open.pcl2.server/");
|
||||
}
|
||||
|
||||
var requestId = Guid.NewGuid().ToString();
|
||||
if (enableLogging)
|
||||
LogWrapper.Info(
|
||||
"Request",
|
||||
$"Send request to {request.RequestUri} (method = {request.Method}, id = {requestId})");
|
||||
|
||||
var resp = await NetworkService.GetRetryPolicy(retryTimes)
|
||||
.ExecuteAsync(
|
||||
async token =>
|
||||
{
|
||||
if (enableLogging)
|
||||
LogWrapper.Debug("Request", $"Try attempt (id = {requestId})");
|
||||
try
|
||||
{
|
||||
using var requestCopy = await request
|
||||
.CloneAsync()
|
||||
.ConfigureAwait(false);
|
||||
return await httpClient
|
||||
.SendAsync(requestCopy, httpCompletionOption, token)
|
||||
.ConfigureAwait(false);
|
||||
}catch(Exception ex)
|
||||
{
|
||||
LogWrapper.Debug(ex, "Request", $"Try attempt failed (id = {requestId})");
|
||||
throw;
|
||||
}
|
||||
},cancellationToken
|
||||
)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (enableLogging)
|
||||
LogWrapper.Info("Request", $"End request, got http status code {resp.StatusCode} (id = {requestId})");
|
||||
return resp;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace PCL.Core.IO.Net.Http;
|
||||
|
||||
public abstract class HttpServer : IDisposable
|
||||
{
|
||||
private readonly HttpListener _server = new();
|
||||
public readonly ushort Port;
|
||||
public readonly string[] Host;
|
||||
|
||||
private Task? _handleLoop;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private readonly Dictionary<(HttpMethod method, string path), Func<HttpListenerRequest, Task<HttpRouteResponse>>> _handlers = new();
|
||||
private bool _initialized = false;
|
||||
|
||||
protected HttpServer(IPAddress[] listenAddr, ushort port = 0)
|
||||
{
|
||||
// Check parameters
|
||||
ArgumentNullException.ThrowIfNull(listenAddr);
|
||||
|
||||
// Resolve port
|
||||
if (port == 0) port = (ushort)NetworkHelper.NewTcpPort();
|
||||
Port = port;
|
||||
|
||||
// Resolve host
|
||||
if (listenAddr.Length == 0)
|
||||
listenAddr = [IPAddress.Loopback, IPAddress.IPv6Loopback];
|
||||
|
||||
var hosts = new List<string>();
|
||||
foreach (var address in listenAddr)
|
||||
{
|
||||
_server.Prefixes.Add($"http://{address}:{port}/");
|
||||
hosts.Add(address.ToString());
|
||||
}
|
||||
Host = hosts.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 初始化路由。子类应在此方法中调用 Register 方法注册路由。
|
||||
/// </summary>
|
||||
protected abstract void Init();
|
||||
|
||||
/// <summary>
|
||||
/// 注册一个路由处理器。
|
||||
/// </summary>
|
||||
/// <param name="method">HTTP 方法</param>
|
||||
/// <param name="path">路由路径</param>
|
||||
/// <param name="handler">请求处理函数</param>
|
||||
protected void Register(HttpMethod method, string path, Func<HttpListenerRequest, Task<HttpRouteResponse>> handler)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(method);
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
|
||||
_handlers[(method, path)] = handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动 HTTP 服务器。
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
// 如果没有注册路由,调用 Init 初始化
|
||||
if (!_initialized && _handlers.Count == 0)
|
||||
{
|
||||
Init();
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_server.Start();
|
||||
_handleLoop = _HandleRequestAsync();
|
||||
}
|
||||
|
||||
private async Task _HandleRequestAsync()
|
||||
{
|
||||
var cancellationToken = _cancellationTokenSource?.Token ?? CancellationToken.None;
|
||||
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _server.GetContextAsync();
|
||||
_ = Task.Run(async () => await _ProcessRequestAsync(context), cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { break; } // Cancellation
|
||||
catch (ObjectDisposedException) { break; } // Disposed
|
||||
catch (HttpListenerException) { break; } // Closed
|
||||
}
|
||||
}
|
||||
|
||||
private async Task _ProcessRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = context.Request;
|
||||
var response = context.Response;
|
||||
var path = request.Url?.AbsolutePath ?? string.Empty;
|
||||
var method = new HttpMethod(request.HttpMethod);
|
||||
|
||||
// 首先尝试精确匹配
|
||||
if (_handlers.TryGetValue((method, path), out var handler))
|
||||
{
|
||||
await _ExecuteHandlerAsync(handler, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果没有精确匹配,尝试通配符匹配
|
||||
if (_handlers.TryGetValue((method, "*"), out var wildcardHandler))
|
||||
{
|
||||
await _ExecuteHandlerAsync(wildcardHandler, request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// 没有找到匹配的路由
|
||||
response.StatusCode = (int)HttpStatusCode.NotFound;
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Response.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors when closing response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task _ExecuteHandlerAsync(Func<HttpListenerRequest, Task<HttpRouteResponse>> handler, HttpListenerRequest request, HttpListenerResponse response)
|
||||
{
|
||||
try
|
||||
{
|
||||
var routeResponse = await handler(request);
|
||||
routeResponse.Pour(response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
response.ContentEncoding = System.Text.Encoding.UTF8;
|
||||
response.ContentType = "text/plain";
|
||||
var errorResponse =
|
||||
HttpRouteResponse.Text($"Internal Server Error:\n{ex}", "text/plain", System.Text.Encoding.UTF8);
|
||||
errorResponse.Pour(response);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 停止 HTTP 服务器。
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_server.Stop();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
GC.SuppressFinalize(this);
|
||||
Stop();
|
||||
_server.Close();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user