Pull to refresh
VK
Building the Internet

Silverlight + nginx = возобновляемая загрузка файлов в браузере

VK corporate blog
В данной статье рассматривается опыт внедрения Silverlight-клиента для организации возобновляемой загрузки файлов на проекте Файлы@Mail.Ru.

Зачем это нужно? Думаю, не нужно рассказывать, что загрузку файлов на сервер и их хранение сейчас предоставляет очень большое количество веб-проектов, от небольших до очень крупных. Причем загрузка обычно реализована в виде обычного <input type=file/>, реже — с помощью Flash, еще реже — иными средствами (загрузку по FTP в данной статье мы не рассматриваем).

Проблема состоит в том, что протокол HTTP изначально текстовый и для передачи больших объемов бинарных данных не очень приспособлен. Отсюда вытекает, что при обрыве связи у пользователя, перезагрузке компьютера и тому подобных факапах наполовину переданный файл приходится начинать загружать заново, а в случае с медленным каналом это превращается в настоящее издевательство.

Что делать?

Как мы дошли до такой жизни


Появление первых слухов о новой версии Adobe Flash 10 с поддержкой метода FileReference.load() для чтения содержимого файла нас воодушевило. Но не тут-то было: Adobe всех «перехитрил». Метод FileReference.load() полностью загружает все содержимое файла в память компьютера, тем самым «подвешивая» машину при попытке прочитать большой файл (в экспериментах «большим» уже оказывался файл примерно 500 МБ на компьютере с 2ГБ ОЗУ). Кроме того, Flash не поддерживает файлы размером более 2ГБ.

Мы были опечалены и разочарованы. К тому же насущно требовалась поддержка частичной загрузки с серверной стороны, а делать её самим было лень.

И вот однажды мы подумали: «А давайте посмотрим на Silverlight, может он нам даcт нечто большее, чем Flash?» — и не ошиблись.

В Silverlight работа с файлами реализована более грамотно и доступно, чем в Flash — мы можем читать выбранный пользователем в диалоге файл по произвольному смещению буферами произвольного размера. При этом размер файла в Silverlight ограничен 64-битным числом, т.е. мы можем загружать файлы практически бесконечного размера (теоретически до 16 384 ПБ).

К тому же, в репозитории Валерия Холодкова (если вдруг кто-то не знает, то это автор отличного модуля nginx_upload_module для загрузки файлов) появилась и неспешно развивалась ветка, называющаяся partial-upload, одно название которой приводило нас в благоговейный трепет.

Заручившись поддержкой Валерия, мы начали писать Silverlight-клиент и «стыковать» его с серверным модулем…

Happy end


После многочасовых переписываний клиентского кода и его тестирования у нас, наконец, появился первый рабочий вариант.

Затаив дыхание, мы начали загружать первый файл — о боже, какое это блаженство, когда ты выдергиваешь сетевой кабель в процессе загрузки, потом втыкаешь его обратно и, о чудо, загрузка возобновляется почти с места обрыва. Но блаженство быстро прошло, т.к. в процессе беглого тестирования были обнаружены баги как в клиентском коде, так и в коде серверного модуля.

Огромное спасибо Валерию за достаточно оперативный фиксинг багов в его модуле, и нам — за борьбу с Silverlight и C#.

В один прекрасный августовский день мы, наконец, окончательно протестировали и пофиксили все найденные баги и не преминули воспользоваться этим с целью осчастливить пользователей Файлы@Mail.Ru – то бишь, пустили это в продакшн.

Ну и наконец – решение в студию!

Немного о взаимодействии клиента и сервера


Загрузка происходит следующим образом.

Клиент генерирует уникальный идентификатор сессии для каждого загружаемого файла.
SessionId = (1100000000 + new Random().Next(10000000, 99999999)).ToString();

* This source code was highlighted with Source Code Highlighter.

Также для каждого файла считается некий хеш, цель которого — однозначно идентифицировать уникальный файл в пределах компьютера пользователя.
UniqueKey = "";
try
{
  if (FileLength < Constants.MinFilesizeToAdd)
  {
    throw new Exception();
  }
  // Adler32 version to compute "unique" file hash
  // UniqueKey will be Constants.NumPoints * sizeof(uint) length
  int part_size = (int)((file.Length / Constants.NumPoints) < Constants.MaxPartSize ? file.Length / Constants.NumPoints : Constants.MaxPartSize);
  byte[] buffer = new Byte[part_size];
  byte[] adler_sum = new Byte[Constants.NumPoints * sizeof(uint) / sizeof(byte)];
  int current_point = 0;
  int bytesRead = 0;
  Stream fs = file.OpenRead();
  AdlerChecksum a32 = new AdlerChecksum();
  while (current_point < Constants.NumPoints && (bytesRead = fs.Read(buffer, 0, part_size)) != 0)
  {
    a32.MakeForBuff(buffer, bytesRead);
    int mask = 0xFF;
    for (int i = 0; i < sizeof(uint) / sizeof(byte); i++)
    {
      UniqueKey += (char)((mask << (i * sizeof(byte)) & a32.ChecksumValue) >> (i * sizeof(byte)));
    }
    fs.Position = ++current_point * file.Length / Constants.NumPoints;
  }
}
catch (Exception) { }


* This source code was highlighted with Source Code Highlighter.

После выбора файла в диалоге и вычисления его хеша мы проверяем наличие информации об этом файле в локальном хранилище Silverlight, и если информация есть — начинаем загрузку с первой «дырки» в загруженных диапазонах байт.

Затем клиент посылает кусок файла, указывая диапазон посланных байт в заголовке X-Content-Range (из-за ограничений Silverlight используется этот заголовок вместо стандартного для HTTP заголовка Content-Range, хотя серверный модуль поддерживает оба заголовка) и идентификатор сессии в заголовке Session-ID. При этом в теле запроса посылаются чисто бинарные данные, т.е. содержимое куска.
UriBuilder ub = new UriBuilder(UploadUrl);
HttpWebRequest webrequest = (HttpWebRequest)WebRequest.Create(ub.Uri);
webrequest.Method = "POST";
webrequest.ContentType = "application/octet-stream";
// Some russian letters in filename lead to exception, so we do uri encode on client side
// and uri decode on server side
webrequest.Headers["Content-Disposition"] = "attachment; filename=\"" + HttpUtility.UrlEncode(File.Name) + "\"";
webrequest.Headers["X-Content-Range"] = "bytes " + currentChunkStartPos + "-" + currentChunkEndPos + "/" + FileLength;
webrequest.Headers["Session-ID"] = SessionId;
webrequest.BeginGetRequestStream(new AsyncCallback(WriteCallback), webrequest);


* This source code was highlighted with Source Code Highlighter.

В заголовке Range-ответа от сервера приходит список диапазонов байт этого файла, которые уже загружены на сервер. Также этот список продублирован в теле ответа (для чего продублирован — см.ниже).

После удачной загрузки каждого чанка информация о загруженных диапазонах сохраняется/обновляется в локальном хранилище Silverlight, ключом при этом является хеш файла. Это позволяет дозагрузить файл даже после закрытия браузера. Серверный модуль после загрузки каждого чанка возвращает нам http-код 201, при этом запрос на загрузку не проксируется на бэкенд.

Когда модуль определяет, что файл полностью загружен — он проксирует запрос на бэкенд со ссылкой на временный файл (так же, как и стандартный upload module). По сути, для бэкенда переход от использования стандартного upload module к использованию модуля partial-upload является полностью прозрачным, т.е. код бэкенда менять вообще не требуется.

Ограничения Silverlight, которые нам пришлось обходить:


1. Нельзя установить заголовок Content-Range, поэтому мы используем заголовок X-Content-Range

2. Нельзя достоверно определить код ответа сервера, мы видим лишь 200 или 404 коды (при использовании Browser HTTP Stack в Silverlight)

3. При использовании Client HTTP Stack в Silverlight мы теряем прокси-авторизацию и вынуждены вручную выставлять куки, но можем точно определить код ответа сервера — поэтому мы используем Browser HTTP Stack с небольшими ухищрениями для определения кода ответа 201:
if (ResponseText != null && ResponseText.Length != 0)
{
  // We cannot check response.StatusCode, see comments in constructor of FileUploadControl
  if (Regex.IsMatch(ResponseText, @"^\d+-\d+/\d+")) // we got 201 response
  {
    ...
  }
  else // we got 200 response
  {
    BytesUploaded = FileLength;
  }
}


* This source code was highlighted with Source Code Highlighter.

4. Вычисление «правильного» хеша файла (например, md5) на больших файлах занимает очень много времени — десятки секунд — что неприемлемо, поэтому мы берем 50 частей файла по 100КБ, для каждой части вычисляем сумму по алгоритму Adler32 (этот алгоритм был выбран из-за его высокой скорости работы по совету знакомого хакера) и затем конкатенируем отдельные суммы — это и есть «уникальный» хеш файла

5. Silverlight при наличии в имени файла определенных русских букв (буква «з» точно попала в немилость Microsoft) выдавал исключение в строке …
webrequest.Headers["Content-Disposition"] = "attachment; filename=\"" + File.Name + "\"";

* This source code was highlighted with Source Code Highlighter.

… поэтому пришлось внести модификацию — кодировать имя файла при загрузке и декодировать на сервере
webrequest.Headers["Content-Disposition"] = "attachment; filename=\"" + HttpUtility.UrlEncode(File.Name) + "\"";

* This source code was highlighted with Source Code Highlighter.

6. Даже несмотря на сброс буферов после загрузки определенного количества байтов, Silverlight кеширует POST запрос и отправляет его полностью. Это делает невозможным загрузку файлов целиком (без чанков), т.к. на больших файлах памяти клиента не хватает для буферизации запроса. Эта особенность также делает невозможной адекватное отображение прогресса загрузки.

Поэтому мы пытаемся разделить файл на 100 чанков для отображения прогресса от 0% до 100%, но при этом ограничиваем размер чанка сверху и снизу для случаев очень больших и очень маленьких файлов соответственно, что может привести как к большему, чем 100, количеству чанков, так и к меньшему.
public long FileLength
{
  get { return fileLength; }
  set
  {
    fileLength = value;
    ChunkSize = (long)(fileLength / (100 / Constants.PercentPrecision));
    if (ChunkSize < Constants.MinChunkSize)
      ChunkSize = Constants.MinChunkSize;
    if (ChunkSize > Constants.MaxChunkSize)
      ChunkSize = Constants.MaxChunkSize;
  }
}


* This source code was highlighted with Source Code Highlighter.

7. В Опере есть неприятный баг (уже можно сбиться, какой по счету): если ответ от сервера имеет тело нулевой длины, то в Silverlight не вызывается обработчик чтения ответного тела. Именно для этого мы попросили Валерия продублировать диапазон загруженных байт в теле ответа сервера.

Мы наступили на множество неприятных грабель, и нам хочется, чтобы путь других разработчиков был менее тернист. Поэтому мы решили открыть код клиентской части. Встречайте MrUploader. Вместе с модулем nginx-upload Валерия Холодкова он особенно вкусен.
Tags:
Hubs:
Total votes 122: ↑101 and ↓21 +80
Views 16K
Comments Comments 41

Information

Founded
Location
Россия
Website
vk.com
Employees
5,001–10,000 employees
Registered
Representative
Миша Буданов