大容量ファイルをアップロードしていたら、通信が切れた。ブラウザーが落ちた。待ち時間が長くてユーザーが離脱した。小さなファイルでは気にならなかった問題が、大きなファイルになると一気に出てきます。
「大容量ファイルのアップロード」と聞くと、少し聞き慣れた話に感じるかもしれません。分割アップロードや中断再開という言葉も、すでに何度も見たことがあると思います。ただ、実際に自分で作ろうとすると話は別です。チャンクサイズはどう決めるのか。進捗はどう計算するのか。サーバー側ではどう結合するのか。途中で止まったら、どこから再開するのか。
今回は、こうした疑問を一つずつコードで追いながら、大容量ファイルアップロードを最初から実装していきます。中心になるのは「ファイルを分けて送る」ことです。React + TypeScriptでフロントエンドを作り、Express側でチャンクを受信・結合し、重複アップロードのスキップと中断再開まで実装します。
なぜ大容量ファイルのアップロードはこんなに難しいのか
普段、APIでプロフィール画像やドキュメントを送る程度なら、ファイルサイズは数MBほどです。FormDataへ入れて、そのまま送ってしまえば終わります。
const formData = new FormData();
formData.append("file", file);
await axios.post("/api/upload", formData);
ところが、500MBの動画や2GBのデザインデータになると、急に問題が増えます。通信が途中で切れたり、リクエストがタイムアウトしたり、一度失敗しただけで最初から送り直すことになったりします。
考え方自体はシンプルです。大きなファイルを小さなチャンクへ分け、一つずつ送り、最後にサーバー側で結合する。大きな家具をそのまま運ぶのではなく、部品に分けて運び、到着してから組み立てるのと同じです。
500MBのファイル
↓
複数のチャンクへ分割
↓
チャンクごとにアップロード
↓
サーバー側で順番に結合
↓
元のファイルを復元
こうすると、1回のリクエスト時間を短くでき、失敗したチャンクだけを再送できます。さらに、複数のチャンクを並列で送れば、1個ずつ順番に送るより待ち時間も減らせます。
前提知識:File・Blob・ArrayBufferを整理する
チャンク分割のコードへ進む前に、ブラウザーでファイルを扱うときによく出てくるFile、Blob、ArrayBufferを整理しておきます。ここが曖昧なままだと、あとでslice()やArrayBufferが出てきたところで分かりにくくなります。
Blob:バイナリデータを入れる容器
BlobはBinary Large Objectの略で、簡単に言えば元のデータを入れておく容器です。ファイルのように扱えますが、ファイル名や更新日時は持ちません。
// テキストからBlobを作る
const textBlob = new Blob(["Hello, World!"], {
type: "text/plain",
});
// JSONからBlobを作る
const jsonBlob = new Blob(
[
JSON.stringify(
{
name: "大容量ファイルアップロード",
version: "1.0",
},
null,
2,
),
],
{
type: "application/json",
},
);
// 複数のデータからBlobを作る
const multiBlob = new Blob(
["前半のデータ", "後半のデータ"],
{
type: "text/plain",
},
);
Blobでよく使うのはsize、type、slice()、arrayBuffer()です。
| 項目 | 内容 |
|---|---|
size | データサイズをバイト単位で返す |
type | MIMEタイプを返す |
slice() | 指定した範囲を別のBlobとして取り出す |
text() | 内容を文字列として読む |
arrayBuffer() | 内容をArrayBufferとして読む |
stream() | ストリームとして読む |
const blob = new Blob(["Hello"], {
type: "text/plain",
});
console.log(blob.size); // 5
console.log(blob.type); // "text/plain"
const chunk = blob.slice(0, 2);
const buffer = await blob.arrayBuffer();
大容量ファイルの分割では、このslice()が中心になります。
https://developer.mozilla.org/ja/docs/Web/API/Blob
File:ユーザーが選んだファイルそのもの
FileはBlobを継承しています。言い換えると、Blobにファイル名や更新日時など、ユーザーのファイルに必要な情報を足したものです。FileはそのままBlobとして扱えます。
ブラウザーでは主に次の2か所から受け取ります。
<input type="file">でユーザーが選んだファイル- ドラッグ&ドロップ時の
DataTransfer
手動で作ることもできます。
const file = new File(
["ファイルの内容"],
"example.txt",
{
type: "text/plain",
lastModified: Date.now(),
},
);
FileはBlobのsizeやtypeに加えて、nameやlastModifiedを持っています。slice()やarrayBuffer()などのメソッドはBlobからそのまま使えます。
https://developer.mozilla.org/ja/docs/Web/API/File
ArrayBuffer:中のバイト列を直接扱う
Blobがデータを入れる「ファイルのような容器」だとすると、ArrayBufferはその中のバイト列を直接扱うためのメモリ領域です。
const buffer = new ArrayBuffer(8);
console.log(buffer.byteLength); // 8
大容量ファイルアップロードでは、ハッシュ計算のときに使います。ファイルを分割するだけならFile.slice()で十分ですが、SparkMD5へデータを渡すときはBlobをArrayBufferへ変換します。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
3つの関係
3つをつなげて見ると、役割はかなり分かりやすくなります。
FileはBlobを継承しているので、ユーザーが選んだファイルは、ざっくり言えば「名前や更新日時を持ったBlob」です。一方、BlobとArrayBufferは親子関係ではありません。BlobをArrayBufferへ変換することも、ArrayBufferをBlobで包むこともできます。
大容量ファイルアップロードでは、それぞれ次の場所で使います。
- ファイルを選ぶときは
Fileを受け取る - 分割するときは
File.slice()を呼び、チャンクとなるBlobを作る - ハッシュを計算するときは
BlobをArrayBufferへ変換する - アップロードするときはチャンクの
BlobをFormDataへ入れて送る - 重複スキップはハッシュ値で完成済みファイルを探す
- 中断再開はハッシュ値と、すでに送ったチャンク番号を組み合わせる
一言でまとめるなら、Fileが元ファイル、Blobが切り出した断片、ArrayBufferが中のバイト列です。この関係が分かれば、ここから先の実装は素直に追えるようになります。
フロントエンド実装:React + TypeScriptで分割アップロードする
前提が分かったところで、ここから実際にコードを書いていきます。フロントエンド側では、ファイルを分け、チャンクを送る順番を決め、進捗を更新し、途中で止める処理まで担当します。
ファイル分割:まずは小さく切る
ファイル分割は最初の一歩であり、一番基本になる処理です。チャンクサイズに決まった正解はなく、一般には1MB〜10MBくらいの範囲で考えます。
小さくすれば、1チャンクが失敗したときの再送量は少なくなります。その代わりリクエスト数は増えます。大きくすればリクエスト数は減りますが、失敗したときに送り直す量が増えます。
今回は折衷案として5MBを使います。ネットワークが不安定なら2MB程度へ小さくし、高速な回線なら10MB程度へ大きくする、といった調整もできます。
/**
* ファイルを指定サイズで分割する
*
* @param file 元ファイル
* @param chunkSize 1チャンクのサイズ。既定値は5MB
*/
export function splitFile(
file: File,
chunkSize: number = 5 * 1024 * 1024,
): Blob[] {
const chunks: Blob[] = [];
let offset = 0;
while (offset < file.size) {
const chunk = file.slice(
offset,
offset + chunkSize,
);
chunks.push(chunk);
offset += chunkSize;
}
return chunks;
}
ここで使っているのはFileがBlobから受け継いだslice()です。ファイルの先頭から5MBずつ切り出し、最後まで来たら終了します。
ファイルを識別するためのハッシュを計算する
ファイルを分割できたら、次はファイルを識別するためのハッシュ値を計算します。ここではMD5を使い、SparkMD5で計算します。
ハッシュには3つの役割があります。
- 重複スキップ:同じファイルがすでにあれば、実データをもう一度送らない
- 中断再開:どのファイルの、どのチャンクまで送ったかを確認する
- 整合性確認:必要に応じて結合後のファイルを確認する
大容量ファイルを一度にメモリへ読み込むのではなく、チャンクを一つずつArrayBufferへ変換してSparkMD5へ追加します。
import SparkMD5 from "spark-md5";
export async function calcFileHash(
chunks: Blob[],
): Promise<string> {
if (chunks.length === 0) {
return "";
}
const spark = new SparkMD5.ArrayBuffer();
for (const chunk of chunks) {
const buffer = await chunk.arrayBuffer();
spark.append(buffer);
}
return spark.end();
}
もう一歩:巨大ファイルは一部だけ読んでハッシュを計算する
ここまでの方法でも動きますが、ファイルが数GB、数十GBになると、ファイル全体を読むハッシュ計算自体に時間がかかります。アップロードが始まる前の待ち時間を短くしたい場合は、計算量を減らす方法も考えられます。
そこで、ファイル全体ではなく一部のチャンクをサンプリングし、その内容とファイルサイズからハッシュ値を作ります。先頭と末尾、それから中間をいくつか均等に選び、最後にファイルサイズも加えてハッシュ化します。
export async function calcSampledFileHash(
chunks: Blob[],
fileSize: number,
): Promise<string> {
if (chunks.length === 0) {
return "";
}
// 5MB × 20チャンク = 100MB程度までは完全ハッシュを使う
if (chunks.length <= 20) {
return calcFileHash(chunks);
}
const totalChunks = chunks.length;
const sampleIndices = new Set<number>([
0,
totalChunks - 1,
]);
// 中間は最大8か所を均等に選ぶ
const middleSampleCount = Math.min(
8,
Math.max(1, Math.floor(totalChunks / 10)),
);
const step = totalChunks / (middleSampleCount + 1);
for (let i = 1; i <= middleSampleCount; i += 1) {
sampleIndices.add(Math.floor(i * step));
}
const spark = new SparkMD5.ArrayBuffer();
// ファイルサイズもハッシュ計算の入力に含める
const sizeBytes = new TextEncoder().encode(
String(fileSize),
);
const sizeBuffer = sizeBytes.buffer.slice(
sizeBytes.byteOffset,
sizeBytes.byteOffset + sizeBytes.byteLength,
);
spark.append(sizeBuffer);
const sortedIndices = Array.from(sampleIndices).sort(
(a, b) => a - b,
);
for (const index of sortedIndices) {
const buffer = await chunks[index].arrayBuffer();
spark.append(buffer);
}
return spark.end();
}
この例では、中間のサンプル数を最大8チャンクとしています。5MBチャンクなら、先頭と末尾を合わせて最大10チャンク、約50MBを読み取ってハッシュ値を計算します。
ただし、ファイル全体を読み取っているわけではないため、これは厳密な完全ハッシュではありません。重複判定などに使う簡易的な識別値として扱います。
ファイルを選び、アップロードを開始する
分割関数とハッシュ計算ができたので、次はユーザーが選んだファイルを受け取ります。Reactでは、ここでは特別なアップロードUIライブラリを使わず、<input type="file">とuseStateだけで書きます。
import {
useState,
type ChangeEvent,
} from "react";
const CHUNK_SIZE = 5 * 1024 * 1024;
export function LargeFileUpload() {
const [currentFile, setCurrentFile] =
useState<File | null>(null);
const [uploadProgress, setUploadProgress] =
useState(0);
const [uploadMessage, setUploadMessage] =
useState("");
const handleFileChange = (
event: ChangeEvent<HTMLInputElement>,
) => {
setCurrentFile(event.target.files?.[0] ?? null);
};
const startUpload = async () => {
if (!currentFile) return;
const chunks = splitFile(
currentFile,
CHUNK_SIZE,
);
const fileHash = await calcFileHash(chunks);
// このあとチャンクを送信する
console.log({ chunks, fileHash });
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
/>
<button
type="button"
onClick={startUpload}
disabled={!currentFile}
>
アップロード開始
</button>
<p>進捗: {uploadProgress}%</p>
{uploadMessage && <p>{uploadMessage}</p>}
</div>
);
}
ファイルはcurrentFileへ保存します。アップロード開始ボタンを押したら、まず分割し、次にハッシュを計算します。ここまでできれば、あとは各チャンクをサーバーへ送るだけです。
チャンクを送信する
各チャンクはFormDataへ入れて送ります。チャンク本体だけでなく、何番目のチャンクか、全部でいくつあるか、どのファイルのチャンクかをサーバー側へ渡します。
interface UploadChunkOptions {
chunk: Blob;
index: number;
totalChunks: number;
fileHash: string;
signal?: AbortSignal;
}
async function uploadChunk({
chunk,
index,
totalChunks,
fileHash,
signal,
}: UploadChunkOptions) {
const formData = new FormData();
formData.append("chunk", chunk);
formData.append("index", String(index));
formData.append(
"totalChunks",
String(totalChunks),
);
formData.append("fileHash", fileHash);
await axios.post(
"/api/upload/chunk",
formData,
{
signal,
},
);
}
一気に全部送ると、今度はリクエストが多すぎる
単純にPromise.all()へ全部のチャンクを渡せば並列アップロードできます。しかし、チャンクが100個あれば100個の処理を一気に開始することになります。
そこでp-limitを使い、同時実行数を5へ制限します。
import pLimit from "p-limit";
async function uploadChunks(
chunks: Blob[],
fileHash: string,
setUploadProgress: (value: number) => void,
) {
const limit = pLimit(5);
const total = chunks.length;
let completed = 0;
const tasks = chunks.map(
(chunk, index) =>
limit(async () => {
await uploadChunk({
chunk,
index,
totalChunks: total,
fileHash,
});
completed += 1;
setUploadProgress(
Math.round((completed * 100) / total),
);
}),
);
await Promise.all(tasks);
}
GitHub - sindresorhus/p-limit: Run multiple promise-returning & async functions with limited concurrency
Run multiple promise-returning & async functions with limited concurrency - sindresorhus/p-limit
https://github.com/sindresorhus/p-limit
これで、チャンクが100個あっても同時に送るのは5個までです。1つ終わったら次の1つを送る、という流れになります。
バックエンド実装:Expressでチャンクを受け取り、最後に結合する
フロントエンドでファイルを分けて送れるようになったら、次はサーバー側です。ここでやることは、送られてきたチャンクを一つずつ受け取り、fileHashごとに保存し、全部そろったら元のファイルへ戻すことです。
今回は、保存先を次のようにします。
uploads/
├── chunks/
│ ├── {fileHash1}/
│ │ ├── chunk-0
│ │ ├── chunk-1
│ │ └── chunk-2
│ └── {fileHash2}/
│ ├── chunk-0
│ └── chunk-1
└── {fileHash}-{filename}
uploads/はアップロードされた実データを置くため、Git管理には入れません。
uploads/
Multerでチャンクを受け取る
チャンクの受信自体は、普通のファイルアップロードと大きく変わりません。ExpressではMulterを使います。
ただし、今回は保存先がfileHashによって変わります。fileHashはreq.bodyに入っているため、先にディスクへ保存してしまうdiskStorageでは扱いにくいところがあります。そこでmemoryStorage()で一度req.file.bufferへ受け取り、保存先を決めてから自分で書き込みます。
const upload = multer({
storage: multer.memoryStorage(),
limits: {
// 5MBのチャンクに少し余裕を持たせる
fileSize: 10 * 1024 * 1024,
},
});
チャンクを受け取ったら、fileHashごとのディレクトリーへ保存します。
export const uploadChunkMiddleware = (
req,
res,
next,
) => {
upload.single("chunk")(
req,
res,
(error) => {
if (error) {
return next(error);
}
const { index, fileHash } = req.body;
if (
!req.file ||
!fileHash ||
index === undefined
) {
return next();
}
const targetDir = getChunksDir(fileHash);
ensureDir(targetDir);
const targetPath = path.join(
targetDir,
`chunk-${index}`,
);
fs.writeFileSync(
targetPath,
req.file.buffer,
);
next();
},
);
};
保存ファイル名へchunk-${index}のように番号を付けておけば、結合するときはchunk-*を読み、番号順に並べるだけです。別の対応表を持つ必要はありません。
ルート側では、必要な項目を確認してレスポンスを返します。
router.post(
"/upload/chunk",
uploadChunkMiddleware,
(req, res) => {
const {
index,
totalChunks,
fileHash,
} = req.body;
if (
index === undefined ||
!totalChunks ||
!fileHash
) {
return res.status(400).json({
success: false,
message:
"必要なチャンク情報が不足しています",
});
}
if (!req.file) {
return res.status(400).json({
success: false,
message:
"チャンクを受信できませんでした",
});
}
return res.json({
success: true,
data: {
index,
totalChunks,
fileHash,
size: req.file.size,
},
});
},
);
チャンクを元のファイルへ結合する
すべてのチャンクを送り終えたら、フロントエンドから結合APIを呼びます。「全部そろったので、もう一つのファイルへ戻してよい」という合図です。
router.post(
"/upload/chunk/merge",
async (req, res) => {
const {
fileHash,
filename,
totalChunks,
} = req.body;
if (
!fileHash ||
!filename ||
totalChunks === undefined
) {
return res.status(400).json({
success: false,
message:
"結合に必要な情報が不足しています",
});
}
// このあと結合処理を行う
},
);
まず、必要な数のチャンクが本当にそろっているか確認します。
const chunkDir = getChunksDir(fileHash);
if (!fs.existsSync(chunkDir)) {
return res.status(400).json({
success: false,
message:
"チャンクディレクトリーがありません",
});
}
const chunkFiles = fs
.readdirSync(chunkDir)
.filter((name) =>
name.startsWith("chunk-"),
)
.sort((a, b) => {
const indexA = Number(
a.replace("chunk-", ""),
);
const indexB = Number(
b.replace("chunk-", ""),
);
return indexA - indexB;
});
if (
chunkFiles.length !== Number(totalChunks)
) {
return res.status(400).json({
success: false,
message:
`チャンク数が一致しません。` +
`期待値: ${totalChunks}, ` +
`実際: ${chunkFiles.length}`,
});
}
ここで大事なのは、チャンクを必ず番号順に並べることです。chunk-10とchunk-2を単純な文字列順で扱わず、chunk-の後ろを数値にして比較します。
全部そろっていたら、WriteStreamへ一つずつ書き込みます。
const uploadsDir = getUploadsDir();
const finalFilename = `${fileHash}-${filename}`;
const outputPath = path.join(
uploadsDir,
finalFilename,
);
const writeStream =
fs.createWriteStream(outputPath);
for (const chunkFile of chunkFiles) {
const chunkPath = path.join(
chunkDir,
chunkFile,
);
const chunkBuffer =
fs.readFileSync(chunkPath);
writeStream.write(chunkBuffer);
}
writeStream.end();
await new Promise<void>((resolve, reject) => {
writeStream.on("finish", resolve);
writeStream.on("error", reject);
});
createWriteStream()で完成ファイルへの書き込みストリームを作り、チャンクを番号順にwriteStream.write()へ渡していきます。
この方法なら、1GBでも10GBでもファイル全体を一度にメモリへ置く必要はありません。処理中のチャンクだけを読み、書き終えたら次へ進みます。
結合が終わったら、一時チャンクを削除します。
fs.rmSync(
chunkDir,
{
recursive: true,
force: true,
},
);
重複スキップ:同じファイルは再送しない
重複スキップの本質は、転送を速くすることではなく、すでに同じファイルがある場合にそもそも送らないことです。
ファイルを選んだら、まずfileHashを計算し、その値を持ってサーバーへ「このファイルはもうありますか?」と問い合わせます。
- サーバーに「ある」なら、その場でアップロード成功として扱う
- 「ない」なら、普通にチャンクアップロードを続ける
たとえばチームの10人が同じ商品紹介動画をアップロードするとします。この仕組みがなければ同じ動画を10回送り、場合によっては10個保存することになります。ハッシュで同じファイルだと分かれば、2人目以降は実データを送る必要がありません。
ハッシュで既存ファイルを確認する
バックエンドへ確認APIを追加します。
router.get(
"/files/check",
(req, res) => {
const { fileHash } = req.query;
if (!fileHash) {
return res.status(400).json({
success: false,
message: "fileHashが必要です",
});
}
const uploadsDir = getUploadsDir();
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(
uploadsDir,
{ recursive: true },
);
}
const files = fs
.readdirSync(uploadsDir)
.filter((filename) => {
const filePath = path.join(
uploadsDir,
filename,
);
return !fs
.statSync(filePath)
.isDirectory();
});
const matchedFilename = files.find(
(filename) =>
filename.startsWith(`${fileHash}-`),
);
if (matchedFilename) {
return res.json({
success: true,
data: {
exists: true,
filename: matchedFilename,
},
});
}
return res.json({
success: true,
data: {
exists: false,
},
});
},
);
React側では、通常のアップロードより先に確認します。
async function startUpload(file: File) {
const chunks = splitFile(
file,
CHUNK_SIZE,
);
const fileHash =
await calcSampledFileHash(
chunks,
file.size,
);
const checkResponse =
await checkFileExists(fileHash);
if (
checkResponse.data.success &&
checkResponse.data.data?.exists
) {
setUploadMessage(
"同じファイルが存在するため、アップロードを省略しました",
);
return;
}
// 存在しない場合だけ通常のアップロードへ進む
}
これで、同じファイルは「1回目はアップロード、2回目からは転送しない」という動きになります。
同時に同じファイルが来た場合も最後に確認する
アップロード前の確認だけでは、ほぼ同時に同じファイルが選ばれた場合に両方が「まだ存在しない」と判断する可能性があります。
そこで、チャンク結合APIでも完成ファイルがすでに存在しないか確認します。
const finalFilename =
`${fileHash}-${filename}`;
const outputPath = path.join(
uploadsDir,
finalFilename,
);
if (fs.existsSync(outputPath)) {
fs.rmSync(
chunkDir,
{
recursive: true,
force: true,
},
);
const stats = fs.statSync(outputPath);
return res.json({
success: true,
message:
"ファイルはすでに存在します",
data: {
filename: finalFilename,
size: stats.size,
path: outputPath,
url: `/uploads/${finalFilename}`,
},
});
}
中断再開:ネットワークが切れても最初からやり直さない
重複スキップが「ファイル全体がもうある」場合を扱うのに対して、中断再開は「途中まで送った」場合を扱います。
たとえば80%まで送ったところでネットワークが切れたなら、もう一度0%から始める必要はありません。すでにサーバーへ届いている80%はそのまま使い、残り20%だけ送ればよいわけです。
考え方は一言で済みます。
送信済みのチャンクは飛ばし、足りないチャンクだけ送る。
バックエンドから不足チャンクを返す
確認APIを少し拡張します。完成済みファイルがなければ、今度はチャンク用ディレクトリーを見て「何番まで届いているか」を確認します。
router.get(
"/files/check",
(req, res) => {
const fileHash = String(
req.query.fileHash ?? "",
);
const total = Number(
req.query.totalChunks,
);
if (
!fileHash ||
!Number.isInteger(total) ||
total < 1
) {
return res.status(400).json({
success: false,
message:
"fileHashとtotalChunksが必要です",
});
}
// ここより前で完成済みファイルを確認する
const chunkDir = getChunksDir(fileHash);
const uploadedChunks: number[] = [];
if (fs.existsSync(chunkDir)) {
fs
.readdirSync(chunkDir)
.filter((name) =>
name.startsWith("chunk-"),
)
.forEach((name) => {
const index = Number(
name.replace("chunk-", ""),
);
if (Number.isInteger(index)) {
uploadedChunks.push(index);
}
});
}
uploadedChunks.sort((a, b) => a - b);
const uploadedSet =
new Set(uploadedChunks);
const missingChunks = Array.from(
{ length: total },
(_, index) => index,
).filter(
(index) => !uploadedSet.has(index),
);
return res.json({
success: true,
data: {
exists: false,
totalChunks: total,
uploadedChunks,
missingChunks,
},
});
},
);
React側ではmissingChunksだけを送ります。
const checkResponse =
await checkFileExists({
fileHash,
totalChunks: chunks.length,
});
if (checkResponse.data.data?.exists) {
return;
}
const missingChunks =
checkResponse.data.data?.missingChunks ?? [];
await uploadLargeFile(
file,
chunks,
fileHash,
missingChunks,
);
AbortControllerで進行中のリクエストも止める
「再開できる」だけでなく、ユーザーが一時停止したときに、すでに通信中のリクエストまで止めたい場合はAbortControllerを使います。
Reactでは、チャンクごとのAbortControllerをuseRefのMapへ保存しておきます。
import { useRef } from "react";
function useUploadAbort() {
const abortControllers = useRef(
new Map<number, AbortController>(),
);
const uploadChunkWithAbort = async (
chunk: Blob,
index: number,
totalChunks: number,
fileHash: string,
) => {
const controller = new AbortController();
abortControllers.current.set(
index,
controller,
);
try {
await uploadChunk({
chunk,
index,
totalChunks,
fileHash,
signal: controller.signal,
});
} finally {
abortControllers.current.delete(index);
}
};
const pauseUpload = () => {
for (
const controller of
abortControllers.current.values()
) {
controller.abort();
}
abortControllers.current.clear();
};
return {
uploadChunkWithAbort,
pauseUpload,
};
}
LargeFileUploadコンポーネント側では、このHookから2つの関数を受け取ります。
const {
uploadChunkWithAbort,
pauseUpload,
} = useUploadAbort();
一時停止したら、Mapに入っているAbortControllerをすべてabort()します。再開するときは、もう一度チェックAPIへ問い合わせ、missingChunksだけを取り出してアップロードし直します。
まとめ
ここまで長くなりましたが、最後に大容量ファイルアップロードの流れを振り返ります。
「大容量ファイルアップロード」は、検索すれば既成のライブラリや実装例がたくさん出てくる、かなり定番のテーマです。それでも、自分で最初から最後まで実装してみると、既成の仕組みをそのまま使うのとは違うものが見えてきます。
この機能を最初から最後まで本当に理解することと、既存のライブラリをそのまま使うことの差は、数行のコードではなく、処理全体を自分で把握できているかどうかにある。
最初はBlobとFileの違いを整理し、ハッシュ計算でArrayBufferが何をしているのかを確認しました。次にFile.slice()で大きなファイルを小さく分け、SparkMD5でハッシュ値を計算し、p-limitで同時アップロード数を抑えました。そのチャンクをバックエンドへ送り、ExpressとMulterで一つずつ受け取り、順番に元のファイルへ戻しました。
そこへ、さらに2つの仕組みを足しました。完成済みのファイルなら実データを送らない重複スキップと、途中まで送ったチャンクをそのまま使う中断再開です。
振り返ると、実装の考え方そのものはそれほど複雑ではありません。ハッシュ値でファイルを識別し、分割によって大きな仕事を小さな単位にし、保存済みチャンクの状態を残してアップロードを復元できるようにする。この3つがそろうことで、大容量ファイルアップロードが途中で失敗しても、最初からやり直さずに済むようになります。
次に大きなファイルを扱うことになったとき、内部で何が起きているかが分かっていれば、途中で止まったときも、結合に失敗したときも、どこから確認すればよいか判断しやすくなるはずです。
参考リンク
GitHub - acexyf/big-file-upload
Contribute to acexyf/big-file-upload development by creating an account on GitHub.
https://github.com/acexyf/big-file-upload
https://developer.mozilla.org/ja/docs/Web/API/Blob
https://developer.mozilla.org/ja/docs/Web/API/File
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
GitHub - sindresorhus/p-limit: Run multiple promise-returning & async functions with limited concurrency
Run multiple promise-returning & async functions with limited concurrency - sindresorhus/p-limit
https://github.com/sindresorhus/p-limit
useState – React
The library for web and native user interfaces
https://react.dev/reference/react/useState