將更智能的 ASP.NET 文件下載體驗內置到您的 Web 應用程序中

 

將更智能的 ASP.NET 文件下載體驗內置到您的 Web 應用程序中

發佈日期: 2006-10-30 | 更新日期: 2006-10-30

Joe Stagner

本文將介紹以下內容:

從 ASP.NET 站點進行動態下載

生成即時鏈接

可恢復下載和自定義處理程序

自定義下載機制所涉及的安全性問題

本文涉及以下技術:

ASP.NET

代碼下載位置:

Downloading2006_09.exe (174KB)

*
本頁內容
基本下載鏈接 基本下載鏈接
適用於所有文件類型的強制下載 適用於所有文件類型的強制下載
將大文件分爲小塊下載 將大文件分爲小塊下載
更有效的解決方案 更有效的解決方案
恢復失敗的下載 恢復失敗的下載

提要欄

無意的文件訪問

您的用戶極有可能需要從貴組織的網站下載文件。既然提供下載和提供鏈接一樣容易,您當然不需要去閱讀有關此過程的文章,對吧?但隨着 Web 領域的巨大進步,我們有很多理由可以相信,這個過程不一定像我們想像的那麼容易。也許您希望將文件作爲一個文件下載,而不是作爲內容在瀏覽器中顯示。也許您還不知道這些文件的路徑(或者它們根本就不在磁盤上),因此那些簡單的 HTML 鏈接不可能實現下載。也許您會擔心用戶在下載大文件期間會失去連接。

在本文中,我將介紹一些解決這些問題的方法,這樣您的用戶就可以擁有快速、無錯的下載體驗了。在整篇文章中,我將討論動態生成的鏈接,說明如何繞過默認文件行爲,並借用 HTTP 1.1 功能來例示可恢復的由 ASP.NET 驅動的下載。

基本下載鏈接

讓我們首先來解決缺失鏈接的問題。如果您不知道某文件的路徑將是什麼,您只需稍後從數據庫中拉出鏈接列表即可。您甚至可以通過在運行時於給定的目錄中枚舉文件來動態建立鏈接列表。這裏我將探討第二種方法。

假設我在 Visual Basic® 2005 中建立一個 DataGrid,並在其中填入指向下載目錄中所有文件的鏈接,如圖1 所示。要完成此操作,可先在頁面內使用 Server.MapPath 來檢索下載目錄的完整路徑(此例中爲 ./downloadfiles/),再使用 DirectoryInfo.GetFiles 檢索該目錄中所有文件的列表,然後從 FileInfo 對象的最終所得數組建立一個 DataTable(其中含有代表每個相關屬性的列)。可將 DataTable 綁定到頁面上的 DataGrid,通過該 DataTable 可生成帶有以下 HyperLinkColumn 定義的鏈接:

<asp:HyperLinkColumn DataNavigateUrlField="Name"  
    DataNavigateUrlFormatString="downloadfiles/{0}" 
    DataTextField="Name" 
    HeaderText="File Name:" 
    SortExpression="Name" />

如果您單擊這些鏈接,就會發現瀏覽器對每個文件類型的處理方式都不同,具體取決於註冊了哪些助手應用程序來打開每個文件類型。默認情況下,如果您單擊 .asp 頁面、.html 頁面、.jpg、.gif 或 .txt,它會在瀏覽器其本身中打開,並且不出現“另存爲”對話框。這是因爲這些文件的擴展名都屬於已知的 MIME 類型。因此,要麼瀏覽器本身知道如何呈現文件,要麼操作系統具有一個將被瀏覽器使用的助手應用程序。Webcasts(.wmv、.avi 等等)、PodCasts(.mp3 或 .wma)、PowerPoint® 文件以及所有的 Microsoft® Office 文檔都屬於已知的 MIME 類型,如果您不想在默認情況下聯機打開這些文件,就產生了一個難題。

.

圖 1 DataGrid 中簡單的 HTML 鏈接

此外,如果您允許以此方式下載,則只有一個非常普通的訪問控制機制可供您使用。您可以逐個目錄地控制下載訪問,但是逐一控制對各個文件或文件類型的訪問需要詳盡複雜的訪問控制,這對於 Web 主管和系統管理員而言是一個非常麻煩的過程。幸運的是,ASP.NET 和 .NET Framework 提供了大量的解決方案。其中包括:

使用 Response.WriteFile 方法

使用 Response.BinaryWrite 方法流式傳送文件

使用 ASP.NET 2.0 中的 Response.TransferFile 方法

使用 ISAPI 篩選器

寫入到自定義瀏覽器控件

適用於所有文件類型的強制下載

在剛纔所列的解決方案中最簡單易用的就是 Response.WriteFile 方法。其基本語法非常簡單;這個完整的 ASPX 頁面將查找被指定爲查詢字符串參數的文件路徑,並將該文件一直伺服到客戶端:

<%@ Page language="VB" AutoEventWireup="false" %>
<html>
   <body>
        <%
            If Request.QueryString("FileName") Then
                Response.Clear()
                Response.WriteFile(Request.QueryString("FileName"))
                Response.End()
            End If
        %>
   </body>
</html>

當在 IIS 輔助進程中運行的代碼(IIS 5.0 上的 aspnet_wp.exe 或 IIS 6.0 上的 w3wp.exe)調用 Response.Write 時,ASP.NET 輔助進程開始向 IIS 進程(inetinfo.exe 或 dllhost.exe)發送數據。在數據從輔助進程發送到 IIS 進程的過程中,要在內存中進行緩衝處理。這在許多情況下不會產生什麼問題。但對於非常大的文件,這卻算不上一個很好的解決方案。

從有利方面看,由於發送文件的 HTTP 響應是在 ASP.NET 代碼中創建的,因此您對所有的 ASP.NET 身份驗證和授權機制都擁有完全訪問權限,從而就可以根據身份驗證狀態、運行時存在的 Identity 和 Principal 對象或者其他任何您認爲適合的機制來做出決策。

這樣,您就可以集成現有的安全機制(例如內置的 ASP.NET 用戶和組機制)、Microsoft 服務器加載項(例如授權管理器和定義的角色組)、Active Directory® 應用程序模式 (ADAM) 乃至 Active Directory,以提供對下載權限的精確控制。

從應用程序代碼內部啓動下載還可以讓您替換對已知 MIME 類型的默認行爲。要完成此操作,您需要更改所顯示的鏈接。以下代碼構造了一個將回發到 ASPX 頁面的超鏈接:

<!-- in the DataGrid definition in FileFetch.aspx -- >
<asp:HyperLinkColumn DataNavigateUrlField="Name"          
    DataNavigateUrlFormatString="FileFetch.aspx?FileName={0}" 
    DataTextField="Name" 
    HeaderText="File Name:" 
    SortExpression="Name" />

接下來,當頁面受到請求時,您需要檢查查詢字符串以確定該請求是否是一個包含要發送到客戶端瀏覽器的文件名參數的回發(參見圖2)。現在,由於有了 Content-Disposition 響應標頭,當您單擊網格中的某個鏈接時,無論文件是否爲 MIME 類型,都會出現“保存”對話框(參見圖3)。同時還應注意,我已根據調用 IsSafeFileName 方法的結果限定了可對哪些文件進行下載。有關這樣操作的原因以及此方法可實現什麼結果的詳細信息,請參閱“無意的文件訪問”提要欄。

.

圖 3 強制顯示文件下載對話框

在使用此方法時要考慮的一個重要度量標準就是文件下載的大小。您必須限制文件的大小,否則就會將您的站點暴露給“拒絕服務”攻擊。如果試圖下載大小超出資源允許範圍的文件,將會產生表明該頁無法顯示的運行時錯誤,或顯示如下所示的錯誤消息:

無法訪問服務器應用程序

您目前無法訪問此 Web 服務器中的 Web 應用程序。請在 Web 瀏覽器中點擊“刷新”按鈕以重新提交請求。

管理員通知:可在 Web 服務器的系統事件日誌中找到造成此特定請求失敗的詳細信息。請查看此日誌條目以找到造成此錯誤的原因。

可下載的文件大小上限是服務器硬件配置和運行時狀態的一個要素。要應對此問題,請參閱知識庫文章“FIX:下載大文件導致大內存丟失並導致 Aspnet_wp.exe 進程以循環”,網址爲 support.microsoft.com/kb/823409

在下載視頻之類的大文件時,此方法可能會出現一些症狀,尤其是在運行 Windows 2000 和 IIS 5.0 的 Web 服務器(或以兼容模式運行 IIS 6.0 的 Windows Server™ 2003)上更是如此。在配置了最低內存的 Web 服務器上,此問題會更加嚴重,因爲必須先將文件加載到服務器內存中才能將其下載到客戶端。

我曾對一個運行 IIS 5.0 並且 RAM 爲 2GB 的服務器進行過測試,實踐證明,當文件大小接近 200MB 時,下載就會失敗。在生產環境中,同時運行的用戶下載越多,就有越多的服務器內存限制導致用戶下載失敗。對於此問題的解決方案需要使用幾行更簡明直接的代碼。

將大文件分爲小塊下載

先前代碼示例所存在的文件大小問題源於對 Response.WriteFile 的單一調用,該調用將在內存中緩衝整個源文件。處理大文件的更有效方法就是將文件分成小的、易管理的文件塊來讀取併發送到客戶端,如圖4 中的示例所示。此版本的 Page_Load 事件處理程序每次使用 while 循環讀取文件中的 10,000 個字節,然後將這些文件塊發送給瀏覽器。因此,在運行時文件不會有任何重要部分保留在內存中。文件塊大小目前被設爲一個常量,但可通過編程方式對其修改,甚至也可以將其移動到配置文件中,以便根據服務器限制和性能要求對其進行更改。我使用一個大小高達 1.6GB 的文件測試了此代碼,結果是下載速度非常塊,並且不會耗用大量的服務器內存。

IIS 本身並不支持文件大小超出 2GB 的文件下載。如果您要下載較大的文件,則需要使用 FTP、第三方控件、Microsoft 後臺智能傳送服務 (BITS) 或一個自定義解決方案(例如,通過套接字將數據流式傳送到託管瀏覽器的自定義控件)。

更有效的解決方案

文件下載要求的共同性以及通常文件大小都在不斷增加的這個事實促使 ASP.NET 開發團隊在 ASP.NET 中添加了一個特定方法,以便在下載文件時,不必在內存中對文件進行緩衝處理就可以將其發送到瀏覽器。該方法就是 Response.TransmitFile,在 ASP.NET 2.0 中提供。

TransmitFile 的用法與 WriteFile 非常相似,但 TransmitFile 通常會產生更好的性能特徵。TransmitFile 還可以與其他功能性相媲美。看一下圖5 中的代碼,此代碼使用新增的 TransmitFile 的一些附加功能來避免上述的內存使用問題。

我只需額外添加幾行代碼就可以增加一些安全性和容錯性。首先,我使用被請求文件的文件擴展名添加了一些安全性和邏輯限制來確定 MIME 類型,並通過設置 Response 對象的“ContentType”屬性來指定 HTTP 標頭中被請求的 MIME 類型:

Response.ContentType = "application/x-zip-compressed"

這使我可以將下載目標僅限制爲某些內容類型,並可將不同的文件擴展名映射到一種單一內容類型。還應注意一下添加 Content-Disposition 標頭的語句。此語句使我可以指定要下載的文件名,此文件名不同於服務器硬盤上的原始文件名。

在此代碼中,我通過在原始文件名中附加一個前綴來創建一個新文件名。儘管此處的前綴是靜態不變的,但我可以動態創建一個前綴,以便下載的文件名絕對不會與用戶硬盤上已有的文件名相沖突。

但是,如果在獲取大文件的中途出現下載失敗怎麼辦?儘管該代碼迄今爲止已從簡單的下載鏈接跨出了一大步,但我仍然無法妥善處理失敗的下載並在中斷後繼續下載已將部分內容從服務器移至客戶端的文件。我至今所檢驗過的所有解決方案都需要用戶在下載失敗時從頭開始重新下載。

恢復失敗的下載

要解決恢復失敗下載這個問題,讓我們回顧一下將文件手動拆分成塊進行傳送的方法。儘管不像使用 TransmitFile 方法的代碼那樣簡單,但手動編寫分塊讀取和發送文件的代碼具備一個優點。在任何給定時刻,運行時狀態都包含了已發送到客戶端的字節數,通過從整個文件大小中減去該字節數,就會得到爲使此文件完整還需要傳送的剩餘字節數。

如果回顧一下該代碼,您就會發現讀取/發送循環會在某循環構成 Response.IsClientConnected 結果的條件時進行檢驗。該測試將確保在與客戶端斷開連接時將傳送過程暫停。在測試結果爲“假”(啓動文件下載的 Web 瀏覽器已斷開連接)的第一次循環迭代中,服務器將停止發送數據,並且可記錄要完成文件所需發送的剩餘字節數。此外,如果用戶試圖完成失敗的下載,可將客戶端收到的部分文件進行保存。

可恢復下載解決方案的剩餘部分是通過 HTTP 1.1 協議中的一些鮮爲人知的功能實現的。通常,HTTP 的無狀態性質是 Web 開發人員的剋星,但在本例中,HTTP 規範卻提供了很大幫助。具體來說,有兩個 HTTP 1.1 標頭元素與我們要完成的這項任務相關。Accept-Ranges 和 Etag。

Accept-Ranges 標頭元素可以非常簡單地向客戶端(本例中指 Web 瀏覽器)指明,此進程支持可恢復下載。實體標記或 Etag 元素將爲該會話指定一個唯一標識符。因此,可由 ASP.NET 應用程序發送到瀏覽器以開始一個可恢復下載的 HTTP 標頭可能如下所示:

HTTP/1.1 200 OK
Connection: close
Date: Mon, 22 May 2006 11:09:13 GMT
Accept-Ranges: bytes
Last-Modified: Mon, 22 May 2006 08:09:13 GMT
ETag: "58afcc3dae87d52:3173"
Cache-Control: private
Content-Type: application/x-zip-compressed
Content-Length: 39551221

由於使用了 ETag 和 Accept-Headers,瀏覽器知道了 Web 服務器將支持可恢復下載。

如果下載失敗,則當該文件再一次被請求時,Internet Explorer 將發送 ETag、文件名和指明在中斷前已成功下載的文件字節數的值範圍,以便 Web 服務器 (IIS) 可以嘗試恢復下載。第二次請求可能如下所示。

GET http://192.168.0.1/download.zip HTTP/1.0
Range: bytes=933714-
Unless-Modified-Since: Sun, 26 Sep 2004 15:52:45 GMT
If-Range: "58afcc3dae87d52:3173"

請注意,If-Range 元素包含服務器可用於標識要重新發送的文件的原始 ETag 值。您還會看到 Unless-Modified-Since 元素包含了最初下載的開始日期和時間。服務器將利用此信息來確定自最初下載開始後該文件是否已被修改過。如果已被修改,則服務器將從頭開始重新下載。

Range 元素也包含在標頭中,它會向服務器指明還需要傳送多少字節才能完成文件,服務器可以利用此信息來確定應從已部分下載文件的何處開始繼續下載。

不同瀏覽器使用這些標頭的方式略有不同。客戶端可能發送的用於唯一標識該文件的其他 HTTP 標頭包括:If-Match、If-Unmodified-Since 和 Unless-Modified-Since。請注意,HTTP 1.1 在某個客戶端應該需要支持哪些標頭方面並沒有特定要求。因此,就有可能出現這樣的情況,某些 Web 瀏覽器不支持這些 HTTP 標頭中的任一個,而其他瀏覽器可能使用不同於 Internet Explorer® 要求的標頭的另一個標頭。

默認情況下,IIS 將包含一個如下所示的標頭集:

HTTP/1.1 206 Partial Content
Content-Range: bytes 933714-39551221/39551222
Accept-Ranges: bytes
Last-Modified: Sun, 26 Sep 2004 15:52:45 GMT
ETag: "58afcc3dae87d52:3173"
Cache-Control: private
Content-Type: application/x-zip-compressed
Content-Length: 2021408

此標頭集包含的響應代碼不同於原始請求的響應代碼。原始響應包含的代碼爲 200,而該請求使用的響應代碼爲 206(即“恢復下載”),用於向客戶端指明,後面的數據不是一個完整文件,而只是繼續先前啓動的下載,該下載的文件名由 ETag 標識。

儘管某些 Web 瀏覽器依賴的是文件名其本身,但 Internet Explorer 非常明確地要求 ETag 標頭。如果 ETag 標頭在最初下載響應或下載恢復中不存在,則 Internet Explorer 不會嘗試恢復下載,而只是開始一個新下載。

爲使 ASP.NET 下載應用程序實現可恢復下載功能,您需要能夠攔截瀏覽器發出的請求(進行下載恢復),並使用請求中的 HTTP 標頭在 ASP.NET 代碼中明確表達相應的響應。要完成此操作,您應在正常處理序列中早一些捕獲該請求。

令人欣慰的是,.NET Framework 可以助我們一臂之力。這是 .NET 基本設計前提的一個極好例子,爲開發人員每天都需要執行的大部分標準探測工作提供了一個被良好分解的功能對象庫。

在這種情況下,您可以利用 .NET Framework 中 System.Web 命名空間所提供的 IHttpHandler 接口來構建您自己的自定義 HTTP 處理程序。通過創建您自己的實現 IHttpHandler 的類,您將能夠攔截對特定文件類型的 Web 請求並用自己的代碼響應這些請求,而不是僅讓 IIS 以其默認行爲做出響應。

本文中的下載代碼包含了支持可恢復下載的 HTTP 處理程序的工作實現。儘管對於此功能存在多個代碼,並且其實現需要對 HTTP 機制有一定了解,但 .NET Framework 使此實現變得相對簡單。此解決方案提供了下載大文件的能力,並且在下載啓動後可以繼續進行瀏覽。然而,還有某些基礎結構注意事項不在您的控制範圍之內。

例如,許多公司和 Internet 服務提供商會維護他們自己的高速緩存機制。出現故障或配置錯誤的 Web 高速緩存服務器會因文件損壞或會話過早終止而導致大文件下載失敗,尤其在您的文件大小超過 255MB 時更是如此。

如果您需要下載超過 255MB 的文件或使用其他自定義功能,您可能會考慮使用自定義的或第三方下載管理器。例如,您可能會構建一個自定義瀏覽器控件或瀏覽器助手功能來管理下載,將它們提交給 BITS,或甚至用自定義代碼將文件請求提交給 FTP 客戶端。擺在眼前的選擇數不勝數,應根據您的特定需要來量身定製。

從通過兩行代碼實現的大文件下載到具有自定義安全性的可分段的可恢復下載,.NET Framework 和 ASP.NET 爲網站的最終用戶提供了多種選擇來打造最適合的下載體驗。

Joe Stagner 於 2001 年加入 Microsoft 擔任技術專家,目前是“工具和平臺產品”小組中“開發人員社區”的項目經理。30 年的開發經驗爲他贏得了跨多種技術平臺創建商業軟件應用程序的機會。

本文摘自 2006 年 9 月發行的 MSDN 雜誌

附錄(代碼):

圖 2 設置 Content-Disposition 響應標頭
Sub Page_Load(ByVal sender As Object, ByVal e As EventArgs)
    Dim dlDir As String = "downloadfiles/"
    Dim strFileName As String = Request.QueryString("FileName")
    Dim path As String = Server.MapPath( _
        dlDir + Request.QueryString("FileName"))
    Dim toDownload As System.IO.FileInfo = New System.IO.FileInfo(path)

    If IsSafeFileName(strFileName) AndAlso toDownload.Exists Then
        Response.Clear()
        Response.AddHeader("Content-Disposition", _
                           "attachment; filename=" & toDownload.Name)
        Response.AddHeader("Content-Length", _
                           file.Length.ToString())
        Response.ContentType = "application/octet-stream"
        Response.WriteFile(file.FullName)
        Response.End()
    Else
        BindFileDataToGrid("Name")
    End If
End Sub

圖 4 將文件塊寫入客戶端
Sub Page_Load(ByVal sender As Object, ByVal e As EventArgs)
    Dim dlDir As String = "downloadfiles/"
    Dim strFileName As String = Request.QueryString("FileName")
    Dim path As String = Server.MapPath( _
        dlDir + Request.QueryString("FileName"))
    Dim toDownload As System.IO.FileInfo = New System.IO.FileInfo(path)

    If IsSafeFileName(strFileName) AndAlso toDownload.Exists Then
        Const ChunkSize As Long = 10000
        Dim buffer(ChunkSize) As Byte
            
        Response.Clear()
        Using iStream As FileStream = File.OpenRead(path)
            Dim dataLengthToRead As Long = iStream.Length
            Response.ContentType = "application/octet-stream"
            Response.AddHeader("Content-Disposition", _
                               "attachment; filename=" & toDownload.Name)
            While dataLengthToRead > 0 AndAlso Response.IsClientConnected
                Dim lengthRead As Integer = _
                    iStream.Read(buffer, 0, ChunkSize)
                Response.OutputStream.Write(buffer, 0, lengthRead)
                Response.Flush()
                dataLengthToRead = dataLengthToRead - lengthRead
            End While
        End Using
        Response.Close()
    Else
        BindFileDataToGrid("Name")
    End If
End Sub

圖 5 使用 TransmitFile
Sub Page_Load(ByVal sender As Object, ByVal e As EventArgs)
    Dim dlDir As String = "downloadfiles/"
    Dim strFileName As String = Request.QueryString("FileName")
    Dim path As String = Server.MapPath( _
        dlDir + Request.QueryString("FileName"))
    Dim toDownload As System.IO.FileInfo = New System.IO.FileInfo(path)

    If IsSafeFileName(strFileName) AndAlso toDownload.Exists Then
        Response.Clear()
        Select Case System.IO.Path.GetExtension(strFileName)
            Case ".zip"
                Response.ContentType = "application/x-zip-compressed"
                Response.AddHeader("Content-Disposition", _
                    "attachment;filename=NEWDL_" + toDownload.Name)
                Response.TransmitFile(path)

            Case Else
               ‘ 文件擴展名不受支持。
        End Select
        Response.End()
    Else
        BindFileDataToGrid("Name")
    End If
End Sub
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章