下载引擎总览
下载子系统分四层:编排层决定「下什么、怎么分摊」,源注册表决定「URL 改写成谁」,分片层负责「一个文件怎么切、怎么并行」,并发治理层负责「这个域名现在允许多少条连接」。这页讲它们怎么串起来,以及批量下载时省流量的三级复用。
DownloadService 是唯一入口。批量下载走 DownloadBatchAsync:过滤已完成文件、本地复用、P2P 复用,然后把剩下的文件丢进 SemaphoreSlim 限流的并发流里。
每个文件进 DownloadFromAsync 后先做一次「能力判定」,决定走哪条物理路径:
if (size >= ChunkedFileDownloader.Threshold && range == true)
await _chunked.DownloadAsync(...); // 分片竞速
// 否则
await DownloadWholeFileAsync(...); // 整文件 + 换源重试range 来自 DownloadSourceRegistry.RangeSupport(host)——主机级记忆,没有记录就探一次。体积不够就直接整文件,不浪费探测;体积够但源不支持 Range 也只能整文件。
分片失败不会让整个下载失败,而是降级到整文件路径。降级是必要的兜底:分片路径有完整性闸门和整文件 SHA1 校验,任何一项不过都抛错,让整文件路径接手。
批量下载的三级复用
第一级:目标已存在且大小相同,直接跳过:
if (File.Exists(f.Dest) && f.Size > 0 && new FileInfo(f.Dest).Length == f.Size)
continue;这里只比大小,不比哈希——多数文件本来就带官方 SHA1。
第二级:本地复用。LocalFileReuse.Scan 扫当前 .minecraft 的 versions/、各实例的 mods 目录,以及启动器自己的 library/ 目录。命中就 File.Copy 过去,不走网络。同版本 jar 是十几 MB 量级,这一项最省时间。
第三级:P2P。局域网内同账号的另一台设备已经有同 SHA1 文件时,直接从网内拉。只认 SHA1——没有哈希的条目一律不参与。
三级都是加速手段,任何一级出错都只是退回正常下载。
进度会重复上报
换源重试和断点续传都会把同一段字节再报一遍。直接累加,进度条会冲过 100%:
var raw = Interlocked.Read(ref doneBytes) + lastReported;
var doneNow = totalBytes > 0 ? Math.Min(raw, totalBytes) : raw;totalBytes 是「未复用部分」的总大小,被复用的文件不参与这个分母——这也是为什么 pending.Count == 0 的分支要小心(会显示成 7300/3650)。
元数据请求必须有独立超时
下载大文件的 HttpClient 是 Timeout.InfiniteTimeSpan,这是必须的——总时长超时会误杀慢速大文件(速度 × 60s ≈ 24MB 处必然被掐死)。但元数据请求走的是同一个 client,不加限制就会永远转圈。
原因很具体:BMCLAPI 会把请求 302 给 P2P 节点,实测约六成节点「TCP 连上了但永不应答」。用户看到的是「点了下载一直转」,无字节、无进度、无报错。
所以元数据走连接级 CTS,每个源 12 秒:
internal static TimeSpan MetadataTimeout = TimeSpan.FromSeconds(12);源之间逐个尝试。池里只有一个源时给两次机会,Mojang 镜像池有多源时一次即换。
镜像返回 HTML 要当故障处理
有些镜像的路径根本不存在,返回的是站点首页 HTML 而不是 404。GetStringAsync 拿到这种内容直接交给 JSON 解析的话,报错会是「意外的字符 '<'」,跟真实原因毫无关系。
我们用「首个非空白字符是不是 <」判断——合法 JSON 永远不会以 < 开头,这个判据足够:
private static bool LooksLikeHtml(string text)
{
foreach (var c in text)
{
if (char.IsWhiteSpace(c)) continue;
return c == '<';
}
return false;
}命中就当这个源故障,换下一个。