company_banner

Новые аттачи в Яндекс.Почте

    Мы стремимся к тому, чтобы все части Яндекс.Почты одинаково хорошо работали у всех пользователей. Сегодня мы расскажем вам о том, как и зачем полностью переписали блок добавления аттачей. В этой статье — про отказ от флеша, поддержку возможностей современных браузеров и, как результат, увеличение скорости и надёжности загрузки файлов.

    Проблема

    Раньше всю аудиторию Яндекс.Почты мы разделяли на пользователей с флешем и без.

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

    А вот с пользователями без флеша (8-10% от дневной аудитории) было сложнее. Мы предлагали им загружать файлы через обычную форму с />. Файлы из неё отправлялись через iframe вместе с содержимым самого письма, и это занимало много времени. Нажав кнопку «Отправить», пользователь долго ждал, пока загрузятся файлы.

    И если небольшие файлы (до 25 Мб) не доставляли особых сложностей, то большие порождали новую проблему: если размер файла превышал допустимый лимит, приходилось использовать сервис Яндекс.Народ, а затем и Яндекс.Диск (с новыми аттачами мы поменяли и хранилище файлов)*.

    * Лимит на размер отправляемых файлов объясняется не столько технологическими ограничениями в Яндекс.Почте, сколько проблемами у сторонних почтовых серверов. Далеко не все они готовы принимать и хранить письма больших размеров. Чтобы такие письма доходили до адресата, мы сохраняем вложения размером больше 25 Мб на Яндекс.Диск и добавляем в письмо ссылки.

    Для определения размера файлов у пользователей без флеша мы подняли внутренний сервис, который работал так: клиент отправлял файл POST запросом на специальный url, сервер читал заголовок Content-Length запроса и закрывал соединение.

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

    Проблемы с прикреплением файлов к письму могли возникнуть у обеих категорий пользователей. Например, флеш-загрузчик хоть и позволяет выбирать несколько файлов и умеет определять их размер, но:
    1. это сторонний плагин, который должен быть установлен на компьютере пользователя, причём он может быть заблокирован другими плагинами или расширениями;
    2. есть проблемы с SSL-соединениями и безопасностью;
    3. сложно решать проблемы и ошибки при загрузке файлов.

    А у обычного /> как минимум нет мультиаттачинга.

    Разумеется, нас не устраивало такое положение дел, и мы не прекращали поиск эффективного решения этих проблем.

    Возможность

    В течение последнего года все браузеры научились самостоятельно (без подключения сторонних плагинов) организовывать работу с файлами. Поближе познакомиться со всеми их современными возможностями можно в статье на сайте Mozilla Developer Network.

    Вот новые возможности, которые появились в ходе развития HTML5:
    — атрибут multiple в теге input (начиная с Chrome 4, Firefox 3.6, IE 10, Opera 11, Safari 5);
    Drag and Drop API (Chrome 4, Firefox 3.5, IE 5.5, Opera 12, Safari 3);
    FormData (Chrome 7, Firefox 4, IE 10, Opera 12, Safari 5);
    XMLHttpRequest level 2 + CORS + progress events (Chrome 7, Firefox 4, IE 10, Opera 12, Safari 5).

    Теоретически мы могли внедрить их ещё год-полтора назад, но изменения коснулись бы только Chrome и Firefox. Эти браузеры имели в сумме хорошую долю, но не были монополистами. Опера и IE к тому моменту ещё не поддерживали эти возможности. А значит, половину аудитории все равно пришлось бы оставить на флеше.

    Поэтому мы ждали. И перед приближением июньского релиза Оперы 12, в котором стало возможно внедрение необходимых технологий, приступили к разработке.

    Что касается IE10, то его выход ожидается в ближайшее время.

    Реализация

    Как уже было сказано выше, мы должны разделять файлы на большие и маленькие. Например, пользователь пытается прикрепить к письму десять файлов, девять из которых в сумме укладываются в допустимый лимит, а десятый в два раза больше всех остальных. Без возможности загружать файлы по отдельности все десять файлов ушли бы на Яндекс.Диск. Однако это не кажется разумным — ведь лучше отправить на Диск только один файл, последний, а все остальные загрузить в письмо. Так мы приняли решение загружать каждый файл отдельно.

    Обычно файлы загружаются через стандартную форму:
    <form action="/upload" method="post">
        <input type="file" multiple="true"/>
        <input type="submit"/>
    </form>
    

    Допустим, мы отправляем форму в скрытый iframe. В этом случае браузер прочитает все выбранные файлы из input (даже если их много) и отправит POST-запросом в /upload. Но тут файлы грузятся все вместе, а нам это не подходит.

    Посмотрим, как нам поможет AJAX. Чтобы отправлять файлы через AJAX, нам нужна поддержка FormData. Без неё нельзя прочитать файлы в input и добавить их в запрос. Попробуем так:
    var formElement = document.getElementById("myFormElement");
    var xhr = new XMLHttpRequest();
    xhr.open("POST", "/upload", true);
    xhr.send(new FormData(formElement));
    

    Но и в этом случае все файлы всё равно отправятся из input. Получается, что надо брать отдельно каждый файл и определять, куда его загрузить (на Диск или в письмо), то есть обрабатывать независимо.
    for (var i = 0, j = input.files.length; i < j; i++) {
        upload(input.files[i]);
    }
    
    function upload(file) {
        var url = "";
        if (file.size > MESSAGE_LIMIT) {
            url = "uploader.disk.yandex.ru";
        } else {
            url = "uploader.mail.yandex.ru";
        }
        var data = new FormData();
        data.append("attachment", file);
        var xhr = new XMLHttpRequest();
        xhr.open("POST",  url, true);
        xhr.send(data);
    }
    

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

    Мы поддерживаем все популярные браузеры, однако не все из них поддерживают современные технологии. Согласно политике feature detection, мы добавили четыре проверки на включение новых возможностей:
    1. Нет поддержки FormData → используем iframe.
    2. Есть поддержка FormData → используем AJAX.
    3. Есть поддержка Drag-n-Drop и FormData → включаем возможности перетаскивать файлы из файлового менеджера. Например, в IE есть первое, но нет второго, поэтому отправить перетащенные файлы мы никак не можем.
    4. Есть поддержка multiple input и FormData → включаем возможность выбирать много файлов. Например, в Opera 11.6 есть multiple input, но нет FormData, соответственно, мы не можем отправлять файлы по одному.

    Третья и четвертая проверки вылились в тесты для Modernizr:
    Modernizr
        .addTest('draganddrop-files', function() {
            return !!(Modernizr['draganddrop'] && window['FormData'] && window['FileReader']);
        })
        .addTest('input-multiple', function() {
            return !!(Modernizr['input']['multiple'] && window['FormData'] && window['FileReader']);
        });
    

    В Safari 5.1 для Windows сразу нашелся баг: при выборе нескольких файлов все они оказывались нулевого размера и пустыми отсылались на сервер. В этом браузере пришлось все новые возможности отключить.

    В дополнение к AJAX-транспорту мы начали использовать Progress events для отрисовки красивого прогресс-бара.

    Мы его используем примерно так:
    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/upload', true);
    if (xhr.upload) {
        xhr.upload.addEventListener('progress', processProgressEvent, false);
    } else {
        drawCommonProgressbar()
    }
    

    Заметим, что при загрузке данных на сервер обработчик событий надо вешать на свойство xhr.upload, а при загрузке данных с сервера — на сам xhr.

    В браузерах, поддерживающих File API, размер файлов можно узнать из объекта File. В старых версиях спецификаций свойство называлось fileSize, а теперь просто size.

    В браузерах без поддержки File API (а таких все меньше и меньше), мы деградируем до использования внутреннего сервиса определения размеров файлов.

    Кстати, с переходом на новые технологии мы смогли реализовать и свою давнюю идею: drag-and-drop загрузку аттачей. Drag-and-drop API — очень общее. Оно касается не только файлов, а любого перетаскивания объектов на странице. Соответственно, в область для файлов можно переместить абсолютно всё.

    Нам пришлось решать и эту проблему: как оставить в почте только возможность загрузки файлов?

    Многое делает сам браузер, но не всё. В событии drop в свойстве event.dataTransfer.files, конечно же, будут только объекты из файловой системы. Но этими объектами могут быть как папки, так и файлы. Чтобы запретить загрузку папок (не все браузеры умеют грузить файлы из папок — первым стал Chrome 21, а Firefox отказался это делать с принципе) мы используем FileReader. Этот API позволяет прочитать файл с диска и работать с ним в JavaScript. И если объект читается, значит, это файл. Небольшую функцию, реализующую этот метод, можно посмотреть на GitHub.
    function isRegularFile(file, callback) {
        // если размер больше, чем 4кб, то это точно файл
        if (file.size > 4096) {
            callback(true);
            return;
        }
    
        if (!window['FileReader']) {
            // невозможно проверить
            callback(null);
    
        } else {
            try {
                var reader = new FileReader();
                reader.onerror = function() {
                    reader.onloadend = reader.onprogress = reader.onerror = null;
                    // Chrome (Linux/Win), Firefox (Linux/Mac), Opera 12.01 (Linux/Mac/Win)
                    callback(false);
                };
                reader.onloadend = reader.onprogress = function() {
                    reader.onloadend = reader.onprogress = reader.onerror = null;
                    // Нельзя делать abort после окончания чтения файла
                    if (e.type != 'loadend') {
                        // прерываем чтение после первого события
                        reader.abort();
                    }                
                    callback(true);
                };
                reader.readAsDataURL(file);
            } catch(e) {
                // Firefox/Win
                callback(false);
            }
        }
    }
    

    Однако такая проверка нужна не для всех браузеров — Chrome для Mac и IE10 для Windows 8 сами отсеивают папки.

    К FileReader надо относиться с очень большой осторожностью, особенно в Chrome, который ведет себя не стабильно: до 21-й версии наблюдались падения вкладки при чтении файла в несколько сотен мегабайт, а в 21-й стал падать и на маленьких файлах. Нам даже пришлось отказаться от использования FileReader для этого браузера.

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

    Для решения этой проблемы в обработчиках dragover и dragenter мы сделали следующую проверку:
    var types = event.dataTransfer.types;
    if (types) {
        for (var i = 0, j = types.length; i < j; i++) {
            if (types[i] == 'Files') {
                showDragArea();
                return false;
            }
        }
    }
    

    Тип «Files» означает, что в перетаскиваемых объектах есть настоящие файлы, а «return false» — начало процесса drag and drop. Эта проверка работает не во всех браузерах, но немного улучшает интерфейс.

    Еще оказалось, что события dragenter, dragover и dragleave, если их повесить на document, подвержены тем же проблемам, что и mouseover, mouseout: они бросаются при каждом перемещении между DOM-нодами.

    Проблему удалось решить тайм-аутом на обработку этих событий.
    var processTimer = null;
    $(document).on({
        'dragover dragenter': function() {
            window.clearTimeout(processTimer);
            showDragArea();
        }.
        'dragleave': function() {
            processTimer = window.setTimeout(function() {
                hideDragArea();
            }, 50);
        }
    });
    

    Кроссдоменные запросы

    Для загрузки на Диск понадобилась поддержка кроссдоменных запросов, которую можно проверить следующим образом:
    window['XMLHttpRequest'] && 'withCredentials' in new XMLHttpRequest()
    

    Политика определения транспорта остаётся такой же.

    Для кроссдоменных запросов надо делать правильную обработку «preflight» OPTIONS-запросов. В этих запросах браузер спрашивает удалённый сервер, можно ли к нему обращаться с текущего домена. Выглядят они примерно так:
    OPTIONS /upload HTTP/1.1
    Host: disk-storage42.mail.yandex.net
    Origin: https://mail.yandex.ru
    Access-Control-Request-Method: POST
    Access-Control-Request-Headers: origin, content-type
    

    На это сервер должен ответить разрешающими заголовками, например, так:
    Access-Control-Allow-Origin: https://mail.yandex.ru
    Allow: POST, PUT, TRACE, OPTIONS
    

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

    Если браузер не получил разрешение на кроссдоменный запрос, то запрос завершится со status=0 (это можно обработать в onreadystatechange). Также это может означать, что запрос был прерван пользователем или сервером. В любом случае стоит сделать fallback на iframe-загрузку.

    Сам процесс загрузки файлов на Яндекс.Диск выглядит так: сначала делается запрос, в котором бекенд Диска возвращает нам url, по которому надо загружать файл в хранилище, а также oid (operation id), по которому можно запросить статус операции. Загрузка — это не синхронная операция, и окончание отправки файла с клиента не означает, что файл готов на сервере, его надо сохранить в правильном месте, проверить антивирусами, записать в базу.

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

    Если progress events не поддерживается, мы запрашиваем статус закачки через каждые одну-две секунды, пока сервер не скажет, что файл готов.

    Успех

    На наш взгляд, игра стоила свеч. Текущее решение нас полностью устраивает, в том числе потому, что мы решили целый ряд проблем, не лишаясь при этом преимуществ, которые есть у флеша:
    • устранили «подводные стуки» — проблемы пользователей с отправкой писем и загрузкой аттачей;
    • отказались от использования флеша;
    • субъективно — уменьшили время, которое пользователи тратили на отправку писем с вложениями;
    • в два раза увеличили количество загрузок файлов на Диск по сравнению с Народом.
    Яндекс
    505.25
    Как мы делаем Яндекс
    Share post

    Comments 40

    • UFO just landed and posted this here
      +24
      Круто! Я даже не ожидал, что тут будет техническая информация о реализации. Надеюсь, что это не последний пост подобного характера.
        +2
        Спасибо, у нас на проекте хотим сделать нечто подобное — у нас люди загружают порой по 100 фото и на каждую приходится тыкать. Ваша статья как раз «в руку».
          0
          Жаль, что даже к версиям XX браузеры не избавились от багов в таких важных и давно введённых «фичах»… ну, будем ждать версий XXX, благо, что сейчас все с этим делом ускорились:)
            +3
            Спасибо за интересную техническую статью. С праздником, Яндекс.Почта! ;-) FYI: www.calend.ru/holidays/0/0/85/
              +1
              Вы не сталкивались с тем, что в опере onreadystatechange вызывается с status=0 без видимой причины?
                0
                нет, а в какой версии и для каких запросов?
                  0
                  12 версия, запросы не через FormData, а через xhr.send(Blob).
                  При этом оперу колбасит и она начинает формировать битые запросы.
                    0
                    У меня не получается. А можете тест-кейс сделать?
                      0
                      http://jsfiddle.net/D89xd/16/
                      Правда воспроизводится исключительно при использовании Blob.slice, так что в вашем случае вероятно неактуально.
                +4
                // если размер больше, чем 4кб, то это точно файл if (file.size > 4096) {
                Не совсем так. Встречаются папки, «размер» которых может быть больше 4096 байт. Так что делать окончательный вывод о том что это не папка, оперирую только размерами <4кб — не совсем верно.
                  +1
                  Да, но в большинстве случаев это подходит. Не всегда подходит в linux или если диск отформатирован не со стандартным кластером в 4кб, может быть есть еще какие-то кейсы.
                  Мы эту проверку уберем, когда внедрим поддержку папок, она там появилась, в основом, из-за падений хрома.
                    0
                    А в каких случаях хром падал, есть ли конкретный кейс как его уронить таким образом? Я просто не замечал падений, даже при файлах > 1гб.
                      0
                      в последнем (22) хроме уже все ок
                        0
                        У меня и в 21 хроме не было падений, отсюда и возник этот вопрос.
                          0
                          если делать abort в filereader, то у нас стабильно через 1-15 минут падала вкладка
                            0
                            кстати, может и в 21-м уже где-то поправили, мы зарелизились в один день с ним и наблюдали эти падения
                              +3
                              А зачем делать abort? Можно было изначально читать не весь файл, а предварительно сделать slice на 1 байт.
                                +2
                                Со slice хорошая идея, надо проверить. Спасибо!
                  +1
                  Было бы хорошо, если бы весь хотя бы клиентский код был доступен в виде готового решения для простых смертных.
                    +1
                    Спасибо! Давно пора было сделать, на самом деле. И ещё в Я.Фотках сделайте нормальный загрузчик, пожалуйста.
                      0
                      Без FormData можно обойтись. Для Оперы 11.60 и выше это можно сделать без проблем(ну или почти без проблем). Кстати, как это сделать, я уже писал почти год назад
                        +3
                        Большое спасибо за это нововведение. Я то его сразу заметил, так как с обычным загрузчиком возникало много проблем на работе.
                        Дело в том что там криво настроенный прокси поднят (не мной, я только пользователь), так вот приходилось каждый раз сначала пытаться загрузить файл в аттач, а когда это не выходило (ошибка подключения) нажимать на ссылку воспользоваться обычным загрузчиком, который в принципе работал (хоть и не был таким удобным.
                        Drag&Drop загрузчик уже опробовал, ооочень удобно стало, и не только из-за исчезнувших технических проблем (я на двух мониторах работаю — с одного на другой очень удобно файлы перетаскивать).
                          0
                          Тоже заметил — спасибо, удобно. Осталось только сделать, чтобы при клике на «Спам» письма с этого ящика больше не приходили, а то в этой кнопке вообще нет смысла — столько раз нажимал, а письма (такие же) все приходят и приходят. Извиняюсь за небольшой оффтоп.
                            0
                            а напишите мне, пожалуйста, в личку логин и id-писем (из урла), мы проверим
                            0
                            А мне кажется спорный момент загружать один из 10 файлов на яндекс.диск, а остальные вкладывать в письмо. Ведь когда смотришь письмо, есть кнопка «Сохранить все файлы/вложения» и в этом случае она теряет смысл. Было бы удобно, если бы в случае 10 файлов (с одним большим) они загружались как пакет (папка) на яндекс.диск и была возможность так же скачать их одним zip-архивом.
                              0
                              «мы традиционно не раскрываем своих планов» ©
                              +1
                              Почта еще сегодня очень порадовала, вывесив при входе что-то вроде «Теперь почта доступна на английском, кликните здесь, чтобы включить».
                                0
                                Скорее всего у вас браузер или ОС не на русском языке.
                                  0
                                  Это не сарказм, я на самом деле переключился на английский. Так что да — и браузер, и ОС не на русском :)
                                    0
                                    значит мы правильно вам подсказали :)
                                +1
                                Очередное доказательство жудкой сложности и неказистости совремнной веб-платформы: такие тривиальные задачи вызывают такое количество роблем :-(

                                Яндексу зачет за такое глубокое исследование роблемы и любовь к деталям в реализации.
                                  0
                                  Сам не так давно долго возился с доработкой d&d аплоадера, так что очень хорошо понимаю эти мучения.
                                  Не совсем понял необходимость пляски с FileReader, если у вас уже есть .dataTransfer с типами.
                                    0
                                    Чтобы не загружать папки
                                      0
                                      Все, понял, не загружать для старших браузеров. А почему родилось такое предположение, что попытка загрузки папки — это скорее всего ошибка пользователя? Или это для стандартизации возможностей?
                                        0
                                        Это нормальная возможность для драг-н-дропа, но с папками из браузера ничего пока сделать нельзя (кроме chrome 21+), поэтому их надо отсеивать
                                    0
                                    супер статья
                                      0
                                      Вот бы вы с Mail.ru сделали единую современню кроссбраузерную OpenSource поддерживаемую и обновляемую библиотеку со всеми этими своими фичами определения возможностей, множественной загрузкой, flash fallback, ресайзом картинок в браузере — миллионы людей бы вам спасибо сказали.

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