Response.WriteFile
Response.End()
And that's where the real troubles begin.| Author's Note: If you haven't installed the .NET Framework version 1.1 Service Pack 1 (SP1), please do it nowSP1 provides numerous fixes and improvements. |
HTTP Protocol Header Support
It turns out that the HTTP protocol supports headers designed for use with interrupted downloads. Using a handful of HTTP headers, you can enhance your download procedure to comply fully with the HTTP Protocol Specification. The specifications work with ranges to provide everything you need to resume interrupted downloads.
Here's how it works. First, a server sends the Accept-Ranges header in its initial response, if it supports letting the client resume downloads. The server also sends an entity tag header, ETag that contains a unique identification string.
The code below shows some of the headers that IIS sends back to the client in response to an initial download request, giving the client detailed information about the requested file.
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
After receiving those headers, if the download is interrupted, Internet Explorer sends the ETag value back to the server with a subsequent download request, along with the Range header. The following code shows some of the headers that Internet Explorer sends to the server in an attempt to resume a broken download. GET http://192.168.100.100/download.zip HTTP/1.0
Range: bytes=822603-
Unless-Modified-Since: Sun, 26 Sep 2004 15:52:45 GMT
If-Range: "47febb2cfd76c41:2062"
These headers show that Internet Explorer caches the entity tag provided by IIS and sends it back to the server in the If-Range header, which is one way to make sure that the download resumes with the exact same file. Unfortunately, not all browsers work exactly the same way. Other HTTP headers that a client might send to verify the file are If-Match, If-Unmodified-Since or Unless-Modified-Since. Apparently, the specification isn't perfectly clear on whether client software must support such headers, or which ones they must use. Therefore, some clients don't use any at all, IE only uses If-Range and Unless-Modified-Since. It's best to have your code check all of them. That way, your application can comply with HTTP at a very high level and work with multiple browsers. The Range header indicates the requested byte rangein this case the starting point from which the server should resume streaming the file. 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
Note that the preceding code has a different HTTP response code than the original download request206 for the resume-download request vs. 200 for the initial download request. This indicates that the content about to come through the line is a partial file. This time, the Content-Range header specifies the exact amount and position of the bytes delivered.
The HttpHandler Class: ZIPHandler
After mapping the .zip extension through to ASP.NET, IIS calls the ZipHandler class's ProcessRequest method (see Listing 1) each time a client requests a .zip file from the server.
The ProcessRequest method first creates an instance of a custom FileInformation class (see Listing 2), which encapsulates the download state (e.g. in-progress, broken, etc.). The sample code hard-codes the path to a sample file named download.zip. If you move the code to your own application, change it to open the requested file instead.
' ToDo - your code here
' Using objRequest, determine which file has been
' requested and open objFile with that file:
' Example:
' objFile = New Download.FileInformation
' (<Full path to file>)
objFile = New Download.FileInformation( _
objContext.Server.MapPath("~/download.zip"))
Then, the procedure does a series of validation checks using the described HTTP headers (if the request provides them). It encapsulates each validation in a small private function, which returns True if the validation succeeds. If any validation check fails, the response terminates immediately, sending an appropriate StatusCode value. If Not objRequest.HttpMethod.Equals( _
HTTP_METHOD_GET) Or Not
objRequest.HttpMethod.Equals( _
HTTP_METHOD_HEAD) Then
' Currently, only the GET and HEAD methods
' are supported...
objResponse.StatusCode = 501 ' Not implemented
ElseIf Not objFile.Exists Then
' The requested file could not be retrieved...
objResponse.StatusCode = 404 ' Not found
ElseIf objFile.Length > Int32.MaxValue Then
' The file size is too large...
objResponse.StatusCode = 413 ' Request Entity
' Too Large
ElseIf Not ParseRequestHeaderRange(objRequest, _
alRequestedRangesBegin, alRequestedRangesend, _
objFile.Length, bIsRangeRequest) Then
' The Range request contained bad entries
objResponse.StatusCode = 400 ' Bad Request
ElseIf Not CheckIfModifiedSince(objRequest, _
objFile) Then
' The entity is still unmodified...
objResponse.StatusCode = 304 ' Not Modified
ElseIf Not CheckIfUnmodifiedSince(objRequest, _
objFile) Then
' The entity was modified since the requested
' date...
objResponse.StatusCode = 412 ' Precondition failed
ElseIf Not CheckIfMatch(objRequest, objFile) Then
' The entity does not match the request...
objResponse.StatusCode = 412 ' Precondition failed
ElseIf Not CheckIfNoneMatch(objRequest, objResponse, _
objFile) Then
' The entity does match the none-match request,
' the response code was set inside the
' CheckIfNoneMatch function
Else
' Preliminary checks were successful...
One of these preliminary functions, ParseRequestHeaderRange (see Listing 3), checks to see if a client requested a file range, and thus a partial download. The method sets bIsRangeRequest to True, if the requested range is valid (invalid ranges are those which exceed the file's size, or contain illogical numbers). If a range was requested, the CheckIfRange method validates the IfRange header. If bIsRangeRequest AndAlso _
CheckIfRange(objRequest, objFile) Then
' This is a Range request...
' If the Range arrays contain more than one entry,
' it even is a multipart range request...
bMultipart = CBool( _
alRequestedRangesBegin.GetUpperBound(0) > 0)
' Go through each Range to get the entire Response
' length
For iLoop = _
alRequestedRangesBegin.GetLowerBound(0) _
To alRequestedRangesBegin.GetUpperBound(0)
' The length of the content (for this range)
iResponseContentLength += _
Convert.ToInt32(alRequestedRangesend( _
iLoop) - alRequestedRangesBegin(iLoop)) + 1
If bMultipart Then
' If this is a multipart range request,
' calculate the length of the intermediate
' headers to send
iResponseContentLength += _
MULTIPART_BOUNDARY.Length
iResponseContentLength += _
objFile.ContentType.Length
iResponseContentLength += _
alRequestedRangesBegin( _
iLoop).ToString.Length
iResponseContentLength += _
alRequestedRangesend( _
iLoop).ToString.Length
iResponseContentLength += _
objFile.Length.ToString.Length
' 49 is the length of line break and other
' needed characters in one multipart header
iResponseContentLength += 49
End If
Next iLoop
If bMultipart Then
' If this is a multipart range request,
' we must also calculate the length of
' the last intermediate header we must send
iResponseContentLength += _
MULTIPART_BOUNDARY.Length
' 8 is the length of dash and line break
' characters
iResponseContentLength += 8
Else
' This is no multipart range request, so
' we must indicate the response Range of
' in the initial HTTP Header
objResponse.AppendHeader( _
HTTP_HEADER_CONTENT_RANGE, "bytes " & _
alRequestedRangesBegin(0).ToString & "-" & _
alRequestedRangesend(0).ToString & "/" & _
objFile.Length.ToString)
End If
' Range response
objResponse.StatusCode = 206 ' Partial Response
Else
' This is not a Range request, or the requested
' Range entity ID does not match the current entity
' ID, so start a new download
' Indicate the file's complete size as content
' length
iResponseContentLength = _
Convert.ToInt32(objFile.Length)
' Return a normal OK status...
objResponse.StatusCode = 200
End If
Next the server must send a few important response headers, such as the content length, the ETag and the file's content type. ' Write the content length into the Response
objResponse.AppendHeader( _
HTTP_HEADER_CONTENT_LENGTH, _
iResponseContentLength.ToString)
' Write the Last-Modified Date into the Response
objResponse.AppendHeader( _
HTTP_HEADER_LAST_MODIFIED, _
objFile.LastWriteTimeUTC.ToString("r"))
' Tell the client software that we accept
' Range requests
objResponse.AppendHeader( _
HTTP_HEADER_ACCEPT_RANGES, _
HTTP_HEADER_ACCEPT_RANGES_BYTES)
' Write the file's Entity Tag into the Response
' (in quotes!)
objResponse.AppendHeader(HTTP_HEADER_ENTITY_TAG, _
"""" & objFile.EntityTag & """")
' Write the Content Type into the Response
If bMultipart Then
' Multipart messages have this special Type.
' In this case, the file's actual mime type is
' written into the Response at a later time...
objResponse.ContentType = MULTIPART_CONTENTTYPE
Else
' Single part messages have the files content
' type...
objResponse.ContentType = objFile.ContentType
End If
Everything is now prepared to begin downloading the file. You use a FileStream object to read byte chunks from the file. Set the State property of the FileInformation instance objFile to fsDownloadInProgress. As long as the client stays connected, the server reads chunks from the file and sends them to the client. The code sends special headers for multipart responses. Should the client break the connection, the server sets the file state to fsDownloadBroken. If the server completes sending the requested range or ranges, it sets the state to fsDownloadFinished (see Listing 4).
The FileInformation Helper Class
As you saw in the ZIPHandler section, FileInformation is a helper class which encapsulates the download state, e.g. in-progress, broken, etc. (see Listing 2 for the complete code).
To create an instance of FileInformation, you pass the class constructor the path to the requested file.
Public Sub New(ByVal sPath As String)
m_objFile = New System.IO.FileInfo(sPath)
End Sub
FileInformation uses a System.IO.FileInfo object to get information about that file, which it exposes as properties, for example, whether the file exists, its full name, size, etc. The class also exposes a DownloadState enumeration that describes the various states of a download request: <Flags()> Enum DownloadState
' Clear: No download in progress,
' the file can be manipulated
fsClear = 1
' Locked: A dynamically created file must
' not be changed
fsLocked = 2
' In Progress: File is locked, and download
' is currently in progress
fsDownloadInProgress = 6
' Broken: File is locked, download was in
' progress, but was cancelled
fsDownloadBroken = 10
' Finished: File is locked, download
' was completed
fsDownloadFinished = 18
End Enum
FileInformation also provides the EntityTag property value. The sample code has a hard-coded value in it, because the sample uses only one download file, which will not be changed, but for a real-world application, where you're serving multiple files, or even create files dynamically, your code must provide unique EntityTag values for each file. Plus, each time that you change or edit the file that value must change as well. This enables client software to verify if the chunk they downloaded before is still up-to-date. Here's the section that returns the hard-coded EntityTag value in the sample code. Public ReadOnly Property EntityTag() As String
' The EntityTag used in the initial (200) response
' to, and in resume-Requests from clients
Get
' ToDo - your code here
' (Create a unique string for your file)
'
' Please note, that this unique code must remain
' the same as long as the file does not change.
' If the file DOES change or is edited, however,
' the code MUST change.
Return "MyExampleFileID"
End Get
End Property
A simple and probably safe enough EntityTag could be a combination of the file name and the file's last modified date. Whatever method you choose, please make sure that it is truly unique and can't be confused with another file's EntityTag. I prefer to name dynamically created files in my applications after the client, customer, and zip queue indexes, and use a GUID saved in a database for the EntityTag. Public Property State() As DownloadState
Get
Return m_nState
End Get
Set(ByVal nState As DownloadState)
m_nState = nState
' ToDo - optional
' At this point, you could delete the
' file automatically.
' If the state is set to Finished, you
' might not need the file anymore:
' If nState = _
' DownloadState.fsDownloadFinished Then
' Clear()
' Else
' Save()
' End If
Save()
End Set
End Property
The ZipFileHandler should call the Save method whenever the file state changes, saving the file's state, so it can be displayed to the user at a later time. You can also use it to save the EntityTag you created. Do not save the file's state and EntityTag value to the Application, Session, or Cacheyou must persist that information across any of those lifecycles. Private Sub Save()
' ToDo - your code here
' Save the state of this file's download
' to a database or XML file...)
'
' If you do not create files dynamically,
' you do not need to save the state, of course.
End Sub
As written, the sample code handles only one existing file (download.zip); however you can enhance it to create requested files on demand.
| DevX is a division of Jupitermedia Corporation © Copyright 2007 Jupitermedia Corporation. All Rights Reserved. Legal Notices |