iT邦幫忙

3

[C#] ASP.NET 檔案下載(3) - 檔案續傳

相信大家都遇過,下載檔案最後剩一點點時出錯失敗,然後要重新下載整個檔案,體驗一定非常差吧,不過如果程式有支援檔案續傳功能,可以從斷線的地方繼續,就不用浪費時間重新下載,今天要和大家介紹檔案續傳的方式。

檔案續傳需要的 Request Header:

  1. Range: 告知伺服器要下載的檔案範圍,等號前為範圍的單位,通常是bytes,等號後為範圍的開始到結束,範圍從 0 開始計算,四種格式如下:
    Range: bytes=500-1000: 從 500 byte 開始,到 1000 byte 結束。
    Range: bytes=500-: 從 500 byte 開始,到檔案的最後結束。
    Range: bytes=-500: 傳回倒數 500 個 byte 的內容,這裡和上面兩種比較不同。
    Range: bytes=500-1000, 1500-2000: 可以指定多個範圍。

  2. If-Range: 確保續傳下載的過程中,這次下載的部分和上次下載的,這之間檔案沒有被變更過。
    為非必要可以不加,但如果有 If-Range 就一定要配合 Range 使用,否則忽略 If-Range
    可以使用 Last-Modified 時間驗證或 ETag 標記驗證,兩者選其一,但不可以兩者同時使用。
    If-Range: Wed, 18 Oct 2017 07:30:00 GMT

檔案續傳需要的 Reponse Header:

  1. Accept-Ranges: 請求範圍的單位。 Accept-Ranges: bytes
  2. Content-Length: 請求的內容長度,不是整個檔案的大小。 Content-Length: 1000
  3. Content-Range: 請求範圍在整個檔案中的位置。
    Content-Range: bytes 500-1000/3000: 請求範圍從 500 byte 開始,到 1000 byte 結束,整個檔案大小 3000 byte。
  4. Last-Modified: 檔案的最後修改時間,使用國際標準時間 GMT。
    Last-Modified: Wed, 18 Oct 2017 07:30:00 GMT
  5. ETag: 檔案的唯一標記,用來驗證檔案是否變更,類似 MD5 的作用。
    ETag: "33a64df5514abcd55bsb2a148795d9f6b989d4"

檔案續傳流程如下:

  1. 第一次下載檔案,沒有傳送 Range 返回狀態碼 200,一般檔案下載。
  2. 第二次發現檔案存在,所以會傳送 Range 從斷掉的地方繼續,返回狀態碼 206,檔案續傳下載。
  3. 如果 If-Range 驗證發現檔案有變動,返回狀態碼 200,重新下載檔案。
  4. 如果 Range 範圍錯誤,返回狀態碼 416

程式碼:

public void ProcessRequest(HttpContext context)
{
    var index = context.Request.Params["index"];

    var fileName = "test.txt";

    var filePath = context.Server.MapPath("~/File/" + fileName);

    //檔案最後修改時間,格式 RFC1123
    var lastModified = File.GetLastWriteTime(filePath).ToString("r");

    using (var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))
    {
        var bufferSize = 102400;             //緩衝區大小 100KB
        var buffer = new byte[bufferSize];   //緩衝區
        var outputLength = fs.Length;        //檔案大小
        var readLength = 0;                  //每次讀取大小
        var sIndex = (long)0;                //開始讀取位置
        var eIndex = outputLength - 1;       //結束讀取位置
        var isPartialContent = false;        //是否為續傳

        if (context.Request.Headers["Range"] != null)
        {
            //判斷檔案最後修改時間是否和 If-Range 相同,相同代表檔案沒有被修改過
            if (context.Request.Headers["If-Range"] == null ||
                context.Request.Headers["If-Range"] == lastModified)
            {
                //取得要續傳的範圍
                var range = context.Request.Headers["Range"];
                var sRange = "";
                var eRange = "";
                
                //驗證續傳的範圍格式是否正確
                var regex = new Regex(@"^[\s]*bytes=(([0-9]*)-([0-9]*))$");
                if (regex.IsMatch(range))
                {
                    var match = regex.Match(range);
                    sRange = match.Groups[2].Value;
                    eRange = match.Groups[3].Value;
                }

                //50-100 : 從第 50 個 byte 開始到第 100 個 byte
                if (!string.IsNullOrEmpty(sRange) && 
                    !string.IsNullOrEmpty(eRange))
                {
                    sIndex = long.Parse(sRange);
                    eIndex = long.Parse(eRange);
                }
                //50- : 從第 50 個 byte 開始到最後
                if (!string.IsNullOrEmpty(sRange) && 
                     string.IsNullOrEmpty(eRange))
                {
                    sIndex = long.Parse(sRange);
                }
                //-50 : 倒數 50 個 byte
                if (string.IsNullOrEmpty(sRange) && !
                    string.IsNullOrEmpty(eRange))
                {
                    sIndex = eIndex + 1 - long.Parse(sRange);
                }

                if (eIndex < 0 || sIndex > outputLength - 1 || 
                    sIndex > eIndex)
                {
                    //Range 範圍不符
                    context.Response.StatusCode = 416;
                    return;
                }
                //是否為檔案續傳
                isPartialContent = true;
            }
        }

        context.Response.Clear();
        context.Response.AddHeader("Accept-Ranges", "bytes");
        context.Response.AppendHeader("Last-Modified", lastModified);
        context.Response.AddHeader(
            "Content-Length", $"{eIndex - sIndex + 1}");
        context.Response.AddHeader(
            "Content-Range", $" bytes {sIndex}-{eIndex}/{outputLength}");
        context.Response.ContentType = "application/octet-stream";
        context.Response.AddHeader(
            "content-disposition", "attachment; filename=" + fileName);
        if (isPartialContent) context.Response.StatusCode = 206;

        try
        {
            var currentIndex = sIndex;
            fs.Seek(currentIndex, SeekOrigin.Begin);
            while (currentIndex <= eIndex && 
                   context.Response.IsClientConnected)
            {
                readLength = 
                    (int)Math.Min(eIndex - currentIndex + 1, bufferSize);
                fs.Read(buffer, 0, readLength);
                context.Response.OutputStream.Write(buffer, 0, readLength);
                context.Response.Flush();
                currentIndex = currentIndex + readLength;
            }
        }
        catch (Exception)
        {
            //傳輸過程中如果客戶端關閉連接,會拋出例外不處理
        }

        context.Response.End();
    }
}

結果:

使用續傳軟體(IDM)的測試結果,支援多點續傳,中斷後也可以恢復下載。
https://ithelp.ithome.com.tw/upload/images/20171021/20106865to55bi8RsG.jpg

下載過程中,我按了暫停然後去修改檔案,再恢復下載時有被 IDM 判斷出來檔案已經被變動,要求重新下載。
https://ithelp.ithome.com.tw/upload/images/20171020/20106865HmkoOJ7Puu.jpg

結語:
我沒有實作 Range 的第四種格式,指定多個範圍,因為不常用到,而且會增加程式閱讀的困難度,If-Range 的部分,我選用 Last-Modified 驗證,因為不想去弄 MD5 偷懶一下 XD。

參考文章:
在ASP.NET中支持断点续传下载大文件(ZT) (转)
HTTP 断点下载功能实现
HTTP断点续传(分块传输)
MDN Web Docs

相關文章:
[C#] ASP.NET 檔案下載(1) - POST 和 GET 觸發檔案下載
[C#] ASP.NET 檔案下載(2) - 大型檔案下載
[C#] ASP.NET 檔案下載(3) - 檔案續傳


尚未有邦友留言

立即登入留言