十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#断点续传下载组件实战:基于HTTP Range的DownLoadFile源码解析

C#断点续传下载组件实战:基于HTTP Range的DownLoadFile源码解析 简介面向C#开发者的网络编程实践源码包专注断点续传下载文件的核心实现代码演示如何通过Range请求从上次中断位置继续下载避免大文件传输因网络波动而前功尽弃特别适合有WinForm或基础网络编程经验的开发者学习。包内含25个文件压缩后仅45KB轻量便于快速分析主要文件包括6个C#源代码文件、3个可直接运行的exe程序、2个resx资源文件界面布局与本地化数据以及ReadMe.txt说明文档和sln解决方案文件目录结构清晰可对照阅读。已有116人学习下载代码覆盖断点续传的关键环节检测本地文件大小、请求远程文件信息、设置Range请求头、分块写入数据、异常重试机制与进度提示初学者可借此理解网络流与文件流的配合方式有经验者也能将其核心类抽取封装用于自己的下载工具或更新模块。1. 断点续传不是重新下载C# 下载组件的关键分水岭下载到一半断网2GB 的安装包白下了——这是不少 c# 开发者在用 WebClient.DownloadFile 时遇到的第一道坎。系统自带的下载 API 默认不具备断点续传能力它每次请求都从服务器请求整个文件本地临时文件一清进度归零。这个名为 DownLoadFile 的 WinForms 源码工程解决的问题正是这一点用 HTTP Range 请求头把「已经下载了多少字节」显式告诉服务器让中断的任务从上次落盘的偏移量继续往下传。适合正在写下载器、做内网包分发或者在上位机固件升级里被大文件传输折腾的人参考。2. HTTP Range 协议断点续传背后的状态记录机制断点续传的起点不是文件而是协议。要告诉服务器从哪里继续前提是把「本地已下载的字节数」翻译成 HTTP 请求里的偏移量。这一节把 Range 请求头、206 响应和 Content-Range 的换算关系拆开看。2.1 Range 请求头与 206 Partial Content 的响应语义HTTP/1.1 通过 Range 请求头支持从指定位置读取资源。最常用的形式是Range: bytesstart-比如bytes75381-表示从第 75381 字节开始取到文件尾部。在 c# 里不需要手工拼接这个字符串HttpWebRequest.AddRange(long)会自动编码成标准请求头。由于这个源码工程没有用 HttpClient我沿用了基于 HttpWebRequest 的写法两者在处理 Range 时的语义一致。AddRange还有一个三参数重载可以指定起始和结束字节常用于分片多线程下载断点续传只需要起始偏移。注意 AddRange 的入参是字节偏移不是块编号也不是剩余字节数差一个字节文件拼回去就是损坏的。实际项目中看到续传后文件打不开的情况八成是这里把现有文件长度多算或少算了一位。当服务端支持 Range 时返回状态码 206 Partial Content。此时响应里的Content-Length是本次传输的字节数也就是剩余文件大小文件总大小要从Content-Range头解析。如果服务端不认 Range则返回 200 OK 并传输完整文件。三种情况的处理策略先用一张表说清状态码含义本地文件处理200 OK服务端忽略 Range返回完整文件清空本地已有内容从头写入206 Partial Content从指定偏移继续传输打开本地文件并 Seek 到偏移处追加416 Range Not Satisfiable请求的偏移超过文件实际大小校验本地文件不匹配则删掉重下这段逻辑是整个断点续传的分水岭206 走追加200 走覆盖416 走重来。源码里对 200 的兜底处理特别重要因为并不是所有服务器——尤其是内网里的一些静态文件服务——都实现了 Range。把 200 当成 206 处理会在已有文件后面不停追加拼出一份乱序文件。2.2 Content-Range 解析总长度与剩余长度的换算206 响应里的Content-Range形如bytes 75381-1048575/1048576包含三段信息起始偏移、结束偏移、资源总大小。最后一段是斜杠后面的数字也是判断整个下载是否完成的依据。习惯上直接用response.ContentLength拿剩余长度但它在某些代理环境下解析不稳定所以更稳妥的做法是解析 Content-Range。private static long ParseTotalFromContentRange(HttpWebResponse response) { string contentRange response.Headers[Content-Range]; if (string.IsNullOrEmpty(contentRange)) { return response.ContentLength; // 没有该头时回退到 Content-Length } int slash contentRange.LastIndexOf(/); if (slash 0) { return response.ContentLength; } string total contentRange.Substring(slash 1); if (total *) // 分块传输时总大小可能是通配符 { return -1; } return long.TryParse(total, out long size) ? size : -1; }这个方法的返回值用于进度计算当前累计下载量 本地已有长度 每轮 Read 写入的字节数分母取这里解析出的总大小。返回 -1 时进度条只能显示字节数无法计算百分比。LastIndexOf(/)是防bytes 0-0/*这类边界响应斜杠后不一定总是数字。另一个容易忽略的点是 long 溢出总大小超过 2GB 时ContentLength返回 64 位整数但不少老代码习惯把它强转 int这里要避免。2.3 本地文件状态检测与续传起点计算续传起点来自本地文件但文件长度不是唯一需要确认的信息。如果上一次下载已经把文件写完整只是在改名或校验环节失败直接续传会请求一个越界的偏移量服务器回 416。所以在构造请求前先做存在性和长度判断long existingLength 0; if (File.Exists(localPath)) { existingLength new FileInfo(localPath).Length; if (existingLength 0) { File.Delete(localPath); // 空文件没有续传价值 } } HttpWebRequest request (HttpWebRequest)WebRequest.Create(url); if (existingLength 0) { request.AddRange(existingLength); }这段代码有一个容易被忽视的细节0 字节文件也存在FileInfo.Length为 0如果不删掉后续写入会从文件开头覆盖看起来没问题但会留下一条无意义的空文件记录。真正要防的是「文件存在、长度非 0、但内容已损坏」的情况。稳妥做法是下载前用 HEAD 请求拿一次服务器端大小与本地长度比较不一致直接重下。HEAD 的开销远小于错误续传后大规模重传的代价。提示断点续传的前提是「同一个 URL、同一个本地路径、文件内容未变化」。任何一环被外部改写续传都会产生错误文件。检测阶段多花一次 HEAD 请求能省掉写完后才发现文件不对的返工。3. DownLoadFile 源码工程剖析从请求构造到分块落盘这一章进入源码本身。DownLoadFile 工程规模不大但结构完整适合作为 c# 网络操作的入门到进阶案例。我按文件职责、核心方法、UI 同步和容错设计四个角度拆。3.1 工程结构拆解Form1.cs 与 Program.cs 的职责打开 DownLoadFile.sln 之后第一眼看到的是标准的 WinForms 布局。核心文件不超过十个文件职责Program.cs应用入口Main 方法里启动 Form1Form1.cs主窗口逻辑含下载按钮事件、进度回调、断点续传核心方法Form1.Designer.cs界面控件定义进度条、文本框、按钮的布局与属性Form1.resx窗体资源文件DownLoadFile.csproj / .sln工程与解决方案配置ReadMe.txt使用说明解释了代码结构、变量含义和操作步骤ReadMe 里提到一个关键设计界面上的 URL 输入框和保存路径选择用 SaveFileDialog目标文件一旦选定后续续传不会因为对话框再次弹出而改变保存路径。这点对续传很重要——断点续传生效的前提是「同一个文件、同一个保存位置、同一个 URL 唯一对应」。如果用户中途换了个路径从新路径取到的文件长度为 0续传逻辑会退化成全量下载行为正确但会让用户误以为功能失效。3.2 核心下载方法实现Range 设置与分块写入Form1.cs 里最核心的方法是 DownloadWithResume它把整个下载过程压成一个循环。下面这段是从源码里梳理出的主体结构精简了异常处理但主逻辑没动private void DownloadWithResume(string url, string localPath) { long existingLength 0; if (File.Exists(localPath)) { existingLength new FileInfo(localPath).Length; } HttpWebRequest request (HttpWebRequest)WebRequest.Create(url); request.Method GET; request.UserAgent DownLoadFile/1.0; request.Timeout 15000; request.ReadWriteTimeout 30000; if (existingLength 0) { request.AddRange(existingLength); // 关键指定续传偏移 } using (HttpWebResponse response (HttpWebResponse)request.GetResponse()) { if (response.StatusCode HttpStatusCode.PartialContent || response.StatusCode HttpStatusCode.OK) { using (FileStream fs new FileStream(localPath, FileMode.OpenOrCreate, FileAccess.Write)) { fs.Seek(existingLength, SeekOrigin.Begin); if (response.StatusCode HttpStatusCode.OK) { fs.SetLength(0); // 服务端忽略 Range清空重写 fs.Seek(0, SeekOrigin.Begin); existingLength 0; } using (Stream ns response.GetResponseStream()) { byte[] buffer new byte[8192]; int bytesRead; long downloaded existingLength; long total ParseTotalFromContentRange(response); while ((bytesRead ns.Read(buffer, 0, buffer.Length)) 0) { fs.Write(buffer, 0, bytesRead); downloaded bytesRead; OnProgress(downloaded, total, localPath); } } } } } }写入顺序是先把文件流 Seek 到偏移处再写入从网络流读出的字节。FileMode.OpenOrCreate保证了第一次下载能建文件、第二次续传能打开已有文件不需要单独写文件创建分支。SetLength(0)那几行是 200 分支的兜底当服务器不支持 Range响应里是整个文件而本地残留着旧数据不清空就会出现新数据拼接旧数据的错乱文件。缓冲区 8192 是这个工程里写死的值对应主流磁盘扇区对齐的尺寸单线程下载时不需要调。改成 64KB 会减少 Read 次数但提高内存占用普通文件下载8KB 到 64KB 都在合理范围。OnProgress 回调在每次缓冲区写满后触发用来刷新界面进度内部实现是调用 BackgroundWorker 的 ReportProgress这里不贴完整实现。3.3 进度上报BackgroundWorker 与 UI 线程安全源码里下载按钮的处理没有直接用 async/await而是用 BackgroundWorker 把网络请求放到后台线程。这与早期 WinForms 工程的做法一致DoWork 里调 DownloadWithResumeProgressChanged 里更新进度条。private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e) { string[] args (string[])e.Argument; DownloadWithResume(args[0], args[1]); } private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e) { var state (DownloadProgress)e.UserState; progressBar1.Maximum state.Total; progressBar1.Value state.Downloaded; lblStatus.Text string.Format({0}% ({1}/{2} KB), state.Downloaded * 100 / state.Total, state.Downloaded / 1024, state.Total / 1024); }ProgressChanged 在 UI 线程触发所以这里直接改控件属性不会抛跨线程异常。如果自己用 Thread 裸起线程就必须在控件上调用 Invoke 或使用 SynchronizationContext.Post。进度刷新频率与缓冲写入频率相同也就是每读 8KB 触发一次对 WinForms 的进度条来说足够跟手。需要注意的边界是 Total 为 -1 时state.Downloaded * 100 / state.Total会得到 0 或抛异常UI 层要做一次判断显示「未知大小」而不是参与计算。3.4 中断恢复保留半成品文件的容错设计DownloadWithResume 没有在方法内部捕获下载中断的异常这个设计初看反常实际是有意的。网络异常抛出后BackgroundWorker 的 RunWorkerCompleted 回调触发用户重新点击下载时File.Exists和FileInfo.Length会从磁盘上取到已经下载的字节数续传自动生效。核心要求是不要在中途删除本地半成品文件。唯一需要主动删文件的场景是 416服务器返回偏移越界说明本地文件长度大于远端资源长度远端可能被替换过。此时删掉本地文件从头下载。更稳的做法是不依赖 416而是把「文件是否完整」做成独立校验用 MD5 或文件大小对比。这里推荐下载完成后走一层校验尤其是目标文件用于上位机固件升级时校验失败的文件刷进去会导致设备无法启动。4. 参数调优与边界情况排查代码能跑通只是第一步断点续传真正考验人的是服务器不按协议来时的表现。这一章讲清楚超时、重试、降级和定位问题的方法。4.1 缓冲区、超时与重试参数设置断点续传代码上线后最常调的是超时和重试。HttpWebRequest.Timeout控制连接建立的等待时间单位毫秒ReadWriteTimeout控制读取响应流时的单次等待。大文件下载时服务器偶尔会停顿几十秒ReadWriteTimeout 设置过短会导致下载被误杀。我一般把两者分别设为 15000 和 60000。参数建议值说明Timeout15000 ms连接超时超过则放弃本次请求ReadWriteTimeout30000 ~ 60000 ms读取间隔超时适合网络抖动场景缓冲区大小8192 ~ 65536 B影响磁盘写入频率和数据吞吐最大重试次数3 ~ 5超过后不再自动续交人工处理重试间隔指数退避 1s / 2s / 4s / 8s避免对服务器造成瞬时压力重试逻辑放在外层比较干净。DownloadWithResume 本身不重试由调用方在捕获 WebException 后按策略重新进入这样才能保证重试时 localPath 带着上次的真实进度int retryCount 0; const int maxRetry 3; while (retryCount maxRetry) { try { DownloadWithResume(url, localPath); break; } catch (WebException ex) when (ex.Status WebExceptionStatus.Timeout || ex.Status WebExceptionStatus.ConnectFailure) { retryCount; Thread.Sleep(1000 * (int)Math.Pow(2, retryCount)); } }指数退避的意义在网络上而不是在代码里连续快速重试大概率发生在网络恢复之前固定 1 秒间隔反而会给服务器造成不必要的压力。这里按1s、2s、4s递增超过 3 次以后不再自动重试。注意 catch 条件只拦超时和连接失败HTTP 4xx、5xx 不在这里重试它们需要看具体状态码做不同处理。4.2 服务端不支持 Range 时的降级处理前面代码里已经用SetLength(0)处理了 200 的情况但更主动的做法是在下载开头用 HEAD 请求探测一次。探测逻辑放在 DownloadWithResume 之前返回Accept-Ranges: bytes则走 Range 路径否则直接全量下载。private static bool ServerSupportRange(string url) { HttpWebRequest request (HttpWebRequest)WebRequest.Create(url); request.Method HEAD; request.UserAgent DownLoadFile/1.0; try { using (HttpWebResponse response (HttpWebResponse)request.GetResponse()) { string acceptRanges response.Headers[Accept-Ranges]; return !string.IsNullOrEmpty(acceptRanges) acceptRanges.IndexOf(bytes, StringComparison.OrdinalIgnoreCase) 0; } } catch { // HEAD 失败不直接判定不支持交给后续 206/200 分支兜底 return true; } }注意这里的返回逻辑HEAD 请求失败时不能断定服务器不支持 Range否则会绕过多数正常服务器所以我返回 true让 DownloadWithResume 里的 206/200 分支自然处理。市面上很多下载工具的做法是「先 GET 一次响应 200 且带 Content-Length 就同时当作 Range 探测」这也是可行方案只是日志里会多一次完整传输记录。4.3 用日志和抓包定位 Range 失效问题断点续传失效时问题通常不在本地代码而在链路。最常见的是下载站在 302 重定向之后丢掉了 Range 头——URL 指向一个跳转地址重定向请求重新发起时原始请求携带的 Range 头不保证被自动带上。检查方式分两层。第一层是程序日志在 AddRange 之后打印一行if (existingLength 0) { request.AddRange(existingLength); Trace.WriteLine($[Resume] Range: bytes{existingLength}-); }第二层是抓包确认。用 Wireshark 过滤http contains Range可以看到最终发到服务器上的请求头如果发现 302 之后的第二次 GET 没有 Range那就是自动重定向的坑。处理办法是设置request.AllowAutoRedirect false手动处理 302 并在重定向后的请求里重新调用 AddRange。另一个值得注意的现象是公司内网代理剥离了 Accept-Ranges 响应头但 Range 请求本身是生效的所以 206 响应出现但 Content-Range 缺失时先抓包确认链路不要急着改本地代码。提示排查顺序固定为「请求头有没有发出 Range → 响应是不是 206 → Content-Range 能不能解析」。按这个顺序看日志和抓包多数续传问题都能在几分钟内定位。5. 把 DownLoadFile 封装成通用下载组件并用本地服务器验证5.1 从 Form 到下载任务类的重构Form1 里的 DownloadWithResume 和 UI 状态耦合较深直接拿来做批量下载不方便。我习惯把它抽成一个 DownloadTask 类对外暴露 Start、Pause、Resume 三个操作public class DownloadTask { public string Url { get; set; } public string LocalPath { get; set; } public long Downloaded { get; private set; } public long Total { get; private set; } public bool IsPaused { get; set; } public void Pause() IsPaused true; public void Resume() IsPaused false; public void Start() { while (!IsPaused) { DownloadWithResume(); // 读取循环内检测 IsPaused } } }这里有个实现细节IsPaused 在 while 循环外检查并不能中断正在阻塞的ns.Read()。Pause 时必须把 response 流也存成字段主动 Dispose 流让 Read 抛出异常在 catch 里吞掉并退出循环。否则按下暂停后界面会一直停在等待网络数据的假死状态。配合 .part 临时文件方案——下载完成后再 rename 成目标文件——可以进一步避免覆盖同名文件的风险。5.2 用本地 HTTP 服务器验证断点续传验证断点续传不需要真实的大文件外网服务器。Python 自带的http.server支持 Range 头一条命令就能起服务python -m http.server 8080在服务目录放一个 200MB 的测试文件用改造后的 DownloadTask 去拉取。下载到一半直接杀掉进程再重新启动观察日志里是否出现Range: bytes上次长度-。下载完成后用 PowerShell 校验哈希Get-FileHash .\test.bin -Algorithm MD5与源文件 MD5 一致说明续传得到的文件完整不一致则回看 4.3 的抓包清单优先检查 Range 是否真的到达了服务器。这个方法也适合用来压测不同缓冲区大小对下载速度的影响——改一次 buffer 值跑一次全量下载对比耗时远比凭经验调参可靠。本文还有配套的精品资源点击获取
返回列表