Asp.net

通過 ASHX 處理程序支持可恢復的 HTTP 下載?

  • July 14, 2016

我們通過 ASP.NET 中的 ASHX 處理程序提供應用程序設置的下載。

一位客戶告訴我們,他使用了一些第三方下載管理器應用程序,而我們提供文件的方式目前不支持他的下載管理器應用程序的“恢復”功能。

我的問題是:

恢復下載背後的基本思想是什麼?是否有某個 HTTP GET 請求告訴我開始的偏移量?

恢復下載通常通過 HTTPRange標頭進行。例如,如果客戶端只需要文件的第二千字節,它可能會發送 header Range: bytes=1024-2048

有關更多資訊,您可以查看HTTP/1.1 的 RFC第 139 頁。

感謝 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 中正常工作。

引用自:https://stackoverflow.com/questions/5429947