背景
在WEB进程中下载一个文档,最简单有效的办法就是直接给个链接到该文档的虚拟路径,把所有的问题交给浏览器和WEB服务器(IIS)去处理,但这种“良好”好的解决方案也会带来一些其它问题,譬如:无法在进程中控制下载权限,无法统计下载信息,无法将文档名更改为一个对客户良好的名字(事实上,为了避免服务器中文档名的重复,我们一般会分配给文档一个很长而又没有任何实际意义的名字,这不是客户端希望看到的,所以我们有必要在下载时重新为文档分配一个有意义的名字)。
基于Asp.net的方案
Response.Write/Response.BinaryWrite
WriteFile
和BinaryWrite
出现得比较早,在获取文档的路径后,会试图将文档流全部读入内存,之后再发送回客户端。对于小文档和流量很小的网站,使用这个方法或许问题不大,但如果文档很大或者网站的流量很大,使用这个方法可以让 aspnet_wp.exe 进程意味终止,导致当前服务器下所有 asp.net 站点全部瘫痪,不仅如此,服务器的物理内存也会在瞬间被填满,导致其它进程运行失败或意外终止。
示例代码:
string filePath = Server.MapPath("test.rar "); FileInfo fileInfo = new FileInfo(filePath); Response.Clear(); Response.ClearContent(); Response.ClearHeaders(); Response.AddHeader("Content-Disposition", "attachment;filename=" + fileInfo.Name); Response.ContentType = "application/octet-stream"; Response.AddHeader("Content-Length", fileInfo.Length.ToString()); Response.WriteFile(fileInfo.FullName); Response.Flush(); Response.End(); |
Response.TransmitFile
TransmitFile
是为了弥补WriteFile
和BinaryWrite
的不足才出现的方法,比WriteFile
和BinaryWrite
更加的稳定强大,对大文档的支持也不错。但其也有不足之处,对断点续传的支持不行,一个大的文档如果一次性没有下载完成的话,就需要从头再来。
示例代码:
Response.ClearContent(); Response.AddHeader("Content-Disposition", "attachment; filename=" + fileInfo.Name); Response.AddHeader("Content-Length", fileInfo.Length.ToString()); Response.ContentType = "application/octet-stream"; Response.TransmitFile(fileInfo.FullName); Response.End(); |
将大文档分为小块下载
每次使用 while 循环读取文档中的 10,000 个字节,然后将这些文档块发送给浏览器。因此,在运行时文档不会有任何重要部分保留在内存中。文档块大小目前被设为一个常量,但可通过编程方式对其修改,甚至也可以将其移动到配置文档中,以便根据服务器限制和性能要求对其进行更改。
示例代码:
// Buffer to read 10K bytes in chunk: byte[] buffer = new Byte[10000]; int length; long dataToRead; System.IO.Stream iStream = null; try { // Open the file. iStream = new System.IO.FileStream(filepath, System.IO.FileMode.Open, System.IO.FileAccess.Read, System.IO.FileShare.Read); // Total bytes to read: dataToRead = iStream.Length; Response.ContentType = "application/octet-stream"; Response.AddHeader("Content-Disposition", "attachment; filename=" + filename); // Read the bytes. while (dataToRead > 0) { // Verify that the client is connected. if (Response.IsClientConnected) { length = iStream.Read(buffer, 0, 10000); Response.OutputStream.Write(buffer, 0, length); Response.Flush(); buffer = new Byte[10000]; dataToRead = dataToRead - length; } else { //prevent infinite loop if user disconnects dataToRead = -1; } } } catch (Exception ex) { Response.Write("Error : " + ex.Message); } finally { if (iStream != null) { iStream.Close(); } } |
文档分块并支持断点续传
结合将大文档分为小块下载和HTTP 1.1
的两个标头元素Accept-Ranges/Etags
实现对断点续传的支持。
为使 ASP.NET 下载应用进程实现可恢复下载功能,您需要能够拦截浏览器发出的请求(进行下载恢复),并使用请求中的 HTTP 标头在 ASP.NET 代码中明确表达相应的响应。要完成此操作,您应在正常处理序列中早一些捕获该请求。
示例代码:
/// <summary> /// 下载文档,支持大文档、续传、速度限制。支持续传的响应头Accept-Ranges、ETag,请求头Range 。 /// Accept-Ranges:响应头,向客户端指明,此进程支持可恢复下载.实现后台智能传输服务(BITS),值为:bytes; /// ETag:响应头,用于对客户端的初始(200)响应,以及来自客户端的恢复请求, /// 必须为每个文档提供一个唯一的ETag值(可由文档名和文档最后被修改的日期组成),这使客户端软件能够验证它们已经下载的字节块是否仍然是最新的。 /// Range:续传的起始位置,即已经下载到客户端的字节数,值如:bytes=1474560- 。 /// 另外:UrlEncode编码后会把文档名中的空格转换中+(+转换为%2b),但是浏览器是不能理解加号为空格的,所以在浏览器下载得到的文档,空格就变成了加号; /// 解决办法:UrlEncode 之后, 将 "+" 替换成 "%20",因为浏览器将%20转换为空格 /// </summary> /// <param name="httpContext">当前请求的HttpContext</param> /// <param name="filePath">下载文档的物理路径,含路径、文档名</param> /// <param name="speed">下载速度:每秒允许下载的字节数</param> /// <returns>true下载成功,false下载失败</returns> public static bool DownloadFile(HttpContext httpContext, string filePath, long speed) { httpContext.Response.Clear(); bool ret = true; try { #region --验证:HttpMethod,请求的文档是否存在#region switch (httpContext.Request.HttpMethod.ToUpper()) { //目前只支持GET和HEAD方法 case "GET": case "HEAD": break; default: httpContext.Response.StatusCode = 501; return false; } if (!File.Exists(filePath)) { httpContext.Response.StatusCode = 404; return false; } #endregion #region 定义局部变量#region 定义局部变量 long startBytes = 0; long stopBytes = 0; int packSize = 1024 * 10; //分块读取,每块10K bytes string fileName = Path.GetFileName(filePath); FileStream myFile = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); BinaryReader br = new BinaryReader(myFile); long fileLength = myFile.Length; int sleep = (int)Math.Ceiling(1000.0 * packSize / speed);//毫秒数:读取下一数据块的时间间隔 string lastUpdateTiemStr = File.GetLastWriteTimeUtc(filePath).ToString("r"); string eTag = HttpUtility.UrlEncode(fileName, Encoding.UTF8) + lastUpdateTiemStr;//便于恢复下载时提取请求头; #endregion #region --验证:文档是否太大,是否是续传,且在上次被请求的日期之后是否被修改过 if (myFile.Length > long.MaxValue) {//-------文档太大了------- httpContext.Response.StatusCode = 413;//请求实体太大 return false; } if (httpContext.Request.Headers["If-Range"] != null)//对应响应头ETag:文档名+文档最后修改时间 { //----------上次被请求的日期之后被修改过-------------- if (httpContext.Request.Headers["If-Range"].Replace(""", "") != eTag) {//文档修改过 httpContext.Response.StatusCode = 412;//预处理失败 return false; } } #endregion try { #region -------添加重要响应头、解析请求头、相关验证 httpContext.Response.Clear(); if (httpContext.Request.Headers["Range"] != null) {//------如果是续传请求,则获取续传的起始位置,即已经下载到客户端的字节数------ httpContext.Response.StatusCode = 206;//重要:续传必须,表示局部范围响应。初始下载时默认为200 string[] range = httpContext.Request.Headers["Range"].Split(new char[] { '=', '-' });//"bytes=1474560-" startBytes = Convert.ToInt64(range[1]);//已经下载的字节数,即本次下载的开始位置 if (startBytes < 0 || startBytes >= fileLength) { //无效的起始位置 return false; } if (range.Length == 3) { stopBytes = Convert.ToInt64(range[2]);//结束下载的字节数,即本次下载的结束位置 if (startBytes < 0 || startBytes >= fileLength) { return false; } } } httpContext.Response.Buffer = false; //httpContext.Response.AddHeader("Content-MD5", FileHash.MD5File(filePath));//用于验证文档 httpContext.Response.AddHeader("Accept-Ranges", "bytes");//重要:续传必须 httpContext.Response.AppendHeader("ETag", """ + eTag + """);//重要:续传必须 httpContext.Response.AppendHeader("Last-Modified", lastUpdateTiemStr);//把最后修改日期写入响应 httpContext.Response.ContentType = "application/octet-stream";//MIME类型:匹配任意文档类型 httpContext.Response.AddHeader("Content-Disposition", "attachment;filename=" + HttpUtility.UrlEncode(fileName, Encoding.UTF8).Replace("+", "%20")); httpContext.Response.AddHeader("Content-Length", (fileLength - startBytes).ToString()); httpContext.Response.AddHeader("Connection", "Keep-Alive"); httpContext.Response.ContentEncoding = Encoding.UTF8; if (startBytes > 0) {//------如果是续传请求,告诉客户端本次的开始字节数,总长度,以便客户端将续传数据追加到startBytes位置后---------- httpContext.Response.AddHeader("Content-Range", string.Format("bytes {0}-{1}/{2}", startBytes, fileLength - 1, fileLength)); } #endregion #region -------向客户端发送数据块------------------- br.BaseStream.Seek(startBytes, SeekOrigin.Begin); int maxCount = (int)Math.Ceiling((fileLength - startBytes + 0.0) / packSize);//分块下载,剩余部分可分成的块数 for (int i = 0; i < maxCount && httpContext.Response.IsClientConnected; i++) {//客户端中断连接,则暂停 httpContext.Response.BinaryWrite(br.ReadBytes(packSize)); httpContext.Response.Flush(); if (sleep > 1) Thread.Sleep(sleep); } #endregion } catch { ret = false; } finally { br.Close(); myFile.Close(); } } catch { ret = false; } return ret; } |
其它可能
结论
基于Asp.net下载的方式可以更灵活的对用户权限、下载统计进行扩展,下面是几个方案的简单对比:
方法 | 优点 | 缺点 |
---|---|---|
Response.Write/BinaryWrite |
代码简洁 | 大文档比较占用Server内存,可能把IIS托垮,不支持断点续传 |
Response.TransmitFile |
代码简洁,大文档下载对服务器端资源占用较少 | 不支持断点续传 |
将大文档分为小文档 | 大文档下载对服务器端资源占用较少 | 不支持断点续传 |
文档分块并支持断点续传 | 支持断点续传 | 需要编码实现 |
选择方案时,要根据可能需要支持的download文档大小以及用户的网络情况,做合理选择。如果需要支持的文档大小不是很大,可以直接使用TransmitFile
进行下载,否则就要考虑支持断点续传。
附录
关于HTTP 1.1的两个标头元素Accept-Ranges和Etags的说明
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 标头中的任一个,而其他浏览器可能使用不同于 IE 要求的标头的另一个标头。
默认情况下,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 不会尝试恢复下载,而只是开始一个新下载。
原文引用 大专栏 https://www.dazhuanlan.com/2019/08/27/5d64bc589a2ed/