大容量ファイルをアップロードしていたら、通信が切れた。ブラウザーが落ちた。待ち時間が長くてユーザーが離脱した。小さなファイルでは気にならなかった問題が、大きなファイルになると一気に出てきます。

「大容量ファイルのアップロード」と聞くと、少し聞き慣れた話に感じるかもしれません。分割アップロードや中断再開という言葉も、すでに何度も見たことがあると思います。ただ、実際に自分で作ろうとすると話は別です。チャンクサイズはどう決めるのか。進捗はどう計算するのか。サーバー側ではどう結合するのか。途中で止まったら、どこから再開するのか。

今回は、こうした疑問を一つずつコードで追いながら、大容量ファイルアップロードを最初から実装していきます。中心になるのは「ファイルを分けて送る」ことです。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を整理する

チャンク分割のコードへ進む前に、ブラウザーでファイルを扱うときによく出てくるFileBlobArrayBufferを整理しておきます。ここが曖昧なままだと、あとで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でよく使うのはsizetypeslice()arrayBuffer()です。

項目内容
sizeデータサイズをバイト単位で返す
typeMIMEタイプを返す
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()が中心になります。

File:ユーザーが選んだファイルそのもの

FileBlobを継承しています。言い換えると、Blobにファイル名や更新日時など、ユーザーのファイルに必要な情報を足したものです。FileはそのままBlobとして扱えます。

ブラウザーでは主に次の2か所から受け取ります。

  1. <input type="file">でユーザーが選んだファイル
  2. ドラッグ&ドロップ時のDataTransfer

手動で作ることもできます。

const file = new File(
  ["ファイルの内容"],
  "example.txt",
  {
    type: "text/plain",
    lastModified: Date.now(),
  },
);

FileBlobsizetypeに加えて、namelastModifiedを持っています。slice()arrayBuffer()などのメソッドはBlobからそのまま使えます。

ArrayBuffer:中のバイト列を直接扱う

Blobがデータを入れる「ファイルのような容器」だとすると、ArrayBufferはその中のバイト列を直接扱うためのメモリ領域です。

const buffer = new ArrayBuffer(8);
console.log(buffer.byteLength); // 8

大容量ファイルアップロードでは、ハッシュ計算のときに使います。ファイルを分割するだけならFile.slice()で十分ですが、SparkMD5へデータを渡すときはBlobArrayBufferへ変換します。

3つの関係

3つをつなげて見ると、役割はかなり分かりやすくなります。

FileBlobを継承しているので、ユーザーが選んだファイルは、ざっくり言えば「名前や更新日時を持ったBlob」です。一方、BlobArrayBufferは親子関係ではありません。BlobArrayBufferへ変換することも、ArrayBufferBlobで包むこともできます。

大容量ファイルアップロードでは、それぞれ次の場所で使います。

  1. ファイルを選ぶときFileを受け取る
  2. 分割するときFile.slice()を呼び、チャンクとなるBlobを作る
  3. ハッシュを計算するときBlobArrayBufferへ変換する
  4. アップロードするときはチャンクのBlobFormDataへ入れて送る
  5. 重複スキップはハッシュ値で完成済みファイルを探す
  6. 中断再開はハッシュ値と、すでに送ったチャンク番号を組み合わせる

一言でまとめるなら、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;
}

ここで使っているのはFileBlobから受け継いだ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);
}

これで、チャンクが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によって変わります。fileHashreq.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-10chunk-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では、チャンクごとのAbortControlleruseRefの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だけを取り出してアップロードし直します。

まとめ

ここまで長くなりましたが、最後に大容量ファイルアップロードの流れを振り返ります。

「大容量ファイルアップロード」は、検索すれば既成のライブラリや実装例がたくさん出てくる、かなり定番のテーマです。それでも、自分で最初から最後まで実装してみると、既成の仕組みをそのまま使うのとは違うものが見えてきます。

この機能を最初から最後まで本当に理解することと、既存のライブラリをそのまま使うことの差は、数行のコードではなく、処理全体を自分で把握できているかどうかにある。

最初はBlobFileの違いを整理し、ハッシュ計算でArrayBufferが何をしているのかを確認しました。次にFile.slice()で大きなファイルを小さく分け、SparkMD5でハッシュ値を計算し、p-limitで同時アップロード数を抑えました。そのチャンクをバックエンドへ送り、ExpressとMulterで一つずつ受け取り、順番に元のファイルへ戻しました。

そこへ、さらに2つの仕組みを足しました。完成済みのファイルなら実データを送らない重複スキップと、途中まで送ったチャンクをそのまま使う中断再開です。

振り返ると、実装の考え方そのものはそれほど複雑ではありません。ハッシュ値でファイルを識別し、分割によって大きな仕事を小さな単位にし、保存済みチャンクの状態を残してアップロードを復元できるようにする。この3つがそろうことで、大容量ファイルアップロードが途中で失敗しても、最初からやり直さずに済むようになります。

次に大きなファイルを扱うことになったとき、内部で何が起きているかが分かっていれば、途中で止まったときも、結合に失敗したときも、どこから確認すればよいか判断しやすくなるはずです。

参考リンク