at backyard

Color my life with the chaos of trouble.

複数ファイルのダウンロード時に、各ダウンロードごとの進捗を個別に表示したい (fetchでStream APIを使う)

fetchを用いてダウンロードする際にダウンロード進捗を表示させる方法については以下に書いた。 今回はこちらの発展形について。

shinshin86.hateblo.jp

複数ファイルのダウンロードをすることになった際、各ファイルそれぞれのダウンロード進捗を表示させる方法について調べたのでサンプルコードを載せる。

なお、今回実現のためにReadbleStreamを用いている

developer.mozilla.org

デモ

ちなみ実際に動かしてみたところのGIFとなる。

設計

今回以下のような順序で処理を行うように実装している。

1.複数ファイルのダウンロードを実行
2.ダウンロード対象のファイルごとに、ダウンロード管理用のobjectを作成し、useStateで定義した配列に格納
3.各ダウンロードファイルごとにReadbleStreamのインスタンスを作成する
4.Stream APIを用いて画像ファイルをダウンロードする。ダウンロード中、進捗率を都度都度stateに反映させる
※なお、この際一気にダウンロード処理を動かすとダウンロードに漏れが生じるので、同時実行数の上限を定めている。
5.ダウンロードが完了次第、 `new Blob` につめてダウンロード実行

似たような実装を探してみたものの、インターネット上では見つけられず、これが最良の方法かと問われれると若干方をすくめてしまう。
というのも、上にも書いたがStream APIを使ってデータを細かく読み込んでいるところで、プログレスバーに表示するための進捗率を取得し、stateを更新するということを行っている。
つまり相当短い間隔でstateに対して更新がかかっているが、これがReact的にはありなのかは自信がない。

しかも setFoo((pre => ・・・) という形で書き、その場で更新を行うようにしている。
そもそもこうしないとその関数内でリアルタイムにstateの反映が行われないからだが、結構無理しているのではないかという印象はある。

eventとかchannelのようなやり方で進捗率だけを対象のデータに対して送れないかと思ったが、なるべくFetch APIの仕組みだけ使って実現しようとしたところ、このような形となった。

※もしこういう処理でよりスマートなやり方があればコメントなど頂けたらと思います。

大量のファイルを一気にダウンロードした際に、ダウンロードに漏れが生じる

ツイートにも書いていたが、大量のファイル(だいたい30ぐらい)を一度にダウンロードしようとした際に、全てのファイルをダウンロードすることなく処理が終了してしまうことがあるようだった。

なぜこういう事が起きるのか?
プログラム上の問題なのか、ブラウザ側での制御が入るのかは詳細は追いきれていないが、大量のファイルを一度にダウンロードしようとするとこのように漏れが生じることがわかったので、同時に動かすダウンロード処理の数に上限を定めるようにした。

下記の中で提示されているコードを参考にしている。

teratail.com

同時に実行する上限数を3にしてみることですべてのファイルを同時にダウンロードできることを確認している。
ちなみに100個のファイルを同時にダウンロードするところまでテストしてみた。

0始まりなので、100個目のファイルの連番が99となっている

サンプルコード

前回書いた内容と同じで Create React Appで作成した雛形を元に作成している。
ちなみに「とりあえず動く」といった形のサンプルなのでエラーハンドリングが足りなかったり完璧な状態ではないし、スタイルはChakra UIを用いているがスタイルなど本題に関係ないところの実装もとても雑なので、その点はご了承いただきたい。
と先に予防線を張らせていただく。

あとこれは言うまでもないことだが、一度に大量のファイルダウンロードを行おうとした場合にアクセス先が他人のサーバである場合は大量のアクセスが行ってしまうので、必ずローカルなど自身が管理している環境内で試すように気をつけること。
(このサンプルコードはローカルに置いた画像ファイルをダウンロードするようにしている)

import "./App.css"; // create react app側で元々用意されているcss(今回の本題とは特に関係ない)
import { useState } from "react";
import { Button, Input, Progress } from "@chakra-ui/react";

const sleep = (msec) => new Promise((resolve) => setTimeout(resolve, msec));

function App() {
  // ダウンロードするファイル数。テスト用に変更できるようにしている
  const [downloadCount, setDownloadCount] = useState(1);

  // ダウンロード状況を管理するオブジェクトの配列
  const [downloadFiles, setDownloadFiles] = useState([]);

  const getDownloadFiles = async (count) => {
    const files = [];
    for (const index of [...Array(+count).keys()]) {
      const id = index + downloadFiles.length;
      const fileName = `test_${id}.png`;

      files.push({ id, fileName, progressPercent: 0, isFinished: false });
    }

    setDownloadFiles(files);
    return files;
  };

  const getUnderlyingSource = (downloadFile) => {
    return {
      async start(controller) {
        // テスト用の画像をローカルに用意しており、そこからダウンロードしている
        const response = await fetch("http://localhost:3000/test-image.png");
        const contentLength = +response.headers.get("Content-Length");
        let reader = response.body.getReader();
        let receivedLength = 0;

        const fetchImage = () => {
          return reader.read().then(async ({ done, value }) => {
            if (done) {
              controller.close();
              return;
            }

            receivedLength += value.length;
            controller.enqueue(value);

            // これは本来不要。sleepを入れることで進捗率の変化をわかりやすくしているだけ
            await sleep(100);

            const progressPercent = Math.round(
              (receivedLength / contentLength) * 100,
            );

            setDownloadFiles((pre) => {
              pre.find((p) => p.id === downloadFile.id).progressPercent =
                progressPercent;
              return [...pre];
            });

            return fetchImage();
          });
        };

        return fetchImage();
      },
    };
  };

  const download = async (downloadFile) => {
    const underlyingSource = getUnderlyingSource(downloadFile);
    const stream = new ReadableStream(underlyingSource);
    const reader = stream.getReader();
    let chunks = [];
    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        break;
      }

      chunks.push(value);
    }

    // あとはダウンロード処理
    const blob = new Blob(chunks);
    const url = window.URL.createObjectURL(blob);

    const a = document.createElement("a");
    a.href = url;
    a.download = downloadFile.fileName;
    document.body.appendChild(a);

    a.click();
    a.remove();
    window.URL.revokeObjectURL(url);

    // ダウンロード完了後にisFinished: trueに更新
    setDownloadFiles((pre) => {
      pre.find((p) => p.id === downloadFile.id).isFinished = true;
      return [...pre];
    });

    return downloadFile;
  };

  return (
    <div className="App">
      <header>
        <div style={{ padding: 30 }}>
          {downloadFiles.length > 0
            ? (
              downloadFiles.map((
                { fileName, progressPercent, isFinished },
                i,
              ) => (
                <div key={i}>
                  <p>{fileName}</p>
                  <Progress hasStripe value={progressPercent} />
                  <div>{progressPercent}%{isFinished && " Finish!"}</div>
                </div>
              ))
            )
            : <div>Multiple download and progress indicator sample</div>}
        </div>
        <div>
          <Input
            type="number"
            onChange={(e) => setDownloadCount(e.target.value)}
            value={downloadCount}
          />
        </div>
        <Button
          colorScheme={"blue"}
          onClick={async () => {
            const files = await getDownloadFiles(downloadCount);

            const promises = [];
            for (const file of files) {
              // ダウンロードのためのpromise関数と、引数として渡すファイル情報をobjectにして格納している
              promises.push({ promise: download, file });
            }

            // 並行処理される上限数を見ながらダウンロード処理を行う
            const runPromises = ((limit) => {
              let i = 0;
              return (promises) => {
                if (!promises.length && !i) {
                  console.log("Finish!");
                  return;
                }

                while (promises.length && i < limit) {
                  ++i;

                  const { promise, file } = promises.shift();
                  promise(file).then((downloadFile) => {
                    console.log("Download file name: ", downloadFile.fileName);

                    --i;
                    runPromises(promises);
                  });
                }
              };
            })(3);
            // 上限数は3に設定している

            runPromises(promises);
          }}
        >
          Download
        </Button>
      </header>
    </div>
  );
}

export default App;