Files
xyou f70b061d1a
CI / Go Backend (push) Canceled after 0s
feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
2026-08-08 14:07:56 +08:00

389 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Ae.Dns.Protocol.Enums;
using Ae.Dns.Protocol.Records;
using PCL.Core.IO.Net.Dns;
using PCL.Core.Logging;
using PCL.Core.Utils;
namespace PCL.Core.Minecraft;
public static class ServerAddressResolver
{
public readonly record struct ResolvedServerAddress(string Host, string? Ip, int Port);
// Minecraft Java 默认端口
private const int DefaultPort = 25565;
// Happy Eyeballs 族间启动间隔(降低首包时延)
private static readonly TimeSpan _HappyEyeballsStagger = TimeSpan.FromMilliseconds(250);
// 单次 TCP 连接超时
private static readonly TimeSpan _ConnectTimeout = TimeSpan.FromSeconds(2.5);
// [IPv6]:port 或 [IPv6]
private static readonly Regex _BracketedIpv6 =
new(@"^\[(?<ip>.+?)\](?::(?<port>\d{1,5}))?$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
// 纯端口(用于 host:port 末尾匹配)
private static readonly Regex _TrailingPort =
new(@":(?<port>\d{1,5})$", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public static async Task<ResolvedServerAddress> GetResolvedServerAddressAsync(string address, CancellationToken cancelToken = default)
{
if (string.IsNullOrWhiteSpace(address))
throw new ArgumentException("服务器地址不能为空", nameof(address));
// 规范化:去除 scheme、空白、尾随斜杠
address = _NormalizeInput(address);
// 1) 解析出 host/ip 与端口(若端口未提供则为 null)
var (hostOrIp, portOpt) = _ParseHostAndPort(address);
// 2) 显式端口 => 禁止 SRV,直接解析 IP 并尝试连接
if (portOpt is { } explicitPort)
{
_ValidatePort(explicitPort);
LogWrapper.Info($"使用显式端口,跳过 SRV{hostOrIp}:{explicitPort}");
var target = await _ResolveReachableAsync(hostOrIp, explicitPort, cancelToken).ConfigureAwait(false);
if (target is not null)
return new ResolvedServerAddress(hostOrIp, target.Value.Ip, target.Value.Port);
// 回退策略:无法连接则仍返回解析到的首个 IP
var fallbackIp = await _ResolveFirstIpAsync(hostOrIp, cancelToken).ConfigureAwait(false);
return new ResolvedServerAddress(hostOrIp, fallbackIp, explicitPort);
}
// 3) 未指定端口
// 3.1 纯 IPIPv4/IPv6=> 直接使用默认端口
if (IPAddress.TryParse(hostOrIp, out _))
{
var target = await _ResolveReachableAsync(hostOrIp, DefaultPort, cancelToken).ConfigureAwait(false);
if (target is not null)
return new ResolvedServerAddress(hostOrIp, target.Value.Ip, target.Value.Port);
return new ResolvedServerAddress(hostOrIp, hostOrIp, DefaultPort);
}
// 3.2 域名 => 先尝试 SRV_minecraft._tcp.),成功则按 SRV 顺序与加权尝试
var idnHost = _ToAsciiIdn(hostOrIp);
var srvOrdered = await _QuerySrvOrderedAsync(idnHost, cancelToken).ConfigureAwait(false);
if (srvOrdered.Count > 0)
{
LogWrapper.Info($"SRV 记录可用({srvOrdered.Count}: _minecraft._tcp.{idnHost}");
foreach (var srv in srvOrdered)
{
var targetHost = _TrimTrailingDot(srv.Target);
var port = srv.Port;
var reachable = await _ResolveReachableAsync(targetHost, port, cancelToken).ConfigureAwait(false);
if (reachable is not null)
{
LogWrapper.Info($"SRV 命中:{targetHost}:{port} -> {reachable.Value.Ip}:{port}");
return new ResolvedServerAddress(idnHost, reachable.Value.Ip, reachable.Value.Port);
}
}
// SRV 全部不可达则回退到 SRV 第一条的解析 IP 或域名默认端口
var first = srvOrdered[0];
var firstIp = await _ResolveFirstIpAsync(_TrimTrailingDot(first.Target), cancelToken).ConfigureAwait(false);
if (!string.IsNullOrEmpty(firstIp)) return new ResolvedServerAddress(idnHost, firstIp, first.Port);
}
else
{
LogWrapper.Info($"无 SRV 记录或查询失败,回退默认端口:{idnHost}:{DefaultPort}");
}
// 3.3 最终回退:域名 + 默认端口
var ip = await _ResolveFirstIpAsync(idnHost, cancelToken).ConfigureAwait(false);
return new ResolvedServerAddress(idnHost, ip, DefaultPort);
}
// 规范化地址输入:去掉 scheme、空白、尾随 '/'
private static string _NormalizeInput(string input)
{
var s = input.Trim();
// 去掉任意 scheme:// 前缀(例如 http://、https://、minecraft://
var schemeIdx = s.IndexOf("://", StringComparison.Ordinal);
if (schemeIdx > 0)
s = s[(schemeIdx + 3)..];
// 去掉尾随的 '/'
while (s.EndsWith("/", StringComparison.Ordinal))
s = s[..^1];
return s;
}
private static void _ValidatePort(int port)
{
if (port is < 1 or > 65535)
throw new FormatException($"无效的端口:{port}");
}
private static string _ToAsciiIdn(string host)
{
try
{
// 处理国际化域名
var idn = new IdnMapping();
// 允许末尾点号(FQDN
var h = _TrimTrailingDot(host);
return idn.GetAscii(h) + (host.EndsWith(".", StringComparison.Ordinal) ? "." : "");
}
catch
{
// 无法转换时返回原值,交给后续 DNS 解析处理
return host;
}
}
private static string _TrimTrailingDot(string host)
=> host.EndsWith(".", StringComparison.Ordinal) ? host[..^1] : host;
private static (string HostOrIp, int? Port) _ParseHostAndPort(string input)
{
// 1) [IPv6] 或 [IPv6]:port
var m = _BracketedIpv6.Match(input);
if (m.Success)
{
var ip = m.Groups["ip"].Value;
if (!IPAddress.TryParse(ip, out _))
throw new FormatException("无效的 IPv6 地址格式");
var portGroup = m.Groups["port"];
if (portGroup.Success)
{
var port = int.Parse(portGroup.Value, CultureInfo.InvariantCulture);
_ValidatePort(port);
return (ip, port);
}
return (ip, null);
}
// 2) 试图解析为纯 IPIPv4 或 IPv6 无端口)
if (IPAddress.TryParse(input, out _))
return (input, null);
// 3) host:port(仅在末尾存在且为纯数字端口时成立)
var pm = _TrailingPort.Match(input);
if (pm.Success)
{
// 防止误把 IPv6 当作 host:portIPv6 必须用中括号携带端口)
// 此处 input 中若包含多个 ':' 则极可能是 IPv6 而非 host:port
var colonCount = input.Count(c => c == ':');
if (colonCount == 1)
{
var port = int.Parse(pm.Groups["port"].Value, CultureInfo.InvariantCulture);
_ValidatePort(port);
var host = input[..^pm.Value.Length];
if (string.IsNullOrWhiteSpace(host))
throw new FormatException("无效的主机名");
return (host, port);
}
}
// 4) 其余情况按“域名(无端口)”处理
return (input, null);
}
// ===== SRV 查询与排序(RFC 2782 =====
private sealed record SrvRecord(int Priority, int Weight, int Port, string Target);
private static async Task<List<SrvRecord>> _QuerySrvOrderedAsync(string domain, CancellationToken ct)
{
try
{
var name = $"_minecraft._tcp.{_TrimTrailingDot(domain)}";
LogWrapper.Info($"尝试 SRV 查询:{name}");
// NDnsQuery.GetSrvRecords 返回 string 列表,为兼容不同实现,这里进行鲁棒解析
var raw = await DnsQuery.Instance.QueryAsync(name, DnsQueryType.SRV, ct);
if (raw is null || raw.Answers.Count == 0) return [];
List<SrvRecord> parsed = [];
foreach (var answer in raw.Answers)
{
if (answer.Resource is not DnsUnknownResource dnsRaw) return [];
var srcRecord = new DnsSrvResource();
var offset = 0;
srcRecord.ReadBytes(dnsRaw.Raw, ref offset, dnsRaw.Raw.Length);
parsed.Add(new SrvRecord(srcRecord.Priority, srcRecord.Weight, srcRecord.Port, srcRecord.Target));
}
// 过滤 target 为 "."(表示服务不可用)
parsed.RemoveAll(p => p.Target == ".");
if (parsed.Count == 0)
return [];
// RFC 2782:按 priority 升序;相同 priority 内按权重加权随机选择顺序
var ordered = new List<SrvRecord>(parsed.Count);
foreach (var group in parsed.GroupBy(p => p.Priority).OrderBy(g => g.Key))
{
var pool = group.ToList();
while (pool.Count > 0)
{
var next = _PopByWeight(pool);
ordered.Add(next);
}
}
return ordered;
}
catch (SocketException ex)
{
LogWrapper.Warn(ex, "SRV 查询失败(网络错误)");
return [];
}
catch (Exception ex)
{
LogWrapper.Warn(ex, "SRV 查询异常");
return [];
}
}
private static SrvRecord _PopByWeight(List<SrvRecord> pool)
{
// RFC 2782 加权随机:在组内以 weight 为权重抽取
var total = pool.Sum(p => p.Weight);
if (total <= 0)
{
// 无权重时等概率
var i = RandomUtils.NextInt(0, pool.Count - 1);
var chosen = pool[i];
pool.RemoveAt(i);
return chosen;
}
var r = RandomUtils.NextInt(1, total); // (1..total)
var sum = 0;
for (var i = 0; i < pool.Count; i++)
{
sum += pool[i].Weight;
if (sum >= r)
{
var chosen = pool[i];
pool.RemoveAt(i);
return chosen;
}
}
// 理论不可达,兜底返回末尾
var last = pool[^1];
pool.RemoveAt(pool.Count - 1);
return last;
}
// ===== DNS 与连接可达性 =====
private static async Task<(string Ip, int Port)?> _ResolveReachableAsync(string hostOrIp, int port, CancellationToken ct)
{
try
{
// 已是字面量 IP
if (IPAddress.TryParse(hostOrIp, out var ipLiteral))
{
var result = await _ConnectOneAsync(ipLiteral, port, ct).ConfigureAwait(false);
if (result.ok) return (ipLiteral.ToString(), port);
return null;
}
var addresses = await Dns.GetHostAddressesAsync(_TrimTrailingDot(hostOrIp), ct).ConfigureAwait(false);
if (addresses.Length == 0)
return null;
// Happy Eyeballs:分组(IPv6、IPv4),按组分阶段并行连接,取首个成功
var v6 = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetworkV6).ToArray();
var v4 = addresses.Where(a => a.AddressFamily == AddressFamily.InterNetwork).ToArray();
// 第一阶段:IPv6
var winner = await _ConnectAnyAsync(v6, port, TimeSpan.Zero, ct).ConfigureAwait(false);
if (winner is not null)
return (winner, port);
// 第二阶段:IPv4(稍作延迟以避免同时轰炸)
winner = await _ConnectAnyAsync(v4, port, _HappyEyeballsStagger, ct).ConfigureAwait(false);
if (winner is not null)
return (winner, port);
return null;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogWrapper.Warn(ex, $"解析或连接失败:{hostOrIp}:{port}");
return null;
}
}
private static async Task<string?> _ResolveFirstIpAsync(string hostOrIp, CancellationToken ct)
{
try
{
if (IPAddress.TryParse(hostOrIp, out var ip))
return ip.ToString();
var addresses = await Dns.GetHostAddressesAsync(_TrimTrailingDot(hostOrIp), ct).ConfigureAwait(false);
var chosen = addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetworkV6)
?? addresses.FirstOrDefault(a => a.AddressFamily == AddressFamily.InterNetwork);
return chosen?.ToString();
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
LogWrapper.Warn(ex, $"DNS 解析失败:{hostOrIp}");
return null;
}
}
private static async Task<string?> _ConnectAnyAsync(IReadOnlyList<IPAddress> addrs, int port, TimeSpan delay, CancellationToken ct)
{
if (addrs.Count == 0) return null;
if (delay > TimeSpan.Zero)
await Task.Delay(delay, ct).ConfigureAwait(false);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var tasks = new List<Task<(bool ok, string ip)>>(addrs.Count);
tasks.AddRange(addrs.Select(ip => _ConnectOneAsync(ip, port, cts.Token)));
while (tasks.Count > 0)
{
var done = await Task.WhenAny(tasks).ConfigureAwait(false);
tasks.Remove(done);
var (ok, ip) = await done.ConfigureAwait(false);
if (!ok) continue;
// 取消其余连接尝试
try { await cts.CancelAsync().ConfigureAwait(false); } catch { /* ignore */ }
return ip;
}
return null;
}
private static async Task<(bool ok, string ip)> _ConnectOneAsync(IPAddress ip, int port, CancellationToken ct)
{
try
{
using var sock = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
sock.NoDelay = true;
using var timeoutCts = new CancellationTokenSource(_ConnectTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
#if NET8_0_OR_GREATER
await sock.ConnectAsync(new IPEndPoint(ip, port), linked.Token).ConfigureAwait(false);
#else
await sock.ConnectAsync(new IPEndPoint(ip, port)).WaitAsync(ConnectTimeout, linked.Token).ConfigureAwait(false);
#endif
return (true, ip.ToString());
}
catch
{
return (false, ip.ToString());
}
}
}