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

в 8:06, , рубрики: html5, javascript, Блог компании Яндекс, Веб-разработка, почта, метки: , ,

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

Проблема

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

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

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

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

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

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

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

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

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

А у обычного <input type="file"/> как минимум нет мультиаттачинга.

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

Возможность

В течение последнего года все браузеры научились самостоятельно (без подключения сторонних плагинов) организовывать работу с файлами. Поближе познакомиться со всеми их современными возможностями можно в статье на сайте 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" miltuple="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 не поддерживается, мы запрашиваем статус закачки через каждые одну-две секунды, пока сервер не скажет, что файл готов.

Успех

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

  • устранили «подводные стуки» — проблемы пользователей с отправкой писем и загрузкой аттачей;
  • отказались от использования флеша;
  • субъективно — уменьшили время, которое пользователи тратили на отправку писем с вложениями;
  • в два раза увеличили количество загрузок файлов на Диск по сравнению с Народом.

Автор: doochik

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js