Obnovitelné nahrávání souboru

S fetch je poměrně snadné nahrát soubor.

Jak obnovit nahrávání po ztrátě připojení? Neexistuje pro to žádná vestavěná možnost, ale máme součásti, které ji implementují.

Obnovitelné nahrávání by mělo být doprovázeno indikací průběhu nahrávání, protože očekáváme velké soubory (pokud budeme muset pokračovat). Takže jako fetch neumožňuje sledovat průběh nahrávání, použijeme XMLHttpRequest.

Nepříliš užitečná událost postupu

Abychom obnovili nahrávání, potřebujeme vědět, kolik bylo nahráno do ztráty připojení.

Je tam xhr.upload.onprogress sledovat průběh nahrávání.

Bohužel nám nepomůže obnovit nahrávání zde, protože se spustí, když jsou data odeslána , ale bylo přijato serverem? Prohlížeč to neví.

Možná byla uložena do vyrovnávací paměti místním síťovým proxy, nebo možná proces vzdáleného serveru prostě umřel a nemohl je zpracovat, nebo se prostě ztratil uprostřed a nedostal se k přijímači.

Proto je tato událost užitečná pouze pro zobrazení pěkného ukazatele průběhu.

K obnovení nahrávání potřebujeme vědět přesně počet bajtů přijatých serverem. A to může říct pouze server, takže podáme další požadavek.

Algoritmus

  1. Nejprve vytvořte ID souboru, které bude jednoznačně identifikovat soubor, který budeme nahrávat:

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

    To je potřeba pro obnovení nahrávání, abychom serveru řekli, co obnovujeme.

    Pokud se změní název nebo velikost nebo datum poslední úpravy, bude zde další fileId .

  2. Odešlete na server požadavek s dotazem, kolik bajtů již má, takto:

    let response = await fetch('status', {
      headers: {
        'X-File-Id': fileId
      }
    });
    
    // The server has that many bytes
    let startByte = +await response.text();

    To předpokládá, že server sleduje nahrávání souborů pomocí X-File-Id záhlaví. Mělo by být implementováno na straně serveru.

    Pokud soubor na serveru ještě neexistuje, měla by být odpověď serveru 0

  3. Pak můžeme použít Blob metoda slice pro odeslání souboru z startByte :

    xhr.open("POST", "upload", true);
    
    // File id, so that the server knows which file we upload
    xhr.setRequestHeader('X-File-Id', fileId);
    
    // The byte we're resuming from, so the server knows we're resuming
    xhr.setRequestHeader('X-Start-Byte', startByte);
    
    xhr.upload.onprogress = (e) => {
      console.log(`Uploaded ${startByte + e.loaded} of ${startByte + e.total}`);
    };
    
    // file can be from input.files[0] or another source
    xhr.send(file.slice(startByte));

    Zde pošleme serveru obě ID souboru jako X-File-Id , takže ví, který soubor nahráváme, a počáteční bajt je X-Start-Byte , takže ví, že jej nenahráváme zpočátku, ale obnovujeme.

    Server by měl zkontrolovat své záznamy a zda došlo k nahrání tohoto souboru a aktuální nahrávaná velikost je přesně X-Start-Byte a poté k němu připojte data.

Zde je ukázka s klientským i serverovým kódem napsaná v Node.js.

Na tomto webu to funguje jen částečně, protože Node.js je za jiným serverem jménem Nginx, který ukládá nahrávání do vyrovnávací paměti a po úplném dokončení je předává Node.js.

Ale můžete si jej stáhnout a spustit lokálně pro úplnou ukázku:

Resultsserver.jsuploader.jsindex.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();
  }

  // we'll files "nowhere"
  let filePath = '/dev/null';
  // could use a real path instead, e.g.
  // let filePath = path.join('/tmp', fileId);

  debug("onUpload fileId: ", fileId);

  // initialize a new upload
  if (!uploads[fileId]) uploads[fileId] = {};
  let upload = uploads[fileId];

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

  let fileStream;

  // if startByte is 0 or not set, create a new file, otherwise check the size and append to existing one
  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;
    }
    // append to existing file
    fileStream = fs.createWriteStream(filePath, {
      flags: 'a'
    });
    debug("File reopened: " + filePath);
  }


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

  // send request body to file
  req.pipe(fileStream);

  // when the request is finished, and all its data is written
  fileStream.on('close', function() {
    if (upload.bytesReceived == req.headers['x-file-size']) {
      debug("Upload finished");
      delete uploads[fileId];

      // can do something else with the uploaded file here

      res.end("Success " + upload.bytesReceived);
    } else {
      // connection lost, we leave the unfinished file around
      debug("File unfinished, stopped at " + upload.bytesReceived);
      res.end();
    }
  });

  // in case of I/O error - finish the request
  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;

    // create fileId that uniquely identifies the file
    // we could also add user session identifier (if had one), to make it even more unique
    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);

    // send file id, so that the server knows which file to resume
    xhr.setRequestHeader('X-File-Id', this.fileId);
    // send the byte we're resuming from, so the server knows we're resuming
    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));

    // return
    //   true if upload was successful,
    //   false if aborted
    // throw in case of an error
    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 triggers only when xhr.abort() is called
      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="Upload (Resumes automatically)">
</form>

<button onclick="uploader.stop()">Stop upload</button>


<div id="log">Progress indication</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>

Jak vidíme, moderní síťové metody jsou svými možnostmi blízké správcům souborů – kontrola nad hlavičkami, indikátor průběhu, odesílání částí souboru atd.

Můžeme implementovat obnovitelné nahrávání a mnoho dalšího.