大型檔案下載若不支援斷點續傳,使用者一旦網路中斷必須重頭下載,體驗極差。本文實作符合 RFC 7233 的 Range 請求處理器,支援單一區間下載、多執行緒分段下載(FlashGet/IDM/FDM 等)、206 Partial Content 回應、Content-Range/Accept-Ranges 標頭,並修正原始碼中 Content-Range 計算錯誤與串流處理問題。
using System;
using System.IO;
using System.Web;
namespace FileDownload
{
public class ResumableDownloadHandler : IHttpHandler
{
public bool IsReusable => false;
public void ProcessRequest(HttpContext context)
{
// 1. 取得檔案路徑 (實務應從資料庫/配置查詢,防止路徑遍歷攻擊)
string filePath = context.Server.MapPath("~/App_Data/Downloads/largefile.zip");
if (!File.Exists(filePath))
{
context.Response.StatusCode = 404;
context.Response.Write("檔案不存在");
return;
}
var fileInfo = new FileInfo(filePath);
long fileSize = fileInfo.Length;
string fileName = fileInfo.Name;
// 2. 解析 Range 標頭
string rangeHeader = context.Request.Headers["Range"];
long start = 0;
long end = fileSize - 1;
bool isPartial = false;
if (!string.IsNullOrEmpty(rangeHeader) && rangeHeader.StartsWith("bytes="))
{
try
{
string range = rangeHeader.Substring("bytes=".Length);
string[] parts = range.Split('-');
if (parts.Length == 2)
{
if (long.TryParse(parts[0], out long s)) start = s;
if (long.TryParse(parts[1], out long e)) end = e;
// 修正:結束位置不可超過檔案大小
if (end >= fileSize) end = fileSize - 1;
if (start <= end && start < fileSize)
{
isPartial = true;
}
}
}
catch { /* 格式錯誤視為完整下載 */ }
}
long contentLength = end - start + 1;
// 3. 設定回應標頭
context.Response.Clear();
context.Response.Buffer = false; // 關鍵:避免大檔案緩衝至記憶體
context.Response.ContentType = "application/octet-stream";
context.Response.AddHeader("Content-Disposition", $"attachment; filename=\"{HttpUtility.UrlEncode(fileName, System.Text.Encoding.UTF8)}\"");
context.Response.AddHeader("Accept-Ranges", "bytes");
context.Response.AddHeader("Content-Length", contentLength.ToString());
context.Response.AddHeader("Last-Modified", fileInfo.LastWriteTimeUtc.ToString("R"));
if (isPartial)
{
context.Response.StatusCode = 206; // Partial Content
// 修正:Content-Range 格式為 bytes start-end/total
context.Response.AddHeader("Content-Range", $"bytes {start}-{end}/{fileSize}");
}
else
{
context.Response.StatusCode = 200;
}
// 4. 串流傳輸 (支援大檔案、低記憶體)
const int bufferSize = 64 * 1024; // 64KB
byte[] buffer = new byte[bufferSize];
try
{
using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))
{
fs.Seek(start, SeekOrigin.Begin);
long remaining = contentLength;
while (remaining > 0 && context.Response.IsClientConnected)
{
int read = fs.Read(buffer, 0, (int)Math.Min(bufferSize, remaining));
if (read == 0) break;
context.Response.OutputStream.Write(buffer, 0, read);
remaining -= read;
}
context.Response.Flush();
}
}
catch (Exception ex) when (ex is HttpException || ex is IOException)
{
// 客戶端斷線、取消下載等,記錄日誌即可
}
finally
{
context.Response.SuppressContent = true;
context.ApplicationInstance.CompleteRequest(); // 避免 ThreadAbortException
}
}
}
}
<system.webServer>
<handlers>
<add name="ResumableDownload" verb="GET,HEAD" path="download.ashx"
type="FileDownload.ResumableDownloadHandler" preCondition="integratedMode" />
</handlers>
<!-- 關閉 IIS 內建靜態檔案處理器的範圍請求,避免衝突 -->
<staticContent>
<clientCache cacheControlMode="UseMaxAge" cacheControlMaxAge="365.00:00:00" />
</staticContent>
</system.webServer>
<a href="/download.ashx?file=largefile.zip">下載大型檔案 (支援斷點續傳)</a>
Range: bytes=start-end (單一區間)start 可省略:bytes=-500 表示最後 500 位元組end 可省略:bytes=500- 表示第 500 位元組到結尾bytes=0-499,500-999 (本範例未支援,回應 416 Range Not Satisfiable 或忽略多區間)| 標頭 | 必要性 | 說明 |
|---|---|---|
| Accept-Ranges: bytes | 必要 | 告知客戶端支援位元組範圍請求 |
| Content-Length | 必要 | 本次回應實體長度 (而非檔案總長) |
| Content-Range | 206 時必要 | 格式:bytes start-end/total,原始碼錯誤寫成 bytes start-(total-start)/total |
| ETag / Last-Modified | 建議 | 支援 If-Range 條件請求,避免檔案異動時續傳錯誤 |
Response.Buffer = false:關閉 ASP.NET 內建緩衝,直接寫入 OutputStream,否則大檔案會佔用大量伺服器記憶體。FileShare.Read:允許其他進程同時讀取同一檔案。context.Response.IsClientConnected:偵測客戶端斷線,及時停止傳輸釋放資源。CompleteRequest():取代 Response.End() 避免 ThreadAbortException 污染執行緒池。fileId 查詢資料庫取得實體路徑,並驗證 Path.GetFullPath 在允許目錄下。app.MapGet("/download/{fileId:guid}", async (Guid fileId, HttpContext ctx, IFileService svc) =>
{
var file = await svc.GetFileMetaAsync(fileId);
if (file == null) return Results.NotFound();
var rangeHeader = ctx.Request.Headers.Range.ToString();
var (start, end, isPartial) = ParseRange(rangeHeader, file.Size);
ctx.Response.Headers.AcceptRanges = "bytes";
ctx.Response.Headers.ContentDisposition = $"attachment; filename*=UTF-8''{Uri.EscapeDataString(file.Name)}";
ctx.Response.Headers.LastModified = file.LastModified.ToString("R");
if (isPartial)
{
ctx.Response.StatusCode = 206;
ctx.Response.Headers.ContentRange = $"bytes {start}-{end}/{file.Size}";
}
ctx.Response.ContentType = "application/octet-stream";
await ctx.Response.SendFileAsync(file.PhysicalPath, start, end - start + 1, ctx.RequestAborted);
});
SendFileAsync 內部使用 FileStream + MemoryMappedFile 零拷貝傳輸,效能極佳。
curl -H "Range: bytes=0-1023" -I https://localhost/download.ashx 應回 206、Content-Range: bytes 0-1023/總大小。<handlers> 註冊順序優先,或在 web.config 移除靜態處理器對特定路徑的處理。Response.Buffer = false,且未在程式碼中讀入完整檔案至 byte[]。Core 使用 SendFileAsync 自動零拷貝。Content-Range 計算是否正確 (start-end/total),確認 FileStream.Seek 位置、contentLength 計算無誤。下載完成後驗證 SHA256。bytes=0-99,100-199)?multipart/byteranges,實作複雜度高。建議回應 416 Range Not Satisfiable 或忽略多區間視為完整下載,主流下載工具單區間即可多執行緒分段。支援斷點續傳的核心在於:正確解析 Range 標頭、回應 206 + Content-Range、關閉緩衝串流傳輸、安全路徑驗證。ASP.NET Core 的 SendFileAsync 大幅簡化實作並提升效能。建議生產環境結合 CDN (支援 Range 回源) 與物件儲存 (S3/MinIO) 卸載頻寬與運維壓力。