Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
410 views
in Technique[技术] by (71.8m points)

asp.net - Supporting resumable HTTP-downloads through an ASHX handler?

We are providing downloads of our application setups through an ASHX handler in ASP.NET.

A customer told us he uses some third party download manager application and that our way of providing the files currently does not support the "resume" feature of his download manager application.

My questions are:

What are the basic ideas behind resuming a download? Is there a certain HTTP GET request that tells me the offset to start at?

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Reply

0 votes
by (71.8m points)

Thanks icktoofay for getting me started, here's a complete example to save other developers some time:

Disk Example

/// <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);
}

Database Example

/// <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);
}

Helper Methods

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 });
}

What this demonstrates is a way of reading part of the file from either the disk or database and outputting as response rather than loading the entire file into memory, which wastes resources if the download is paused or resumed half way through.

Edit: added etag to enable resumable downloads in IE9, thanks to EricLaw for his help in getting it to work correctly in IE9.


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
OGeek|极客中国-欢迎来到极客的世界,一个免费开放的程序员编程交流平台!开放,进步,分享!让技术改变生活,让极客改变未来! Welcome to OGeek Q&A Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...