XMLHttpRequest: возобновляемая закачка

Современный XMLHttpRequest даёт возможность загружать файл как угодно: во множество потоков, с догрузкой, с подсчётом контрольной суммы и т.п.

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

Поддержка – все браузеры кроме IE9-.

Неточный upload.onprogress

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

Есть же xhr.upload.onprogress – ставим на него обработчик, по свойству loaded события onprogress смотрим, сколько байт загрузилось. А при обрыве – возобновляем загрузку с последнего байта.

К счастью, отослать на сервер не весь файл, а только нужную часть его – не проблема, File API позволяет прочитать выбранный участок из файла и отправить его.

Примерно так:

var slice = file.slice(10, 100); // прочитать байты с 10-го по 99-й включительно

xhr.send(slice); // ... и отправить эти байты в запросе.

…Но такая модель не жизнеспособна!

Всё дело в том, что upload.onprogress срабатывает, когда байты отправлены, но были ли они получены сервером – браузер не знает. Может, их прокси-сервер забуферизовал, может серверный процесс «упал» в процессе обработки, может соединение порвалось и байты так и не дошли до получателя.

Поэтому onprogress годится лишь для красивенького рисования прогресса.

Для загрузки нам нужно точно знать количество загруженных байт. Это может сообщить только сервер.

Алгоритм возобновляемой загрузки

Загрузкой файла будет заведовать объект Uploader, его примерный общий вид:

function Uploader(file, onSuccess, onFail, onProgress) {

  var fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;

  var errorCount = 0;

  var MAX_ERROR_COUNT = 6;

  function upload() {
    ...
  }

  function pause() {
    ...
  }

  this.upload = upload;
  this.pause = pause;
}
  • Аргументы для new Uploader:
file
Объект File API. Может быть получен из формы, либо как результат Drag’n’Drop.
onSuccess, onFail, onProgress
Функции-коллбэки, которые будут вызываться в процессе (`onProgress`) и при окончании загрузки.
  • Подробнее про важные данные, с которыми мы будем работать в процессе загрузки:
fileId
Уникальный идентификатор файла, генерируется по имени, размеру и дате модификации. По нему мы всегда сможем возобновить загрузку, в том числе и после закрытия и открытия браузера.
startByte
С какого байта загружать. Изначально – с нулевого.
errorCount / MAX_ERROR_COUNT
Текущее число ошибок / максимальное число ошибок подряд, после которого загрузка считается проваленной.

Алгоритм загрузки:

  1. Генерируем fileId из названия, размера, даты модификации файла. Можно добавить и идентификатор посетителя.
  2. Спрашиваем сервер, есть ли уже такой файл, и если да – сколько байт уже загружено?
  3. Отсылаем файл с позиции, которую сказал сервер.

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

Демо ниже, к сожалению, работает лишь частично, так как на этом сайте Node.JS стоит за сервером Nginx, который буферизует все закачки, не передавая их в Node.JS до полного завершения.

Вы можете скачать пример и запустить локально для полноценной демонстрации:

Результат
server.js
uploader.js
index.html
var http = require('http');
var static = require('node-static');
var fileServer = new static.Server('.');
var path = require('path');
var fs = require('fs');

var uploads = {};

function onUpload(req, res) {

  var fileId = req.headers['x-file-id'];
  var startByte = req.headers['x-start-byte'];

  if (!fileId) {
    res.writeHead(400, "No file id");
    res.end();
  }

  // файлы будем записывать "в никуда"
  var filePath = '/dev/null';
  // можно положить файл и в реальное место
  // var filePath = path.join('/tmp', fileId);

  console.log("onUpload fileId: ", fileId);

  // инициализация новой загрузки
  if (!uploads[fileId]) uploads[fileId] = {};
  var upload = uploads[fileId];

  console.log("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte)

  // если байт 0, то создать новый файл, иначе проверить размер и дописать
  if (startByte == 0) {
    upload.bytesReceived = 0;
    var fileStream = fs.createWriteStream(filePath, {
      flags: 'w'
    });
    console.log("New file created: " + filePath);
  } else {
    if (upload.bytesReceived != startByte) {
      res.writeHead(400, "Wrong start byte");
      res.end(upload.bytesReceived);
      return;
    }
    // добавляем в существующий файл
    fileStream = fs.createWriteStream(filePath, {
      flags: 'a'
    });
    console.log("File reopened: " + filePath);
  }


  req.on('data', function(data) {
    upload.bytesReceived += data.length;
  });

  // отправить тело запроса в файл
  req.pipe(fileStream);

  // в конце -- событие end
  fileStream.on('close', function() {
    if (upload.bytesReceived == req.headers['x-file-size']) {
      // полностью загрузили
      console.log("File finished");
      delete uploads[fileId];

      // при необходимости - обработать завершённую загрузку файла

      res.end("Success " + upload.bytesReceived);
    } else {
      // соединение оборвано, дескриптор закрылся но файл оставляем
      console.log("File unfinished, stopped at " + upload.bytesReceived);
      res.end();
    }
  });

  // при ошибках - завершение запроса
  fileStream.on('error', function(err) {
    console.log("fileStream error");
    res.writeHead(500, "File error");
    res.end();
  });

}

function onStatus(req, res) {
  var fileId = req.headers['x-file-id'];
  var upload = uploads[fileId];
  console.log("onStatus fileId:", fileId, " upload:", upload);
  if (!upload) {
    res.end("0")
  } else {
    res.end(String(upload.bytesReceived));
  }
}


function accept(req, res) {
  if (req.url == '/status') {
    onStatus(req, res);
  } else if (req.url == '/upload' && req.method == 'POST') {
    onUpload(req, res);
  } else {
    fileServer.serve(req, res);
  }

}




// -----------------------------------

if (!module.parent) {
  http.createServer(accept).listen(8080);
  console.log('Сервер запущен на порту 8080');
} else {
  exports.accept = accept;
}
function Uploader(file, onSuccess, onFail, onProgress) {

  // fileId уникальным образом идентифицирует файл
  // можно добавить идентификатор сессии посетителя, но он и так будет в заголовках
  var fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;

  // сделать из fileId число (хеш, алгоритм неважен), мы будем передавать его в заголовке,
  // в заголовках разрешены только ASCII-символы
  fileId = hashCode(fileId);

  var errorCount = 0;

  // если количество ошибок подряд превысит MAX_ERROR_COUNT, то стоп
  var MAX_ERROR_COUNT = 6;

  var startByte = 0;

  var xhrUpload;
  var xhrStatus;

  function upload() {
    console.log("upload: check status");
    xhrStatus = new XMLHttpRequest();

    xhrStatus.onload = xhrStatus.onerror = function() {

      if (this.status == 200) {
        startByte = +this.responseText || 0;
        console.log("upload: startByte=" + startByte);
        send();
        return;
      }

      // что-то не так
      if (errorCount++ < MAX_ERROR_COUNT) {
        setTimeout(upload, 1000 * errorCount); // через 1 сек пробуем ещё раз
      } else {
        onError(this.statusText);
      }

    };

    xhrStatus.open("GET", "status", true);
    xhrStatus.setRequestHeader('X-File-Id', fileId);
    xhrStatus.send();
  }


  function send() {

    xhrUpload = new XMLHttpRequest();
    xhrUpload.onload = xhrUpload.onerror = function() {
      console.log("upload end status:" + this.status + " text:" + this.statusText);

      if (this.status == 200) {
        // успешное завершение загрузки
        onSuccess();
        return;
      }

      // что-то не так
      if (errorCount++ < MAX_ERROR_COUNT) {
        setTimeout(resume, 1000 * errorCount); // через 1,2,4,8,16 сек пробуем ещё раз
      } else {
        onError(this.statusText);
      }
    };

    xhrUpload.open("POST", "upload", true);
    // какой файл догружаем /загружаем
    xhrUpload.setRequestHeader('X-File-Id', fileId);

    xhrUpload.upload.onprogress = function(e) {
      errorCount = 0;
      onProgress(startByte + e.loaded, startByte + e.total);
    }

    // отослать, начиная с байта startByte
    xhrUpload.send(file.slice(startByte));
  }

  function pause() {
    xhrStatus && xhrStatus.abort();
    xhrUpload && xhrUpload.abort();
  }


  this.upload = upload;
  this.pause = pause;
}

// вспомогательная функция: получение 32-битного числа из строки

function hashCode(str) {
  if (str.length == 0) return 0;

  var hash = 0,
    i, chr, len;
  for (i = 0; i < str.length; i++) {
    chr = str.charCodeAt(i);
    hash = ((hash << 5) - hash) + chr;
    hash |= 0; // Convert to 32bit integer
  }
  return hash;
};
<!DOCTYPE HTML>
<html>

<body>

  <head>
    <meta charset="utf-8">
    <script src="uploader.js"></script>
  </head>
  <form name="upload" method="POST" enctype="multipart/form-data" action="/upload">
    <input type="file" name="myfile">
    <input type="submit" name="submit" value="Загрузить">
  </form>

  <button onclick="uploader.pause()">Пауза</button>


  <div id="log">Индикация прогресса</div>

  <script>
    function log(html) {
      document.getElementById('log').innerHTML = html;
      //console.log(html);
    }

    function onSuccess() {
      log('success');
    }

    function onError() {
      log('error');
    }

    function onProgress(loaded, total) {
      log("progress " + loaded + ' / ' + total);
    }

    var uploader;

    document.forms.upload.onsubmit = function() {
      var file = this.elements.myfile.files[0];
      if (!file) return false;

      uploader = new Uploader(file, onSuccess, onError, onProgress);
      uploader.upload();
      return false;
    }
  </script>
</body>

</html>

Полный код включает также сервер на Node.JS с функциям onUpload – начало и возобновление загрузки, а также onStatus – для получения состояния загрузки.

Итого

Мы рассмотрели довольно простой алгоритм возобновляемой загрузки.

Его можно усложнить:

  • добавить подсчёт контрольных сумм, проверку целостности пересылаемых файлов,
  • для индикации прогресса вместо неточного xhr.upload.onprogress – сделать дополнительный запрос к серверу, в который тот будет отдавать текущий прогресс.
  • разбивать файл на части и грузить в несколько потоков, несколькими параллельными запросами.

Как можно видеть, возможности современного XMLHttpRequest в плане загрузки файлов приближаются к полноценному файловому менеджеру – полный контроль над заголовками, индикатор прогресса и т.п.

Карта учебника

Комментарии

перед тем как писать…
  • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.