ASP.NET MVC–Descargar ficheros con soporte para continuación – ii

En el post anterior vimos todo la parte teórica de HTTP que nos permite realizar descargas de ficheros, pausarlas y continuarlas. Pero ahora viene lo bueno… Vamos a implementar el soporte en el servidor para soportar dichas descargas.

Dado que nuestro amado FileStreamResult no soporta la cabecera Range, nos va a tocar a nostros hacer todo el trabajo. Pero, la verdad… no hay para tanto.

Nota importante: Todo, absolutamente todo el código que pongo en este blog está para que hagas con él lo que quieras. Pero del mismo modo, el código que hay en este blog NO ES CÓDIGO DE PRODUCCIÓN. En muchos casos no está suficientemente probado y en otros, para simplificar, no se tienen en cuenta todas las casuísticas o no hay apenas control de errores. Si quieres hacer copy/paste eres totalmente libre de hacerlo, pero revisa luego el código. Entiéndelo y hazlo tuyo.

Bien, el primer paso va a ser crearnos un ActionResult nuevo, en este caso una clase llamada RangeFileActionResult y que herederá pues de ActionResult. En dicho ActionResult vamos a obtener el valor del campo Range y a parsearlo. Empecemos por el constructor:

  1. public RangeFileActionResult(string filename, string contentType)
  2. {
  3.     _stream = new FileStream(filename, FileMode.Open, FileAccess.Read);
  4.     _length = _stream.Length;
  5.     _contentType = contentType;
  6.  
  7.     byte[] hash;
  8.     using (var md5 = MD5.Create())
  9.     {
  10.         hash = md5.ComputeHash(_stream);
  11.     }
  12.     _etag = Convert.ToBase64String(hash);
  13.     _stream.Seek(0, SeekOrigin.Begin);
  14. }

Siplemente nos guardamos el stream al fichero y calculamos el MD5. Vamos a usar el MD5 como ETag del fichero.

Ahora, el siguiente paso es implementar el método ExecuteResult, que es donde se procesa toda la petición:

  1. public override void ExecuteResult(ControllerContext context)
  2. {
  3.     using (_stream)
  4.     {
  5.         var ranges = ParseRangeHeader(context.HttpContext.Request);
  6.         if (ranges == null || ranges.Empty)
  7.         {
  8.             new FileStreamResult(_stream, _contentType).ExecuteResult(context);
  9.             return;
  10.         }
  11.         if (ranges.Multiple)
  12.         {
  13.             ProcessMultipleRanges(context, ranges);
  14.         }
  15.         else
  16.         {
  17.             ProcessSingleRange(context, ranges.Values.Single());
  18.         }
  19.     }
  20. }

Básicamente:

  • Miramos el valor de la cabecera Range. Si no existe o no lo sabemos interpretar, creamos un FileStreamResult tradicional y lo ejecutamos. Es decir, nos saltamos todo lo de los rangos y devolvemos una descarga de fichero tradicional.
  • En caso de que haya más de un rango vamos a generar la respuesta multipart, con el método ProcessMultipleRanges.
  • En el caso de que haya un solo rango vamos a generar la respuesta con el método ProcessSingleRange.

El método ParseRangeHeader se encarga de parsear el valor de la cabecera Range:

  1. private Ranges ParseRangeHeader(HttpRequestBase request)
  2. {
  3.     var rangeHeader = request.Headers["Range"];
  4.     if (string.IsNullOrEmpty(rangeHeader)) return null;
  5.     rangeHeader = rangeHeader.Trim();
  6.  
  7.     if (!rangeHeader.StartsWith("bytes="))
  8.     {
  9.         return Ranges.InvalidUnit();
  10.     }
  11.  
  12.     return Ranges.FromBytesUnit(rangeHeader.Substring("bytes=".Length));
  13.  
  14. }

Se apoya en unas clases llamadas Ranges (que es básicamente una coleccion de objetos Range) y Range que representa un rango (origen, destino). El método importante de Ranges es el FromBytesUnit que es el que realmente parsea una cadena (sin el prefijo bytes=):

  1. public static Ranges FromBytesUnit(string value)
  2. {
  3.     var tokens = value.Split(',');
  4.  
  5.     var ranges = new Ranges();
  6.     foreach (var token in tokens)
  7.     {
  8.         ranges.Add(new Range(token));
  9.     }
  10.  
  11.     return ranges;
  12. }

Y la otra parte del trabajo la realiza el constructor de Range. Así si tenemos la cadena “0-100, 101-200” se llamará dos veces al constructor de Range pasándole primero la cadena “0-100” y luego “101-200”.

  1. public Range(string value)
  2. {
  3.     if (!value.Contains("-"))
  4.     {
  5.         _from = long.Parse(value);
  6.         _to = 1;
  7.     }
  8.     else if (value.StartsWith("-"))
  9.     {
  10.         _from = 1;
  11.         _to = long.Parse(value.Substring(1));
  12.     }
  13.     else
  14.     {
  15.         var idx = value.IndexOf('-');
  16.         _from = long.Parse(value.Substring(0, idx));
  17.         _to = idx == value.Length 1 ? 1 :
  18.             long.Parse(value.Substring(idx + 1));
  19.     }
  20. }

Sirviendo rangos

Empecemos por el caso más sencillo: Tenemos un solo rango. En este caso el método ProcessSingleRange toma el control:

  1. private void ProcessSingleRange(ControllerContext context, Range range)
  2. {
  3.     var response = context.HttpContext.Response;
  4.     response.StatusCode = 206;
  5.     response.AddHeader("Content-Range", new ContentRange(range, _length).ToString());
  6.     response.AddHeader("ETag", _etag);
  7.     response.ContentType = _contentType;
  8.     FlushRangeDataInResponse(range, response);
  9. }

Este método es muy sencillo. Establece el código de respuesta (206) y crea las cabeceras Content-Range, ETag y Content-Type. Luego llama al método FlushRangeDataInResponse. Dicho método va leyendo los bytes del fichero y los va escribiendo en el buffer de respuesta. Para evitar cargar todo el rango en memoria del servidor (un rango puede ser todo lo largo que desee el cliente), los datos se van leyendo en bloques de 1024 bytes y se van escribiendo en el buffer de salida:

  1. private void FlushRangeDataInResponse(Range range, HttpResponseBase response)
  2. {
  3.     var creader = new ChunkReader(_stream);
  4.     ChunkResult result = null;
  5.     var startpos = 0;
  6.     do
  7.     {
  8.         result = creader.GetBytesChunk(range, startpos);
  9.         startpos += result.BytesRead;
  10.         if (result.BytesRead > 0)
  11.         {
  12.             response.OutputStream.Write(result.Data, 0, result.BytesRead);
  13.         }
  14.         response.Flush();
  15.     } while (result.MoreToRead);
  16. }

Aquí el que hace el trabajo de verdad es la clase ChunkReader. Esta clase es la que va leyendo un stream, por “trozos” y devuelve después de cada trozo si hay “más trozos por leer”:

  1. public class ChunkReader
  2. {
  3.     private readonly Stream _stream;
  4.     public ChunkReader(Stream stream)
  5.     {
  6.         _stream = stream;
  7.     }
  8.  
  9.  
  10.     public ChunkResult GetBytesChunk(Range range, int startpos)
  11.     {
  12.         var chunk = new ChunkResult();
  13.         var reader = new BinaryReader(_stream);
  14.         var remainingLen = range.Length != 1 ? range.Length startpos : 1;
  15.         if (remainingLen == 0)
  16.         {
  17.             return new ChunkResult();
  18.         }
  19.                 
  20.         var bytesWanted = remainingLen != 1 ? Math.Min(1024, remainingLen) : 1024;
  21.         reader.BaseStream.Seek(range.FromBegin ? startpos : range.From + startpos, SeekOrigin.Begin);
  22.         var buffer = new byte[bytesWanted];
  23.         chunk.BytesRead = reader.Read(buffer, 0, (int)bytesWanted);
  24.         chunk.Data = buffer;
  25.         chunk.MoreToRead = remainingLen != 1
  26.             ? chunk.BytesRead != remainingLen
  27.             : chunk.BytesRead != 0;
  28.  
  29.         return chunk;
  30.     }
  31. }

El objeto ChunkResult contiene los bytes leídos, el número real de bytes leídos y un booleano que indica si hay “más datos” que leer.

Vayamos ahora al soporte para múltiples rangos. La idea es exactamente la misma, salvo que entre cada rango hay que generar el boundary correspondiente en la respuesta. Y eso es exactamente lo que hace el método ProcessMultipleRanges:

  1. private void ProcessMultipleRanges(ControllerContext context, Ranges ranges)
  2. {
  3.     var response = context.HttpContext.Response;
  4.     response.StatusCode = 206;
  5.     response.AddHeader("ETag", _etag);
  6.     response.AddHeader("Content-Type", "multipart/byteranges; boundary=THIS_STRING_SEPARATES");
  7.     foreach (var range in ranges.Values)
  8.     {
  9.         AddRangeInMultipartResponse(context, range);
  10.     }
  11. }

Primero añadimos los campos comunes (es decir el código de respuesta, el ETagm el content-type con el boundary). Y luego para cada rango llamamos al método AddRageInMultipartResponse, que simplemente coloca el boundary, luego el content-range y el content-type correspondiente y finalmente volca los datos del rango en el buffer de respuesta:

  1. private void AddRangeInMultipartResponse(ControllerContext context, Range range)
  2. {
  3.     var response = context.HttpContext.Response;
  4.     response.Write("– THIS STRING SEPARATES\x0D\x0A");
  5.     response.Write(string.Format("Content-Type: {0}\x0D\x0A", _contentType));
  6.     var contentRange = new ContentRange(range, _length);
  7.     if (contentRange.IsValid)
  8.     {
  9.         response.Write("Content-Range: " + contentRange + "\x0D\x0A\x0D\x0A");
  10.     }
  11.  
  12.     FlushRangeDataInResponse(range, response);
  13.     response.Write("\x0D\x0A");
  14. }

¡Y ya estamos! Algunos ejemplos de lo que vemos. La imagen de la izquierda contiene un solo rango y la de la derecha dos:

imageimage

Con esto hemos visto como añadir soporte para rangos desde el servidor. Por supuesto no está del todo pulido, faltaría añadir el soporte para el If-Range pero bueno… los mimbres vendrían a ser eso.

Nota: Si lo pruebas y colocas un valor de Range inválido (p. ej. Range: bytes=100) recibirás un HTTP 400. Este 400 es generado por IIS incluso antes de que la petición llegue a ASP.NET MVC.

Saludos!

He subido todo el código del POST en un repositorio de GitHub: https://github.com/eiximenis/PartialDownloads. La aplicación web de demo simplemente tiene el siguiente código en Home/Index:

  1. public ActionResult Index()
  2. {
  3.     return this.RangeFile("~\\Content\\test.png", "image/png");
  4. }

Por lo tanto si navegas con un navegador a /Home/Index deberás ver o descargarte la imagen entera. Pero usando fiddler o cURL puedes generar una petición con rangos para ver como funciona.

Para usar cURL te basta con la opción –header:

curl –header "Range: bytes=0-100" http://localhost:39841/

Saludos!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *