2 ноября 2022 г.

Возобновляемая загрузка файлов

При помощи fetch достаточно просто отправить файл на сервер.

Но как возобновить загрузку, если соединение прервалось? Для этого нет готовой настройки, но у нас есть все средства, чтобы решить эту задачу самостоятельно.

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

Не очень полезное событие progress

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

Можно установить обработчик xhr.upload.onprogress, чтобы отслеживать процесс загрузки, но, к сожалению, это бесполезно, так как этот обработчик вызывается, только когда данные отправляются, но были ли они получены сервером? Браузер этого не знает.

Возможно, отправленные данные оказались в буфере прокси-сервера локальной сети или удалённый сервер просто отключился и не смог принять их, или данные потерялись где-то по пути при разрыве соединения и так и не достигли пункта назначения.

В общем, событие progress подходит только для того, чтобы показывать красивый индикатор загрузки, не более.

Для возобновления же загрузки нужно точно знать, сколько байт было получено сервером. И только сам сервер может это сказать, поэтому будем делать для этого отдельный запрос.

Алгоритм

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

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

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

    Если имя или размер или дата модификация файла изменятся, то у него уже будет другой fileId.

  2. Далее, посылаем запрос к серверу с просьбой указать количество уже полученных байтов:

    let response = await fetch('status', {
      headers: {
        'X-File-Id': fileId
      }
    });
    
    // сервер получил столько-то байтов
    let startByte = +await response.text();

    Предполагается, что сервер учитывает загружаемые файлы с помощью заголовка X-File-Id. Это на стороне сервера должно быть реализовано.

    Если файл серверу неизвестен, то он должен ответить 0.

  3. Затем мы можем использовать метод slice объекта Blob, чтобы отправить данные, начиная со startByte байта:

    xhr.open("POST", "upload");
    
    // Идентификатор файла, чтобы сервер знал, что мы загружаем
    xhr.setRequestHeader('X-File-Id', fileId);
    
    // Номер байта, начиная с которого мы будем отправлять данные.
    // Таким образом, сервер поймёт, с какого момента мы возобновляем загрузку
    xhr.setRequestHeader('X-Start-Byte', startByte);
    
    xhr.upload.onprogress = (e) => {
      console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    };
    
    // файл file может быть взят из input.files[0] или другого источника
    xhr.send(file.slice(startByte));

    Здесь мы посылаем серверу и идентификатор файла в заголовке X-File-Id, чтобы он знал, что мы загружаем, и номер стартового байта в заголовке X-Start-Byte, чтобы он понял, что мы продолжаем отправку, а не начинаем её с нуля.

    Сервер должен проверить информацию на своей стороне, и если обнаружится, что такой файл уже когда-то загружался, и его текущий размер равен значению из заголовка X-Start-Byte, то вновь принимаемые данные добавлять в этот файл.

Ниже представлен демо-код как для сервера (Node.js), так и для клиента.

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

Но вы можете скачать код и запустить его локально, чтобы увидеть полный пример в действии:

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

let uploads = Object.create(null);

function onUpload(req, res) {

  let fileId = req.headers['x-file-id'];
  let startByte = parseInt(req.headers['x-start-byte']);

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

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

  debug("onUpload fileId: ", fileId);

  // инициируем новую загрузку
  if (!uploads[fileId]) uploads[fileId] = {};
  let upload = uploads[fileId];

  debug("bytesReceived:" + upload.bytesReceived + " startByte:" + startByte)

  let fileStream;

  // если стартовый байт 0 или не указан - создаём новый файл, иначе проверяем размер и добавляем данные к уже существующему файлу
  if (!startByte) {
    upload.bytesReceived = 0;
    fileStream = fs.createWriteStream(filePath, {
      flags: 'w'
    });
    debug("New file created: " + filePath);
  } else {
    // we can check on-disk file size as well to be sure
    if (upload.bytesReceived != startByte) {
      res.writeHead(400, "Wrong start byte");
      res.end(upload.bytesReceived);
      return;
    }
    // добавить к существующему файлу
    fileStream = fs.createWriteStream(filePath, {
      flags: 'a'
    });
    debug("File reopened: " + filePath);
  }


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

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

  // когда обработка запроса завершена и все данные записаны
  fileStream.on('close', function() {
    if (upload.bytesReceived == req.headers['x-file-size']) {
      debug("Upload finished");
      delete uploads[fileId];

      // мы можем сделать что-то ещё с загруженным файлом

      res.end("Success " + upload.bytesReceived);
    } else {
      // соединение потеряно, остаются незавершённые загрузки
      debug("File unfinished, stopped at " + upload.bytesReceived);
      res.end();
    }
  });

  // в случае ошибки ввода/вывода завершаем обработку запроса
  fileStream.on('error', function(err) {
    debug("fileStream error");
    res.writeHead(500, "File error");
    res.end();
  });

}

function onStatus(req, res) {
  let fileId = req.headers['x-file-id'];
  let upload = uploads[fileId];
  debug("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('Server listening at port 8080');
} else {
  exports.accept = accept;
}
class Uploader {

  constructor({file, onProgress}) {
    this.file = file;
    this.onProgress = onProgress;

    // создаём уникальный идентификатор файла
    // для большей уникальности мы также могли бы добавить идентификатор пользовательской сессии (если она есть)
    this.fileId = file.name + '-' + file.size + '-' + +file.lastModifiedDate;
  }

  async getUploadedBytes() {
    let response = await fetch('status', {
      headers: {
        'X-File-Id': this.fileId
      }
    });

    if (response.status != 200) {
      throw new Error("Can't get uploaded bytes: " + response.statusText);
    }

    let text = await response.text();

    return +text;
  }

  async upload() {
    this.startByte = await this.getUploadedBytes();

    let xhr = this.xhr = new XMLHttpRequest();
    xhr.open("POST", "upload", true);

    // Идентификатор файла, чтобы сервер знал, что мы загружаем
    xhr.setRequestHeader('X-File-Id', this.fileId);

    // Номер байта, начиная с которого мы будем отправлять данные.
    // Таким образом, сервер поймёт, с какого момента мы возобновляем загрузку
    xhr.setRequestHeader('X-Start-Byte', this.startByte);

    xhr.upload.onprogress = (e) => {
      this.onProgress(this.startByte + e.loaded, this.startByte + e.total);
    };

    console.log("send the file, starting from", this.startByte);
    xhr.send(this.file.slice(this.startByte));

    // возвращаем
    //   true, если загрузка успешно завершилась
    //   false, если она отменена
    // выбрасываем исключение в случае ошибки
    return await new Promise((resolve, reject) => {

      xhr.onload = xhr.onerror = () => {
        console.log("upload end status:" + xhr.status + " text:" + xhr.statusText);

        if (xhr.status == 200) {
          resolve(true);
        } else {
          reject(new Error("Upload failed: " + xhr.statusText));
        }
      };

      // этот обработчик срабатывает, только когда вызывается xhr.abort()
      xhr.onabort = () => resolve(false);

    });

  }

  stop() {
    if (this.xhr) {
      this.xhr.abort();
    }
  }

}
<!DOCTYPE HTML>

<script src="uploader.js"></script>

<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.stop()">Остановить загрузку</button>


<div id="log">Показ прогресса</div>

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

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

  let uploader;

  document.forms.upload.onsubmit = async function(e) {
    e.preventDefault();

    let file = this.elements.myfile.files[0];
    if (!file) return;

    uploader = new Uploader({file, onProgress});

    try {
      let uploaded = await uploader.upload();

      if (uploaded) {
        log('success');
      } else {
        log('stopped');
      }

    } catch(err) {
      console.error(err);
      log('error');
    }
  };

</script>

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

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

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