斷點續傳是下載工具的核心功能,允許使用者在網路中斷後從中斷處繼續下載,避免重頭開始。本文深入解析 HTTP Range 標頭原理,並使用 `HttpWebRequest` 與 `FileStream` 實作完整的斷點續傳下載器。
客戶端在請求標頭加入 `Range` 指定下載位元組範圍:
// 從第 1024 位元組開始下載到檔案結尾
Range: bytes=1024-
// 下載第 0~1023 位元組(前 1 KB)
Range: bytes=0-1023
// 多範圍請求(較少用)
Range: bytes=0-500, 1000-1500
伺服器支援續傳時回應 206 Partial Content,並包含 Content-Range 標頭:
HTTP/1.1 206 Partial Content
Content-Range: bytes 1024-2826/2827
Content-Length: 1803
| 類別 | 命名空間 | 關鍵功能 |
|---|---|---|
HttpWebRequest | System.Net | 發送 HTTP 請求,AddRange() 設定 Range 標頭 |
HttpWebResponse | System.Net | 取得回應串流、狀態碼、Content-Range 標頭 |
FileStream | System.IO | 隨機存取檔案,Seek() 移動寫入位置 |
SeekOrigin | System.IO | 定位基準:Begin/Current/End |
using System;
using System.IO;
using System.Net;
public class ResumableDownloader
{
private const int BufferSize = 8192; // 8KB 緩衝區
public void Download(string url, string filePath)
{
long startPos = 0;
FileStream fs = null;
Stream responseStream = null;
try
{
// 1. 檢查本地檔案是否存在,決定起始位置
if (File.Exists(filePath))
{
fs = new FileStream(filePath, FileMode.Open, FileAccess.Write);
startPos = fs.Length;
fs.Seek(startPos, SeekOrigin.Begin);
Console.WriteLine($"偵測到部分檔案,從 {startPos} 位元組繼續下載...");
}
else
{
fs = new FileStream(filePath, FileMode.Create, FileAccess.Write);
Console.WriteLine("新檔案下載...");
}
// 2. 建立 HTTP 請求
HttpWebRequest request = (HttpWebRequest)WebRequest.Create(url);
request.Method = "GET";
request.UserAgent = "C# ResumableDownloader/1.0";
request.Timeout = 30000;
request.ReadWriteTimeout = 30000;
// 3. 設定 Range 標頭(關鍵步驟)
if (startPos > 0)
{
request.AddRange(startPos);
}
// 4. 取得回應
using (HttpWebResponse response = (HttpWebResponse)request.GetResponse())
{
// 檢查伺服器是否支援續傳
if (startPos > 0 && response.StatusCode != HttpStatusCode.PartialContent)
{
Console.WriteLine("伺服器不支援斷點續傳,重新下載...");
fs.SetLength(0); // 清空檔案
startPos = 0;
fs.Seek(0, SeekOrigin.Begin);
}
long totalSize = startPos + response.ContentLength;
Console.WriteLine($"檔案總大小: {FormatBytes(totalSize)}");
responseStream = response.GetResponseStream();
byte[] buffer = new byte[BufferSize];
int bytesRead;
long downloaded = startPos;
DateTime lastReport = DateTime.Now;
// 5. 迴圈讀取並寫入
while ((bytesRead = responseStream.Read(buffer, 0, buffer.Length)) > 0)
{
fs.Write(buffer, 0, bytesRead);
downloaded += bytesRead;
// 進度報告(每秒最多一次)
if ((DateTime.Now - lastReport).TotalSeconds >= 1)
{
double percent = (double)downloaded / totalSize * 100;
double speed = bytesRead / 1024.0; // KB/s 近似值
Console.Write($"\r進度: {percent:F1}% ({FormatBytes(downloaded)}/{FormatBytes(totalSize)}) 速度: {speed:F0} KB/s");
lastReport = DateTime.Now;
}
}
Console.WriteLine($"\n下載完成: {filePath}");
}
}
catch (WebException ex)
{
HandleWebException(ex, filePath, startPos);
}
catch (Exception ex)
{
Console.WriteLine($"\n下載發生錯誤: {ex.Message}");
}
finally
{
responseStream?.Close();
fs?.Close();
}
}
private void HandleWebException(WebException ex, string filePath, long startPos)
{
if (ex.Response is HttpWebResponse httpResponse)
{
Console.WriteLine($"\nHTTP 錯誤: {(int)httpResponse.StatusCode} {httpResponse.StatusCode}");
if (httpResponse.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable)
{
Console.WriteLine("範圍請求無效,檔案可能已完整或伺服器檔案已變更");
File.Delete(filePath); // 刪除損壞檔案
}
}
else
{
Console.WriteLine($"\n網路錯誤: {ex.Message}");
}
}
private string FormatBytes(long bytes)
{
string[] units = { "B", "KB", "MB", "GB" };
int i = 0;
double size = bytes;
while (size >= 1024 && i < units.Length - 1)
{
size /= 1024;
i++;
}
return $"{size:F1} {units[i]}";
}
}
// 使用範例
class Program
{
static void Main()
{
var downloader = new ResumableDownloader();
downloader.Download("https://example.com/largefile.zip", @"C:\Downloads\largefile.zip");
Console.ReadKey();
}
}
// 將檔案分成 N 段,每段用一條執行緒下載,最後合併
// 需要伺服器支援 Accept-Ranges: bytes
// 適用於大檔案(>100MB)且頻寬充足的情境
// 將 downloaded 位元組數寫入 .progress 檔或資料庫
// 程式重啟時讀取進度繼續,避免記憶體遺失
// 下載完成後計算 MD5/SHA256 與伺服器提供的雜湊值比對
// 或使用 ETag/Last-Modified 標頭驗證檔案未被竄改
| 問題 | 原因 | 解決方案 |
|---|---|---|
| 伺服器回應 200 而非 206 | 不支援 Range 或 Range 無效 | 檢查 Accept-Ranges 標頭,不支援則重新下載 |
| 續傳後檔案損壞 | 伺服器檔案已更新(ETag 變更) | 下載前比對 ETag/Last-Modified,不同則重下 |
| 大檔案記憶體不足 | 緩衝區過大或未及時 Flush | 使用 4-8KB 緩衝區,定期 fs.Flush() |
| 網路中斷拋出例外 | Read/Write 逾時 | 捕獲 WebException,記錄位置,重試機制 |
using System.Net.Http;
public async Task DownloadWithHttpClientAsync(string url, string filePath, CancellationToken ct = default)
{
using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Get, url);
long startPos = 0;
FileMode mode = FileMode.Create;
if (File.Exists(filePath))
{
var fi = new FileInfo(filePath);
startPos = fi.Length;
mode = FileMode.Open;
request.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(startPos, null);
}
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
response.EnsureSuccessStatusCode();
using var fs = new FileStream(filePath, mode, FileAccess.Write, FileShare.None, 8192, true);
if (startPos > 0) fs.Seek(startPos, SeekOrigin.Begin);
await response.Content.CopyToAsync(fs, ct);
}
斷點續傳的核心在於 HTTP Range 標頭 與 FileStream 隨機存取 的結合。傳統 `HttpWebRequest` 雖屬舊 API 但相容性佳;新專案建議使用 `HttpClient` 搭配非同步串流複製,程式碼更簡潔且效能更好。實務部署時務必加入重試機制、完整性驗證與進度持久化,打造健壯的下載體驗。