通过ASHX处理程序支持可恢复的HTTP下载?

10
我们通过ASP.NET中的ASHX处理程序提供我们应用程序设置的下载。
客户告诉我们他使用了一些第三方下载管理器应用程序,我们目前提供文件的方式不支持他的下载管理器应用程序的“继续”功能。
我的问题是:
恢复下载背后的基本思想是什么?是否有某个HTTP GET请求可以告诉我从哪个偏移量开始呢?
2个回答

17
感谢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中正确工作方面提供的帮助。

1
@mcm_ham:请提供下载的URL或Fiddler捕获,以便我们查看为什么您无法看到恢复下载。 - EricLaw
1
@EricLaw 感谢您的帮助。这是网址和 Fiddler 抓包:http://www.minsoft.org/handler.ashx http://www.minsoft.org/HandlerCapture.xml - Michael
2
正如我在http://blogs.msdn.com/b/ieinternals/archive/2011/06/03/send-an-etag-to-enable-http-206-file-download-resume-without-restarting.aspx中所解释的那样,您的页面可以很好地恢复。您不应该依赖F12开发人员工具来显示HTTP/206响应。 - EricLaw
1
@EricLaw,感谢您在Fiddler方面的帮助。我发现我在“Content-Range”头部缺少“bytes”,现在在IE9中它可以正常工作了。 - Michael
2
@Michael,非常感谢!我花了很长时间才弄清楚为什么iPad无法播放流式传输的wav文件,但当IIS提供服务时就可以了。 - Candide
1
双倍牛逼!这让我在寻找几天如何隐藏文件并仅基于ID提供字节后,HTML5视频流正常工作了!需要一些微调,但它非常稳定!你应该得到一箱啤酒的奖励! - Piotr Kula

4

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

您可以查看HTTP/1.1 RFC文档的第139页获取更多信息。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接