【JavaScript】Streams API を使って fetch の逐次ダウンロード & ファイル保存

[2023-12-02 更新]

JavaScript を使って巨大なファイルをダウンロードして、それをストレージに保存したいと考えたことはないでしょうか。これまで通り、fetch を使って一気に巨大ファイルをダウンロードできなくはないのですが、ローカルマシンのリソースを無駄に消費してしまいそうです。まだダウンロード処理と保存処理の時間軸が分かれてしまい、トータルで時間がかかってしまいそうです。できればダウンロードしたデータを少しずつ逐次保存する方がローカルマシンに優しいだけでなくパフォーマンスも良いのではないでしょうか。今回は、そういったケースの解決策となる Streams API の基本を解説します。

Streams API とは

Streams API は、連続するデータを少しずつ取り出しながら処理する枠組みを提供する API です。たとえば fetch であれば、ファイルをダウンロード後に処理するのではなく、ダウンロードできたデータを逐次的に処理できるようになります。この逐次的に取り出したデータの塊をチャンクと呼びます。また、データの読み取りだけでなく、File System Access API を使ってファイルを書き込む場合も、逐次的にデータを書き込むことができます。つまりチャンクを逐次的に書き込むことができます。

Streams API で取り出したチャンクはバイナリーデータとして JavaScript からアクセスすることも可能です。そのため、ファイルをダウンロードしながら何かしらの情報を読み取ることもできますし、場合によっては、加工しながら保存することすら可能になります。

このように、Streams API は、これまでアクセスできなかったデータストリームにダイレクトにアクセスできる低レベル API です。この API の仕様は WHATWG にて策定されています。この API のユースケースとしては、ビデオストリームをリアルタイムに映像効果をつける、.tgz といった圧縮ファイルをリアルタイムに解凍する、画像データをビットマップにデコードする、といった処理が挙げられています。実際に、このような高度な処理を自作するのは難しいのではないでしょうか。実際に私たちが Streams API を使うとしたら、本記事の冒頭にも触れたように、巨大なファイルをダウンロードしてローカルストレージに保存するケースでしょう。

ストリームを読み取る ReadableStream オブジェクト

まずは fetch を使ってダウンロードしたファイルのチャンクを取り出すコードを見てみましょう。次のコードは動画ファイルを fetch で取得しますが、Streams API を使ってチャンクを読み取っています。なお、以降のコードは async ブロックの中であることを前提にしていますのでご了承ください。

// 動画ファイルを fetch でダウンロード開始
const response = await fetch('sample.mp4');

// ReadableStream オブジェクト
const rstream = response.body;

// ReadableStreamDefaultReader オブジェクト
const reader = await rstream.getReader();

while (true) {
    // チャンクを取得
    const { done, value } = await reader.read();

    // ストリームが終わったらループを抜ける
    if (done === true) {
        break;
    }

    // value は Uint8Array オブジェクト
    console.log(value.length);
}

fetch を呼び出して Response オブジェクト (変数 response) を取得します。そして、Response オブジェクト (変数 response) の body プロパティから ReadableStream オブジェクト (変数 rstream) を取り出します。

次に、ReadableStream オブジェクト (変数 rstream) の getReader() メソッドを使って ReadableStreamDefaultReader オブジェクト (変数 reader) を取得します。ReadableStreamDefaultReader オブジェクトの read() メソッドを呼び出すと直近のチャンクを返します。このチャンクは { value: chunkdata, done: false } のようなオブジェクトです。チャンクデータが存在すれば value プロパティに Uint8Array の型付き配列 (8 ビット符号なし整数値の配列) で表されたチャンクデータがセットされ、done プロパティには false がセットされます。もしチャンクデータが得られなければ { value: undefined, done: true } が返されます。

前述のサンプルコードでは、チャンクのデータサイズをコンソールに出力しています。チャンクのデータは一定ではなく、状況によって変動します。筆者が実際に試したところ、84 MB ほどのサイズの動画ファイルをダウンロードした場合、Chrome と Edge の場合は 35 K バイト程度のチャンクを 2500 回ほどに分けて、Firefox の場合は 24 K バイト程度のチャンクを 3,500 回ほどに分けて、Safari の場合は 200 K バイト程度のシャンクを 420 回ほどに分けて受信していました。ただしネットワーク環境による変動も大きいでしょうから、あくまでも目安程度にとらえてください。

ダウンロードの進捗を把握する

ダウンロードの進捗をリアルタイムに把握するには、ダウンロード対象のファイルの合計サイズを知る必要があります。しかし、Streams API はデータのストリームを扱うだけの API のため、ファイルのサイズを知るはずもありません。fetch であれば、HTTP レスポンスヘッダーのうち Content-Length ヘッダーを値を読み取ることでファイルサイズを知ることができます。

次のサンプルコードは前述のサンプルを改良したものです。fetch から Response オブジェクトが得られた段階(HTTP サーバーからレスポンスヘッダーが得られた段階)で Content-Length レスポンスヘッダーの値を読み取ります。そして、チャンクを読み取る都度、進捗をパーセンテージでコンソールに出力します。

const response = await fetch('sample.mp4');

// Content-Length レスポンスヘッダーを読み取ってファイルサイズを取得
let total = response.headers.get('Content-Length');
total = parseInt(total, 10);

const rstream = response.body;
const reader = await rstream.getReader();

let dsize = 0; // ダウンロード済みのデータサイズ

while (true) {
    const { done, value } = await reader.read();

    if (done === true) {
        break;
    }

    // 進捗を出力
    dsize += value.length;
    const percent = Math.round(dsize * 100 / total);
    console.log(percent + ' %');
}

なお、JavaScript と動画ファイルが異なるサーバーにホスティングされている場合、より正確に言うと、クロスオリジンの場合、Content-Length ヘッダーの読み取りは CORS 制約の対象になります。その場合、.htaccess が利用可能なサーバーであれば、動画ファイルをホスティングしているディレクトリ直下に次のような .htaccess を設置することで解決します。

Header set Access-Control-Allow-Origin "*"
Header set Access-Control-Allow-Headers "Content-Length"

ストリームを書き込む WritableStream オブジェクト

Streams API には読み取り用の ReadableStream オブジェクトだけではなく、書き込み用の WritableStream オブジェクトも用意されています。ここでは、WritableStream オブジェクトのインタフェースをサポートしているFile System Access API の Origin Private File System を例に解説します。Origin Private File System の詳細は当サイトの記事「File System Access API – 仮想的なファイルシステム Origin Private File System 編」をご覧ください。ただし、以降は Safari では動作しませんのでご注意ください。

まずストリームを書き込むには WritableStream オブジェクトを取得しなければいけません。Origin Private File System の場合は、ファイルハンドルを表す FileSystemFileHandle オブジェクトの createWritable() メソッドを使って WritableStream オブジェクトを取得します。次のサンプルコードは Origin Private File System のルートディレクトリ直下に sample.mp4 というファイルを新たに生成し、そのファイルハンドルから WritableStream オブジェクトを取得します。

// ルートの FileSystemDirectoryHandle オブジェクト
const root = await navigator.storage.getDirectory();

// sample.mp4 を生成して FileSystemFileHandle オブジェクトを取得
const fh = await root.getFileHandle('sample.mp4', { create: true });

// FileSystemWritableFileStream オブジェクトを取得
const wstream = await fh.createWritable();

FileSystemFileHandle オブジェクトの createWritable() メソッドは、正確に言うと、WritableStream オブジェクトではなく FileSystemWritableFileStream オブジェクトを返します。このオブジェクトは、WritableStream オブジェクトを継承して、File System Access API に必要なメソッドなどを追加したオブジェクトです。そのため、Streams API の WritableStream として扱っても問題ありません。

WritableStream オブジェクトが得られたら、getWriter() メソッドを使って WritableStreamDefaultWriter オブジェクトを取得します。

const writer = wstream.getWriter();

getWriter() メソッドを呼び出すと、該当の書き込みストリームはロックされます。つまり、このロックが解除されるまで、該当のファイルハンドルから新たな WritableStreamDefaultWriter オブジェクトを生成することができなくなります。ロックの解除方法は後述します。

ストリームの書き込みは、この WritableStreamDefaultWriter オブジェクト (変数 writer) の write() メソッドを通して行います。write() メソッドには、読み取りストリームで得られたチャンクの Uint8Array オブジェクト (以下のコードでは変数 value) を与えることができます。

await writer.write(value);

前述の fetch によるファイルダウンロードで読み取りストリームから得られる Uint8Array オブジェクトを繰り返し write() メソッドを使って書き込んでいくことで、ダウンロードファイルの逐次書き込みが行われます。

すべてのデータの書き込みが終わったら、書き込みストリームを閉じなければいけません。閉じるには WritableStreamDefaultWriter オブジェクト (変数 writer) の close() メソッドを使います。

await writer.close();

以上でストリームをファイルに書き込む一連の手順は終わりです。

ダウンロードデータをストリームでファイルに書き込む流れ

以上の説明にあった一連の手順をまとめてみましょう。次のコードは、動画ファイルを fetch でダウンロードしながら、ストリームで File System Access API の Origin Private File System のファイルに書き込みます。そして、ファイルに書き込んだら、その動画を再生します。

<button type="button" onclick="startDownload(this)">
    動画ダウンロード
</button>
<video width="320" height="180" controls></video>
async function startDownload(btn_el) {
    btn_el.disabled = true;

    // Origin Private File System のルートに sample.mp4 を新規に生成
    const root = await navigator.storage.getDirectory();

    // sample.mp4 を生成して FileSystemFileHandle オブジェクトを取得
    const fh = await root.getFileHandle('sample.mp4', { create: true });

    // FileSystemWritableFileStream オブジェクトを取得
    const wstream = await fh.createWritable();

    // WritableStreamDefaultWriter オブジェクトを取得
    const writer = wstream.getWriter();

    // 動画ファイルを fetch でダウンロード開始
    const response = await fetch('sample.mp4');

    // HTTP レスポンスヘッダーからファイルのサイズを取得
    let total = response.headers.get('Content-Length');
    total = parseInt(total, 10);

    let dsize = 0;

    // ReadableStream オブジェクト
    const rstream = response.body;

    // ReadableStreamDefaultReader オブジェクト
    const reader = await response.body.getReader();

    while (true) {
        // チャンクを取得
        const { done, value } = await reader.read();

        if (done === true) {
            // 書き込みストリームのロックを解除してループを抜ける
            await writer.close();
            break;
        } else {
            // チャンクの Uint8Array オブジェクトを書き込む
            await writer.write(value);

            // 受信進捗をパーセンテージで表示
            dsize += value.length;
            const per = parseInt(100 * dsize / total, 10);
            btn_el.textContent = `${per} %`;
        }
    }

    // FileSystemFileHandle オブジェクトから File オブジェクトを取得
    const file = await fh.getFile();

    // File オブジェクトのオブジェクト URL を生成して動画を再生
    const video_el = document.querySelector('video');
    video_el.src = window.URL.createObjectURL(file);
    video_el.play();
}

受信データをそのまま保存するなら pipeTo() メソッド

これまでのサンプルは、読み込みストリームから得られたデータのチャンクを個別に書き込みストリームに引き渡していました。ダウンロードの進捗をリアルタイムに把握したり、受信データをリアルタイムに変換するのであれば良いのですが、単に受信データをファイルに保存したいだけだとしたら、やや無駄な処理をしているように感じられるのではないでしょうか。

もしダウンロードの進捗すら把握する必要がなく、単にファイルに保存したいだけなのであれば、ReadableStream オブジェクトの pipeTo() メソッドを使うのが良いでしょう。pipeTo() メソッドは引数に WritableStream オブジェクトを与えます。以下のコードでは、変数 rstreamReadableStream オブジェクトで、変数 wstreamWritableStream オブジェクトです。

await rstream.pipeTo(wstream);

これによって、読み取りストリームから流入してくるデータのチャンクを、そのまま書き込みストリームにバイパスします。pipeTo() メソッドは読み取りストリームからのチャンク流入が無くなれば自動的に書き込みストリームのロックを解除して閉じます。

前述のサンプルコードを pipeTo() メソッドで書き直すと、次のようになります。

async function startDownload(btn_el) {
    btn_el.disabled = true;

    // Origin Private File System のルートに sample.mp4 を新規に生成
    const root = await navigator.storage.getDirectory();

    // sample.mp4 を生成して FileSystemFileHandle オブジェクトを取得
    const fh = await root.getFileHandle('sample.mp4', { create: true });

    // FileSystemWritableFileStream オブジェクトを取得
    const wstream = await fh.createWritable();

    // 動画ファイルを fetch でダウンロード開始
    const response = await fetch('sample.mp4');

    // ReadableStream オブジェクト
    const rstream = response.body;

    // ストリーム同士をパイプ接続する
    await rstream.pipeTo(wstream);

    // FileSystemFileHandle オブジェクトから File オブジェクトを取得
    const file = await fh.getFile();

    // File オブジェクトのオブジェクト URL を生成して動画を再生
    const video_el = document.querySelector('video');
    video_el.src = window.URL.createObjectURL(file);
    video_el.play();
}

かなりのコードが削られ、すっきりしたのではないでしょうか。

まとめ

Streams API は、これまでウェブアプリケーションでは関われなかったデータストリームにバイトレベルかつリアルタイムにアクセスできる低レベル API です。皆さんが自らこの API を使わなかったとしても、様々な JS ライブラリやネットサービスなどが Streams API を利用して高度なアプリケーションを提供することでしょう。

実は筆者自身、この記事で紹介したような、巨大な動画ファイルをダウンロードして、それをローカルのストレージに保存し、以降はローカルのファイルを再生する、というケースに出くわし、この API を使ったという経緯があります。Streams API は低レベル API とはいえ、基礎レベルであれば、我々でも役に立つユースケースが数多く存在することでしょう。また思いついたら、新たな記事で Streams API の応用を紹介したいと考えています。

今回は以上で終わりです。最後まで読んでくださりありがとうございました。それでは次回の記事までごきげんよう。

Share