16 липня 2023 р.

Відновлюване завантаження файлу

За допомогою методу fetch досить легко завантажити файл.

Але як відновити завантаження після втрати з’єднання? Для цього немає вбудованого функціонала, але у нас є все необхідне для його реалізації.

Відновлювані завантаження файлів повинні мати індикацію прогресу, оскільки ми очікуємо завантаження великих файлів. Отже, оскільки fetch не дозволяє відстежувати хід завантаження на сервер, ми будемо використовувати XMLHttpRequest.

Не дуже корисна подія progress

Щоб відновити завантаження, нам потрібно знати, скільки даних було завантажено до втрати з’єднання.

Існує подія xhr.upload.onprogress, яка використовується для відстежування ходу завантаження на сервер.

Але, на жаль, вона нам не допоможе відновити завантаження, оскільки ця подія спрацьовує в момент, коли дані відсилаються, але чи отримав їх сервер? Браузер цього не знає.

Можливо, дані були буферизовані проксі-сервером локальної мережі, або, можливо, процес сервера просто завершився і не зміг їх обробити, або дані просто загубилися в процесі передачі і не досягли одержувача.

Тому ця подія корисна лише для того, щоб показати гарний індикатор прогресу.

Для відновлення завантаження, нам потрібно точно знати кількість байтів, отриманих сервером. І тільки сервер має цю інформацію, тому ми зробимо додатковий запит.

Алгоритм

  1. Спочатку створюємо ідентифікатор файлу, щоб однозначно ідентифікувати файл, який ми збираємося завантажити на сервер:

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

    Це потрібно, щоб повідомити серверу, для якого саме файлу ми відновлюємо завантаження.

    Якщо змінюється назва, розмір або дата останньої модифікації файлу, 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(`Завантажено ${startByte + e.loaded} з ${startByte + e.total}`);
    };
    
    // файл може бути з 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 = +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;

  // якщо startByte не встановлений або дорівнює 0, то створюємо новий файл, в противному випадку перевіряємо розмір і додаємо дані до наявного файлу
  if (!startByte) {
    upload.bytesReceived = 0;
    fileStream = fs.createWriteStream(filePath, {
      flags: 'w'
    });
    debug("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'
    });
    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;

    // створюємо fileId, який однозначно ідентифікує файл
    // ми також могли б додати ідентифікатор сесії користувача (якщо він є), щоб зробити його ще більш унікальним
    this.fileId = file.name + '-' + file.size + '-' + file.lastModified;
  }

  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 якщо перервано
    // throw в разі помилки
    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));
        }
      };

      // onabort запускається лише тоді, коли викликається 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("завантажується " + 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('успішно');
      } else {
        log('зупинено');
      }

    } catch(err) {
      console.error(err);
      log('помилка');
    }
  };

</script>

Як бачимо, сучасні мережеві методи за своїми можливостями близькі до файлових менеджерів – контроль над заголовками, індикатор прогресу, надсилання частин файлу тощо.

Ми можемо реалізувати як відновлюване завантаження файлів, так і багато іншого.

Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)