HTML5 Ajax upload дуже великих файлів

У цій невеликій нотатці розглянуто спосіб завантаження дуже великих файлів (більше 2Гб) через браузер. Метод наведений у цій замітці хороший тим, що:

  • Файл завантажується асинхронно. У цей час на сторінці з'являється індикатор завантаження (ProgressBar);
  • Файл завантажується частинами і не зчитується повністю на згадку ні сервером ні браузером клієнта;
  • Браузер повторює завантаження секції, якщо попередня спроба закінчилася невдало. Це дуже корисна властивість при завантаженні великих файлів по HTTP.

Поганий цей метод тим, що вимагає використання браузера на базі Gecko 2.0 або вище або WebKit. На даний момент це такі браузери як FireFox 4.0+, SeaMonkey 2.1+ або Google Chrome 11+.

Основна ідея

Файл зчитується частинами за допомогою методів slice(), mozSlice() або webkitSlice() об'єкта Blob [2]. Після цього кожна частина відправляється на сервер за допомогою sendAsBinary() об'єкта XMLHttpRequest [1]. Якщо частина з якоїсь причини не завантажилася, робиться спроба завантажити частину знову.

Варто звернути увагу, що метод slice() об'єкта Blob застарів починаючи з версії Gecko 2.0 (FireFox 4), метод mozSlice() доданий до Gecko 5.0 (FireFox 5) та метод webkitSlice() доданий Google Chrome 11.

Ще в Google Chrome немає методу sendAsBinary() об'єкта XMLHttpRequest.

fileuploader.js

// Для початку визначимо метод XMLHttpRequest.sendAsBinary(), // якщо він не визначений (Наприклад, для браузера Google Chrome).

if (! XMLHttpRequest. prototype. sendAsBinary)

XMLHttpRequest. prototype. sendAsBinary = function ( datastr ) < function byteValue ( x ) < return x. charCodeAt (0) & 0xff; > var ords = Array. prototype. map. call ( datastr , byteValue ); var ui8a = new Uint8Array (ords); this . send (ui8a. buffer); > >

/** * Клас FileUploader. * @param ioptions Асоціативний масив опцій завантаження */ function FileUploader ( ioptions )

// Позиція, з якої завантажуватимемо файл this . position = 0;

// Розмір файлу, що завантажується this . filesize = 0;

// Об'єкт Blob чи File (FileList[i]) this . file = null;

// Асоціативний масив опцій this. options = ioptions;

// Якщо не визначено опцію uploadscript, то повертаємо null. Не можна продовжувати, якщо ця опція не визначена. if (this. options ['uploadscript'] == undefined) return null;

/* * Перевірка, чи браузер підтримує необхідні об'єкти * @return true, якщо браузер підтримує всі необхідні об'єкти */ this . CheckBrowser = function ( ) < if ( window. File & window. FileReader &&window. FileList &&window. Blob ) return true ; else return false; >

/* * Завантаження частини файлу на сервер * @param from Позиція, з якої будемо завантажувати файл */ this . UploadPortion = function ( from )

// Об'єкт FileReader, в нього будемо зчитувати частину файлу, що завантажується var reader = new FileReader ( ) ;

// Поточний об'єкт var that = this;

// Позиція з якою завантажуватимемо файл var loadfrom = from ;

// Об'єкт Blob, для часткового зчитування файлу var blob = null;

// Таймаут для функції setTimeout. За допомогою цієї функції реалізовано повторну спробу завантаження // по таймууту (що не зовсім коректно) var xhrHttpTimeout = null ;

/* * Подія, що спрацьовує після читання частини файлу в FileReader * @param evt Подія */ reader. onloadend = function ( evt ) < if ( evt. target . readyState == FileReader. DONE )

// Ідентифікатор завантаження (щоб знати за сервера що з чим склеювати) xhr. setRequestHeader ("Upload-Id", that. options ['uploadid']); // Позиція почала у файлі xhr. setRequestHeader ("Portion-From", from); // Розмір порції xhr. setRequestHeader ("Portion-Size", that. options [ 'portion']));

// Встановимо тайм-аут that. xhrHttpTimeout = setTimeout ( function ( ) < xhr.

/* * Подія XMLHttpRequest.onProcess. Відображення ProgressBar. * @param evt Подія */ xhr. upload. addEventListener ( "progress" , function ( evt ) < if ( evt. lengthComputable )

// Порахуємо кількість закачаного у відсотках (з точність до 0.1) var percentComplete = Math. round ((loadfrom + evt. loaded) * 1000/that. filesize); %Complete /= 10;

// Порахуємо ширину синьої смужки ProgressBar var width = Math. round ((loadfrom + evt. loaded) * 300/that. filesize);

// Змінимо властивості елементом ProgressBar'а, додамо до нього текст var div1 = document. getElementById('cnuploader_progressbar'); var div2 = document. getElementById('cnuploader_progresscomplete');

div1. style. display = 'block'; div2. style. display = 'block'; div2. style. width = width + 'px'; if (%Complete that. position) < що. UploadPortion (that. position); > else < // Якщо всі порції завантажені, повідомимо про це сервер. XMLHttpRequest, метод GET, PHP скрипт той же. var gxhr = новий XMLHttpRequest ( ) ; gxhr. open ('GET', що. options [ 'uploadscript'] + '?action=done', true);

// Встановимо ідентифікатор завантаження. gxhr. setRequestHeader ("Upload-Id", that. options ['uploadid']);

/* * Подія XMLHttpRequest.onLoad. Закінчення завантаження повідомлення про закінчення завантаження :). * @param evt Подія */ gxhr. addEventListener ( "load" , function ( evt )

// Якщо сервер не повернув статус HTTP 200, то виведемо вікно з повідомленням сервера. if ( evt. target . status ! = 200 ) < alert ( evt. target . responseText . toString ( ) ) ; return; > // Якщо все нормально, то відправимо користувача далі. Там може бути повідомлення // про успішне завантаження або наступний крок форми з додатковими полями. else window. parent. location = що. options [ 'redirect_success'] ; > , false);

// Відправимо HTTP GET запит gxhr. sendAsBinary (''); > > , false);

/* * Подія XMLHttpRequest.onError. Помилка під час завантаження * @param evt Подія */ xhr. addEventListener ( "error" , function ( evt )

// Очистимо тайм clearTimeout (that. xhrHttpTimeout);

// Повідомимо сервер про помилку під час завантаження, сервер зможе видалити вже завантажені частини. // XMLHttpRequest, метод GET, PHP скрипт той же. var gxhr = новий XMLHttpRequest ( ) ;

gxhr. open ( 'GET', that. options [ 'uploadscript'] + '?action=abort', true);

// Встановимо ідентифікатор завантаження. gxhr. setRequestHeader ("Upload-Id", that. options ['uploadid']);

/* * Подія XMLHttpRequest.onLoad. Закінчення завантаження повідомлення про помилку завантаження:). * @param evt Подія */ gxhr. addEventListener ( "load" , function ( evt )

// Якщо сервер неповернув HTTP статус 200, то виведемо вікно із повідомленням сервера. if ( evt. target . status ! = 200 ) < alert ( evt. target . responseText ) ; return; > > , false);

// Відправимо HTTP GET запит gxhr. sendAsBinary ('');

// Відобразимо повідомлення про помилку if (that. options [ 'message_error'] == undefined ) alert ("There was an error attempting to upload the file."); else alert (that. options ['message_error']); > , false);

/* * Подія XMLHttpRequest.onAbort. Якщо з якоїсь причини перервано передачу, повторимо спробу. * @param evt Подія */ xhr. addEventListener ( "abort", function (evt) < clearTimeout (that. xhrHttpTimeout); that. UploadPortion (that. position); >, false);

// Відправимо порцію методом POST xhr. sendAsBinary ( evt. target . result ) ; > > ;

що. blob = null;

// Вважаємо порцію об'єкт Blob. Три умови для трьох можливих визначень Blob.[.*] slice (). if (this. file. slice) that. blob = this. file. slice ( from , from + that. options [ ' portion ' ] ) ; else < if (this. file. webkitSlice) that. blob = this. file. webkitSlice ( from , from + that. options [ 'portion'] ) ; else < if (this. file. mozSlice) that. blob = this. file. mozSlice ( from , from + that. options [ ' portion ' ] ) ; > >

// Вважаємо Blob (частина файлу) у FileReader reader. readAsBinaryString (that. blob); >

/* * Завантаження файлу на сервер * return Число. Якщо не 0, то сталася помилка */ this . Upload = function ( )

// Прихуємо форму, щоб користувач не відправив файл двічі var e = document. getElementById (this . options ['form']); if (e) e. style. display = 'none';

if(!this.file) return-1; else

// Якщо розмір файлу більше розміру порції і обмежимося однією порцією if (this. filesize & this. options [ 'portion'])) this. UploadPortion (0, this. options ['portion']));

// Інакше відправимо файл цілком else this. UploadPortion ( 0 , this . filesize ) ; > >

if ( this . CheckBrowser ( ) )

// Встановимо значення за умовчанням if (this. options ['portion'] == undefined) this. options [ 'portion'] = 1048576; if ( this . options [ 'timeout' ] == undefined ) this . options [ 'timeout'] = 15000;

// Додамо обробку події вибору файлу document. getElementById (this. options ['formfiles']). addEventListener('change', function(evt)

var files = evt. target. files;

// Виберемо тільки перший файл for (var i = 0, f; f = files [i]; i ++) < що. filesize = f. size; що. file = f; break; > > , false);

// Додамо обробку події на Submit форми документ. getElementById (this. options ['form']). addEventListener('submit', function(evt) < that.Upload(); (arguments [0]. preventDefault)? , false); >

PHP скрипт працює на стороні сервера. Його завдання - приймати та склеювати порції. Після закінчення передачі всіх порцій скрипт перейменовує файл і створює прапор, що сигналізує про те, що файл готовий до обробки. У моєму випадку окремий демон перевіряє цей прапор і бере файли "в обіг".

// Каталог у який завантажуватиметься файл $uploaddir = "./uploaddir";

// Ідентифікатор завантаження (аплоада). Для генерації ідентифікатора зазвичай використовую функцію md5() $hash = $_SERVER [ "HTTP_UPLOAD_ID" ] ;

// Інформацію про хід завантаження збережемо в системний лог, це дозволити вирішувати проблеми оперативніше openlog ("html5upload.php", LOG_PID LOG_PERROR, LOG_LOCAL0);

// Перевіримо коректність ідентифікатора if ( preg_match ( "/^[0123456789abcdef]$/i" , $hash ) )

// Якщо HTTP запит зроблено методом GET, це не завантаження порції, а пост-обробка if ( $_SERVER [ "REQUEST_METHOD" ] == "GET" )

// abort - зітр завантажується файл. Завантаження не вдалося. if ( $_GET [ "action" ] == "abort" ) < if (is_file ($uploaddir. "/". $hash. ".html5upload")) unlink ($uploaddir. "/". $hash. ".html5upload"); print "ok abort"; return; >

// done - Завантаження завершено успішно. Перейменуємо файл та створимо файл-прапор. if ( $_GET [ "action" ] == "done" ) < syslog (LOG_INFO, "Finished for hash". $ hash);

// Якщо файл існує, то видалимо його if ( is_file ( $uploaddir . "/" . $hash . ".original" ) ) unlink ( $uploaddir . "/" . $hash . ".original" ) ;

// Перейменуємо завантажуваний файл rename ($uploaddir. "/". $ hash. ".html5upload", $uploaddir. "/". $ hash. ".

// Створимо файл-прапор $fw = fopen ($uploaddir. "/". $hash. ".original_ready", "wb"); if ($ fw) fclose ($ fw); > >

// Якщо HTTP запит зроблено методом POST, це завантаження порції elseif ( $_SERVER [ "REQUEST_METHOD" ] == "POST" )

syslog ( LOG_INFO , "Uploading chunk. Hash " . $hash . " (" . intval ( $_SERVER [ "HTTP_PORTION_FROM" ] ) ) . "-" . intval ( $_SERVER ["HTTP_PORTION_FROM" ] + $_SERVER [ "HTTP_PORTION_SIZE" ] ) . ", size: ". intval ( $_SERVER [ "HTTP_PORTION_SIZE" ] ) . ")");

// Ім'я файлу отримаємо з ідентифікатора завантаження $filename = $uploaddir . "/". $hash . ".html5upload";

// Якщо завантажується перша порція, то відкриємо файл для запису, а то й перша, то дозапису. if ( intval ( $_SERVER [ "HTTP_PORTION_FROM" ] ) == 0 ) $fout = fopen ( $filename , "wb" ) ; else $ fout = fopen ($ filename, "ab");

// Якщо не змогли відкрити файл на запис, видаємо повідомлення про помилку if ( ! $fout ) < syslog ( LOG_INFO , " Can't open file for writing: " . $filename ) ; header ("HTTP/1.0 500 Internal Server Error"); print "Змінити файл для написання." ; return; >

// З stdin читаємо дані відправлені методом POST - це і є вміст порцій $ fin = fopen ( "php://input", "rb"); if ($fin) < while (! feof ($ fin)) < // Вважаємо 1Мб з stdin $ data = fread ($ fin, 1024 * 1024); // Збережемо лічені дані у файл fwrite ($ fout, $ data); > fclose ($ fin); >

// Все нормально, повернемо HTTP 200 і тіло відповіді "ok" header ("HTTP/1.0 200 OK"); print "ok \n"; > else < // Якщо неправильний ідентифікатор завантаження, то повернемо HTTP 500 і повідомлення про помилку syslog (LOG_INFO, "Uploading chunk. Wrong hash". $ hash); header ("HTTP/1.0 500 Internal Server Error"); print "Wrong session hash." ; >

// Закриємо syslog лог closelog ();

Файл із HTML формою та ініціалізацією скрипту завантаження.

Заголовок HTML документа. Тут, наприклад, замінено ідентифікатор завантаження. Ви можете генерувати його на своєрозсуд.

Таблиця стилів для індикатора прогресу (PorgressBar)