首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> .NET 網頁程式設計 >> ASP.NET 基礎 >> ASP.NET 基礎

ASP.NET 大檔案下載支援斷點續傳 (Range/ETag/206) 完整實作

     編輯:ASP.NET 基礎

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 (上傳/下載協定)
}

關鍵 HTTP 標頭對照表

標頭方向用途範例值
Accept-RangesResponse宣告支援範圍請求bytes
RangeRequest請求位元組範圍bytes=1024-
Content-RangeResponse回應實際範圍與總長度bytes 1024-2047/5000
ETagResponse資源唯一指紋 (檔名+修改時間)"file.txt-638000000000000000"
If-RangeRequest條件範圍請求:ETag 一致才續傳"file.txt-638000000000000000"
If-Unmodified-SinceRequest條件請求:未修改才續傳Wed, 21 Oct 2023 07:28:00 GMT
206 Partial ContentStatus部分內容回應狀態碼-
412 Precondition FailedStatus條件不符 (檔案已變更)-
416 Range Not SatisfiableStatus範圍無效 (超過檔案長度)-

效能與安全建議

  • X-Sendfile / X-Accel-Redirect:Nginx/Apache/IIS 直接傳檔,釋放 ASP.NET 執行緒,支援 `sendfile` 系統呼叫零拷貝。
  • 速度限制:依用戶等級動態調整 `speedLimitBps`。
  • 權限驗證:下載前驗證用戶是否擁有檔案存取權。
  • 防盜鏈:驗證 `Referer` 或簽名 URL (含過期時間)。
  • 大檔案 (>2GB):使用 `long` 定位,`FileStream.Seek` 支援 64-bit,IIS 需啟用 64-bit 應用程式池。
  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved