527.18
Rating
Mail.ru Group
Building the Internet
10 February 2011

Загрузка файлов с помощью html5 File API, с преферансом и танцовщицами

Mail.ru Group corporate blog

Предисловие


Загрузка файлов всегда занимала особое место в веб-разработке.
О трудности оформления стилями <input type=file/> уже сказано немало, почитать об этом можно, например, по ссылкам раз, два, три, четыре, пять, шесть.
Но и сам процесс загрузки файлов нетривиален, есть много разных способов – и ни одного идеального.

Я уже писал о внедрении на нашем проекте Файлы@Mail.Ru silverlight-загрузчика полгода назад. На тот момент у нас подерживались iframe, flash, silverlight и обычная загрузка файлов. Но прогресс не стоит на месте, и вот уже последние бета-версии всеми горячо любимых браузеров в полной мере поддерживают html5 FileAPI (справедливости ради, стоит заметить, что, как обычно, некоторые поддерживают своеобразно, но об этом — ниже).

Пока писалась статья, Chrome 9 был объявлен stable и форсировано обновился уже на 75% установок 8 версии. Так, что празднуем поддержку File API первым стабильным браузером, ура!

Мы подумали, что не использовать такую технологию было бы преступлением против юзеров пользователей.
Подумали — и внедрили html5 загрузку в дополнение к уже существующим вариантам.
В итоге наши пользователи получили множество плюшек:
— прозрачная дозагрузка после обрыва соединения (и даже рестарта браузера!);
— очередь загрузки;
— прогресс-бар (пользователи MacOS и Safari наконец могут видеть прогресс без всяких инородных плагинов), возможность удаления файлов из очереди, если передумал.


Используя File API мы можем программно, из javascript-кода:
1. получить список выбранных в диалоге файлов, их размеры и mime-типы (на которые, к слову, не стоит рассчитывать, т.к. некоторые популярные типы файлов браузеры по расширению не определяют).
2. получить необходимый диапазон байтов из файла, не загружая целиком содержимое файла в память (в отличие от Flash и Firefox 3 – см. примечание 1).
3. загрузить на сервер как целый файл, так и его кусочек.
4. загружать файлы в один drag-n-drop.
5. загружать одновременно (параллельно) несколько файлов.
Т.е. нам не нужны никакие плагины для манипуляций с файлами, и это, безусловно, очень круто!

Фабула


Собственно загрузка файлов реализуется в File API всего в несколько строк, но мы добавили несколько приятных фич (очередь загрузки, дозагрузка при обрыве соединения) и код стал немного сложнее.
Код загрузчика на проекте Файлы@Mail.Ru доступен и не обфусцирован и его можно изучить, но он привязан к проекту и его особенностям, поэтому мы рассмотрим этот механизм загрузки в чистом виде на примере проекта lightweight uploader.

Итак, поехали…

Мы вешаем на input обработчик onchange.

oself.file_elm.onchange = function() {
	oself.onSelect(this); // 'this' is a DOM object here
}


Объект input поддерживает html5 атрибуты multiple для разрешения выбора нескольких файлов за раз в диалоге и accept (см. прим. 2), который производит фильтрацию файлов в диалоге согласно заданным mime-типам.

В методе onSelect пробегаемся по массиву files (который содержит сформированный браузером список выбранных файлов), выставляем дефолтные свойства и генерируем событие onSelect для каждого файла.
После этого пересоздаем кнопку, т.е. удаляем input и создаем его заново. Это делается для того, чтобы исключить повторную загрузку выбранных файлов при отправке формы на сервер в случае, когда кнопка находится внутри формы.
Инициатором начала загрузки в данном случае выступает слушатель события onSelect, вызывая метод объекта-загрузчика enqueueUpload.

/*
 n - номер загрузчика на странице, исключительно для удобства
 file - сам объект типа File
 idx - порядковый номер текущего файла
 cnt - общее количество выбранных за раз файлов
*/
function onSelect(n, file, idx, cnt) {
	if(file.size > 1 * 1024 * 1024) {
		alert("File is too big!\nMaximum size is 1 MB.");
		return;
	}
	var d = document.createElement('div');
	d.id = 'file_' + file.id + '_' + n;
	document.getElementById('file_list_' + n).appendChild(d);
	d.innerHTML = '<a href="#" id="file_' + file.id + '_cancel_' + n + '">X</a>' + file.name + ' (' + file.size + ') <span id="file_' + file.id + '_status_' + n + '">...</span>'
	document.getElementById('file_' + file.id + '_cancel_' + n).onclick = function() {
		window['up' + n].cancelUpload(file.id);
		return false;
	};
	window['up' + n].enqueueUpload(file, 'http://lwu.no-ip.org/upload', "arg1=val1&arg2=val2");
}


Метод enqueueUpload добавляет файл во внутреннюю очередь загрузчика, добавляет файл в очередь фронтенда (фронтенд — это сущность, взаимодействующая с пользователем и позволяющая ему выбирать файлы, т.е. либо input, либо плагин Flash или Silverlight) и вызывает метод startNextUpload, который либо сразу стартует загрузку этого файла, либо откладывает её, если уже одновременно загружается заданное при инициализации количество файлов.

При добавлении файла в очередь фронтенда, html5 фронтенд запускает механизм обсчета уникального хеша файла, с помощью которого [хеша] реализуется дозагрузка. Подробности можно посмотреть в статье про silverlight-загрузчик.
Да-да, хеш опять подсчитывается по алгоритму Adler32.

oself.addFile = function(fo) {
	upFE_html5.superclass.addFile.apply(oself, [fo]);
	oself.calcChunkSize(fo);
	oself.calcFileHash(); // run calculation for next file
};


После подсчета хеша происходит обращение к локальному хранилищу для проверки, есть ли там информация о предыдущей неудачной загрузке этого файла. Если информация находится — атрибуты файла url, sessionID и uploadedRange перезаписываются информацией из локального хранилища.
Локальное хранилище (оно же WebStorage) — это еще один элемент html5, который позволяет хранить произвольные данные в формате ключ-значение на стороне пользователя либо на время сессии (SessionStorage), либо постоянно (LocalStorage).
Когда доходит очередь до загрузки файла, вызывается метод загрузчика startUpload, который генерирует событие onStart и запускает загрузку.

oself.startUpload = function(id, url, data) {
	var fo = oself.getFile(id);
	fo.url = url; // at this moment url already fetched from localStorage if info presents
	fo.data = data;
	fo.full_url = fo.url + (fo.url.match(/\?/) ? '&' : '?') + fo.data;
	fo.retry = oself.opts.maxChunkRetries;
	oself.broadcast('onStart', fo);
	oself.uploadFile(fo);
};


Метод uploadFile производит непосредственную загрузку файла на сервер.

oself.uploadFile = function(fo) {
	oself.calcNextChunkRange(fo);
	var blob, simple_upload = 0;
	try {
		blob = fo.slice(fo.currentChunkStartPos, fo.currentChunkEndPos - fo.currentChunkStartPos + 1);
	} catch(e) { // Safari doesn't support Blob.slice method
		blob = new FormData();
		blob.append('Filedata', fo);
		simple_upload = 1;
	};
	fo.xhr = new XMLHttpRequest();
	fo.xhr.onreadystatechange = function() {
		if(this.readyState == 4) {
			try {
				if(this.status == 201) { // chunk was uploaded succesfully
					var range = this.responseText;
					try { // getResponseHeader throws exception during cross-domain upload, but this is most reliable variant
						range = this.getResponseHeader('Range');
					} catch(e) {};
					if(!range) {
						throw new Error('No range in 201 answer');
					}
					fo.uploadedRange = range; // store range for case of later retry
					fo.retry = oself.opts.maxChunkRetries; // restore retry counter
					userStorage.set(fo); // add or update file info in localStorage
					oself.uploadFile(fo);
				} else if(this.status == 200) {
					fo.responseText = this.responseText;
					fo.loaded = fo.size; // all bytes were uploaded
					userStorage.del(fo); // delete file info from localStorage
					oself.broadcast('onDone', fo, fo.responseText);
				} else if(this.status == 0 && fo.cancel == 1) {
					//t('Aborted uploading for id=' + fo.id);
				} else {
					throw new Error('Bad http answer code');
				}
			} catch(e) { // any exception means that we need to retry upload
				oself.retryUpload(fo);
			};
		}
	};
	fo.xhr.open("POST", fo.full_url, true);
	fo.xhr.upload.onprogress = function(evt) {
		fo.loaded = (simple_upload ? 0 : fo._loaded) + evt.loaded;
		oself.broadcast('onProgress', fo);
	};
	if(!simple_upload) {
		fo.xhr.setRequestHeader('Session-ID', fo.sessionID);
		fo.xhr.setRequestHeader('Content-Disposition', 'attachment; filename="' + encodeURI(fo.name) + '\"');
		fo.xhr.setRequestHeader('Content-Range', 'bytes ' + fo.currentChunkStartPos + '-' + fo.currentChunkEndPos + '/' + fo.size);
		fo.xhr.setRequestHeader('Content-Type', 'application/octet-stream');
	}
	fo.xhr.withCredentials = true; // allow cookies to be sent
	fo.xhr.send(blob);
};


Комментарии в коде ясно показывают неполную поддержку html5 File API в браузере Safari (по крайней мере, в OS Windows), см. прим. 3.
При возникновении ошибок запускается метод retryUpload, который повторно пытается загрузить файл указанное при инициализации загрузчика количество раз, увеличивая промежуток между попытками при каждой неудаче.
В случае исчерпания количества попыток генерируется событие onError.

oself.retryUpload = function(fo) {
	fo.retry--;
	if(fo.retry > 0) {
		var timeout = oself.opts.retryTimeoutBase * (oself.opts.maxChunkRetries - fo.retry);
		setTimeout(function(){oself.uploadFile(fo)}, timeout);
	} else {
		oself.broadcast('onError', fo. lwu.ERROR_CODES.OTHER_ERROR);
	}
};


Для работы всего этого чуда на сервере должен быть установлен nginx с upload-модулем. Чуть подробнее об этом было написано в предыдущей статье.

Вместо послесловия...


Хочется высказать несколько мыслей:
1. На данный момент FileAPI поддерживают Chrome 8 и выше, Firefox 4 beta и частично Safari 5. Про внедрение поддержки в InternetExplorer и Opera мне ничего не известно.
Однако, Chrome 8 мы отключили из-за досадного бага, из-за которого нельзя выбрать много файлов в диалоге.
Firefox 3 поддерживает FileAPI по-своему, там нет поддержки насущно необходимого объекта FormData, поэтому загрузка больших файлов невозможна, т.к. требует чтения всего содержимого файла в память компьютера.
2. Атрибут accept работает очень коряво, много mime-типов браузеры просто не понимают. Поэтому для меня остается загадкой, почему фильтрация сделана именно так, а не по списку расширений, как это сделано в Flash и Silverlight.
3. Браузер Safari не реализует объект FileReader и метод Blob.slice, поэтому в нём не работает дозагрузка средствами html5. Т.к., дозагрузка — это очень полезная «плюшка», то мы поменяли в Safari порядок вызова загрузчиков, сделав Silverlight более предпочтительным.
4. Не совсем очевидно, но при использовании битовых операций Javascript преобразует операнды к типу signed int32. А т.к. для подсчета контрольной суммы Adler32 нужны беззнаковые числа, пришлось отказаться от битового сдвига влево и использовать умножение на 65536.
5. Нужно делать URI-кодирование имени файла на клиенте и декодирование на сервере, т.к. имя попадает в заголовок Content-Disposition, а заголовки не должны по стандарту содержать не-ASCII символы.
6. Обязательно нужно предупреждать пользователей о необходимости отключения плагина Firebug или ему подобных и вот почему: Firebug на вкладке Сеть логирует всю сетевую активность и полностью сохраняет все запросы, а т.к. наши запросы небольшие по размеру, то встроенный ограничитель плагина не срабатывает и на больших файлах мы можем получить большое потребление памяти браузером.

Дмитрий Дедюхин, ведущий разработчик Файлы@Mail.Ru
Tags:mail.ruhtml5uploadernginxFile API
Hubs: Mail.ru Group corporate blog
+98
42.6k 213
Comments 32
Popular right now