續上一篇 (http://blog.csdn.net/querw/archive/2009/08/24/4477182.aspx) 談談在APS.NET中如何控制檔案下載.
設計目的和要求
假設這麼一個應用情境:
一個主機,上面存有許多檔案資料,有各種檔案格式.(PDF, DOC, EXE ... 等等).
該主機上運行一個ASP.NET網站, 使用者註冊,並付費之後允許他/她下載資料.
檔案是放在IIS伺服器上的, 如果使用者知道具體路徑那麼他是可以隨時下載的. (在沒有或者不能設定存取權限的情況下.)
如果直接把下載路徑發送給付費使用者,肯定是行不通的,會被散播出去. 所以不能把讓用戶端得知具體路徑,檔案內容由 ASP.NET 伺服器頁面讀取後發送給用戶端.
我要做的就是: 編寫一個ASP.NET 頁面伺服器代碼, 讀取指定檔案,並發送給客戶
.
總體思路
.net 裡, 有2個函數可以用來傳送檔案 Response.WriteFile 和 Response.TransmiteFile
它們的主要區別是: WriteFile 是先把檔案內容讀取到伺服器緩衝,然後再發送到用戶端. 所以對於大檔案,會造成伺服器很大的壓力.
一般用來處理小檔案,比如,發送給 excel 報表之類的. TransmiteFile 不緩衝資料, 直接拋給用戶端, 所以可以用來發大檔案.
( 我採用 TransmiteFile 來實現.)
具體實現
1. 給客戶一個連結,形如 http://xxxx/downloads.aspx?Key=ABCD123456
2. 在downloads.aspx的伺服器代碼中, 通過Key的值,查詢資料庫,得到伺服器上的真實檔案路徑. 這個時候,控制權在 downloads.aspx, 所以可以編寫複雜的控制功能, 比如看看使用者有沒有登入,有沒有付費之類的,從而避免外部盜鏈.
3. 得到檔案路徑後,調用 Response.TransmiteFile 傳送檔案給用戶端.
4. 因為給客戶的連結裡沒有任何檔案名稱的資訊, 所以要在HTTP回應標頭裡添加一句,告訴用戶端檔案名稱: Response.AddHeader("Content-Disposition", "attachment; filename=/"" + 你的檔案名稱 + "/""); (如果要支援中文,要考慮編碼的問題, 我這裡不說,不是我們的主題.)
5. 如果是一個大檔案, 比如1G, 不支援斷點續傳,是沒有意義的. 那麼如何?呢?
(1) 要讓用戶端知道我們的伺服器支援斷點續傳, 要在HTTP回應標頭中包含 Accept-Ranges: bytes 和 ETag: "XXXX".
ETag 是一個檔案的標識, 供用戶端判斷它請求的是同一個檔案, ETag 的內容在HTTP規範裡並沒有具體要求,只要保證在同一個伺服器上,同一個檔案有相同的ETag 就行了, 一般就根據檔案名稱和最後修改時間產生一個字串就可以了.
程式碼範例:
Response.AddHeader("Accept-Ranges", "bytes"); // 斷點續傳控制.
Response.AddHeader("ETag", "/"" + strETag + "/""); // 允許斷點續傳
(2) 要處理用戶端請求中的 "Range" 欄位. 一般格式是這樣: Range: bytes=1234- 或者 Range: bytes=1234-12345
分別表示從地1235個位元組開始下載和下載第1235到第12346個位元組之間的資料.
伺服器首先要添加 Content-Range 回應標頭, 然後用 TransmiteFile 發送指定的資料.
程式碼範例:
Response.StatusCode = 206;
Response.AddHeader("Content-Length", (lTo - lFrom + 1).ToString());
Response.AddHeader("Content-Range", string.Format("bytes {0}-{1}/{2}", lFrom, lTo, fi.Length)); // 參數0 和 參數1 是位置. 參數2是檔案長度
Response.TransmitFile(strFilePath, lFrom, lTo - lFrom + 1);
( 其中, lFrom 和 lTo 是根據用戶端請求中的 Range 欄位得到的.)
總結
這個功能說起來一點點檔案就寫完了,做的時候做了很久. 中間還碰到一個問題: 我用 VS2008 開發, 沒有在機器上裝IIS. 結果調試的時候,發現Accept-Ranges 和 Content-Range兩個回應標頭始終加不進去.
後來把代碼上傳到一個真實伺服器才測試通過, 看來 VS2008 內建的.net伺服器設定有寫古怪.
說一下優缺點:
1. 可以隨心所欲的控制下載.
2. 可以繞過伺服器檔案類型下載的限制, 比如我的伺服器不允許下載 ISO 和 NRG 副檔名的檔案, 如果直接輸入RUL會提示404, 但是用上述的方法可以下載.
3.用這種辦法的話,下載是在.net的一個線程裡做的,如果使用者量大的話,需要維護多個響應, 我不知道會不會對伺服器效能有什麼影響.
目前我還不瞭解這種方法和直接輸入URL下載對IIS伺服器來說有沒有什麼不同.
不過,對於IIS來說, 如果使用者直接輸入檔案的URL通過下載工具來多線程下載, 也同樣會有這個問題, 要維護多個響應.
如果您有什麼見解,請賜教, 謝謝. querw@sina.com
附註:
1. TransmitFile(String) ( 函數是 .net 2.0 才加上去的.
2. TransmitFile(String, Int64,
Int64) 帶發送位置參數的重載是 .net 2.0 sp1 以後才支援的. 所以要用本文所說的方法實現斷點續傳, 至少要支援.net 2.0 sp1
3. 我沒有檢測要求標頭中的 If-Range 和 Unless-Modified-Since, 如果有需要,在得到檔案名稱之後就可以校正一下, 分別對應 ETag 和 Last-Modified.
4. 本文才剛發到CSDN沒兩天就被 www.diybl.com 轉載, 居然註明作者 "佚名", 我不反對轉載本文
, 本來就是要和大家分享, 但是我要求保留我的署名
, 不過分吧? (也許不是我第一個用這種方法並公布出來, 但是文章確是我原創,並且編寫代碼做了測試.)
=============================傳說中的分割線======================================
上面說的可能比較簡略, 我貼一段代碼,附帶注釋,不求所有人都能看懂, 但是如果你正在做類似的工作,相信能有所協助
// 1. 擷取伺服器上的檔案路徑 // 這裡,如果檔案路徑有問題, 無法映射則會拋出異常, strURL 是根據 Key從資料庫中查詢到的真實檔案路徑
string strFilePath = Server.MapPath("~" + strURL);
// 2. 擷取檔案名稱
string strFileName = System.IO.Path.GetFileName(strFilePath);
// 3. 確認檔案是否存在
FileInfo fi = new FileInfo(strFilePath);
if (!fi.Exists)
{
// 退出點,檔案不存在
}
// 4. 拋給用戶端
strFileName.Replace(" ", "%20"); // 處理檔案名稱含空格的情況
string strETag = strFileName.ToUpper() + ":" + fi.Length.ToString(); // 我的Etag 是用檔案名稱和位元組數構成,馬馬虎虎湊合用.
string strLastTime = fi.LastWriteTimeUtc.ToString("r");
Response.Clear(); // 先把響應流清空
Response.ContentType = "application/octet-stream"; // 指定檔案類型,使用戶端總是彈出儲存檔案的框框.
Response.AddHeader("Content-Disposition", "attachment; filename=/"" + strFileName + "/"");
Response.AddHeader("Accept-Ranges", "bytes"); // 斷點續傳控制.
Response.AddHeader("ETag", "/"" + strETag + "/""); // 允許斷點續傳
Response.AddHeader("Last-Modified", strLastTime);//把最後修改日期寫入響應
// 擷取用戶端請求的範圍, 並且要校正這個範圍的有效性
long lFrom = 0;
long lTo = 0;
bool bParts = false;
string strRange = Request.Headers["Range"];
if (ParseRange(strRange, out lFrom, out lTo)) /// ParseRange 是我自己寫的函數, 從 Range 中讀取2個位置.代碼在後面.
{
if (-1 == lFrom && -1 == lTo)
{
// 不允許2個值都不指定
}
else
{
if (lTo == -1) lTo = fi.Length - 1; // 用戶端未指定結束位置,則認為是檔案的最後一個字元 Range: bytes=123- 的情況
if (lFrom == -1) // Range: bytes=-123 的情況, 請求最後的123個位元組
{
lFrom = fi.Length - lTo;
lTo = fi.Length - 1;
}
if (lFrom < 0 || lFrom >= fi.Length || lFrom > lTo || lTo < 0 || lTo >= fi.Length)
{
// 以上幾種情況下,範圍的值能解析出來,但是不合法.
// 首先 From 和 To 的下標都應該在檔案長度範圍內
// 其次 From 應該 <= To
}
else
{
bParts = true;
}
}
}
// 根據使用者請求,返回資料區段或者整個檔案
if(bParts)
{
Response.StatusCode = 206;
Response.AddHeader("Content-Length", (lTo - lFrom + 1).ToString());
Response.AddHeader("Content-Range", string.Format("bytes {0}-{1}/{2}", lFrom, lTo, fi.Length)); // 參數0 和 參數1 是位置,從0開始. 參數2是檔案長度
Response.TransmitFile(strFilePath, lFrom, lTo - lFrom + 1);
}
else
{
Response.AddHeader("Content-Length", fi.Length.ToString());
Response.TransmitFile(strFilePath);
}
Response.End();
}
=============================傳說中的分割線======================================
protected bool ParseRange(string strRange, out long lFrom, out long lTo)
{
lFrom = 0;
lTo = 0;
long lTemp = 0;
if (strRange == null || strRange == "")
{
return false; // 字串為空白
}
else
{
strRange = strRange.Replace(" ", ""); // 去除多餘的空格
string[] range = strRange.Split(new char[] { '=', '-' });
// 1.分割後,包含3段 第一段是 "Range: bytes", 第二段是起始位置, 第三段是結束位置
if (range.Length != 3)
{
return false; // 格式不正確 只支援 Range: bytes=89294317- 或者 Range: bytes=1234-1235 或者 Range: bytes=-500 3種格式.
}
// 2. 解析起始位置
if (range[1].Length <= 0)
{
// 起始位置未指定
lFrom = -1;
}
else
{
if (!long.TryParse(range[1], out lTemp))
{
return false; // 起始位置無法解析
}
lFrom = lTemp;
}
// 3. 解析結束位置
if (range[2].Length <= 0)
{
lTo = -1; // 沒有指定結束位置 Range: bytes=1234- 的情況
}
else
{
if (!long.TryParse(range[2], out lTemp)) // 排除 byte=xxxx- 的情況 TryParse 失敗, 會把lTemp 置零
{
return false; // 第三度的內容不為空白,但是無法解析
}
lTo = lTemp;
}
return true;
}
}