【问题标题】:Supporting resumable HTTP-downloads through an ASHX handler?通过 ASHX 处理程序支持可恢复的 HTTP 下载?
【发布时间】:2011-07-22 17:52:18
【问题描述】:

我们通过 ASP.NET 中的 ASHX 处理程序提供应用程序设置的下载。

一位客户告诉我们他使用了一些第三方下载管理器应用程序,而我们提供文件的方式目前不支持他的下载管理器应用程序的“恢复”功能。

我的问题是:

恢复下载的基本思路是什么?是否有某个 HTTP GET 请求告诉我开始的偏移量?

【问题讨论】:

    标签: asp.net download ashx download-manager


    【解决方案1】:

    恢复下载通常通过 HTTP Range 标头进行。例如,如果客户端只需要文件的第二千字节,它可能会发送标头 Range: bytes=1024-2048

    您可以查看the RFC for HTTP/1.1 的第 139 页了解更多信息。

    【讨论】:

      【解决方案2】:

      感谢 icktoofay 让我开始,这是一个完整的示例,可以为其他开发人员节省一些时间:

      磁盘示例

      /// <summary>
      /// Writes the file stored in the filesystem to the response stream without buffering in memory, ideal for large files. Supports resumable downloads.
      /// </summary>
      /// <param name="filename">The name of the file to write to the HTTP output.</param>
      /// <param name="etag">A unique identifier for the content. Required for IE9 resumable downloads, must be a strong etag which means begins and ends in a quote i.e. "\"6c132-941-ad7e3080\""</param>
      public static void TransmitFile(this HttpResponse response, string filename, string etag)
      {
          var request = HttpContext.Current.Request;
          var fileInfo = new FileInfo(filename);
          var responseLength = fileInfo.Exists ? fileInfo.Length : 0;
          var buffer = new byte[4096];
          var startIndex = 0;
      
          //if the "If-Match" exists and is different to etag (or is equal to any "*" with no resource) then return 412 precondition failed
          if (request.Headers["If-Match"] == "*" && !fileInfo.Exists ||
              request.Headers["If-Match"] != null && request.Headers["If-Match"] != "*" && request.Headers["If-Match"] != etag)
          {
              response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
              response.End();
          }
      
          if (!fileInfo.Exists)
          {
              response.StatusCode = (int)HttpStatusCode.NotFound;
              response.End();
          }
      
          if (request.Headers["If-None-Match"] == etag)
          {
              response.StatusCode = (int)HttpStatusCode.NotModified;
              response.End();
          }
      
          if (request.Headers["Range"] != null && (request.Headers["If-Range"] == null || request.Headers["IF-Range"] == etag))
          {
              var match = Regex.Match(request.Headers["Range"], @"bytes=(\d*)-(\d*)");
              startIndex = Parse<int>(match.Groups[1].Value);
              responseLength = (Parse<int?>(match.Groups[2].Value) + 1 ?? fileInfo.Length) - startIndex;
              response.StatusCode = (int)HttpStatusCode.PartialContent;
              response.Headers["Content-Range"] = "bytes " + startIndex + "-" + (startIndex + responseLength - 1) + "/" + fileInfo.Length;
          }
      
          response.Headers["Accept-Ranges"] = "bytes";
          response.Headers["Content-Length"] = responseLength.ToString();
          response.Cache.SetCacheability(HttpCacheability.Public); //required for etag output
          response.Cache.SetETag(etag); //required for IE9 resumable downloads
          response.TransmitFile(filename, startIndex, responseLength);
      }
      
      public void ProcessRequest(HttpContext context)
      {
          var id = Parse<int>(context.Request.QueryString["id"]);
          var version = context.Request.QueryString["v"];
          var db = new DataClassesDataContext();
          var filePath = db.Documents.Where(d => d.ID == id).Select(d => d.Fullpath).FirstOrDefault();
      
          if (String.IsNullOfEmpty(filePath) || !File.Exists(filePath))
          {
              context.Response.StatusCode = (int)HttpStatusCode.NotFound;
              context.Response.End();
          }
      
          context.Response.AddHeader("content-disposition", "filename=" + Path.GetFileName(filePath));
          context.Response.ContentType = GetMimeType(filePath);
          context.Response.TransmitFile(filePath, version);
      }
      

      数据库示例

      /// <summary>
      /// Writes the file stored in the database to the response stream without buffering in memory, ideal for large files. Supports resumable downloads.
      /// </summary>
      /// <param name="retrieveBinarySql">The sql to retrieve the binary data of the file from the database to be transmitted to the client. Parameters can be reffered to by {0} the index in the supplied parameter array.</param>
      /// <param name="retrieveBinarySqlParameters">The parameters used in the sql query. Specify null if no parameters are required.</param>
      /// <param name="connectionString">The connectring string for the sql database.</param>
      /// <param name="contentLength">The length of the content in bytes.</param>
      /// <param name="etag">A unique identifier for the content. Required for IE9 resumable downloads, must be a strong etag which means begins and ends in a quote i.e. "\"6c132-941-ad7e3080\""</param>
      /// <param name="useFilestream">If the binary data is stored using Sql's Filestream feature set this to true to stream the file directly.</param>
      public static void TransmitFile(this HttpResponse response, string retrieveBinarySql, object[] retrieveBinarySqlParameters, string connectionString, int contentLength, string etag, bool useFilestream)
      {
          var request = HttpContext.Current.Request;
          var responseLength = contentLength;
          var buffer = new byte[4096];
          var startIndex = 0;
      
          //if the "If-Match" exists and is different to etag (or is equal to any "*" with no resource) then return 412 precondition failed
          if (request.Headers["If-Match"] == "*" && contentLength == 0 ||
              request.Headers["If-Match"] != null && request.Headers["If-Match"] != "*" && request.Headers["If-Match"] != etag)
          {
              response.StatusCode = (int)HttpStatusCode.PreconditionFailed;
              response.End();
          }
      
          if (contentLength == 0)
          {
              response.StatusCode = (int)HttpStatusCode.NotFound;
              response.End();
          }
      
          if (request.Headers["If-None-Match"] == etag)
          {
              response.StatusCode = (int)HttpStatusCode.NotModified;
              response.End();
          }
      
          if (request.Headers["Range"] != null && (request.Headers["If-Range"] == null || request.Headers["IF-Range"] == etag))
          {
              var match = Regex.Match(request.Headers["Range"], @"bytes=(\d*)-(\d*)");
              startIndex = Parse<int>(match.Groups[1].Value);
              responseLength = (Parse<int?>(match.Groups[2].Value) + 1 ?? contentLength) - startIndex;
              response.StatusCode = (int)HttpStatusCode.PartialContent;
              response.Headers["Content-Range"] = "bytes " + startIndex + "-" + (startIndex + responseLength - 1) + "/" + contentLength;
          }
      
          response.Headers["Accept-Ranges"] = "bytes";
          response.Headers["Content-Length"] = responseLength.ToString();
          response.Cache.SetCacheability(HttpCacheability.Public); //required for etag output
          response.Cache.SetETag(etag); //required for IE9 resumable downloads
          response.BufferOutput = false; //don't load entire data into memory (buffer) before sending
      
          if (!useFilestream)
          {
              using (var connection = new SqlConnection(connectionString))
              {
                  connection.Open();
                  var command = new SqlCommand(retrieveBinarySql, connection);
      
                  for (var i = 0; retrieveBinarySqlParameters != null && i < retrieveBinarySqlParameters.Length; i++)
                  {
                      command.Parameters.AddWithValue("p" + i, retrieveBinarySqlParameters[i]);
                      command.CommandText = command.CommandText.Replace("{" + i + "}", "@p" + i);
                  }
      
                  var reader = command.ExecuteReader(CommandBehavior.SequentialAccess);
                  if (!reader.Read())
                  {
                      response.StatusCode = (int)HttpStatusCode.NotFound;
                      response.End();
                  }
      
                  for (var i = startIndex; i < contentLength; i += buffer.Length)
                  {
                      var bytesRead = (int)reader.GetBytes(0, i, buffer, 0, buffer.Length);
                      response.OutputStream.Write(buffer, 0, bytesRead);
                  }
              }
          }
          else
          {
              using (var connection = new SqlConnection(connectionString))
              {
                  connection.Open();
                  var tran = connection.BeginTransaction(IsolationLevel.ReadCommitted);
                  var command = new SqlCommand(Regex.Replace(retrieveBinarySql, @"select \w+ ", v => v.Value.TrimEnd() + ".PathName(), GET_FILESTREAM_TRANSACTION_CONTEXT() "), connection);
                  command.Transaction = tran;
      
                  for (var i = 0; retrieveBinarySqlParameters != null && i < retrieveBinarySqlParameters.Length; i++)
                  {
                      command.Parameters.AddWithValue("p" + i, retrieveBinarySqlParameters[i]);
                      command.CommandText = command.CommandText.Replace("{" + i + "}", "@p" + i);
                  }
      
                  var reader = command.ExecuteReader();
                  if (!reader.Read())
                  {
                      response.StatusCode = (int)HttpStatusCode.NotFound;
                      response.End();
                  }
      
                  var path = reader.GetString(0);
                  var transactionContext = (byte[])reader.GetValue(1);
      
                  using (var fileStream = new SqlFileStream(path, transactionContext, FileAccess.Read, FileOptions.SequentialScan, 0))
                  {
                      fileStream.Seek(startIndex, SeekOrigin.Begin);
                      int bytesRead;
                      do
                      {
                          bytesRead = fileStream.Read(buffer, 0, buffer.Length);
                          response.OutputStream.Write(buffer, 0, bytesRead);
                      }
                      while (bytesRead == buffer.Length);
                  }
      
                  tran.Commit();
              }
          }
      }
      
      public void ProcessRequest(HttpContext context)
      {
          var id = Parse<int>(context.Request.QueryString["id"]);
          var db = new DataClassesDataContext();
          var doc = db.Documents.Where(d => d.ID == id).Select(d => new { d.Data.Length, d.Filename, d.Version }).FirstOrDefault();
      
          if (doc == null)
          {
              context.Response.StatusCode = (int)HttpStatusCode.NotFound;
              context.Response.End();
          }
      
          context.Response.AddHeader("content-disposition", "filename=" + doc.Filename);
          context.Response.ContentType = GetMimeType(doc.Filename);
          context.Response.TransmitFile("select data from documents where id = {0}", new[] { id }, db.Connection.ConnectionString, doc.Length, doc.Version, false);
      }
      

      辅助方法

      public static T Parse<T>(object value)
      {
          //convert value to string to allow conversion from types like float to int
          //converter.IsValid only works since .NET4 but still returns invalid values for a few cases like NULL for Unit and not respecting locale for date validation
          try { return (T)System.ComponentModel.TypeDescriptor.GetConverter(typeof(T)).ConvertFrom(value.ToString()); }
          catch (Exception) { return default(T); }
      }
      
      public string GetMimeType(string fileName)
      {
          //note use version 2.0.0.0 if .NET 4 is not installed, in .NET 4.5 this method has now been made public, this method apparently stores a list of mime types which would be more complete then using registry
          return (string)Assembly.Load("System.Web, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a")
              .GetType("System.Web.MimeMapping")
              .GetMethod("GetMimeMapping", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static)
              .Invoke(null, new object[] { fileName });
      }
      

      这演示了一种从磁盘或数据库中读取部分文件并作为响应输出的方法,而不是将整个文件加载到内存中,如果下载中途暂停或恢复,这会浪费资源。

      编辑: 添加了 etag 以在 IE9 中启用可恢复下载,感谢 EricLaw 帮助使其在 IE9 中正常工作。

      【讨论】:

      • @mcm_ham:请提供您下载的 URL 或 Fiddler 捕获,以便我们调查您看不到简历的原因。
      • @EricLaw 感谢您的帮助。这是 url 和 fiddler 捕获:minsoft.org/handler.ashxminsoft.org/HandlerCapture.xml
      • 正如我在帖子blogs.msdn.com/b/ieinternals/archive/2011/06/03/… 中解释的那样,您的页面恢复正常。您不应依赖 F12 开发人员工具向您显示 HTTP/206 响应。
      • @EricLaw,感谢您对提琴手的帮助我发现我在“Content-Range”标头的开头缺少“字节”,现在它在 IE9 中可以正常工作。
      • @Michael Dude,非常感谢!我花了很长时间才弄清楚为什么 iPad 不能播放 wav 流式波形文件,但是当 IIS 为它们提供服务时没关系。
      猜你喜欢
      • 2011-04-11
      • 2015-02-06
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2010-12-12
      • 1970-01-01
      相关资源
      最近更新 更多