檔案下載之斷點續傳(客戶端與服務端的實現)

neuyu發表於2021-09-09

前面講了檔案的上傳,今天來聊聊檔案的下載。

老規矩,還是從最簡單粗暴的開始。那麼多簡單算簡單?多粗暴算粗暴?我告訴你可以不寫一句程式碼,你信嗎?直接把一個檔案往IIS伺服器上一扔,就支援下載。還TM麼可以斷點續傳(IIS服務端預設支援)。

在貼程式碼之前先來了解下什麼是斷點續傳(這裡說的是下載斷點續傳)?怎麼實現的斷點續傳?
斷點續傳就是下載了一半斷網或者暫停了,然後可以接著下載。不用從頭開始下載。

很神奇嗎,其實簡單得很,我們想想也是可以想到的。
首先客戶端向服務端傳送一個請求(下載檔案)。然後服務端響應請求,資訊包含檔案總大小、檔案流開始和結束位置、內容大小等。那具體是怎麼實現的呢?
HTTP/1.1有個頭屬性Range。比如你傳送請求的時候帶上Range:0-199,等於你是請求0到199之間的資料。然後伺服器響應請求Content-Range: bytes 0-199/250 ,表示你獲取了0到199之間的資料,總大小是250。(也就是告訴你還有資料沒有下載完)。

我們來畫個圖吧。
圖片描述

是不是很簡單?這麼神奇的東西也就是個“約定”而已,也就是所謂的HTTP協議。
然而,協議這東西你遵守它就存在,不遵守它就不存在。就像民國時期的錢大家都信它,它就有用。如果大部分人不信它,也就沒鳥用了。
這個斷點續傳也是這樣。你服務端遵守就支援,不遵守也就不支援斷點續傳。所以我們寫下載工具的時候需要判斷響應報文裡有沒有Content-Range,來確定是否支援斷點續傳。
廢話夠多了,下面擼起袖子開幹。

檔案下載-服務端

使用a標籤提供檔案下載

利用a標籤來下載檔案,也就是我們前面說的不寫程式碼就可以實現下載。直接把檔案往iis伺服器上一扔,然後把連結貼到a標籤上,完事。


簡單、粗暴不用說了。如真得這麼好那大家也不會費力去寫其他下載邏輯了。這裡有個致命的缺點。這種方式提供的下載不夠安全。誰都可以下載,沒有許可權控制,說不定還會被人檔案掃描(好像csdn就出過這檔子事)。

使用Response.TransmitFile提供檔案下載

上面說直接a標籤提供下載不夠安全。那我們怎麼提供相對安全的下載呢。asp.net預設App_Data資料夾是不能被直接訪問的,那我們把下載檔案放這裡面。然後下載的時候我們讀取檔案在返回到響應流。

//檔案下載public void FileDownload5(){              //前面可以做使用者登入驗證、使用者許可權驗證等。    string filename = "大資料.rar";   //客戶端儲存的檔名      string filePath = Server.MapPath("/App_Data/大資料.rar");//要被下載的檔案路徑     Response.ContentType = "application/octet-stream";  //二進位制流    Response.AddHeader("Content-Disposition", "attachment;filename=" + filename);    Response.TransmitFile(filePath); //將指定檔案寫入 HTTP 響應輸出流}

其他方式檔案下載

在網上搜尋C#檔案下載一般都會搜到所謂的“四種方式”。其實那些程式碼並不能拿來直接使用,有坑的。
第一種:(Response.BinaryWrite)

 public void FileDownload2() {     string fileName = "新建資料夾2.rar";//客戶端儲存的檔名       string filePath = Server.MapPath("/App_Data/新建資料夾2.rar");//要被下載的檔案路徑        Response.ContentType = "application/octet-stream";//二進位制流     //通知瀏覽器下載檔案而不是開啟       Response.AddHeader("Content-Disposition", "attachment;  filename=" + HttpUtility.UrlEncode(fileName, System.Text.Encoding.UTF8));     //以字元流的形式下載檔案       using (FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read))     {         Response.AddHeader("Content-Length", fs.Length.ToString());         //這裡容易記憶體溢位         //理論上陣列最大長度 int.MaxValue 2147483647          //(實際分不到這麼多,不同的程式能分到值也不同,本人機器,winfrom( 2147483591 相差56)、iis(也差不多2G)、iis Express(只有100多MB))         byte[] bytes = new byte[(int)fs.Length];         fs.Read(bytes, 0, bytes.Length);         Response.BinaryWrite(bytes);     }     Response.Flush();     Response.End(); }

首先陣列最大長度為int.MaxValue,然後正常程式是不會分這麼大記憶體,很容易搞掛伺服器。(也就是可以下載的檔案,極限值最多也就2G不到。)【不推薦】

第二種:(Response.WriteFile)

public void FileDownload3(){    string fileName = "新建資料夾2.rar";//客戶端儲存的檔名      string filePath = Server.MapPath("/App_Data/新建資料夾2.rar");//要被下載的檔案路徑      FileInfo fileInfo = new FileInfo(filePath);    Response.Clear();    Response.ClearContent();    Response.ClearHeaders();    Response.AddHeader("Content-Disposition", "attachment;filename="" + HttpUtility.UrlEncode(fileName, System.Text.Encoding.UTF8) + """);    Response.AddHeader("Content-Length", fileInfo.Length.ToString());//檔案大小    Response.AddHeader("Content-Transfer-Encoding", "binary");    Response.ContentType = "application/octet-stream";    Response.WriteFile(fileInfo.FullName);//大小引數必須介於零和最大的 Int32 值之間(也就是最大2G,不過這個操作非常耗記憶體)    //這裡容易記憶體溢位    Response.Flush();    Response.End();}

問題和第一種類似,也是不能下載大於2G的檔案。然後下載差不多2G檔案時,機器也是處在被掛的邊緣,相當恐怖。【不推薦】

第三種:(Response.OutputStream.Write)

public void FileDownload4(){    string fileName = "大資料.rar";//客戶端儲存的檔名      string filePath = Server.MapPath("/App_Data/大資料.rar");//要被下載的檔案路徑       if (System.IO.File.Exists(filePath))    {        const long ChunkSize = 102400; //100K 每次讀取檔案,只讀取100K,這樣可以緩解伺服器的壓力          byte[] buffer = new byte[ChunkSize];        Response.Clear();        using (FileStream fileStream = System.IO.File.OpenRead(filePath))        {            long fileSize = fileStream.Length; //檔案大小              Response.ContentType = "application/octet-stream"; //二進位制流            Response.AddHeader("Content-Disposition", "attachment; filename=" + HttpUtility.UrlEncode(fileName, System.Text.Encoding.UTF8));            Response.AddHeader("Content-Length", fileStream.Length.ToString());//檔案總大小            while (fileSize > 0 && Response.IsClientConnected)//判斷客戶端是否還連線了伺服器            {                //實際讀取的大小                  int readSize = fileStream.Read(buffer, 0, Convert.ToInt32(ChunkSize));                Response.OutputStream.Write(buffer, 0, readSize);                Response.Flush();//如果客戶端 暫停下載時,這裡會阻塞。                fileSize = fileSize - readSize;//檔案剩餘大小            }        }        Response.Close();    }}

這裡明顯看到了是在迴圈讀取輸出,比較機智。下載大檔案時沒有壓力。【推薦】

第四種:(Response.TransmitFile)
也就上開始舉例說的那種,下載大檔案也沒有壓力。【推薦】

public void FileDownload5(){              //前面可以做使用者登入驗證、使用者許可權驗證等。    string filename = "大資料.rar";   //客戶端儲存的檔名      string filePath = Server.MapPath("/App_Data/大資料.rar");//要被下載的檔案路徑     Response.ContentType = "application/octet-stream";  //二進位制流    Response.AddHeader("Content-Disposition", "attachment;filename=" + filename);    Response.TransmitFile(filePath); //將指定檔案寫入 HTTP 響應輸出流}

檔案下載-客戶端

上面實現了檔案下載的服務端實現,接下來我們實現檔案下載的客戶端實現。客戶端的下載可以直接是瀏覽器提供的下載,也可以是迅雷或者我們自己寫的下載程式。這裡為了更好的分析,我們來用winfrom程式自己寫個下載客戶端。

直接下載

private async void button1_ClickAsync(object sender, EventArgs e){    using (HttpClient http = new HttpClient())    {        var httpResponseMessage = await http.GetAsync("新建資料夾2.rar");//傳送請求 (連結是a標籤提供的)        var contentLength = httpResponseMessage.Content.Headers.ContentLength;//讀取檔案大小        using (var stream = await httpResponseMessage.Content.ReadAsStreamAsync())//讀取檔案流        {            var readLength = 1024000;//1000K  每次讀取大小            byte[] bytes = new byte[readLength];            int writeLength;            while ((writeLength = stream.Read(bytes, 0, readLength)) > 0)//分塊讀取檔案流            {                using (FileStream fs = new FileStream(Application.StartupPath + "/temp.rar", FileMode.Append, FileAccess.Write))//使用追加方式開啟一個檔案流                {                    fs.Write(bytes, 0, writeLength);//追加寫入檔案                    contentLength -= writeLength;                    if (contentLength == 0)//如果寫入完成 給出提示                        MessageBox.Show("下載完成");                }            }        }    } }

看著這麼漂亮的程式碼,好像沒問題。可現實往往事與願違。
圖片描述

我們看到了一個異常“System.Net.Http.HttpRequestException:“不能向緩衝區寫入比所配置最大緩衝區大小 2147483647 更多的位元組。”,什麼鬼,又是2147483647這個數字。因為我們下載的檔案大小超過了2G,無法緩衝下載。
可是“緩衝下載”下又是什麼鬼。我也不知道。那我們試試可以關掉這個東東呢?答案是肯定的。

var httpResponseMessage = await http.GetAsync("新建資料夾2.rar");//傳送請求

改成下面就可以了

var httpResponseMessage = await http.GetAsync("新建資料夾2.rar",HttpCompletionOption.ResponseHeadersRead);//響應一可用且標題可讀時即應完成的操作。 (尚未讀取的內容。)

圖片描述
我們看到列舉HttpCompletionOption的兩個值。一個是響應讀取內容,一個是響應讀取標題(也就是Headers裡的內容)。

非同步下載

我們發現在下載大檔案的時候會造成介面假死。這是UI單執行緒程式的通病。當然,這麼差的使用者體驗是我們不能容忍的。下面我們為下載開一個執行緒,避免造成UI執行緒的阻塞。

/// /// 非同步下載/// /// /// private async void button2_ClickAsync(object sender, EventArgs e){    //開啟一個非同步執行緒    await Task.Run(async () =>    {        //非同步操作UI元素        label1.Invoke((Action)(() =>                {                    label1.Text = "準備下載...";                }));        long downloadSize = 0;//已經下載大小        long downloadSpeed = 0;//下載速度        using (HttpClient http = new HttpClient())        {            var httpResponseMessage = await http.GetAsync("新建資料夾2.rar", HttpCompletionOption.ResponseHeadersRead);//傳送請求            var contentLength = httpResponseMessage.Content.Headers.ContentLength;   //檔案大小                            using (var stream = await httpResponseMessage.Content.ReadAsStreamAsync())            {                var readLength = 1024000;//1000K                byte[] bytes = new byte[readLength];                int writeLength;                var beginSecond = DateTime.Now.Second;//當前時間秒                while ((writeLength = stream.Read(bytes, 0, readLength)) > 0)                {                    //使用追加方式開啟一個檔案流                    using (FileStream fs = new FileStream(Application.StartupPath + "/temp.rar", FileMode.Append, FileAccess.Write))                    {                        fs.Write(bytes, 0, writeLength);                    }                    downloadSize += writeLength;                    downloadSpeed += writeLength;                    progressBar1.Invoke((Action)(() =>                    {                        var endSecond = DateTime.Now.Second;                        if (beginSecond != endSecond)//計算速度                        {                            downloadSpeed = downloadSpeed / (endSecond - beginSecond);                            label1.Text = "下載速度" + downloadSpeed / 1024 + "KB/S";                            beginSecond = DateTime.Now.Second;                            downloadSpeed = 0;//清空                        }                        progressBar1.Value = Math.Max((int)(downloadSize * 100 / contentLength), 1);                    }));                }                label1.Invoke((Action)(() =>                {                    label1.Text = "下載完成";                }));            }        }    });}

效果圖:
圖片描述

斷點續傳

上面的方式我們發現,如果下載到一個半斷網了下次會重頭開始下載。這和我們今天的主題明顯不符嘛。下面我們開始正式進入主題檔案下載之斷點續傳。把前面我們說到的頭屬性Range用起來。

var request = new HttpRequestMessage { RequestUri = new Uri(url) };request.Headers.Range = new RangeHeaderValue(rangeBegin, null); //【關鍵點】全域性變數記錄已經下載了多少,然後下次從這個位置開始下載。var httpResponseMessage = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);

完整程式碼:

/// /// 是否暫停/// static bool isPause = true;/// /// 下載開始位置(也就是已經下載了的位置)/// static long rangeBegin = 0; //(當然,這個值也可以存為持久化。如文字、資料庫等)private async void button3_ClickAsync(object sender, EventArgs e){    isPause = !isPause;    if (!isPause)//點選下載    {        button3.Text = "暫停";        await Task.Run(async () =>        {            //非同步操作UI元素            label1.Invoke((Action)(() =>           {               label1.Text = "準備下載...";           }));            long downloadSpeed = 0;//下載速度            using (HttpClient http = new HttpClient())            {                var url = "新建資料夾2.rar";                var request = new HttpRequestMessage { RequestUri = new Uri(url) };                request.Headers.Range = new RangeHeaderValue(rangeBegin, null); //【關鍵點】全域性變數記錄已經下載了多少,然後下次從這個位置開始下載。                var httpResponseMessage = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);                var contentLength = httpResponseMessage.Content.Headers.ContentLength;//本次請求的內容大小                if (httpResponseMessage.Content.Headers.ContentRange != null) //如果為空,則說明伺服器不支援斷點續傳                {                    contentLength = httpResponseMessage.Content.Headers.ContentRange.Length;//伺服器上的檔案大小                }                using (var stream = await httpResponseMessage.Content.ReadAsStreamAsync())                {                    var readLength = 1024000;//1000K                    byte[] bytes = new byte[readLength];                    int writeLength;                    var beginSecond = DateTime.Now.Second;//當前時間秒                    while ((writeLength = stream.Read(bytes, 0, readLength)) > 0 && !isPause)                    {                        //使用追加方式開啟一個檔案流                        using (FileStream fs = new FileStream(Application.StartupPath + "/temp.rar", FileMode.Append, FileAccess.Write))                        {                            fs.Write(bytes, 0, writeLength);                        }                        downloadSpeed += writeLength;                        rangeBegin += writeLength;                        progressBar1.Invoke((Action)(() =>                        {                            var endSecond = DateTime.Now.Second;                            if (beginSecond != endSecond)//計算速度                            {                                downloadSpeed = downloadSpeed / (endSecond - beginSecond);                                label1.Text = "下載速度" + downloadSpeed / 1024 + "KB/S";                                beginSecond = DateTime.Now.Second;                                downloadSpeed = 0;//清空                            }                            progressBar1.Value = Math.Max((int)((rangeBegin) * 100 / contentLength), 1);                        }));                    }                    if (rangeBegin == contentLength)                    {                        label1.Invoke((Action)(() =>                        {                            label1.Text = "下載完成";                        }));                    }                }            }        });    }    else//點選暫停    {        button3.Text = "繼續下載";        label1.Text = "暫停下載";    }}

效果圖:
圖片描述

到現在為止,你以為我們的斷點續傳就完成了嗎?
錯,你有沒有發現我們使用的下載連結是a標籤的。也就是我們自己寫服務端提供的下載連結是不是也可以支援斷點續傳呢?下面我換個下載連結試試便知。

斷點續傳(服務端的支援)

測試結果如下:
圖片描述

發現並不支援斷點續傳。為什麼a標籤連結可以直接支援,我們寫的下載卻不支援呢。
a標籤的連結指向的直接是iis上的檔案(iis預設支援),而我們寫的卻沒有做響應報文表頭Range的處理。(沒想象中的那麼智慧嘛 >_

前面我們說過,斷線續傳是HTTP的一個協議。我們遵守它,它就存在,我們不遵守它也就不存在。
那下面我們修改前面的檔案下載程式碼(服務端):

public void FileDownload5(){              //前面可以做使用者登入驗證、使用者許可權驗證等。    string filename = "大資料.rar";   //客戶端儲存的檔名      string filePath = Server.MapPath("/App_Data/大資料.rar");//要被下載的檔案路徑     var range = Request.Headers["Range"];    if (!string.IsNullOrWhiteSpace(range))//如果遵守協議,支援斷點續傳    {        var fileLength = new FileInfo(filePath).Length;//檔案的總大小        long begin;//檔案的開始位置        long end;//檔案的結束位置        long.TryParse(range.Split('=')[1].Split('-')[0], out begin);        long.TryParse(range.Split('-')[1], out end);        end = end - begin > 0 ? end : (fileLength - 1);// 如果沒有結束位置,那我們讀剩下的全部        //表頭 表明  下載檔案的開始、結束位置 和檔案總大小        Response.AddHeader("Content-Range", "bytes " + begin + "-" + end + "/" + fileLength);        Response.ContentType = "application/octet-stream";        Response.AddHeader("Content-Disposition", "attachment;filename=" + filename);        Response.TransmitFile(filePath, begin, (end - begin));//傳送 檔案開始位置讀取的大小    }    else    {        Response.ContentType = "application/octet-stream";        Response.AddHeader("Content-Disposition", "attachment;filename=" + filename);        Response.TransmitFile(filePath);    }}

然後再測試斷點續傳,完美支援。

多執行緒同時下載(分片下載)

檔案的斷點續傳已經分析完了。不過中間有些細節的東西你可以根據實際需求去完善。如:檔案命名、斷點續傳的檔案是否發生了改變、下載完成後驗證檔案和伺服器上的是否一致。
還有我們可以根據表頭屬性Range來實現多執行緒下載,不過這裡就不貼程式碼了,貼個效果圖吧。和上一篇檔案上傳裡的多執行緒上傳同理。您也可以根據提供的demo程式碼下載檢視,內有完整實現。
圖片描述

 


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4550/viewspace-2812364/,如需轉載,請註明出處,否則將追究法律責任。

相關文章