asp.net

位置:IT落伍者 >> asp.net >> 浏览文章

ASP.NET中大文件下载的跟蹤和恢复


发布日期:2018年03月05日
 
ASP.NET中大文件下载的跟蹤和恢复

在Web应用程序中处理大文件下载的问题一直出了名的困难因此对于大多数站点来说如果用户的下载被中断了它们只能说悲哀降临到用户的身上了但是我们现在不必这样了因为你可以使自己的ASPNET应用程序有能力支持可恢复(继续)的大文件下载使用本文提供的方法的时候你可以跟蹤下载的过程这样你就可以处理动态建立的文件——而且要达到这个目标根本不需要旧式的ISAPI动态链接库和非受控的(unmanaged)C++代码

为客户端提供从互联网上下载文件的服务最容易了对吗?仅仅只需要把可下载的文件复制到你的Web应用程序目录中发布链接并让IIS完成所有相关的工作但是文件服务不应该比脖子上的疼痛还要多(还要麻烦)你不希望整个世界都能访问自己的数据你不希望服务器被数百个静态文件塞满了你甚至于希望下载临时文件——只有当客户端开始下载后的空闲时间才建立这些文件

不幸的是使用IIS对下载请求的默认的响应是不可能达到这些效果的因此在一般情况下为了获得对下载过程的控制权开发者需要链接到一个定制的aspx页面在这个页面中它们检查用户凭证(credential)建立可以下载的文件并使用下面的代码把该文件推送给客户端

ResponseWriteFile

ResponseEnd()

而这就是出现真正麻烦的地方

有什么问题?

WriteFile方法看起来非常完美它使文件的二进制数据流向客户端但是直到最近我们才知道WriteFile方法是一个出名的内存占用狂它把整个文件载入服务器的RAM中来提供服务(实际上它甚至于会占用文件两倍大小的空间)对于大文件这会引起服务内存问题并且可能重复ASPNET过程但是在月微软发布了一个补丁解决了这个问题这个补丁现在是NET Framework 补丁包(SP)的一部分

这个补丁引入了TransmitFile方法它把一个磁盘文件读入到较小的内存缓沖区之后就开始传输该文件尽管这个方案解决了内存和循环的问题但是它仍然不能令人满意你不能控制响应的生命周期你无法知道下载是否正确地完成了你没有办法知道下载是否被中断了并且(如果你建立了临时文件)你也不知道是否应该以及什么时候可以删除这些文件更糟的是如果下载的确失败了TransmitFile方法又从客户端下次尝试的文件头部开始下载

其中一种可能的解决方案——实现后台智能传输服务(BITS)对于多数站点来说是不可行的因为这会毁掉维持客户端浏览器和操作系统独立性而作出的努力

令人满意的解决方案的基础还是来自微软用于解决WriteFile引起的内存混乱问题的第一次尝试(见知识库文章那篇文章演示了智能的大块数据下载过程它从文件流中读取数据在服务器把字节块发送给客户端之前它使用ResponseIsClientConnected属性检查客户端是否仍然保持着连接如果仍然保持连接它就继续发送流字节否则就停止以防止服务器发送不必要的数据

这就是我们采用的方法特别是在下载临时文件的时候在IsClientConnected返回False的情况下你就知道下载过程被中断了你应该保存文件反之当这个过程成功完成的时候你就删除临时文件此外为了恢复中断了的下载你需要做的工作是从上次下载尝试过程中客户端连接失败的文件点开始下载

HTTP协议和头信息(Header)支持

HTTP协议支持可以用于处理被中断下载的头信息使用少量的HTTP头信息你可以增强自己的下载过程使它完全遵循HTTP协议规范这个规范与ranges一起提供恢复被中断的下载所需要的一切信息

下面是它的工作方式首先如果服务器支持客户端断点续传它就在初始的响应中发送AcceptRanges头信息服务器还发送一个实体标签(entity tag)头信息(ETag)它包含一个唯一的标识字符串

下面的代码显示了IIS发送给客户端的用于响应一个初始下载请求的一些头信息它向客户端传递了被请求的文件的详细信息

HTTP/ OK

Connection: close

Date: Tue Oct :: GMT

AcceptRanges: bytes

LastModified: Sun Sep :: GMT

ETag: febbcfdc:

CacheControl: private

ContentType: application/xzipcompressed

ContentLength:

在接收这些头信息之后如果下载被中断了IE浏览器在后来的下载请求中会把Etag值和Range头信息发送回服务器下面的代码显示了尝试恢复被中断下载时IE发送给服务器的一些头信息

GET HTTP/

Range: bytes=

UnlessModifiedSince: Sun Sep :: GMT

IfRange: febbcfdc:

这些头信息表明IE缓存了IIS提供的实体标签并在IfRange头信息中把它发送回服务器了这是确保下载从准确相同的文件恢复的一种途径不幸的是并非所有的浏览器的工作方式都相同客户端发送的用于验证文件的其它HTTP头信息可能是IfMatchIfUnmodifiedSince或者UnlessModifiedSince很明显该规范对于客户端软件必须支持哪些头信息或者必须使用哪些头信息没有明确的规定因此有些客户端根本就没有使用头信息而IE只使用IfRange和UnlessModifiedSince你最好用代码检查这些信息采用这种方式的时候你的应用程序可以在非常高的层次遵循HTTP规范并可以使用多种浏览器Range头信息指明了被请求的字节范围——在例子中它是服务器应该恢复文件流的起始点

当IIS接收到恢复下载的请求类型时它发回包含下面的头信息的响应信息

HTTP/ Partial Content

ContentRange: bytes /

AcceptRanges: bytes

LastModified: Sun Sep :: GMT

ETag: febbcfdc:

CacheControl: private

ContentType: application/xzipcompressed

ContentLength:

请注意上面的代码与最初的下载请求的HTTP响应有点差别——恢复下载的请求是而最初下载的请求是这表明通过线路传递进来的内容是部分文件这一次ContentRange头信息指出了被传递字节的精确数量和位置

IE对于这些头信息是很挑剔的如果最初的响应没有包含Etag头信息IE永远不会尝试恢复下载我测试过的其它客户端不使用ETag头信息它们简单得依赖于文件名请求范围并使用LastModified头信息(如果它们试图验证该文件)

深入了解HTTP协议

前面的部分中显示的头信息对于使恢复下载的解决方案运行来说是足够的但是它没有完全覆盖HTTP规范

在单个请求中Range头信息可以询问多个范围这种特性称为多部分范围(multipart ranges)请不要与分段下载(segmented downloading)混淆几乎所有的下载工具都使用分段下载来提高下载速度这些工具声称通过打开两个或多个并发的连接(每个连接请求文件的不同范围)提高了下载速度

多部分范围的想法并没有开启多个连接但是它可以使客户端软件可以在单个请求/响应周期中请求某个文件的最前面的十个和最后面的十个字节

诚实地说我从来都没有找到使用这种特性软件片断但是我拒绝在代码声明中写入它并不是完全的HTTP兼容的略去这个特性必定会触犯墨菲法则(Murphys Law)无论如何多部分范围还是被用于电子邮件传输中把头信息普通文本和附件分开

示例代码

我们知道了客户端和服务器如何交换头信息以保证可恢复的下载把这些知识与文件块流的思想结合起来你就可以给自己的ASPNET应用程序增加可靠的下载管理能力了

获取下载过程的控制权的方法是从客户端截取下载请求读取头信息并适当地响应NET之前你必须编写ISAPI(Internet服务器API)应用程序来实现这种功能但是NET框架组件提供了一个IHttpHandler接口在类中实现的时候它允许你仅仅使用NET代码就能够截取和处理请求这意味着你的应用程序对于下载过程有完全控制权和响应性再也不会涉及或使用IIS的自动化函数

示例代码在HttpHandlervb文件中包含了一个自定义的HttpHandler类(ZIPHandler)ZipHandler实现了IhttpHandler接口并且处理对所有zip文件的请求

为了测试示例代码你需要在IIS中建立一个新的虚拟目录并把源文件复制到那儿在该目录中建立一个叫做downloadzip的文件(请注意IIS和ASPNET不能处理大于GB的下载因此要确保你的文件没有超过该限制)配置你的IIS虚拟目录通过aspnet_isapidll映射zip扩展名

HttpHandler类ZIPHandler

在ASPNET中映射了zip扩展名之后客户端每次向服务器请求zip文件的时候IIS调用ZipHandler类的ProcessRequest方法(见下载代码)

ProcessRequest方法首先建立自定义的FileInformation类(见下载代码)的一个实例它封装了下载的状态(例如进行中被中断了等等)示例把downloadzip示例文件的路径硬编码到代码中了如果把这段代码应用于你自己的应用程序需要修改它来打开被请求的文件

使用objRequest检测请求了哪个文件用该文件打开objFile

例如objFile = New DownloadFileInformation(<完整文件名>)

objFile = New DownloadFileInformation( _

objContextServerMapPath(~/downloadzip))

接下来程序使用描述的HTTP头信息(如果请求提供了头信息)执行一系列的验证检查它把每种检查都封装在小型私有函数中如果验证成功的话就返回True如果某个验证检查失败了响应会立即终止并发送适当的StatusCode值

If Not objRequestHttpMethodEquals(HTTP_METHOD_GET) Or Not

objRequestHttpMethodEquals(HTTP_METHOD_HEAD) Then

目前只支持GET和HEAD方法

objResponseStatusCode = 没有执行

ElseIf Not objFileExists Then

无法找到被请求的文件

objResponseStatusCode = 没有找到

ElseIf objFileLength > IntMaxValue Then

文件太大了

objResponseStatusCode = 请求实体太大

ElseIf Not ParseRequestHeaderRange(objRequest alRequestedRangesBegin alRequestedRangesend _

objFileLength bIsRangeRequest) Then

Range请求中包含无用的实体

objResponseStatusCode = 无用的请求

ElseIf Not CheckIfModifiedSince(objRequestobjFile) Then

实体没有被修改过

objResponseStatusCode = 没有被修改过

ElseIf Not CheckIfUnmodifiedSince(objRequestobjFile) Then

实体在上次被请求的日期之后被修改过

objResponseStatusCode = 预处理失败

ElseIf Not CheckIfMatch(objRequest objFile) Then

实体与请求不匹配

objResponseStatusCode = 预处理失败

ElseIf Not CheckIfNoneMatch(objRequest objResponseobjFile) Then

实体的确与nonematch请求匹配

响应代码位于CheckIfNoneMatch函数中

Else

初步检查成功

这些初步检查的函数中的ParseRequestHeaderRange(见下载代码)检查客户端是否请求了文件范围(这意味着是一个局部下载)如果被请求的范围是无效的(无效范围指超越文件大小或包含不合理数字的范围数值)该方法把bIsRangeRequest设置为True如果请求了范围CheckIfRange方法会验证IfRange头信息

如果被请求的范围是有效的代码会计算响应信息的大小如果客户端请求了多个范围响应信息大小的数值会包含多部分头部信息长度的数值

如果不能确定某个发送的头部信息值程序将把这个下载请求作为最初请求而不是部分下载来处理从文件的顶部开始发送一个新的下载流

If bIsRangeRequest AndAlso CheckIfRange(objRequest objFile) Then

这是范围请求

如果Range数组包含多个实体它还是一个多部分范围请求

bMultipart = CBool(alRequestedRangesBeginGetUpperBound()>)

进入每个范围来获取整个响应长度

For iLoop = alRequestedRangesBeginGetLowerBound() To alRequestedRangesBeginGetUpperBound()

内容的长度(这个范围的)

iResponseContentLength += ConvertToInt(alRequestedRangesend( _

iLoop) alRequestedRangesBegin(iLoop)) +

If bMultipart Then

如果是多部分范围请求计算出将发送的中间头信息的长度

iResponseContentLength += MULTIPART_BOUNDARYLength

iResponseContentLength += objFileContentTypeLength

iResponseContentLength += alRequestedRangesBegin(iLoop)ToStringLength

iResponseContentLength += alRequestedRangesend(iLoop)ToStringLength

iResponseContentLength += objFileLengthToStringLength

是多部分下载中换行和其它必要的字符的长度

iResponseContentLength +=

End If

Next iLoop

If bMultipart Then

如果是多部分范围请求

我们还必须计算出将发送的最后一个中间头信息的长度

iResponseContentLength +=MULTIPART_BOUNDARYLength

是破折号和换行符的长度

iResponseContentLength +=

Else

不是多部分下载因此我们必须说明初始HTTP头信息的响应范围

objResponseAppendHeader( HTTP_HEADER_CONTENT_RANGE bytes & _

alRequestedRangesBegin()ToString & & _

alRequestedRangesend()ToString & / & _

objFileLengthToString)

End If

范围响应

objResponseStatusCode = 局部响应

Else

这不是范围请求或者被请求的范围实体ID与当前的实体ID不匹配

因此开始新的下载

指明文件完成部分的大小等于内容的长度

iResponseContentLength =ConvertToInt(objFileLength)

返回正常的OK状态

objResponseStatusCode =

End If

接下来服务器必须发送几个重要的响应头信息例如内容长度Etag和文件的内容类型

把内容长度写入响应

objResponseAppendHeader( HTTP_HEADER_CONTENT_LENGTHiResponseContentLengthToString)

把最后修改日期写入响应

objResponseAppendHeader( HTTP_HEADER_LAST_MODIFIEDobjFileLastWriteTimeUTCToString(r))

告诉客户端软件我们接受了范围请求

objResponseAppendHeader( HTTP_HEADER_ACCEPT_RANGESHTTP_HEADER_ACCEPT_RANGES_BYTES)

把文件的实体标签写入响应(用引号括起来)

objResponseAppendHeader(HTTP_HEADER_ENTITY_TAG & objFileEntityTag & )

把内容类型写入响应

If bMultipart Then

多部分消息有这种特殊的类型

在例子中文件实际的mime类型在以后才写入响应

objResponseContentType = MULTIPART_CONTENTTYPE

Else

单个部分消息拥有的文件内容类型

objResponseContentType = objFileContentType

End If

下载所需要的一切都准备好了可以开始下载文件了你将使用FileStream对象从文件中读取字节块把FileInformation实例objFile的State属性设置为fsDownloadInProgress只要客户端保持连接服务器就从文件中读取字节块并发送给客户端对于多部分下载这段代码会发送特定的头信息如果客户端中断连接服务器就把文件状态设置为fsDownloadBroken如果服务器完成了被请求范围的发送过程它会把状态设置为fsDownloadFinished(见下载代码)

FileInformation辅助类

在ZIPHandler部分中你会发现FileInformation是一个辅助类它封装了下载状态信息(例如下载中中断等等)

为了建立FileInformation的实例你需要把被请求文件的路径传递给该类的构造函数

Public Sub New(ByVal sPath As String)

m_objFile = New SystemIOFileInfo(sPath)

End Sub

FileInformation使用SystemIOFileInfo对象来获取文件的信息这些信息是作为该对象的属性暴露的(例如文件是否存在文件全名大小等等)这个类还暴露了一个DownloadState枚举它描述了下载请求的多种状态

<Flags()> Enum DownloadState

Clear没有下载过程文件可能在维护

fsClear =

Locked动态建立的文件不能被更改

fsLocked =

In Progress文件被锁定了下载过程正在进行

fsDownloadInProgress =

Broken文件被锁定了下载过程正在进行但是被取消了

fsDownloadBroken =

Finished文件被锁定了下载过程完成了

fsDownloadFinished =

End Enum FileInformation还提供了EntityTag属性值示例代码中的这个值是硬编码的这是由于示例代码只使用了一个下载文件并且该文件不会被改变但是对于实际应用程序来说你会提供多个文件甚至于动态地建立文件你的代码必须为每个文件提供一个唯一的EntityTag值此外每次改变或修改该文件的时候这个值也必须改变这使客户端软件能够验证它们已经下载的字节块是否仍然是最新的下面是示例代码中返回硬编码EntityTag值的部分

Public ReadOnly Property EntityTag() As String

EntityTag用于对客户端的初始()响应以及来自客户端的恢复请求

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 =DownloadStatefsDownloadFinished Then

Clear()

Else

Save()

End If

Save()

End Set

End Property

在文件状态发生改变的任何时候ZipFileHandler都应该调用Save方法保存文件的状态这样在以后才能显示给用户你还可以用它来保存你自己建立的EntityTag请不要把文件的状态和EntityTag值保存在ApplicationSession或Cache中——你必须跨越所有的这些这些对象的生命周期来保存信息

Private Sub Save()

把该文件下载的状态保存到数据库或XML文件中

当然如果你并没有动态地建立文件就不需要保存这个状态

End Sub

前面提到示例代码只处理一个已有的文件(downloadzip)但是你可以进一步增强这个程序根据需要建立被请求的文件

测试示例代码的时候你的本地系统或LAN可能太快了以至于无法中断下载过程因此我推荐你使用慢速LAN连接(在IIS中减少站点的带宽是一种模拟的方法)或者把服务器放到互联网上

在客户端上下载文件仍然很艰难ISP操作的不对的或配置错误的Web缓沖服务器都可能使大文件下载过程失败包括下载状况恶化或早期对话终结如果文件大小超过了MB你就应该鼓励顾客使用第三方下载管理软件尽管某些最新的浏览器内建了基本的下载管理器

如果你希望进一步扩展示例代码查阅一下HTTP规范是有益的你可以为下载建立MD校验值使用ContentMD头信息添加它们提供一种验证下载文件完整性的途径示例代码除了GET和HEAD之外没有涉及到其它的HTTP方法

上一篇:ASP.NET验证码技术

下一篇:ASP.NET Starter Kit开始 Web 站点开发