fetch
メソッドを使用すると、ファイルをアップロードするのは非常に簡単です。
接続が失われた後にアップロードを再開するにはどうすればよいでしょうか? それには組み込みのオプションはありませんが、それを実装するための要素は揃っています。
再開可能なアップロードは、(再開が必要になるかもしれないので) 大きなファイルを想定しているので、アップロードの進行状況表示とセットになるはずです。そのため、fetch
ではアップロードの進行状況を追跡できないので、XMLHttpRequestを使用します。
それほど役立たない進行状況イベント
アップロードを再開するには、接続が失われるまでにどれだけアップロードされたかを知る必要があります。
アップロードの進行状況を追跡するためのxhr.upload.onprogress
があります。
残念ながら、ここでアップロードを再開するのに役立ちません。これはデータが送信されたときにトリガーされるものであり、サーバーが受信したかどうかはわからないためです。ブラウザはそれを知りません。
おそらくローカルネットワークプロキシによってバッファリングされたか、リモートサーバープロセスがダウンして処理できなかったか、途中で失われて受信機に到達しなかった可能性があります。
そのため、このイベントは、良いプログレスバーを表示するのにのみ役立ちます。
アップロードを再開するには、サーバーが受信したバイト数を正確に知る必要があります。そして、サーバーだけがそれを伝えられるため、追加のリクエストを行います。
アルゴリズム
-
まず、アップロードするファイルを一意に識別するためにファイルIDを作成します。
let fileId = file.name + '-' + file.size + '-' + file.lastModified;
それは、サーバーに何を再開しようとしているかを伝えるために、アップロードを再開するために必要です。
名前、サイズ、または最終更新日が変更されると、別の
fileId
になります。 -
サーバーにリクエストを送信し、以下のように、すでに何バイトあるかを確認します。
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
である必要があります -
次に、
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 に渡すため、このサイトでは部分的にのみ機能します。
しかし、完全にデモを行うためにダウンロードしてローカルで実行できます。
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>
ご覧のとおり、最新のネットワークメソッドは、ヘッダーの制御、進行状況インジケーター、ファイルパーツの送信など、ファイルマネージャーの機能に近づいています。
再開可能なアップロードなどを実装できます。
コメント
<code>
タグを使用し、複数行の場合は<pre>
タグで囲み、10行以上の場合はサンドボックスを使用します(plnkr、jsbin、codepen…)