Dateien blockweise in einer Windows Azure Web Rolle hochladen

Lastenträger (Nepal)Nachdem ich bereits in meinem Blog Post über "Dateien blockweise im Windows Azure Blob Storage speichern" berichtet hatte, wie man Stück für Stück eine Datei mittels Rich Client in den Windows Azure Storage hochladen kann, stand ich bei meinem aktuellen Projekt vor der Herausforderung, dieses in einer Web Rolle umzusetzen.

Meine hierbei entstandene Lösung besteht aus einer ASP.NET MVC 3 Web Rolle, gespickt mit HTML5 Funktionen, und steht zum Download am Ende dieses Blog Post zur Verfügung…

 

Das HTML Formular

Starten möchte den Rundgang mit dem HTML Formular…

Die Index-View der ASP.NET MVC 3 Web Rolle enthält ein ein File-Upload-Steuerelement, eine Auswahlliste für die Blockgrößenbestimmung, 3 Button-Steuerelemente, von denen der Submit-Button ausschließlich zur Abwärtskompatibilität dient (Dazu später mehr), ein <div> für Statusmeldungen, sowie eine Progress-Bar:

@using (Html.BeginForm(
    "Index", 
    "Home", 
    FormMethod.Post, 
    new { enctype = "multipart/form-data" }))
{
  <fieldset class="form-horizontal">
    <legend>File Upload</legend>
    <div class="control-group">
      <label class="control-label" for="fileInput">File</label>
      <div class="controls">
        <input type="file" class="input-xlarge" name="file" 
               id="fileInput" />
        <p class="help-block">Please select a file, 
        which you want to upload to the Windows Azure Blob 
        Storage</p>
      </div>
    </div>
    <div id="blockLengthGroup" class="control-group">
      <label class="control-label" for="blockLengthSelector">
        Block Size
      </label>
      <div class="controls">
        <select class="input-xlarge" id="blockLengthSelector">
          <option value="524288">512</option>
          <option value="1048576" selected="selected">1024</option>
          <option value="1572864">1536</option>
          <option value="2097152">2048</option>
          <option value="2621440">2560</option>
          <option value="3145728">3072</option>
          <option value="3670016">3584</option>
          <option value="4194304">4096</option>
        </select>
        <p class="help-block">This is the Block Size in Kilobytes, 
        which specifies the size of the file chunks for each upload 
        call.</p>
      </div>
    </div>
    <div class="form-actions">
      <input type="button" id="uploadButton" value="Upload" 
             class="btn" />
      <input type="submit" id="submitButton" value="Upload" 
             class="btn" />    
      <input type="button" id="cancelButton" value="Cancel" 
             class="btn" />
    </div>
  </fieldset>
  <div id="statusMessage" class="@(ViewBag.Error 
    ? "alert alert-error" 
    : (ViewBag.Message != "" 
      ? "alert alert-success" 
      : ""))">
    @ViewBag.Message
  </div>
  <progress id="uploadProgress" />
}

 

Die File API

Weiter geht es mit den Kernelementen der JavaScript-Datei FileUpload.js

Um lokale Dateien im Browser lesen, zerstückeln und hochladen zu können, verwende ich die File API, die mit HTML5 hinzugekommen ist.

An den Nicht-Submit-Button, mit der ID uploadButton, wird die startUpload-Funktion gebunden.

Diese prüft zuerst, ob die File API vom Browser unterstützt wird und eine Datei ausgewählt ist:

function startUpload() {
  var files = document.getElementById('fileInput').files;
  if (!files) {
    alert("Your browser doesn't support the HTML 5 File API!");
    return;
  }
  if (!files.length) {
    alert('Please select a file!');
    return;
  }

  var blockLength = $('#blockLengthSelector').val();

  $('#statusMessage').text('');
  $('#uploadButton').hide();
  $('#cancelButton').show();

  var totalBlocks = Math.ceil(files[0].size / blockLength);

 

Leider wird die File API und vor allem die .slice() Methode nicht von allen Browsern unterstützt:

  • Firefox 3.6+ (Teilweise unterstützt, aber nicht die .slice() Methode)
  • Firefox 4+ (Volle File API Unterstützung)
  • Chrome 6+ (Volle File API Unterstützung)

Anschließend definiert sie die sendFile-Funktion, sowie die rekursive Funktion sendNextBlock, die später die einzelnen Dateiblöcke an die UploadBlock-Aktion des Home-Controllers schickt:

  var sendFile = function(blockSize) {
    var start = 0,
      end = Math.min(blockSize, files[0].size),
      blockId = 1,
      retryCount = 0,
      maxRetries = 3,
      retryAfterSeconds = 10;

    var sendNextBlock = function() {
      var fileBlock = new window.FormData();
      renderProgress(blockId, totalBlocks);
      if (files[0].slice) {
        fileBlock.append('Slice', files[0].slice(start, end));
      } else if (files[0].webkitSlice) {
        fileBlock.append('Slice', files[0].webkitSlice(start, end));
      } else if (files[0].mozSlice) {
        fileBlock.append('Slice', files[0].mozSlice(start, end));
      } else {
        $('#statusMessage').text("This Browser is not supported.");
        return;
      }
      jqxhr = $.ajax({
        async: true,
        type: 'POST',
        url: ('/Home/UploadBlock/' + blockId),
        data: fileBlock,
        cache: false,
        contentType: false,
        processData: false,
        error: function(request, error) {
          if (error !== 'abort' && retryCount < maxRetries) {
            retryCount++;
            setTimeout(sendNextBlock, retryAfterSeconds * 1000);
          }

          if (error === 'abort') {
            $('#statusMessage').text("The upload has been aborted.");
            resetControls();
          } else {
            if (retryCount === maxRetries) {
              $('#statusMessage')
                .text("Failed to upload file. Max retries exceeded.");
              resetControls();
            } else {
              $('#statusMessage').text("Failed to upload block. (" 
                + retryCount + " of " + maxRetries + " retries)");
            }
          }
          return;
        },
        success: function(notice) {
          if (notice.error || notice.isLastBlock) {
            $('#statusMessage').text(notice.message);
            resetControls();
            return;
          }

          blockId++;
          start = (blockId - 1) * blockSize;
          end = Math.min(blockId * blockSize, files[0].size) - 1;
          retryCount = 0;
          sendNextBlock();
        }
      });
    };

    $('#statusMessage').text("Uploading file...");
    sendNextBlock();
  };

 

Im letzten Abschnitt der startUpload-Funktion wird die PrepareUpload-Aktion des Home-Controllers aufgerufen, um die Sendevorgang zu initialisieren.
Bei Erfolg wird der Upload-Vorgang mit dem Aufruf der o.g. sendFile-Funktion gestartet.

  $('#statusMessage')
    .text("Preparing file upload...");
  $.ajax({
    type: "POST",
    async: true,
    url: '/Home/PrepareUpload',
    data: {
      'blocksCount': totalBlocks,
      'fileName': files[0].name,
      'fileSize': files[0].size,
      'fileType': files[0].type
    },
    dataType: "json",
    error: function() {
      $('#statusMessage').text("Unable to prepare the upload.");
      resetControls();
    },
    success: function(notice) {
      if (notice.error) {
        $('#statusMessage').text(notice.message);
        resetControls();
        return;
      } else {
        sendFile(blockLength);
      }
    }
  });
}

 

Das FileUpload-Model und der Home-Controller

Für den Datei-Upload habe ich ein einfaches Model erstellt, welches die zu erwartende Anzahl an Dateiblöcken, einige Metadaten der Datei, sowie eine Referenz auf dem Windows Azure Block Blob enthält:

public class FileUploadModel
{
  public int BlockCount { get; set; }
  public string FileName { get; set; }
  public long FileSize { get; set; }
  public string FileType { get; set; }
  public CloudBlockBlob BlockBlob { get; set; }
}

 

Die PrepareUpload-Aktion des Home-Controllers erstellt eine BlockBlob-Referenz auf die zukünftige Datei, sowie ein FileUploadModel-Objekt für die Metadaten des Upload-Vorgangs.
Dieses wird anschließend im Session-State hinterlegt.

[HttpPost]
public ActionResult PrepareUpload(int blocksCount, string fileName, 
  long fileSize, string fileType)
{
  try
  {
    var container = CloudStorageAccount
      .Parse(
        CloudConfigurationManager
        .GetSetting("StorageAccountConnectionString"))
      .CreateCloudBlobClient()
      .GetContainerReference("uploads");
    container.CreateIfNotExist();

    var fileToUpload = new FileUploadModel
    {
      BlockCount = blocksCount,
      FileName = fileName,
      FileSize = fileSize,
      FileType = fileType,
      BlockBlob = container.GetBlockBlobReference(fileName)
    };

    Session.Add("FileAttributesSession", fileToUpload);

    return Json(new { error = false, message = string.Empty });
  }
  catch (Exception ex)
  {
    return Json(new { error = true, message = ex.Message });
  }
}

 

Mit der UploadBlock-Aktion des Home-Controllers werden die einzelnen Dateiblöcke im Windows Azure Storage gespeichert.
Wenn alle Dateiblöcke erfolgreich übertragen wurden, wird der Vorgang mittels der .PutBlockList() Methode abgeschlossen und der Session-State geleert.

[HttpPost]
[ValidateInput(false)]
public ActionResult UploadBlock(int id)
{
  var chunk = new byte[Request.InputStream.Length];
  Request.InputStream.Read(
    chunk, 
    0, 
    Convert.ToInt32(Request.InputStream.Length));
  if (Session["FileAttributesSession"] != null)
  {
    var model = 
      (FileUploadModel)Session["FileAttributesSession"];
    using (var chunkStream = new MemoryStream(chunk))
    {
      var blockId =
        Convert.ToBase64String(
          Encoding.UTF8.GetBytes(
            string.Format(
              CultureInfo.InvariantCulture, 
              "{0:D4}", id)));
      try
      {
        model.BlockBlob.PutBlock(
          blockId, 
          chunkStream, 
          null, 
          new BlobRequestOptions 
          { 
            RetryPolicy = RetryPolicies.Retry(
              3, TimeSpan.FromSeconds(10)) 
          });
      }
      catch (StorageException e)
      {
        return Json(new 
        { 
          error = true, 
          isLastBlock = false, 
          message = e.Message 
        });
      }
    }

    if (id == model.BlockCount)
    {
      try
      {
        var blockList = 
          Enumerable.Range(1, model.BlockCount)
          .ToList().ConvertAll(rangeElement =>
          Convert.ToBase64String(
            Encoding.UTF8.GetBytes(
              string.Format(
                CultureInfo.InvariantCulture, 
                "{0:D4}", rangeElement))));
        model.BlockBlob.Properties.ContentType = 
          model.FileType;
        model.BlockBlob.PutBlockList(blockList);
      }
      catch (StorageException e)
      {
        return Json(new 
        { 
          error = true, 
          isLastBlock = true, 
          message = e.Message 
        });
      }
      finally
      {
        Session.Clear();
      }

      return Json(new 
      { 
        error = false, 
        isLastBlock = true, 
        message = "File upload completed successfully." 
      });
    }
  }
  else
  {
    return Json(new 
    { 
      error = true, 
      isLastBlock = false, 
      message = "Failed To Upload File. Session expired." 
    });
  }
  return Json(new 
  { 
    error = false, 
    isLastBlock = false, 
    message = string.Empty 
  });
}

 

Für den Benutzer entsteht hierbei folgendes Bild:

Windows Azure File Upload Demo - Vor dem File API Upload

Windows Azure File Upload Demo - Während des Upload-Vorgangs

Windows Azure File Upload Demo - Abgeschlossener File API Upload

 

Abwärtskompatibilität

Da noch nicht alle Browser die FileList-Klasse und .slice() Methode unterstützen, habe ich auch einen klassischen Datei-Upload in die Beispielapplikation integriert, …

[HttpPost]
public ActionResult Index(HttpPostedFileBase file)
{
  try
  {
    if (file != null && file.ContentLength > 0)
    {
      var container = CloudStorageAccount
        .Parse(
          CloudConfigurationManager
            .GetSetting("StorageAccountConnectionString"))
        .CreateCloudBlobClient()
        .GetContainerReference("uploads");
      container.CreateIfNotExist();

      var fileName = Path.GetFileName(file.FileName);

      var blob = container.GetBlockBlobReference(fileName);
      blob.Properties.ContentType = 
        GetMimeType(Path.GetExtension(file.FileName));
      blob.UploadFromStream(
        file.InputStream, 
        new BlobRequestOptions 
        { 
          RetryPolicy = RetryPolicies.Retry(
            3, TimeSpan.FromSeconds(10)) 
        });
      }
      ViewBag.Message = "File upload completed successfully.";
      ViewBag.Error = false;
    }
    catch (Exception ex)
    {
      ViewBag.Message = ex.Message;
      ViewBag.Error = true;
    }

    return View("Index");
}

 

… der sich dem Benutzer wie folgt präsentiert:

Windows Azure File Upload Demo - Vor dem klassischen Datei-Upload

Windows Azure File Upload Demo - Abgeschlossener Datei-Upload

 


Download Download der Beispielanwendung:

Weitere Informationen


Verwendete Bildquelle "Lastenträger (Nepal)":
© Dieter Schütz / PIXELIO

Check Also

Time Machine Backups nach Microsoft Azure

Seit einigen Jahren verwende ich eine Apple Time Capsule, um meine Time Machine Backups an einem zentralen Ort speichern zu können. Bislang hatte das für mich auch vollkommen ausgereicht. Seitdem ich jedoch immer mehr unterwegs bin, habe ich nach einer Lösung gesucht, die ich auch von unterwegs nutzen kann. In diesem Blog Post zeige ich deshalb, wie man Time Machine Backups nach Microsoft Azure machen kann.