2022年10月13日

再開可能なファイルアップロード

fetchメソッドを使用すると、ファイルをアップロードするのは非常に簡単です。

接続が失われた後にアップロードを再開するにはどうすればよいでしょうか? それには組み込みのオプションはありませんが、それを実装するための要素は揃っています。

再開可能なアップロードは、(再開が必要になるかもしれないので) 大きなファイルを想定しているので、アップロードの進行状況表示とセットになるはずです。そのため、fetchではアップロードの進行状況を追跡できないので、XMLHttpRequestを使用します。

それほど役立たない進行状況イベント

アップロードを再開するには、接続が失われるまでにどれだけアップロードされたかを知る必要があります。

アップロードの進行状況を追跡するためのxhr.upload.onprogressがあります。

残念ながら、ここでアップロードを再開するのに役立ちません。これはデータが送信されたときにトリガーされるものであり、サーバーが受信したかどうかはわからないためです。ブラウザはそれを知りません。

おそらくローカルネットワークプロキシによってバッファリングされたか、リモートサーバープロセスがダウンして処理できなかったか、途中で失われて受信機に到達しなかった可能性があります。

そのため、このイベントは、良いプログレスバーを表示するのにのみ役立ちます。

アップロードを再開するには、サーバーが受信したバイト数を正確に知る必要があります。そして、サーバーだけがそれを伝えられるため、追加のリクエストを行います。

アルゴリズム

  1. まず、アップロードするファイルを一意に識別するためにファイルIDを作成します。

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

    それは、サーバーに何を再開しようとしているかを伝えるために、アップロードを再開するために必要です。

    名前、サイズ、または最終更新日が変更されると、別のfileIdになります。

  2. サーバーにリクエストを送信し、以下のように、すでに何バイトあるかを確認します。

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

    これは、サーバーがX-File-Idヘッダーでファイルアップロードを追跡することを前提としています。サーバー側で実装する必要があります。

    サーバーにまだファイルが存在しない場合、サーバーの応答は0である必要があります

  3. 次に、Blobメソッドsliceを使用して、startByteからファイルを送信できます。

    xhr.open("POST", "upload");
    
    // 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));

    ここでは、サーバーにX-File-Idとしてファイル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();
  }

  // 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>

ご覧のとおり、最新のネットワークメソッドは、ヘッダーの制御、進行状況インジケーター、ファイルパーツの送信など、ファイルマネージャーの機能に近づいています。

再開可能なアップロードなどを実装できます。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点について提案がある場合は、コメントする代わりにGitHub issueを送信するか、プルリクエストをしてください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • コードを数単語挿入するには、<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行以上の場合はサンドボックスを使用します(plnkrjsbincodepen…)