company_banner

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

    В данной статье рассматривается опыт внедрения 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 Валерия Холодкова он особенно вкусен.
    Mail.ru Group
    1234.83
    Строим Интернет
    Share post

    Similar posts

    Comments 41

      +8
      жаль прогрессбара нет. А в целом — шикарная работа, и исходники доступны, это вам +.
        0
        Молодцы. Пожалуй, зря я изучение Silverlight забросил. Про обработку кода ответа сервера — было интересно, хороший обход =)
          0
          мм это, я так понимаю, работает только на дефолтной платформе?
            0
            Еще должно на маке, как минимум.
            А вот про костыли (моно) было бы интересно узнать: работает или нет.
              +2
              мм а где посмотреть собственно сам сервис? на файлах@mail.ru обычная хттп загрузка у меня.
                0
                ie?
                  0
                  У меня хром.
                    0
                    Linux?
                      0
                      угу.
                      ну речь же идет про моно. моно у меня стоит.
                      0
                      похоже они по очереди для разных браузеров этот сервис запустили, у меня уже Silverlight в хроме
                  +2
                  На Маке работает.
                0
                Элегантное решение
                  –2
                  ну че, первая годная статья от майла и наверно 1ое че то хорошее на ваших сервисах. И да, без прогрессбара смыл докачки файлов почти почти теряется.
                    +1
                    Что Вы имели в виду?
                    Поэтому мы пытаемся разделить файл на 100 чанков для отображения прогресса от 0% до 100% [...]
                      +1
                      У вас не отображается прогресс в виде бегущих процентов?
                      Возможно, у вас просто не успел инициализироваться silverlight-модуль.
                      Наш загрузчик — это результат более чем 2-летнего «вылизывания» кода, и он автоматически откатывается по цепочке silverlight -> flash -> iframe даже при наличии банерорезок. Для отката предусмотрен механизм таймаутов, в течении которых мы ждем инициализации плагинов (Silverlight и Flash). Если у вас установлена банерорезка или какой-либо прокси между вами и нашим сервером «режет» контент, то вполне возможно, что произошел откат до iframe-варианта, над отображением процентов в котором мы даже не заморачиваемся.
                        0
                        Что Вы имели в виду?

                        То, что для отображения прогресса от 0% до 100% файл размером 10 000 000 байт нужно разделить на 100 чанков по 100 000 байт каждый.
                        Но делать слишком маленькие, либо наоборот, слишком большие чанки, не оправданно. Поэтому файл размером 500 000 байт мы делим на 10 чанков по 50 000 байт, при этом отображается прогресс с шагом 10%. А файл размером 1 000 000 000 байт мы делим на 2000 чанков по 500 000 байт каждый для того, чтобы в случае сбоя загрузки терять в среднем 250КБ.
                          0
                          А о реализации на базе веб-сокетов не думали? Чтобы откат шёл от них шёл на silverlight -> flash -> iframe
                        0
                        При всей моей неприязни к C#, статью читал с удовольствием. До мелочей просчитанный механизм, молодцы.
                        P.S./~offtop: лично я для бесперебойной отправки файлов(в личных целях) использовал bittorrent протокол, результат всегда был положительный, из минусов разве что необходимость наличия торрент-клиента у получателя; и при нестабильном IP DynDNS нужен как вода (для безтреккерной передачи, когда адрес руками вбивается).
                          0
                          А что не так с C#, если не секрет?
                            0
                            Идеологическое отторжение на уровне «Да они же слегка изменили джаву и теперь перетягивают на себя одеяло, ох уж этот мелкософт!»
                            Ясное дело, что сделать свой продукт выгодней, чем развивать продукт компании Sun, но лично мне неприятно, когда плодятся похожие ЯП, и это разобщает программистов по каким-то признакам.
                              0
                              Ну то есть к самому продукту с точки зрения его функциональности и прмиенимости претензий нет? ;)
                                0
                                Когда два года назад его изучали, всё тип-топ было. Я ещё помнится на примере C# докладывал о сериализации.
                                Во мне просто остался осадок оттого, что C# изучался первым, а Java — побочным; хотя все задачи из этой области можно было бы решить в рамках одного языка, и Java был первым.
                          +1
                          Проблема состоит в том, что протокол HTTP изначально текстовый и для передачи больших объемов бинарных данных не очень приспособлен.

                          На самом деле HTTP такой же текстовый, как и FTP. Проблем с передачей бинарных потоков ни в одну из сторон у него нет. Есть только проблемы у отдельно взятых клиентов/серверов с пониманием Content-заголовков.

                          Клиент генерирует уникальный идентификатор сессии для каждого загружаемого файла.

                          Я бы на вашем месте использовал guid. Random хорош для генерации шума, но никак ни для обеспечения уникальности.

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

                          А текста response у вас разве нет?

                          5. Silverlight при наличии в имени файла определенных русских букв (буква «з» точно попала в немилость Microsoft) выдавал исключение в строке …

                          Microsoft тут не причем, во всем виноват RFC :) Согласно ему ваша попытка засунуть русские имена в Content-Disposition — это несоответствие стандарту.

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

                          Если бы вы сразу посмотрели на существующие на рынке решения, то увидели бы, что практически все по этим граблям уже прогулялись и используют загрузку чанками. Это решает и проблемы с Content-Range, и с хэшированием, и упрощает отображение прогресс-бара (если бы файловые сессии хранились на сервере, то простыми AJAX-запросами к простенькому же хэндлеру это реализуется без проблем), и дает возможность многопоточной загрузк, и еще много всяких фенек.
                            +1
                            А текста response у вас разве нет?

                            Сознайтесь, что вы невнимательно прочитали статью.

                            Microsoft тут не причем, во всем виноват RFC :) Согласно ему ваша попытка засунуть русские имена в Content-Disposition — это несоответствие стандарту.

                            У вас есть ответ на вопрос, почему Exception выбрасывается не на всех русских буквах?

                            Если бы вы сразу посмотрели на существующие на рынке решения, то увидели бы, что практически все по этим граблям уже прогулялись и используют загрузку чанками.

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

                            Это решает и проблемы с Content-Range, и с хэшированием, и упрощает отображение прогресс-бара (если бы файловые сессии хранились на сервере, то простыми AJAX-запросами к простенькому же хэндлеру это реализуется без проблем), и дает возможность многопоточной загрузк, и еще много всяких фенек.

                            Могу поспорить, что вы не смотрели код, в противном случае теряюсь в догадках, к чему эта фраза относится.
                              0
                              Видно что комментарий от человека в теме. Пристану с вопросом тогда

                              >сразу посмотрели на существующие на рынке решения,
                              Не могли бы тогда, дать пару названий, по которым надо гуглить? Мне вспомнился только swfupload
                              который сильно далек от представленного тут решения. Был бы очень благодарен!
                                +1
                                Вопрос видимо не ко мне, но отвечу: посмотрите на plupload
                                  0
                                  Спасибо большое — по описанию очень интересно! А почему вы не использовали его?
                                  Лицензия? Не вписывался в инфраструктуру? или все же есть претензии к качеству кода?
                                    0
                                    1. Когда мы начали писать свой загрузчик, его еще не существовало.
                                    2. Он похоже не умеет деградировать при наличии банерорезок.
                                  0
                                  В свое время, когда я изучал этот вопрос, то взял решения от известных брендов (триалы от Telerik, Devexpress, ComponentOne и т.п.) и просто Fiddler'ом посмотрел, каким образом они ведут работу.
                                0
                                Сознайтесь, что вы невнимательно прочитали статью

                                Если имеется ввиду, что далее именно текст ответа и был использован, то читал. И именно поэтому мне показался странным тот тезис, который я прокомментировал.

                                У вас есть ответ на вопрос, почему Exception выбрасывается не на всех русских буквах?

                                Наверно потому же, почему несоответствие стандартам W3C, например, не отображается одинаково плохо во всех браузерах. Нестандартное поведение зачастую неопределено.

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

                                Это написано в любом букваре по IO-операциям. Вы копирование файла из БД или загрузку его из сети как бы делали? Засосали весь в память, а потом скинули на диск или все-таки выполняли эти операции чанками? Из этих же соображений видится странной методика определения размера чанка. Обычно он зависит от инфраструктуры ввода-вывода (сеть, ФС и т.п.) и подбирается экспериментальным путем для достижения точки наивысшей производительности при разумном использовании ресурсов. Но никак не зависит от размера файла, как не зависит от размера файла MTU или размер кластера.

                                Могу поспорить, что вы не смотрели код, в противном случае теряюсь в догадках, к чему эта фраза относится.

                                Честно признаюсь — не смотрел, ибо думал, что код кореллирует с постом. А относится эта фраза к тому, что если бы вы сказали в самом начале «будем делать чанками, ибо IO-операции так принятор делать», то многие из дальнейших описанных проблем не возникли бы в принципе.

                                И не принимайте близко к сердцу мои придирки. То исследование, которые вы провели — суть совершенно правильный путь, ибо идеальных по эффективности все равно не будет. Что-то я почерпнул из вашего поста, что-то вы из комментариев :)
                                  0
                                  будем делать чанками, ибо IO-операции так принято делать

                                  Так в том всё и дело, что сильверлайт позволяет читать файл чанками, и даже отправлять чанками в пределах одного POST-запроса, периодически делая Flush. Но видимо из-за невозможности вручную выставить Content-Length сильверлайт целиком буферизует запрос (несмотря на Flush'и) и только после полной буферизации (когда он уже знает длину запроса) добавляет к запросу заголовок Content-Length и отправляет его на сервер.
                                    0
                                    Кстати, вспомнил еще одну причину ограничения размера чанка сверху — это антивирусы (пламенный привет лаборатории Касперского), которые всенепременно хотят проверить, что же пользователь отправляет в сеть, и тем самым при загрузке больших файлов провоцируют таймауты в Flash-загрузчике, которые (таймауты) в Flash'е увы нам неподвластны.
                                    0
                                    Нашел багу — кнопки нет.

                                    Скрин
                                    img-fotki.yandex.ru/get/4804/deerares.61/0_44f74_fae6f40c_L.jpg

                                    Mac OS 10.6.4
                                    Safari 5.0.1
                                      0
                                      Да, мы знаем об этой баге в Safari под MacOS…
                                      Это либо баг сафари, либо сильверлайта.
                                      Если вас не затруднит, не могли бы вы посниферить http-запросы при загрузке страницы? :)
                                      0
                                      Хотя сам скрин закачать смог — кликнув по этому пустому месту
                                      files.mail.ru/1E95PU
                                        0
                                        Скажите, у вас появляется кнопка если открыть сайт, а затем открыть Разработка -> Показать веб-инспектор -> закладка «Элементы»?
                                          0
                                          Да, появляется.
                                          Если перейти по ссылке на скачивание файла, например, files.mail.ru/1E95PU
                                          То тоже показывается сразу.
                                          Если же переходить на морду, то нет.
                                            0
                                            Исправили (советую очистить кеш браузера).
                                            Глюк рендеринга сафари, проявлялся только под MacOS почему-то…
                                      0
                                      Вопрос, какая у кода лицензия? (затаил дыхание в надежде на BSD, MIT или Apache License 2.0)

                                      Второй вопрос: выше прочитал, что в случае чего идет fallback на Flash и на iframe. Как это реализовано?

                                      Ой, и еще вопрос: а в сторону web-sockets не смотрели?
                                        0
                                        1. Отвечу чуть позже, после уточнения.

                                        2. Вся логика очереди загрузки (и fallback в том числе) реализована в javascript. Изначально так сложилось потому, что JS для нас саппортить гораздо легче и проще, нежели код плагинов (хорошо, что в Silverlight это C#, в случае Flash это птичий язык под названием Actionscript).
                                        Как это реализовано.
                                        Каждый плагин (Flash и Silverlight) сделан максимально простым: он умеет показывать диалог выбора файла, вызывать JS-callback о выборе файла(ов) и загружать файлы на заданный адрес, вызывая колбэки прогресса загрузки, окончания загрузки и ошибок загрузки. После своей инициализации на странице плагин вызывает JS-callback; если после вставки кода плагина на страницу в течение заданного таймаута callback не был вызван — значит либо что-то не так с плагином, либо банерорезка его «зарезала», либо просто медленный канал/компьютер у пользователя. В этом случае мы откатываемся: в случае отката на Flash повторяется такая же процедура ожидания, в случае отката на iframe нам нужен только javascript, поэтому никакого ожидания нет.

                                        3. Не смотрели, потому что имхо технология пока находится в стадии альфы.
                                        Возможно посмотрим тогда, когда nginx будет нативно их поддерживать.
                                        Пока мы следим за развитием JS FileAPI и ждем, когда ребята из мозиллы разродятся Blob'ами и Firefox4 выйдет из состояния беты для того, чтобы сходный функционал дозагрузки можно было реализовать только лишь средствами JS, без помощи Silverlight.

                                      Only users with full accounts can post comments. Log in, please.