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:
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:
Download der Beispielanwendung:
Weitere Informationen |
Verwendete Bildquelle "Lastenträger (Nepal)":
© Dieter Schütz / PIXELIO