文/陶剛
在Web應用程式中處理大檔案下載的問題一直出了名的困難,因此對於大多數網站來說,如果用戶的下載被中斷了,它們只能說悲哀降臨到用戶的身上了。但我們現在不必這樣做了,因為你可以讓自己的ASP.NET應用程式有能力支援可恢復(繼續)的大檔案下載。使用本文提供的方法的時候,你可以追蹤下載的過程,這樣你就可以處理動態建立的檔案--而且要達到這個目標根本不需要舊式的ISAPI動態連結函式庫和非受控的(unmanaged)C++程式碼。
為客戶端提供從互聯網上下載文件的服務最容易了,對嗎?僅僅只需要把可下載的檔案複製到你的網路應用程式目錄中,發布連結並讓IIS完成所有相關的工作。但是,文件服務不應該比脖子上的疼痛還要多(還要麻煩),你不希望整個世界都能訪問自己的數據,你不希望伺服器被數百個靜態文件塞滿了,你甚至於希望下載臨時檔案--只有當客戶端開始下載後的空閒時間才建立這些檔案。
不幸的是,使用IIS對下載請求的預設的回應是不可能達到這些效果的。因此在一般情況下,為了獲得對下載過程的控制權,開發者需要連結到一個定制的.aspx頁面,在這個頁面中它們檢查用戶憑證(credential)、建立可以下載的文件並使用下面的程式碼把該檔案推送給客戶端:
Response.WriteFile
Response.End()
而這就是出現真正麻煩的地方。
有什麼問題?
WriteFile方法看起來非常完美,它使檔案的二進位資料流向客戶端。但直到最近我們才知道,WriteFile方法是個出名的記憶體佔用狂,它把整個檔案載入伺服器的RAM來提供服務(實際上它甚至於會佔用檔案兩倍大小的空間)。對於大文件,這會造成服務記憶體問題,並且可能重複ASP.NET過程。但是在2004年6月微軟發布了一個補丁解決了這個問題。這個補丁現在是.NET Framework 1.1補丁包(SP1)的一部分。
這個補丁引入了TransmitFile方法,它把一個磁碟檔案讀入到較小的記憶體緩衝區之後就開始傳輸該檔案。儘管這個方案解決了記憶體和循環的問題,但是它仍然不能令人滿意。你不能控制響應的生命週期。你無法知道下載是否正確地完成了,你沒有辦法知道下載是否被中斷了,並且(如果你建立了臨時文件)你也不知道是否應該、以及什麼時候可以刪除這些文件。更糟的是,如果下載的確失敗了,TransmitFile方法又從客戶端下次嘗試的檔案頭開始下載。
其中一個可能的解決方案--實現後台智慧傳輸服務(BITS)對於多數站點來說是不可行的,因為這會毀掉維持客戶端瀏覽器和作業系統獨立性而做出的努力。
令人滿意的解決方案的基礎還是來自微軟用來解決WriteFile造成的記憶體混亂問題的第一次嘗試(見知識庫文章812406)。那篇文章示範了智慧的大塊資料下載過程,它從檔案流中讀取資料。在伺服器把位元組區塊傳送給客戶端之前,它使用Response.IsClientConnected屬性來檢查客戶端是否仍然保持連線。如果仍然保持連接,它就繼續發送流字節,否則就停止,以防止伺服器發送不必要的資料。
這就是我們採用的方法,特別是在下載臨時檔案的時候。在IsClientConnected回傳False的情況下,你就知道下載過程被中斷了,你應該要儲存檔案;反之,當這個過程成功完成的時候,你就刪除臨時檔案。此外,為了恢復中斷了的下載,你需要做的工作是從上次下載嘗試過程中客戶端連線失敗的檔案點開始下載。
HTTP協定和頭資訊(Header)支援
HTTP協定支援可以用來處理中斷下載的頭資訊。使用少量的HTTP頭訊息,你可以增強自己的下載過程,使它完全遵循HTTP協定規範。這個規範與ranges一起提供恢復中斷的下載所需的一切資訊。
下面是它的工作方式。首先,如果伺服器支援客戶端斷點續傳,它就在初始的回應中傳送Accept-Ranges頭訊息。伺服器也會發送一個實體標籤(entity tag)頭資訊(ETag),它包含一個唯一的識別字串。
下面的程式碼顯示了IIS發送給客戶端的一些用於回應一個初始下載請求的頭訊息,它向客戶端傳遞了被要求的文件的詳細資訊。
HTTP/1.1 200 OK
Connection: close
Date: Tue, 19 Oct 2004 15:11:23 GMT
Accept-Ranges: bytes
Last-Modified: Sun, 26 Sep 2004 15:52:45 GMT
ETag: "47febb2cfd76c41:2062"
Cache-Control: private
Content-Type: application/x-zip-compressed
Content-Length: 2844011
在接收這些頭資訊之後,如果下載中斷了,IE瀏覽器在後來的下載請求中會把Etag值和Range頭訊息傳回伺服器。下面的程式碼顯示了嘗試恢復中斷下載時IE傳送給伺服器的一些頭訊息。
GET
這些頭資訊表明IE快取了IIS提供的實體標籤,並在If-Range頭資訊中把它發送回伺服器了,這是確保下載從準確相同的檔案恢復的一種途徑。不幸的是,並非所有的瀏覽器的工作方式都相同。用戶端發送的用於驗證檔案的其它HTTP頭資訊可能是If-Match、If-Unmodified-Since或Unless-Modified-Since。很明顯,該規範對於客戶端軟體必須支援哪些頭訊息,或者必須使用哪些頭資訊沒有明確的規定。因此,有些客戶端根本沒有使用頭資訊,而IE只使用If-Range和Unless-Modified-Since。你最好用程式碼檢查這些資訊。採用這種方式的時候,你的應用程式可以在非常高的層次上遵循HTTP規範,並且可以使用多種瀏覽器。 Range頭資訊指明了被要求的位元組範圍--在例子中它是伺服器應該恢復檔案流的起始點。
當IIS接收到恢復下載的請求類型時,它會傳回包含下面的頭資訊的回應資訊:
HTTP/1.1 206 Partial Content
Content-Range: bytes 822603-2844010/2844011
Accept-Ranges: bytes
Last-Modified: Sun, 26 Sep 2004 15:52:45 GMT
ETag: "47febb2cfd76c41:2062"
Cache-Control: private
Content-Type: application/x-zip-compressed
Content-Length: 2021408
請注意上面的程式碼與最初的下載請求的HTTP回應有點差別--恢復下載的請求是206而最初下載的請求是200。這表示透過線路傳遞進來的內容是部分文件。這次Content-Range頭資訊指出了被傳遞位元組的精確數量和位置。
IE對於這些頭資訊是很挑剔的。如果最初的回應沒有包含Etag頭訊息,IE永遠不會嘗試恢復下載。我測試過的其它客戶端不使用ETag頭信息,它們簡單得依賴於文件名、請求範圍,並使用Last-Modified頭資訊(如果它們試圖驗證該文件)。
深入了解HTTP協定
前面的部分中顯示的頭資訊對於使恢復下載的解決方案運行來說是足夠的,但是它沒有完全覆蓋HTTP規範。
在單一請求中,Range頭資訊可以詢問多個範圍,這種特性稱為"多部分範圍(multipart ranges)"。請不要與分段下載(segmented downloading)混淆,幾乎所有的下載工具都使用分段下載來提高下載速度。這些工具聲稱透過開啟兩個或多個並發的連線(每個連線請求檔案的不同範圍)提高了下載速度。
多部分範圍的想法並沒有開啟多個連接,但是它可以使客戶端軟體可以在單一請求/回應週期中請求某個文件的最前面的十個和最後面的十個位元組。
誠實地說,我從來都沒有找到使用這種特性軟體片段。但是我拒絕在程式碼聲明中寫入"它並不是完全的HTTP相容的"。略去這個特性必定會觸犯墨菲法則(Murphy's Law)。無論如何,多部分範圍還是被用於電子郵件傳輸中,把頭訊息、普通文字和附件分開。
範例程式碼
我們知道了客戶端和伺服器如何交換頭資訊以確保可恢復的下載,把這些知識與檔案區塊流的想法結合起來,你就可以為自己的ASP.NET應用程式增加可靠的下載管理能力了。
取得下載過程的控制權的方法是從客戶端截取下載請求、讀取頭資訊並適當地回應。在.NET之前,你必須編寫ISAPI(Internet伺服器API)應用程式來實現這種功能,但是.NET框架組件提供了一個IHttpHandler接口,在類中實現的時候,它允許你僅僅使用.NET代碼就能夠截取和處理請求。這意味著你的應用程式對於下載過程有完全控制權和響應性,再也不會涉及或使用IIS的自動化函數。
範例程式碼在HttpHandler.vb檔案中包含了一個自訂的HttpHandler類別(ZIPHandler)。 ZipHandler實作了IhttpHandler接口,並且處理對所有.zip檔案的請求。
為了測試範例程式碼,你需要在IIS中建立一個新的虛擬目錄,並把原始檔複製到那裡。在該目錄中建立一個叫做download.zip的檔案(請注意IIS和ASP.NET不能處理大於2GB的下載,因此請確保你的檔案沒有超過該限制)。設定你的IIS虛擬目錄,透過aspnet_isapi.dll映射.zip副檔名。
HttpHandler類別:ZIPHandler
在ASP.NET中映射了.zip副檔名之後,客戶端每次向伺服器請求.zip檔案的時候,IIS呼叫ZipHandler類別的ProcessRequest方法(請參閱下載程式碼)。
ProcessRequest方法首先建立自訂的FileInformation類別(請參閱下載程式碼)的實例,它封裝了下載的狀態(例如進行中、中斷了等等)。範例把download.zip範例檔的路徑硬編碼到程式碼中了。如果把這段程式碼應用於你自己的應用程序,需要修改它來打開被要求的文件。
' 使用objRequest偵測請求了哪個文件,用該文件開啟objFile。
' 例如objFile = New Download.FileInformation(<完整檔案名稱>)
objFile = New Download.FileInformation( _
objContext.Server.MapPath("~/download.zip"))
接下來,程式使用所描述的HTTP頭資訊(如果請求提供了頭資訊)執幸幌盜械難櫓ぜ觳欏K?衙恐旨觳榿擠庾霸諦⌒退接瀉??校?綺?櫓こ曬Φ幕熬頭禱豑rue。如果某個驗證檢查失敗了,回應會立即終止,並傳送適當的StatusCode值。
If Not objRequest.HttpMethod.Equals(HTTP_METHOD_GET) Or Not
objRequest.HttpMethod.Equals(HTTP_METHOD_HEAD) Then
' 目前只支援GET和HEAD方法objResponse.StatusCode = 501 ' 沒有執行
ElseIf Not objFile.Exists Then
' 無法找到被要求的檔案objResponse.StatusCode = 404 ' 沒有找到
ElseIf objFile.Length > Int32.MaxValue Then
' 檔案太大了objResponse.StatusCode = 413 ' 請求實體太大
ElseIf Not ParseRequestHeaderRange(objRequest, alRequestedRangesBegin, alRequestedRangesend, _
objFile.Length, bIsRangeRequest) Then
' Range請求中包含無用的實體objResponse.StatusCode = 400 ' 無用的請求
ElseIf Not CheckIfModifiedSince(objRequest,objFile) Then
' 實體沒有被修改過objResponse.StatusCode = 304 ' 沒有被修改過
ElseIf Not CheckIfUnmodifiedSince(objRequest,objFile) Then
' 實體在上次被要求的日期之後被修改過objResponse.StatusCode = 412 ' 預處理失敗
ElseIf Not CheckIfMatch(objRequest, objFile) Then
' 實體與請求不符objResponse.StatusCode = 412 ' 預處理失敗
ElseIf Not CheckIfNoneMatch(objRequest, objResponse,objFile) Then
' 實體的確與none-match請求相符。
' 回應代碼位於CheckIfNoneMatch函數中
Else
' 初步檢查成功
這些初步檢查的函數中的ParseRequestHeaderRange(請參閱下載程式碼)檢查客戶端是否請求了檔案範圍(這表示是局部下載)。如果被要求的範圍是無效的(無效範圍指超越檔案大小或包含不合理數字的範圍數值),則該方法把bIsRangeRequest設定為True。如果請求了範圍,CheckIfRange方法會驗證IfRange頭資訊。
如果被要求的範圍是有效的,程式碼會計算回應訊息的大小。如果用戶端要求了多個範圍,則回應資訊大小的數值會包含多部分頭部資訊長度的數值。
如果無法確定某個發送的頭部訊息值,程式將把這個下載請求作為最初請求而不是部分下載來處理,從檔案的頂部開始發送一個新的下載流。
If bIsRangeRequest AndAlso CheckIfRange(objRequest, objFile) Then
' 這是範圍請求' 如果Range數組包含多個實體,它還是一個多部分範圍請求bMultipart = CBool(alRequestedRangesBegin.GetUpperBound(0)>0)
' 進入每個範圍來取得整個回應長度For iLoop = alRequestedRangesBegin.GetLowerBound(0) To alRequestedRangesBegin.GetUpperBound(0)
' 內容的長度(這個範圍的)
iResponseContentLength += Convert.ToInt32(alRequestedRangesend( _
iLoop) - alRequestedRangesBegin(iLoop)) + 1
If bMultipart Then
' 如果是多部分範圍請求,計算將發送的中間頭資訊的長度iResponseContentLength += MULTIPART_BOUNDARY.Length
iResponseContentLength += objFile.ContentType.Length
iResponseContentLength += alRequestedRangesBegin(iLoop).ToString.Length
iResponseContentLength += alRequestedRangesend(iLoop).ToString.Length
iResponseContentLength += objFile.Length.ToString.Length
' 49是多部分下載中換行和其它必要的字元的長度iResponseContentLength += 49
End If
Next iLoop
If bMultipart Then
' 如果是多部分範圍請求,
' 我們還必須計算將發送的最後一個中間頭資訊的長度iResponseContentLength +=MULTIPART_BOUNDARY.Length
' 8 是破折號和換行符的長度iResponseContentLength += 8
Else
' 不是多部分下載,因此我們必須說明初始HTTP頭資訊的回應範圍objResponse.AppendHeader( HTTP_HEADER_CONTENT_RANGE, "bytes " & _
alRequestedRangesBegin(0).ToString & "-" & _
alRequestedRangesend(0).ToString & "/" & _
objFile.Length.ToString)
'End If
' 範圍響應objResponse.StatusCode = 206 ' 局部回應Else
' 這不是範圍請求,或被要求的範圍實體ID與目前的實體ID不匹配,
' 因此開始新的下載' 指明文件完成部分的大小等於內容的長度iResponseContentLength =Convert.ToInt32(objFile.Length)
' 傳回正常的OK狀態objResponse.StatusCode = 200
End If
' 接下來伺服器必須傳送幾個重要的回應頭訊息,例如內容長度、Etag、和檔案的內容類型:
' 把內容長度寫入回應objResponse.AppendHeader( HTTP_HEADER_CONTENT_LENGTH,iResponseContentLength.ToString)
' 把最後修改日期寫入回應objResponse.AppendHeader( HTTP_HEADER_LAST_MODIFIED,objFile.LastWriteTimeUTC.ToString("r"))
' 告訴客戶端軟體我們接受了範圍請求objResponse.AppendHeader( HTTP_HEADER_ACCEPT_RANGES,HTTP_HEADER_ACCEPT_RANGES_BYTES)
' 把檔案的實體標籤寫入回應(用引號括起來)
objResponse.AppendHeader(HTTP_HEADER_ENTITY_TAG, """" & objFile.EntityTag & """")
' 把內容類型寫入回應If bMultipart Then
' 多部分訊息有這種特殊的類型' 在例子中文件實際的mime類型在以後才寫入回應objResponse.ContentType = MULTIPART_CONTENTTYPE
Else
' 單一部分訊息擁有的檔案內容類型objResponse.ContentType = objFile.ContentType
End If
下載所需的一切都準備好了,可以開始下載檔案了。你將使用FileStream物件從檔案中讀取位元組區塊。把FileInformation實例objFile的State屬性設定為fsDownloadInProgress。只要客戶端保持連接,伺服器就從檔案中讀取位元組區塊並發送給客戶端。對於多部分下載,這段程式碼會傳送特定的頭資訊。如果客戶端中斷連接,伺服器就把檔案狀態設定為fsDownloadBroken。如果伺服器完成了被請求範圍的傳送過程,它會把狀態設定為fsDownloadFinished(見下載代碼)。
FileInformation輔助類別
在ZIPHandler部分你會發現,FileInformation是一個輔助類,它封裝了下載狀態資訊(例如下載中、中斷等等)。
為了建立FileInformation的實例,你需要把被請求檔案的路徑傳遞給該類別的建構子:
Public Sub New(ByVal sPath As String)
m_objFile = New System.IO.FileInfo(sPath)
End Sub
FileInformation使用System.IO.FileInfo物件來取得檔案的信息,這些資訊是作為該物件的屬性所暴露的(例如檔案是否存在、檔案全名、大小等等)。這個類別也揭露了一個DownloadState枚舉,它描述了下載請求的多種狀態:
Enum DownloadState
' Clear:沒有下載過程,檔案可能在維護fsClear = 1
' Locked:動態建立的檔案不能被更改fsLocked = 2
' In Progress:檔案被鎖定了,下載過程正在進行fsDownloadInProgress = 6
' Broken:檔案鎖定了,下載過程正在進行,但被取消了fsDownloadBroken = 10
' Finished:檔案被鎖定了,下載過程完成了fsDownloadFinished = 18
End Enum
FileInformation也提供了EntityTag屬性值。範例程式碼中的這個值是硬編碼的,這是由於範例程式碼只使用了一個下載文件,並且該文件不會被改變,但是對於實際應用程式來說,你會提供多個文件,甚至於動態地建立文件,你的程式碼必須為每個文件提供一個唯一的EntityTag值。此外,每次改變或修改該檔案的時候,這個值也必須改變。這使客戶端軟體能夠驗證它們已經下載的位元組區塊是否仍然是最新的。下面是範例程式碼中傳回硬編碼EntityTag值的部分:
Public ReadOnly Property EntityTag() As String
' EntityTag用於對客戶端的初始(200)回應,以及來自客戶端的復原請求Get
' 為檔案建立唯一的字串。
' 注意,只要檔案沒有改變,該唯一碼就必須保留。
' 但是,如果檔案的確改變了或被修改了,這個碼必須改變。
Return "MyExampleFileID"
End Get
End Property
一個簡單的和大致足夠安全的EntityTag可能由檔案名稱和檔案最後被修改的日期組成。無論使用什麼方法,你都必須確保這個值是真的是唯一的,不會與其它檔案的EntityTag混淆。我希望在自己的應用程式中按照客戶、顧客和郵編索引來動態地替被建立的文件命名,並把用作EntityTag的GUID儲存在資料庫中。
ZipFileHandler類別讀取和設定公共的State屬性。在完成下載以後,它把State設定為fsDownloadFinished。這時候你就可以刪除臨時檔案了。這兒一般需要呼叫Save方法來維持狀態。
Public Property State() As DownloadState
Get
Return m_nState
End Get
Set(ByVal nState As DownloadState)
m_nState = nState
' 可選操作:這個時候你可以自動刪除檔案。
' 如果狀態被設定為Finished ,你就再也不需要這個檔案了。
' If nState =DownloadState.fsDownloadFinished Then
' Clear()
' Else
' Save()
' End If
Save()
End Set
End Property
在檔案狀態改變的任何時候ZipFileHandler都應該呼叫Save方法,儲存檔案的狀態,這樣以後才能顯示給使用者。你也可以用它來保存你自己建立的EntityTag。請不要把檔案的狀態和EntityTag值保存在Application、Session或Cache中--你必須跨越所有的這些這些物件的生命週期來保存資訊。
Private Sub Save()
' 把該檔案下載的狀態儲存到資料庫或XML檔案中。
' 當然,如果你並沒有動態地建立文件,就不需要保存這個狀態。
End Sub
前面提到,範例程式碼只處理一個現有的檔案(download.zip),但你可以進一步增強這個程序,根據需要建立被要求的檔案。
測試範例程式碼的時候,你的本機系統或LAN可能太快了,以至於無法中斷下載過程,因此我推薦你使用慢速LAN連線(在IIS中減少網站的頻寬是一種模擬的方法)或把伺服器放到網路上。
在客戶端上下載文件仍然很艱難。 ISP操作的不對的或配置錯誤的Web緩衝伺服器都可能使大檔案下載過程失敗,包括下載狀況惡化或早期對話終結。如果檔案大小超過了255MB,你就應該鼓勵顧客使用第三方下載管理軟體,儘管某些最新的瀏覽器內建了基本的下載管理器。
如果你希望進一步擴展範例程式碼,查閱一下HTTP規格是有益的。你可以為下載建立MD5校驗值,使用Content-MD5頭資訊新增它們,提供驗證下載檔案完整性的途徑。範例程式碼除了GET和HEAD之外沒有涉及到其它的HTTP方法。