HTTP 1.1 定義 `Range` 請求標頭與 `206 Partial Content` 回應,配合 `Accept-Ranges`、`ETag`、`Content-Range` 即可實現斷點續傳。本文提供生產級 `DownloadFile` 靜態方法,支援大檔案、速度限制、ETag 驗證、多執行緒安全。
public static class FileDownloader
{
private const int BufferSize = 1024 * 10; // 10KB
///
/// 支援斷點續傳的檔案下載
///
/// HttpContext
/// 伺服器實體路徑
/// 速度限制 (bytes/秒),0=不限制
/// true=完成/客戶端中斷, false=錯誤
public static bool DownloadFile(HttpContext context, string filePath, long speedLimitBps = 0)
{
if (!File.Exists(filePath))
{
context.Response.StatusCode = 404;
return false;
}
var fileInfo = new FileInfo(filePath);
long fileLength = fileInfo.Length;
string fileName = fileInfo.Name;
string etag = $"\"{fileName}-{fileInfo.LastWriteTimeUtc.Ticks}\"";
string lastModified = fileInfo.LastWriteTimeUtc.ToString("R");
// 1. 解析 Range 標頭
long startPos = 0;
string rangeHeader = context.Request.Headers["Range"];
bool isPartial = false;
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes="))
{
string[] parts = rangeHeader.Substring(6).Split('-');
if (long.TryParse(parts[0], out long start) && start >= 0 && start < fileLength)
{
startPos = start;
isPartial = true;
}
}
// 2. 驗證 If-Range / If-Unmodified-Since (ETag/時間戳一致性)
string ifRange = context.Request.Headers["If-Range"];
if (!string.IsNullOrEmpty(ifRange) && ifRange.Trim('"') != etag.Trim('"'))
{
context.Response.StatusCode = 412; // Precondition Failed
return false;
}
// 3. 設定回應標頭
context.Response.Clear();
context.Response.Buffer = false; // 關閉緩衝,直接串流
context.Response.ContentType = "application/octet-stream";
context.Response.AddHeader("Content-Disposition",
$"attachment; filename=\"{HttpUtility.UrlEncode(fileName, Encoding.UTF8).Replace("+", "%20")}\"");
context.Response.AddHeader("Accept-Ranges", "bytes");
context.Response.AddHeader("ETag", etag);
context.Response.AddHeader("Last-Modified", lastModified);
context.Response.AddHeader("Content-Length", (fileLength - startPos).ToString());
context.Response.AddHeader("Connection", "keep-alive");
if (isPartial)
{
context.Response.StatusCode = 206; // Partial Content
context.Response.AddHeader("Content-Range", $"bytes {startPos}-{fileLength - 1}/{fileLength}");
}
else
{
context.Response.StatusCode = 200;
}
// 4. 串流傳輸
try
{
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
fs.Seek(startPos, SeekOrigin.Begin);
byte[] buffer = new byte[BufferSize];
int bytesRead;
long remaining = fileLength - startPos;
int sleepMs = speedLimitBps > 0 ? (int)Math.Ceiling(1000.0 * BufferSize / speedLimitBps) : 0;
while (remaining > 0 && context.Response.IsClientConnected)
{
int toRead = (int)Math.Min(BufferSize, remaining);
bytesRead = fs.Read(buffer, 0, toRead);
if (bytesRead == 0) break;
context.Response.OutputStream.Write(buffer, 0, bytesRead);
context.Response.Flush();
remaining -= bytesRead;
if (sleepMs > 0) Thread.Sleep(sleepMs);
}
}
return true;
}
catch (Exception ex)
{
// 記錄日誌
return false;
}
finally
{
context.Response.End(); // 確保結束回應
}
}
}
// .ashx 處理常式
public class DownloadHandler : IHttpHandler
{
public void ProcessRequest(HttpContext context)
{
string fileId = context.Request.QueryString["id"];
if (string.IsNullOrEmpty(fileId)) { context.Response.StatusCode = 400; return; }
string filePath = GetFilePathById(fileId); // 自行實作
long speedLimit = 1024 * 1024; // 1MB/s 限制,0 為不限制
FileDownloader.DownloadFile(context, filePath, speedLimit);
}
public bool IsReusable => false;
}
// 使用 fetch + Range 標頭實現續傳 (現代瀏覽器)
async function downloadWithResume(url, fileName) {
let start = 0;
let fileHandle;
// 檢查本地是否有部分檔案 (File System Access API 需 HTTPS + 使用者授權)
// 簡化版:直接由瀏覽器處理 (支援續傳的下載管理器/瀏覽器內建)
// 針對大檔案建議使用成熟庫:
// - axios + http-range
// - resumable.js
// - tus.io (上傳/下載協定)
}
| 標頭 | 方向 | 用途 | 範例值 |
|---|---|---|---|
| Accept-Ranges | Response | 宣告支援範圍請求 | bytes |
| Range | Request | 請求位元組範圍 | bytes=1024- |
| Content-Range | Response | 回應實際範圍與總長度 | bytes 1024-2047/5000 |
| ETag | Response | 資源唯一指紋 (檔名+修改時間) | "file.txt-638000000000000000" |
| If-Range | Request | 條件範圍請求:ETag 一致才續傳 | "file.txt-638000000000000000" |
| If-Unmodified-Since | Request | 條件請求:未修改才續傳 | Wed, 21 Oct 2023 07:28:00 GMT |
| 206 Partial Content | Status | 部分內容回應狀態碼 | - |
| 412 Precondition Failed | Status | 條件不符 (檔案已變更) | - |
| 416 Range Not Satisfiable | Status | 範圍無效 (超過檔案長度) | - |