P2P 下载加速
你在客厅电脑上装了个整合包,同一个星云 ID 的笔记本上已经有了——那何必再从公网下一遍。我们的下载管线有第三个源:同一局域网里同账号的其他设备。这页讲它怎么工作,以及为什么它失败的时候你什么都不会看到。
只认 SHA1
下载管线里每个待下文件都带着 SHA1(这是 Mojang 清单给的口径)。P2P 查找就是拿这个 SHA1 去问对端:「你有没有这个文件?」
为什么不能靠文件名:版本 jar 在不同实例里叫法不一样(versions/1.20.1/1.20.1.jar vs versions/1.20.1-fabric/1.20.1-fabric.jar),两个实例的 mod 文件名可能撞车但内容不同。文件名只能筛候选,最后必须靠内容判定。
所以安全线是:没有期望哈希一律不认。LocalFileReuse.FindByHash 里第一行就是:
if (string.IsNullOrEmpty(expectedSha1)) return null;先比大小(省掉绝大多数哈希计算),再算 SHA1 校验,对不上就当没找到。
PeerCount == 0 时完全跳过
public int PeerCount => _settings().P2pDownloadEnabled && Running ? _beacon?.Snapshot().Count ?? 0 : 0;下载管线的接入点:
var peer = PeerSource?.Invoke();
if (peer is { PeerCount: > 0 })
{
foreach (var f in pending.ToList())
{
if (string.IsNullOrEmpty(f.Sha1)) continue; // 没哈希可比:不冒险
if (!peer.TryProvide(f.Dest, f.Sha1, f.Size, out var via)) continue;
pending.Remove(f);
}
}PeerCount == 0 时整个 for 循环都不进——不建连接、不发包、不算哈希,零开销。这是绝大多数用户的情况:没开设备链路,或者开着但局域网里只有自己一台。
失败静默回退公网
TryProvide 里对每个对端:
try
{
var has = client.CommandAsync(new { cmd = "p2p.has", sha1, size, destName });
if (!has.Ok) continue; // 对端没有 → 试下一个
var result = client.CommandAsync(new { cmd = "p2p.fetch", ... }, destFile, expectSha256: sha1);
if (result.Ok) { viaPeer = peer.Name; return true; }
}
catch
{
// 单个对端失败不影响其余对端与公网回退
}任何一步失败——对端没这个文件、连接超时、校验不过——都只是「这个对端不行」,接着试下一个对端,全试完还是不行就返回 false,下载管线照常走公网。
成功时才会留下一条日志(P2P 复用 xxx.jar ← 我的笔记本)和进度文案里的 (P2P 复用 3 个)。
P2P 是加速,不是功能
界面上永远不出现「内网没找到」「P2P 不可用」这类提示。P2P 命中就快一点,没命中就照常走公网——两者都是正常路径。把加速手段的失败当错误报出来,用户会以为出了故障,实际上什么都没发生。
两层缓存
直接对每个对端发请求是可行的,但有个明显问题:同一次下载里可能有几十个文件,每个文件都问一遍所有对端,问的还是同一批 SHA1。所以本地做两层缓存。
第一层:哈希 → 路径索引(进程内,_hashIndex)。现场算过一次 SHA1 → 命中路径的对应关系记住:
private readonly Dictionary<string, string> _hashIndex = new(StringComparer.OrdinalIgnoreCase);对端答复「我有这个文件」时,第二次问同一个 SHA1 直接拿路径,不用再算。
第二层:本地复用索引(_reuseIndex),10 分钟重建一次:
if (_reuseIndex is null || Environment.TickCount64 - _reuseIndexAt > TimeSpan.FromMinutes(10).TotalMilliseconds)
{
_reuseIndex = LocalFileReuse.Scan(s.DotMinecraft);
_reuseIndex.AddLibraryDirectory(LauncherPaths.LibraryDir);
_reuseIndexAt = Environment.TickCount64;
}这一层扫的是本机已有的文件(各版本的 mod / 资源包 / 光影 / 数据包、版本 jar,加上启动器自己的资源库 library/),对端要回答「你有没有这个文件」时就在这个索引里查。
10 分钟是权衡:重建要遍历目录列文件,几十 MB 的实例每次重建都有开销;同时安装过程中本地文件在变,太久不重建会漏掉新装的文件。十分钟够反映一次安装,又不会每次问对端都重扫一遍。
LocalFileReuse 只建文件名索引
这一点容易看错。LocalFileReuse.Scan 不算任何哈希,它只做一件事——把文件名映射到候选路径列表:
private readonly Dictionary<string, List<string>> _byName = new(StringComparer.OrdinalIgnoreCase);扫描范围是各版本目录下的 mods / resourcepacks / shaderpacks / datapacks(只扫顶层文件——mods 下的子目录不是规范布局,深扫会把索引建得又慢又大),加上共享根的同名目录,以及所有 versions/<id>/<id>.jar。
哈希是查询时才算的,而且只算候选:
if (size > 0 && info.Length != size) continue; // 先比大小:省掉绝大多数哈希
if (Sha1Matches(candidate, expectedSha1)) return candidate;候选通常只有个位数,单文件哈希毫秒级,所以现场算完全够用。版本 jar 是唯一需要跨文件名认亲的目标——它的文件名里带版本 id,每个实例都不一样,所以单独存在 _versionJars 列表里。
FindByHash 和 TryProvide 共用这条安全线:绝不拿「文件名像」的文件凑数。
顺序:本地复用在前,P2P 在后
下载管线里这两个加速是串起来的:
- 本地复用(
LocalFileReuse)——同一份文件别的实例里已经有了,直接复制。同一版本的 client jar 是十几 MB 量级,这一项最省时间。 - P2P 对等复用(
IPeerFileSource)——局域网同账号设备已有同 SHA1 文件,网内拉取。 - 公网下载。
两者都只认 SHA1、都静默失败、都不影响正确性——它们只是把「从公网下」换成「从本地拷」或「从邻居拷」,内容该校验还是校验。