Follow Me Widget

пятница, 17 августа 2012 г.

Асинхронная загрузка больших файлов в Blob Storage.

Привет всем! Сегодня я хотел бы подробно описать процедуру загрузки больших файлов в хранилище блобов. Данная задача возникла пару месяцев назад на одном из наших проектов и заставила меня искать возможные варианты ее решения. Предложенный подход не является чем-то совершенно новым и скорее всего не вызовет у Вас удивления. Он заключается в использовании такой распространенной библиотеки как JQuery File Upload (http://blueimp.github.com/jQuery-File-Upload/) в связке с возможностью поблочной загрузки блобов в хранилище. Процесс загрузки должен оптимально использовать канал и в меньшей степени нагружать промежуточный веб-сервер, поэтому собственно и было принято решение не загружать файл целиком, а предварительно разбивать его на небольшие фрагменты.

Итак, для демонстрации я создал простое тестовое веб-приложение, в котором существует одна простая страница. На этой странице пользователь имеет возможность выбрать файл и стартовать процесс загрузки. Механизм загрузки файла естественно должен быть асинхронным и пользователь должен иметь возможность отслеживать прогресс операции. Давайте посмотрим на последовательность операций, необходимых для успешной загрузки файла в хранилище.
Untitled
Прежде чем инициировать загрузку нового файла, клиент (в нашем случае браузер) создает новую загрузочную сессию и присваивает ей уникальный идентификатор. После чего файл автоматически разбивается на части и загружается последовательно на сервер. Сервер в свою очередь принимает входящий поток байтов и отправляет его в виде блока в хранилище блобов (для этого используется операция PutBlock). Как только все фрагменты файла успешно загружены на сервер, клиент инициирует операцию их слияния. Для этого отправляется HTTP запрос на сервер, где он трансформируется в операцию PutBlockList, после чего исходный файл становится доступным в хранилище блобов.
Давайте посмотрим на то, как этот процесс реализован в Visual Studio. Проект относительно простой, на следующей картинке можно увидеть его структуру.

Untitled2

Из всех присутствующих файлов нас интересуют лишь 5. Прежде всего очень важно представление Index.chtml – здесь сосредоточена вся логика связанная с клиентской частью. Исходный код этого файла представлен ниже.
<html>
<head>
    <link href="../../Content/bootstrap.css" rel="stylesheet" type="text/css" />
    <script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
    <script src="../../Scripts/jquery.ui.widget.js" type="text/javascript"></script>
    <script src="../../Scripts/jquery.iframe-transport.js" type="text/javascript"></script>
    <script src="../../Scripts/jquery.fileupload.js" type="text/javascript"></script>
    <script type="text/javascript">
        function guidGenerator() {
            var S4 = function () {
                return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
            };
            return (S4() + S4() + "-" + S4() + "-" + S4() + "-" + S4() + "-" + S4() + S4() + S4());
        }
        function MergeBlocks(filename) {
            $.post('/upload/mergeblocks', { FileName: filename, SessionId: $('#sessionId').val() });
        }
        $(function () {
            $('#dictionaryFileInputContainer').fileupload({
                url: '/filetransfer.ashx',
                paramName: "dictionaryFile",
                maxChunkSize: 4194304,
                add: function (e, data) {
                    $('#createButton').on('click', function () {
                        $('#sessionId').val(guidGenerator());
                        data.submit();
                    })
                }
            })
                .bind('fileuploadprogressall', function (e, data) {
                    var progress = parseInt(data.loaded / data.total * 100, 10);
                    progress = progress == 99 ? 100 : progress;
                    $('#uploadingProgressBar').css('width', progress + '%');
                })
                .bind('fileuploadalways', function (e, data) {
                    $('#uploadingProgressBar').css('width', '100%');
                    MergeBlocks(data.files[0].name);
                })
        })
    </script>
</head>
    <body>
        <div class="container">
            <form class="form-horizontal" enctype="multipart/form-data" id="dictionaryCreationForm" action="upload" method="POST">
                <fieldset>
                    <input type="hidden" name="sessionId" id="sessionId" />
                    <div class="control-group">
                        <div class="controls">
                            <input type="file" id="dictionaryFileInputContainer" name="uploadedFileName" />
                        </div>
                    </div>
                    <div class="control-group">
                        <label class="control-label" for="uploadingProgressBar">
                            Uploading Progress</label>
                        <div class="controls">
                            <div class="progress progress-info" style="width: 30%">
                                <div class="bar" style="width: 0%;" id="uploadingProgressBar">
                                </div>
                            </div>
                        </div>
                    </div>
                    <div class="control-group">
                        <div class="controls">
                            <input type="button" id="createButton" class="btn" value="Submit">
                        </div>
                    </div>
                </fieldset>
        </form>
        </div>
    </body>
</html>
Визуальных элементов на этой странице минимум – это кнопки выбора файла и начала загрузки. Больший интерес представляет js-код. Здесь используется библиотека JQuery File Upload. Все, что нам необходимо это сконфигурировать движок и “подвеситься” на необходимые нам обработчики событий.
        $(function () {
            $('#dictionaryFileInputContainer').fileupload({
                url: '/filetransfer.ashx',
                paramName: "dictionaryFile",
                maxChunkSize: 4194304,
                add: function (e, data) {
                    $('#createButton').on('click', function () {
                        $('#sessionId').val(guidGenerator());
                        data.submit();
                    })
                }
            })
                .bind('fileuploadprogressall', function (e, data) {
                    var progress = parseInt(data.loaded / data.total * 100, 10);
                    progress = progress == 99 ? 100 : progress;
                    $('#uploadingProgressBar').css('width', progress + '%');
                })
                .bind('fileuploadalways', function (e, data) {
                    $('#uploadingProgressBar').css('width', '100%');
                    MergeBlocks(data.files[0].name);
                })
        })
Как видим обрабатывать фрагменты файлов будет хендлер filetransfer.ashx, а максимальный размер запроса не будет превышать 4Мб (это обусловлено ограничением на размер одного блока в хранилище блобов). Как только файл будет выбран и пользователь нажмет на кнопку его отправки – будет создана новая сессия и клиент начнет отправлять файл. Вот как это реализовано:
  add: function (e, data) {
                    $('#createButton').on('click', function () {
                        $('#sessionId').val(guidGenerator());
                        data.submit();
                    })
                }

Для того, чтобы отслеживать прогресс загрузки мы используем 2 обработчика события – это fileuploadprogressall и fileuploadalways. Последний срабатывает в момент завершения загрузки финального фрагмента. В этом обработчике мы отправляем запрос на слияние предварительно загруженных фрагментов путем вызова метода MergeBlocks. Вот его код.
        function MergeBlocks(filename) {
            $.post('/upload/mergeblocks', { FileName: filename, SessionId: $('#sessionId').val() });
        }
Как видим мы просто отправляем POST-запрос на определенный URL с указанием названия файла и идентификатора сессии.

Следующий, интересующий нас файл, это FileTranfer.ashx. Он обрабатывает приходящие фрагменты файлов и отправляет их в хранилище. Вот его исходный код:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.StorageClient;
using System.IO;
using Newtonsoft.Json;
using System.Text;
using AsyncLargeFilesUpload.Web.Models;

namespace AsyncLargeFilesUpload.Web
{
    public class FileTransfer : IHttpHandler
    {
        private const string SessionIdKeyName = "sessionId";

        public void ProcessRequest(HttpContext context)
        {
            if (!context.Request.Params.AllKeys.Contains(SessionIdKeyName))
            {
                throw new ArgumentNullException("Session id required");
            }
            SessionsManager.CreateIfNotExists(context.Request[SessionIdKeyName]);
            context.Response.AddHeader("Pragma", "no-cache");
            context.Response.AddHeader("Cache-Control", "private, no-cache");
            UploadFile(context);
        }

        private void UploadFile(HttpContext context)
        {
            var headers = context.Request.Headers;

            if (!String.IsNullOrEmpty(headers["X-File-Name"]))
            {
                UploadPartialFile(Path.GetFileName(headers["X-File-Name"]), context);
            }

            WriteResponse(context);
        }

        private void WriteResponse(HttpContext context)
        {
            context.Response.AddHeader("Vary", "Accept");
            try
            {
                if (context.Request["HTTP_ACCEPT"].Contains("application/json"))
                    context.Response.ContentType = "application/json";
                else
                    context.Response.ContentType = "text/plain";
            }
            catch
            {
                context.Response.ContentType = "text/plain";
            }

            context.Response.Write(@"{ Result: 1, Message: ""Chunk Uploaded"" }");
        }

        private void UploadPartialFile(string fullName, HttpContext context)
        {
            if (context.Request.Files.Count != 1)
                throw new HttpRequestValidationException("Attempt to upload chunked file containing more than one fragment per request");
            var inputStream = context.Request.Files[0].InputStream;
            byte[] fileBytes;
            using (var reader = new BinaryReader(inputStream))
            {
                fileBytes = reader.ReadBytes((int)inputStream.Length);
            }
            var blockId = Guid.NewGuid().ToString();
            SessionsManager.AddItem(context.Request[SessionIdKeyName], blockId);
            var storageAccount = CloudStorageAccount.Parse("UseDevelopmentStorage=true");
            var blobClient = storageAccount.CreateCloudBlobClient();
            CloudBlobContainer container = blobClient.GetContainerReference(MvcApplication.FilesContainerName);
            container.CreateIfNotExist();
            var permission = container.GetPermissions();
            permission.PublicAccess = BlobContainerPublicAccessType.Container;
            container.SetPermissions(permission);
            CloudBlockBlob blob = container.GetBlockBlobReference(fullName);
            using (var stream = new MemoryStream(fileBytes, true))
            {
                blob.PutBlock(Convert.ToBase64String(Encoding.UTF8.GetBytes(blockId)), stream, null);
            }
        }

        public bool IsReusable
        {
            get
            {
                return false;
            }
        }
    }
}
Код относительно прост и не требует особых пояснений. В нем можно отметить лишь процесс отправки блока. Для этого используется метод PutBlock, он принимает идентификатор блока (в кодировке base64) и собственно сам набор байтов. Так как нам для слияния блоков в дальнейшем потребуются их идентификаторы – необходимы их где-то хранить. Для этого был разработан класс SessionsManager. Вот его исходный код:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

namespace AsyncLargeFilesUpload.Web.Models
{
    public static class SessionsManager
    {
        static Dictionary<string, IList<string>> fileParts = new Dictionary<string, IList<string>>();

        public static void AddItem(string key, string value)
        {
            fileParts[key].Add(value);
        }

        public static bool CreateIfNotExists(string key)
        {
            bool result = false;
            if (
          !fileParts.Keys.Contains(key) ||
          fileParts[key] == null ||
          fileParts[key].Count == 0)
            {
                fileParts.Add(key, new List<string>());
                result = true;
            }
            return result;
        }

        public static IList<string> GetBlockListBySessionId(string sessionId)
        {
            return fileParts[sessionId];
        }

        public static void RemoveSessionBlocks(string sessionId)
        {
            fileParts.Remove(sessionId);
        }
    }
}
Для тестирования решения мы просто храним идентификаторы блоков в статической коллекции (в производственной среде возможно следует подумать о другом хранилище). Каждая такая коллекция привязана к одной сессии поэтому используется структура Dictionary<string, IList<string>>. Последний проектный файл, который необходимо рассмотреть – это UploadController. Вот его исходный код:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using AsyncLargeFilesUpload.Web.Models;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.StorageClient;
using System.Text;

namespace AsyncLargeFilesUpload.Web.Controllers
{
    public class UploadController : Controller
    {
        public ActionResult Index()
        {
            return View();
        }

        public JsonResult MergeBlocks(CompleteFileUploadRequest file)
        {
            var storageAccount = CloudStorageAccount.Parse("UseDevelopmentStorage=true");
            var blobClient = storageAccount.CreateCloudBlobClient();
            CloudBlobContainer container = blobClient.GetContainerReference(MvcApplication.FilesContainerName);
            var blockList = SessionsManager.GetBlockListBySessionId(file.SessionId);
            var blob = container.GetBlockBlobReference(file.FileName);
            blob.PutBlockList(blockList.Select(entry => Convert.ToBase64String(Encoding.UTF8.GetBytes(entry))));
            SessionsManager.RemoveSessionBlocks(file.SessionId);
            return Json("Ok");
        }
    }
}
Для слияния блоков используется экшен MergeBlocks, он принимает комплексный объект CompleteFileUploadRequest, содержащий название файла и идентификатор сессии. Последовательность действий очень проста:
  1. Получить ссылку на интересующий блоб
  2. Получить список блоков для переданной в параметре сессии var blockList = SessionsManager.GetBlockListBySessionId(file.SessionId)
  3. Слить блоки в один блоб blob.PutBlockList(blockList.Select(entry => Convert.ToBase64String(Encoding.UTF8.GetBytes(entry))));
Вот собственно и все. Ссылку на скачивание готового решения можно найти чуть ниже. Аргументированная критика и возможности по улучшению подхода приветствуются :) Спасибо за внимание!

Комментариев нет: