373 lines
16 KiB
Markdown
373 lines
16 KiB
Markdown
# 组播服务发现叠加 实施计划
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** 在现有 UDP 广播发现机制上叠加组播探测路径(组 `239.192.48.92:48923`,TTL=8),使发现请求有机会跨网段到达开发机;现有广播/loopback/写死 IP 路径全部保留。
|
|
|
|
**Architecture:** 服务端响应器在现有 `0.0.0.0:48923` socket 上逐网卡加入组播组(收发逻辑零改动);客户端两份探测器(热更层 + 底包 AOT 层,协议内联须同步)在每轮重发中逐网卡设 `MulticastInterface` 向组播组发查询,回复仍走单播。客户端只发不收组播,Android 无需 MulticastLock。
|
|
|
|
**Tech Stack:** C# / .NET(netstandard2.1 共享协议)、Unity 客户端、xUnit 服务端测试。
|
|
|
|
**Spec:** `docs/superpowers/specs/2026-07-06-multicast-discovery-design.md`
|
|
|
|
**注意:** 项目根 `.git` 是空目录、非有效 git 仓库,故本计划省略 commit 步骤,以"运行测试/编译验证"作为每任务收尾。若后续 `git init`,可按任务边界补提交。
|
|
|
|
**关键背景(执行者必读):**
|
|
- `Server\Framework.Shared\Framework.Shared.csproj` 通过 `<Compile Include="..\..\Client\Assets\Framework\Shared\**\*.cs" />` 直接编译 Client 共享源码 → 协议常量只需改 `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs` 一处,服务端自动生效。
|
|
- `Client\Assets\Base\LocalCdnDiscovery.cs` 属底包(AOT)程序集,**不能**引用上述共享程序集(运行时才由 LoadDll 加载),协议常量是手工内联的,改动须与共享层同步(文件头注释已有此约定)。
|
|
- 发现机制是"多路齐发、谁通算谁",所有网络操作失败均静默吞掉,不中断其余路径——新增代码须保持此风格。
|
|
- 测试命令均在仓库根 `D:\UD\AI\AIC#Project` 下执行;路径含 `#`,bash 中必须加引号。
|
|
|
|
---
|
|
|
|
### Task 1: 协议常量 + 服务端组播 join(TDD)
|
|
|
|
**Files:**
|
|
- Modify: `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs`(加常量)
|
|
- Modify: `Server\Gateway\DevDiscoveryResponder.cs`(逐网卡 join 组播组)
|
|
- Test: `Server\Gateway.Tests\DevDiscoveryResponderTests.cs`(加组播用例)
|
|
|
|
- [ ] **Step 1: 加协议常量**
|
|
|
|
在 `Client\Assets\Framework\Shared\Protocol\DevDiscovery.cs` 的 `DevDiscovery` 静态类中新增(放在 `Port` 之后):
|
|
|
|
```csharp
|
|
// 组播发现组:组织本地范围(RFC 2365, 239.192.0.0/14),配合发送端 TTL>1 可跨内网多级路由;
|
|
// 能否真正跨网段取决于路由器是否开启组播路由(PIM/IGMP Proxy)。
|
|
public const string MulticastGroup = "239.192.48.92";
|
|
```
|
|
|
|
- [ ] **Step 2: 写失败测试**
|
|
|
|
在 `Server\Gateway.Tests\DevDiscoveryResponderTests.cs` 中新增用例(与现有用例同级):
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task MulticastQuery_ReceivesReply()
|
|
{
|
|
var responder = new DevDiscoveryResponder("dev-pc", "ws://x", "http://y", token: "secret", listenPort: 0);
|
|
responder.Start();
|
|
try
|
|
{
|
|
using var client = new UdpClient(AddressFamily.InterNetwork);
|
|
// 同机回环验证组播收包路径:loopback 开、TTL=1 不出网段
|
|
client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastLoopback, true);
|
|
client.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 1);
|
|
byte[] q = DevDiscoveryCodec.EncodeQuery(new DevDiscoveryQuery
|
|
{
|
|
ProtoVersion = DevDiscovery.ProtoVersion,
|
|
Nonce = 21,
|
|
Token = "secret",
|
|
});
|
|
var groupEp = new IPEndPoint(IPAddress.Parse(DevDiscovery.MulticastGroup), responder.Port);
|
|
await client.SendAsync(q, q.Length, groupEp);
|
|
|
|
var recv = client.ReceiveAsync();
|
|
var done = await Task.WhenAny(recv, Task.Delay(2000));
|
|
Assert.Same(recv, done); // 超时即失败:响应器没收到组播查询
|
|
UdpReceiveResult result = await recv;
|
|
Assert.True(DevDiscoveryCodec.TryDecodeReply(result.Buffer, out var reply));
|
|
Assert.Equal(21u, reply.Nonce);
|
|
Assert.Equal("secret", reply.Token);
|
|
}
|
|
finally { await responder.StopAsync(); }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: 跑测试确认失败**
|
|
|
|
```bash
|
|
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
|
|
```
|
|
|
|
预期:FAIL,`Assert.Same` 断言失败(2 秒超时,响应器未 join 组播组收不到查询)。
|
|
|
|
- [ ] **Step 4: 实现服务端逐网卡 join**
|
|
|
|
修改 `Server\Gateway\DevDiscoveryResponder.cs`:
|
|
|
|
文件头 using 增加:
|
|
|
|
```csharp
|
|
using System.Net.NetworkInformation;
|
|
```
|
|
|
|
构造函数中 `Port = ...` 赋值行之后新增一行调用:
|
|
|
|
```csharp
|
|
JoinMulticastOnAllNics(_udp, IPAddress.Parse(DevDiscovery.MulticastGroup));
|
|
```
|
|
|
|
类中新增私有静态方法(放在构造函数之后):
|
|
|
|
```csharp
|
|
// 逐块 Up 状态非回环 IPv4 网卡加入组播组:默认 JoinMulticastGroup(group) 只加默认网卡,
|
|
// 多网卡开发机(有线+无线/虚拟网卡)会漏收。逐网卡 try/catch,单卡失败不中断其余;
|
|
// 一块都没 join 上时兜底按默认网卡 join 一次。
|
|
private static void JoinMulticastOnAllNics(UdpClient udp, IPAddress group)
|
|
{
|
|
bool joinedAny = false;
|
|
NetworkInterface[] nics;
|
|
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
|
|
catch { nics = Array.Empty<NetworkInterface>(); }
|
|
foreach (NetworkInterface ni in nics)
|
|
{
|
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
|
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
|
IPInterfaceProperties props;
|
|
try { props = ni.GetIPProperties(); }
|
|
catch { continue; }
|
|
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
|
{
|
|
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
|
try { udp.JoinMulticastGroup(group, ua.Address); joinedAny = true; }
|
|
catch (SocketException) { }
|
|
}
|
|
}
|
|
if (!joinedAny)
|
|
{
|
|
try { udp.JoinMulticastGroup(group); }
|
|
catch (SocketException) { }
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: 跑测试确认通过**
|
|
|
|
```bash
|
|
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Gateway.Tests --filter MulticastQuery_ReceivesReply
|
|
```
|
|
|
|
预期:PASS。
|
|
|
|
- [ ] **Step 6: 跑服务端全量测试防回归**
|
|
|
|
```bash
|
|
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
|
|
```
|
|
|
|
预期:全部 PASS(尤其 `DevDiscoveryResponderTests` 原有 3 条、`GameServerHostDiscoveryTests`、`DevDiscoveryCodecTests`)。
|
|
|
|
---
|
|
|
|
### Task 2: 客户端热更层 DevServerDiscovery 发组播
|
|
|
|
**Files:**
|
|
- Modify: `Client\Assets\Script\xmain\MiniGame\DevServerDiscovery.cs`
|
|
|
|
无法为 Unity 客户端写自动化测试,验证靠 Task 4 的编辑器实测。
|
|
|
|
- [ ] **Step 1: 更新写死 IP 的注释**
|
|
|
|
将字段 `DevExtraProbeIps` 上方注释(两行)替换为:
|
|
|
|
```csharp
|
|
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
|
|
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
|
|
// 已叠加组播探测(DevDiscovery.MulticastGroup):若实测组播能跨网段,可删本兜底。
|
|
```
|
|
|
|
- [ ] **Step 2: socket 初始化后设组播 TTL**
|
|
|
|
在 `Run()` 中 `udp.Client.ReceiveTimeout = 250;` 之后新增:
|
|
|
|
```csharp
|
|
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
|
|
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
|
|
```
|
|
|
|
- [ ] **Step 3: 每轮重发时逐网卡发组播**
|
|
|
|
在 `Run()` 中 `IPEndPoint[] targets = targetList.ToArray();` 之后新增:
|
|
|
|
```csharp
|
|
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
|
|
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
|
|
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(DevDiscovery.MulticastGroup), DevDiscovery.Port);
|
|
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
|
|
Debug.Log("[DevTest] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
|
|
```
|
|
|
|
在发送循环(`foreach (IPEndPoint t in targets) { try { udp.Send(...) } catch { } }`)之后、`nextSend = ...` 之前新增:
|
|
|
|
```csharp
|
|
foreach (IPAddress nicAddr in multicastNics)
|
|
{
|
|
try
|
|
{
|
|
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
|
|
udp.Send(query, query.Length, multicastEp);
|
|
}
|
|
catch { }
|
|
}
|
|
if (multicastNics.Count == 0)
|
|
{
|
|
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: 新增网卡地址枚举辅助方法**
|
|
|
|
在 `GetDirectedBroadcasts()` 之后新增(枚举骨架与其一致,但无需掩码):
|
|
|
|
```csharp
|
|
// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。
|
|
private static List<IPAddress> GetLocalIPv4Addresses()
|
|
{
|
|
List<IPAddress> list = new List<IPAddress>();
|
|
NetworkInterface[] nics;
|
|
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
|
|
catch (Exception e) { Debug.LogWarning("[DevTest] enum nics for multicast failed: " + e.Message); return list; }
|
|
|
|
foreach (NetworkInterface ni in nics)
|
|
{
|
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
|
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
|
|
|
IPInterfaceProperties props;
|
|
try { props = ni.GetIPProperties(); }
|
|
catch { continue; }
|
|
|
|
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
|
{
|
|
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
|
list.Add(ua.Address);
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: 确认编译无错**
|
|
|
|
Unity 编辑器会自动编译;若编辑器未开,至少目视核对:新代码只用了文件已 using 的命名空间(`System.Net`、`System.Net.NetworkInformation`、`System.Net.Sockets`),`DevDiscovery.MulticastGroup` 来自已 using 的 `XWorld.Framework.Protocol`。
|
|
|
|
---
|
|
|
|
### Task 3: 客户端底包 LocalCdnDiscovery 发组播(与热更层同步)
|
|
|
|
**Files:**
|
|
- Modify: `Client\Assets\Base\LocalCdnDiscovery.cs`
|
|
|
|
底包不能引用共享程序集,常量手工内联;逻辑与 Task 2 完全对应。
|
|
|
|
- [ ] **Step 1: 内联组播常量**
|
|
|
|
在 `private const int Port = 48923;` 一组常量之后(`TypeReply` 之后)新增:
|
|
|
|
```csharp
|
|
private const string MulticastGroup = "239.192.48.92"; // = DevDiscovery.MulticastGroup
|
|
```
|
|
|
|
- [ ] **Step 2: 更新写死 IP 的注释**
|
|
|
|
将字段 `DevExtraProbeIps` 上方注释(两行)替换为:
|
|
|
|
```csharp
|
|
// 跨网段内网联调用:手机与开发机不在同一子网时,广播到不了开发机(定向广播默认不被路由器转发,RFC 2644,已实测不通)。
|
|
// 故直接向"已知开发机单播 IP"发发现查询——单播能跨网段路由,响应器绑 0.0.0.0:48923 单播照收照回。换机器/网段改这里。
|
|
// 已叠加组播探测(MulticastGroup):若实测组播能跨网段,可删本兜底。
|
|
```
|
|
|
|
- [ ] **Step 3: socket 初始化后设组播 TTL**
|
|
|
|
在 `Discover()` 中 `udp.Client.ReceiveTimeout = 250;` 之后新增:
|
|
|
|
```csharp
|
|
// 组播默认 TTL=1 出不了本网段;8 覆盖内网多级路由
|
|
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 8);
|
|
```
|
|
|
|
- [ ] **Step 4: 每轮重发时逐网卡发组播**
|
|
|
|
在 `Discover()` 中 `IPEndPoint[] targets = targetList.ToArray();` 之后新增:
|
|
|
|
```csharp
|
|
// 组播端点不进 targets:发组播走哪块网卡由 IP_MULTICAST_IF 决定(默认只走默认路由网卡),
|
|
// 故须逐网卡切换后各发一次,覆盖编辑器/PC 多网卡;手机一般单网卡。
|
|
IPEndPoint multicastEp = new IPEndPoint(IPAddress.Parse(MulticastGroup), Port);
|
|
List<IPAddress> multicastNics = GetLocalIPv4Addresses();
|
|
UnityEngine.Debug.Log("[Discovery] multicast=" + multicastEp + " via " + multicastNics.Count + " nic(s)");
|
|
```
|
|
|
|
在发送循环(`foreach (IPEndPoint t in targets) { try { udp.Send(...) } catch { } }`)之后、`nextSend = ...` 之前新增:
|
|
|
|
```csharp
|
|
foreach (IPAddress nicAddr in multicastNics)
|
|
{
|
|
try
|
|
{
|
|
udp.Client.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastInterface, nicAddr.GetAddressBytes());
|
|
udp.Send(query, query.Length, multicastEp);
|
|
}
|
|
catch { }
|
|
}
|
|
if (multicastNics.Count == 0)
|
|
{
|
|
try { udp.Send(query, query.Length, multicastEp); } catch { } // 枚举不到网卡时按默认网卡发
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: 新增网卡地址枚举辅助方法**
|
|
|
|
在 `GetDirectedBroadcasts()` 之后新增:
|
|
|
|
```csharp
|
|
// 枚举本机所有 Up 状态非回环网卡的 IPv4 地址,供逐网卡发组播用。
|
|
private static List<IPAddress> GetLocalIPv4Addresses()
|
|
{
|
|
List<IPAddress> list = new List<IPAddress>();
|
|
NetworkInterface[] nics;
|
|
try { nics = NetworkInterface.GetAllNetworkInterfaces(); }
|
|
catch (Exception e) { UnityEngine.Debug.LogWarning("[Discovery] enum nics for multicast failed: " + e.Message); return list; }
|
|
|
|
foreach (NetworkInterface ni in nics)
|
|
{
|
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
|
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
|
|
|
IPInterfaceProperties props;
|
|
try { props = ni.GetIPProperties(); }
|
|
catch { continue; }
|
|
|
|
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
|
|
{
|
|
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
|
list.Add(ua.Address);
|
|
}
|
|
}
|
|
return list;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: 目视核对两边一致性**
|
|
|
|
对照 `DevServerDiscovery.cs`:组播组地址、TTL 值、逐网卡发送逻辑、兜底发送逻辑须完全一致(仅日志前缀 `[Discovery]`/`[DevTest]` 和常量引用方式不同)。
|
|
|
|
---
|
|
|
|
### Task 4: 端到端验证
|
|
|
|
**Files:** 无代码改动,纯验证。
|
|
|
|
- [ ] **Step 1: 服务端全量测试**
|
|
|
|
```bash
|
|
cd "/d/UD/AI/AIC#Project" && dotnet test Server/Server.sln
|
|
```
|
|
|
|
预期:全部 PASS。
|
|
|
|
- [ ] **Step 2: 编辑器同机自测(需用户配合)**
|
|
|
|
启动服务端网关(带发现响应器)与 Unity 编辑器客户端,观察客户端日志:
|
|
- 出现 `[DevTest] multicast=239.192.48.92:48923 via N nic(s)`(N ≥ 1)
|
|
- 出现 `[Discovery] multicast=239.192.48.92:48923 via N nic(s)`(底包路径,若走到)
|
|
- 发现成功(同机场景下 loopback 路径本来就通,此步主要确认组播代码不报错、不拖慢发现)
|
|
|
|
- [ ] **Step 3: 真机验证(需用户配合,自动化范围之外)**
|
|
|
|
按 spec 验收标准:
|
|
1. 真机同网段:发现成功。
|
|
2. 真机跨网段:观察组播路径是否通。通 → 建后续任务删 `DevExtraProbeIps` 写死 IP;不通 → 保留写死 IP,评估"探测 IP 可配置化"方案(spec 非目标节)。
|