2015 年 11 月

第 30 卷,第 12 期

ASP.NET - 将 ASP.NET 用作高性能文件下载器

作者 Doug Duerner

缓慢故障连接一直都是大型文件下载问题的症结所在。您可能会在机场大厅用不完善的 WiFi 连接收集媒体,以便在长途飞行中制作演示文稿;也可能会在非洲大草原上尝试通过太阳能水泵的卫星链接下载大型安装文件。无论是哪种情况,大型文件下载故障的代价都是相同的:既浪费了时间、使人心力交瘁,还存在无法完成任务的危险。

但并不一定会到如此地步。在本文中,我们将展示如何创建实用工具来解决恢复和继续失败下载的问题,此问题是由于大型文件传输过程中易于脱机的状况不佳连接所致。

背景

我们希望能创建简单的文件下载器实用工具,并用极其简单且易于使用的客户端程序(或仅使用 Web 浏览器作为客户端)将此实用工具轻松地添加到您现有的 IIS Web 服务器中。

事实证明,IIS Web 服务器是高度可扩展的企业级 Web 服务器,可在多年内向浏览器提供文件。我们基本上是想利用 IIS Web 服务器同时并行处理多个 HTTP Web 请求的能力,并将其应用于文件下载(复制)。

从根本上讲,我们需要的是能够为世界各地的用户下载大型文件的文件下载器实用工具,这些用户有时会位于偏远地区,只能使用经常发生故障的缓慢网络链接。由于某些处于世界偏远地区的用户仍可能在使用可能会随机脱机或周期性地切换联机和脱机状态的调制解调器链接或故障的卫星链接,因此实用工具必须极具复原性,能够仅重新下载无法下载的文件部分。我们不希望用户将整个晚上的时间都浪费在用缓慢链接下载大型文件上。如果网络链接出现一个小问题,就需要重新开始整个下载流程。我们还需要确保这些下载的大型文件不会在服务器内存中缓冲,且服务器内存使用量最低。这样一来,当多位用户同时下载文件时,内存使用量就不会持续增加,也就不会导致服务器故障。

反之,如果用户足够幸运,拥有可靠的高速网络链接(其客户端和服务器计算器均为配备了多个 CPU 和网卡的高端计算机),我们希望用户能够使用多线程和多连接来下载文件,这样文件的多个区块就能够使用所有硬件资源同时并行下载,同时确保服务器内存使用量最低。

简而言之,我们创建的是简单的文件下载实用工具,不仅能多线程并行下载,还能确保较低的内存使用率。它可以将文件分成几个区块、在单独的线程上下载各个区块,并允许用户仅重新下载无法下载的区块。

本文中随附的示例项目包括文件下载实用工具的代码,并提供了将来可扩展的最基本基础结构,使您能够根据需要设计更复杂的结构。

示例项目概述

从本质上讲,DownloadHandler.dll 将现有的 IIS Web 服务器转换成多线程文件下载器,让您能够使用独立可执行客户端 (FileDownloader.exe) 中的简单 URL 将文件分成几个区块并行下载,如图 1 所示。请注意,参数 (chunksize=5242880) 是可选的。如果未包括在内,则默认为通过一个区块下载整个文件。图 2图 3 展示了您如何能够反复地重新下载无法下载的文件部分,直到成功下载为止,而不用像其他大多数文件下载软件那样从头开始完全重新下载整个文件。

DownloadHandler.dll 处理流的简略设计概览
图 1:DownloadHandler.dll 处理流的简略设计概览(将 FileDownloader.exe 用作客户端)

独立可执行的下载客户端
图 2:独立可执行的下载客户端(含无法下载的区块)

独立可执行的下载客户端
图 3:独立可执行的下载客户端(重试后)

图 1 是 DownloadHandler.dll 和 FileDownloader.exe 的简略设计概览,将处理流展示为,服务器计算机的硬盘驱动器上的文件区块通过 DownloadHandler.dll 和 FileDownloader.exe 进入客户端计算机的硬盘驱动器上的文件,同时展示了这一流程中包含的 HTTP 协议标头。

图 1 中,FileDownloader.exe 通过使用简单的 URL 调用服务器来启动文件下载,URL 包含要下载的文件名称,作为 URL 查询字符串参数 (file=file.txt),并在内部使用 HTTP 方法 (HEAD)。所以,服务器最初只会发送回其响应头,其中一个包含总文件大小。然后,客户端会使用 Parallel.ForEach 构造进行循环访问,根据参数中的区块大小 (chunksize=5242880) 将总文件大小拆分成各个区块大小(字节范围)。对于各个循环访问,Parallel.ForEach 构造会在单独的线程上执行处理方法,传递关联的字节范围。在处理方法中,客户端会使用相同的 URL 向服务器发出 HttpWebRequest 调用,并在内部附加 HTTP 请求头,其中包含提供给处理方法的字节范围(即 Range: bytes=0-5242880、Range: bytes=5242880-10485760 等)。

在服务器计算机上,我们的 IHttpAsync­Handler 接口 (System.Web.IHttpAsyncHandler) 实现可在单独线程上处理各个请求,同时执行 HttpResponse.Transmit­File 方法,以便将服务器计算机中的文件请求获取的字节范围直接写入网络流(无显式缓冲),所以对服务器的内存影响几乎不存在。服务器会发送回具有 HTTP 状态代码 206 (PartialContent) 的响应,并在内部附加可标识返回的字节范围的 HTTP 响应头(即 Content-Range: bytes 0-5242880/26214400、Content-Range: bytes 5242880-10485760/26214400 等)。由于每个线程都会在客户端计算机上收到 HTTP 响应,因此,它会将响应中返回的字节写入客户端计算机硬盘驱动器上的文件的相应部分,这会在 HTTP 响应头 (Content-Range) 中进行标识。它使用异步重叠文件 I/O(以确保 Windows I/O 管理器不会在将 I/O 请求数据包分派给内核模式驱动程序以便完成文件写入操作之前,将 I/O 请求串行化)。如果多个用户模式线程全都在执行文件写入,但您没有为异步重叠 I/O 打开文件,则请求会进行串行化,且内核模式驱动程序一次只会收到一个请求。有关异步重叠 I/O 的更多信息,请参阅硬件开发者中心网站上的“让驱动程序一次处理多个 I/O 请求”(bit.ly/1NIaqxP) 和“支持异步 I/O”(bit.ly/1NIaKMW)。

为了在我们的 IHttpAsyncHandler 上实现异步性,我们会将重叠的 I/O 结构手动发布到 I/O 完成端口,且 CLR ThreadPool 会在完成端口线程上运行重叠结构中提供的完成委托。这些与大多数内置异步方法所用的完成端口线程相同。一般来说,最好对大部分 I/O 工作使用全新的内置异步方法,但在此示例中,我们希望使用 HttpResponse.TransmitFile 函数,因为它具备出色的大型文件传输能力,而不会在服务器内存中进行显式缓冲。太不可思议了!

Parallel.ForEach 主要用于 CPU 工作,由于它具有屏蔽特质,因此绝不应将其真正用于服务器实现。我们将这项工作分流至 CLR ThreadPool 中的完成端口线程(而非 CLR ThreadPool 中的常规工作线程),以避免消耗 IIS 使用的相同线程来服务于传入请求。此外,完成端口更有效地处理工作多少会限制服务器上的线程消耗。在同一示例项目代码中,IOThread 类顶部的注释部分中列出了图解和更详细的说明,其中突出显示了 CLR ThreadPool 中的完成端口线程和工作线程的区别。由于扩展到数百万用户并非此实用工具的主要目标,因此,我们能够承受消耗所需的其他服务器线程来运行 HttpResponse.TransmitFile 函数,以便在传输大型文件时节省相关服务器内存。从根本上讲,我们是在以损失可伸缩性为代价,导致这种损失的原因是在服务器上使用其他线程(而不是无线程的内置异步方法),以便使用 HttpResponse.TransmitFile 函数,这样消耗的服务器内存极低。虽然这已经超出了本文的范围,但您可以视需要选择将内置异步方法与无缓冲的文件 I/O 结合使用,以实现相似的内存节省效果,而无其他任何线程。不过,据我们了解,一切都必须按扇区对齐,正确实现起来稍微有些困难。除此之外,Microsoft 似乎是特意从 FileOptions 枚举中删除了 NoBuffering 项,以便真正避免无缓冲的文件 I/O(需要手动获取才能如愿以偿)。我们对与未正确实现相关的风险持相当紧张的态度,并决定了采用风险较低的 HttpResponse.TransmitFile 选项(已经过全面测试)。

FileDownloader.exe 能够启动多个线程,每个线程都会根据总文件大小如何划分成指定的“区块字节”,发出与所下载文件的各个区块(字节范围)相对应的单独 HttpWebRequest 调用,如图 2 所示。

只需重复执行相同的 HttpWebRequest 调用(仅针对失败的字节范围),即可重试任何无法下载 HttpWebRequest 调用中指定的文件部分(字节范围)的线程,直至最终成功下载为止,如图 3 所示。您不会丢失已下载的文件部分,如果连接速度缓慢,则可能意味着节省数小时的下载时间。您几乎可以消除不断脱机的故障连接造成的不利影响。利用以多线程同时并行下载文件的不同部分的设计(无显式缓冲,直接写入网络流),以及下载到带有异步重叠文件 I/O 的硬盘驱动器上的设计,如果实际联机的是不可靠的连接,则您可以在一段时间内最大限度地增加完成的下载数量。此工具会在网络链接每次恢复联机时继续完成剩余的部分,而不会丢失任何作业。我们更愿意将它看作是“可重试的”文件下载器,而不是“可恢复的”文件下载器。

可以通过假设的示例来阐述这两者之间的区别。您将要下载一个大型文件,需要整晚上的时间。您在下班时启动可恢复的文件下载器,并让其一直运行。当您第二天早上来上班时,您发现文件下载在 10% 处就失败了,并已准备好恢复下载。但在恢复时,它仍需要重新运行整个晚上才能完成剩余的 90%。

相比之下,您在下班时启动可重试的文件下载器,并让其整晚运行。当您第二天早上来上班时,您发现文件下载在 10% 处有一个区块失败了,但仍继续下载了文件的剩余区块。现在,您只需重新下载无法下载的那个区块,就大功告成了。在遇到因短暂的网络链接问题而无法下载的那个区块后,它会在网络链接恢复联机后用这一晚的剩余时间继续并完成剩余的 90%。

使用 URL(如 https://localhost/DownloadPortal/Download?file=test.txt&chunksize=5242880),也可以将 Web 浏览器内置的默认下载客户端用作下载客户端。

请注意,在将 Web 浏览器用作下载客户端时,参数 (chunksize=5242880) 也是可选的。如果未包括在内,则服务器会使用相同的 HttpResponse.TransmitFile 通过一个区块下载整个文件。如果包括在内,则会为每个区块单独执行 HttpResponse.TransmitFile 调用。

图 4 展示了在将不支持部分内容的 Web 浏览器用作下载客户端时,DownloadHandler.dll 的简略设计概览。图中将处理流展示为,服务器计算机的硬盘驱动器上的文件区块通过 DownloadHandler.dll 和 Web 浏览器进入 Web 浏览器计算机的硬盘驱动器上的文件。

DownloadHandler.dll 处理流的简略设计概览
图 4:DownloadHandler.dll 处理流的简略设计概览(将不支持部分内容的 Web 浏览器用作客户端)

在 IIS Web 服务器上实现 IHttpAsyncHandler 接口提供了一项精彩功能,即通过在 HTTP 响应中发送 Accept-Ranges HTTP 标头 (Accept-Ranges: bytes) 来支持“字节提供”,这会告知客户端它将提供文件的各个部分(部分内容范围)。如果 Web 浏览器内的默认下载客户端支持部分内容,则它能够在 HTTP 请求中向服务器发送 Range HTTP 标头 (Range: bytes=5242880-10485760);当服务器将部分内容发送回客户端时,它会在 HTTP 响应中发送回 Content-Range HTTP 标头 (Content-Range: bytes 5242880-10485760/26214400)。因此,您可以获得部分与我们的独立可执行客户端相同的优势,具体视您所使用的 Web 浏览器和浏览器中内置的默认下载客户端而定。无论如何,大部分 Web 浏览器都会允许您构建您自己的自定义下载客户端,并将其插入浏览器中,替换内置的默认客户端。

示例项目配置

对于示例项目,只需将 DownloadHandler.dll 和 IOThreads.dll 复制到虚拟目录下的 \bin 目录中,并将条目放入 web.config 中的处理程序部分和模块部分即可,如下所示:

<handlers>
  <add name="Download" verb="*" path="Download"
    type="DownloaderHandlers.DownloadHandler" />
</handlers>
<modules>
  <add name="CustomBasicAuthenticationModule" preCondition="managedHandler"
    type="DownloaderHandlers.CustomBasicAuthenticationModule" />
</modules>

如果 IIS 服务器上没有任何虚拟目录,请创建一个包含 \bin 目录的虚拟目录,使其投入使用,并确保它使用的是 Microsoft.NET Framework 4 应用程序池。

自定义的基本身份验证模块使用易用的 AspNetSqlMembershipProvider,与当前许多 ASP.NET 网站使用的一样,同时在 SQL 服务器上的 aspnetdb 数据库中存储下载文件所需的用户名和密码。使用 AspNetSqlMembershipProvider 即可获得的优势之一是,用户无需在 Windows 域上拥有帐户。有关如何安装 AspNetSqlMembershipProvider 以及配置用户帐户和 SSL 证书所需的 IIS 服务器设置的详细说明,请转到示例项目代码,参阅 CustomBasicAuthentication­Module 类顶部的注释部分中列出的说明。用于优化 IIS 服务器的其他高级配置选项通常已由管理服务器的 IT 部门进行了设置,并且此内容不在本文的范围内。不过,如果还未配置,您可访问 bit.ly/1JRJjNS 上的 TechNet 库,了解相关信息。

至此大功告成。就是这么简单。

备受瞩目的因素

本设计最备受瞩目的因素不仅包括它能提高速度,还包括它对因不可靠、不稳定的网络链接不断切换联机和脱机状态而引起的网络中断更具复原性和容错性。通常情况下,借助一个连接并通过一个区块来下载一个文件的吞吐量最高。

此规则也有一些独特的例外,如镜像服务器环境。在这一环境中,文件是通过单独的区块下载,文件的每一区块都是从不同的镜像服务器中获得,如图 5 所示。然而,一般来说,通过多线程下载文件实际上要比通过单线程下载文件慢,因为网络通常是瓶颈。不过,能够仅反复地重新下载无法下载的文件部分,直至成功下载为止,而无需重启整个下载流程,我们认为这在某种程度上就类似于容错。

假设的未来增强功能以便模拟最基本的镜像基础结构
图 5:假设的未来增强功能以便模拟最基本的镜像基础结构

此外,如果有人要将这一设计修改为未来增强功能以便模拟最基本的镜像服务器基础结构(如图 5 所示),则可以认为是类似于容错。

从根本上讲,通过这一设计,您可以通过不可靠的网络可靠地下载文件。网络链接上短暂出现的问题并不表示您必须从头开始下载;相反,您可以只重新下载无法下载的文件区块。此设计的附加优势(可提高复原性)在于,您可在下载的同时,将文件下载的当前进度状态存储至硬盘驱动器上的文件中,这样您就可以基本上跨客户端应用程序和客户端计算机重启重新下载无法下载的部分了。不过,这是留给读者的练习。

另一个备受瞩目的因素(在突出程度上与之前提到的因素相媲美)在于,使用服务器上的 HttpResponse.TransmitFile 直接将文件字节写入网络流(无显式缓冲),从而最大限度地将对服务器内存的影响降至最低。令人惊讶的是,这对服务器内存的影响微乎其微,甚至是在下载极大的文件时。

还有其他三个并不那么重要的因素,但它们依然备受瞩目。

首先,由于设计包括前端客户端和后端服务器,因此您可以完全控制服务器端配置。这样一来,您便可以自由地调整配置设置,这些设置经常会极大地妨碍服务器上的文件下载流程。这些服务器属于其他人,不受您的控制。例如,您可以将每个客户端 IP 地址限定的连接限制调整为一个大于常规限制(两个连接)的值。您也可以将每个客户端连接的限制调整为更大的值。

其次,我们的前端客户端 (FileDownloader.exe) 和后端服务器 (DownloadHandler.dll) 中的示例项目代码能够用作简单明确的示例代码块,用于展示 如何使用在 HTTP 协议中支持部分内容字节范围所需的 HTTP 请求头和响应头。客户端为了请求获得字节范围而必须发送的 HTTP 请求头,以及服务器为了将字节范围作为部分内容返回而必须发送的 HTTP 响应头,都很容易就能看出来。将代码修改为实现更高级别的功能(在这一简单的基本功能之上),或者实现更复杂的软件包中可用的部分更高级功能,应该相对简单。此外,您还可以将它用作简单的起始模板,以便您可以相对轻松地添加对其他一些更高级的 HTTP 标头(例如,Content-Type: multipart/byteranges、Content-MD5: md5-digest、If-Match: entity-tag 等)的支持。

第三,由于设计使用的是 IIS Web 服务器,因此您可以自动受益于服务器提供的一些内置功能。例如,通信可以自动加密(使用带 SSL 证书的 HTTPS)和压缩(使用 gzip 压缩)。不过,如果对极大的文件运行 gzip 压缩会对您的服务器 CPU 造成太大的压力,那么我们不建议您这样做。然而,如果您的服务器 CPU 能够承受额外的负担,则传输小得多的压缩数据的效率有时会对整个系统的总吞吐量造成巨大影响。

未来改进

示例项目代码只提供了让文件下载器运行至少所需的核心功能。我们的目标是让设计简单易懂,以便用户能够相对轻松地将其用作基本设计,并以此为基础添加增强功能和额外功能。这只是作为起始的基本模板。在开始用于生产环境之前,绝对有必要添加其他许多增强功能。添加能够额外提供这种更高级的功能的更高级别抽象层是留给读者的练习。不过,我们将会阐述几项更至关重要的增强功能。

示例项目代码暂未在文件中添加 MD5 哈希校验和。事实上,有必要采用某种文件校验和策略,以确保下载到客户端的文件与服务器上的文件是一致的,且文件未受到任何形式的篡改或更改。HTTP 标头 (Content-MD5: md5-digest) 让这变得简单。实际上,其中就包含我们首批原型中的一个,它在每次有文件请求时,就对文件执行 MD5 哈希校验和,并在文件离开服务器之前,将摘要插入标头 (Content-MD5: md5-digest) 中。然后,客户端会对收到的文件执行相同的 MD5 哈希校验和,并验证生成的摘要是否与服务器返回的标头 (Content-MD5: md5-digest) 中的摘要一致。如果不一致,则说明文件受到了篡改或损坏。虽然这完成了确保文件未被更改的目标,但大型文件会对服务器 CPU 造成巨大压力,而且执行时间也会过长。

实际上,可能需要某种高速缓存层对文件执行 MD5 哈希校验和处理(在后台,一次即可覆盖整个文件生命周期),并将生成的摘要存储在字典中(将文件名用作密钥)。这样一来,在服务器上获取文件的摘要只需执行简单的字典查找即可,在文件离开服务器时可以将摘要添加到标头中(瞬间完成),对服务器 CPU 造成的影响也最小。

示例项目代码也暂未限制客户端使用数量庞大的线程并将文件拆分成大量的区块。它基本上允许客户端执行“需要的操作”来确保其能够下载文件。实际上,可能需要某种能够对客户端施加限制的基础结构,这样一个客户端就无法盗用服务器,也无法使其他任何客户端停止了。

图 5 展示了假设的未来增强功能以便模拟最基本的镜像基础结构,主要是通过将设计修改为提供“节点名称/字节范围”对列表作为 URL 查询字符串参数,而不是使用当前设计的“chunksize”参数。您可以相对轻松地将当前设计修改为,仅通过循环访问“节点名称/字节范围”对,为每个对启动 HttpWebRequest,从而从不同的服务器中获取文件的每个区块,而不用通过内部循环访问来根据“chunksize”参数将总文件大小拆分成各个区块大小,并为每个区块启动 HttpWebRequest。

您可以仅用“节点名称/字节范围”对列表中的相关节点名称替换服务器名称,将相关字节范围添加到 Range HTTP 标头(即,Range: bytes=0-5242880),然后将“节点名称/字节范围”列表从 URL 中完全删除,以此来为 HttpWebRequest 构造 URL。某种元数据文件可以确定文件区块所在的服务器,然后请求计算机能够从分布在不同服务器上的文件区块中组合出一个文件。

如果某文件有 10 个镜像服务器,则您可以将设计修改为从服务器 1 镜像副本上获取文件区块 1,从服务器 2 镜像副本上获取文件区块 2,从服务器 3 镜像副本上获取文件区块 3,依此类推。同样,在您检索了文件的所有区块,并在客户端上组合出了完整的文件后,有必要对文件执行 MD5 哈希校验和,以确保所有镜像服务器上的文件区块均未损坏,且您确实收到了整个文件。您甚至可以让设计变得花哨一点,并上升到新的高度,即跨整个国家/地区分布服务器,让代码智能起来,使其能够确定哪些服务器的处理负载最小,然后使用这些服务器服务于返回文件区块的请求。

总结

我们的设计并不以创建更快速、更可缩放的文件下载器为目标,而是以创建一个对瞬时网络中断具有极强复原性的文件下载器为目标。

我们不遗余力地确保设计极为简单,并能明确地展示如何对“字节提供”字节范围和部分内容使用 HTTP 协议标头。

在研究中,我们发现,要找到一个明确的实用示例来展示如何执行简单的 HTTP 字节提供,以及如何在 HTTP 协议中正确使用字节范围标头确实相当困难。大多数示例要么过于复杂,要么使用了其他许多标头在 HTTP 协议中实现更多高级功能,使其难以理解,更不用说今后进行增强或扩展了。

我们想为您提供简单坚实的基础设计,其中只包括最低限度的必要功能,以便您可以相对容易地进行实验,并随着时间的推移逐步添加更多高级功能,或者甚至实现整个更高级别的抽象层来添加 HTTP 协议的一些更高级功能。

我们只想为您提供直观的示例,以供您学习并以此为基础进行构建。请尽情体验吧!


Doug Duerner是一名高级软件工程师,在使用 Microsoft 技术设计和实现大型系统方面拥有超过 15 年的经验。他曾就职于多家财富 500 强银行机构和一家商业软件公司,这家公司设计并构建大型分布式网络管理系统,以供美国国防部国防信息系统局 (DISA)(用于其“全球信息网络”)和美国国务院使用。他有一颗极客的心,专注于各个方面,对最复杂且具有挑战性的技术难题情有独钟,尤其是那些大家都说“无法完成”的事情。 您可以通过 coding.innovation@gmail.com 与 Duerner 取得联系。

Yeon Chang Wang是一名高级软件工程师,在使用 Microsoft 技术设计和实现大型系统方面拥有超过 15 年的经验。他也曾就职于一家财富 500 强银行机构和一家商业软件公司,这家公司设计并构建大型分布式网络管理系统,以供美国国防部国防信息系统局 (DISA)(用于其“全球信息网络”)和美国国务院使用。他还为全球最大的芯片制造商之一设计并实现了大型驱动程序认证系统。Wang 获有计算机科学专业的硕士学位。他非常喜欢解决复杂问题,您可以通过 yeon_wang@yahoo.com 与他取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅: Stephen Cleary 和 James McCaffrey