いまどきのWebアプリにおいては、ファイルのダウンロード機能が必要な場面が多々あります。例えば、バックエンドが生成したCSVデータをファイルとしてダウンロードさせる「CSVダウンロード」機能などです。
!この記事は筆者が趣味で書いたものです。筆者の業務とは一切関係ありません。関係ありませんよ。
今回はAPI[1]から得られたデータをファイルとしてダウンロードさせたい場合のフロントエンドの実装方法について考察します。
今回考える要件は、前述のとおり、APIから得られたデータをファイルとしてダウンロードさせることです。具体的には、以下のような要件を考えます。
追加の要件次第でやり方は変わりますが、とりあえず以上の前提で考えます。
とりあえず、筆者が考える一番ベストな方法を紹介します。
それは、APIのURLにナビゲーションして全部ブラウザに任せることです。
例えば、/api/csvがCSVデータを返すAPIのエンドポイントだとします。
フロントエンドでは、これだけでCSVをダウンロードさせられます。
location.href='/api/csv';ただし、CSVデータがページとして表示されるのではなくファイルとしてダウンロードされるようにするために、API側でContent-Dispositionヘッダを使う必要があるかもしれません。
これで済ませられる場合は、これが一番シンプルだし望ましい方法であるというのが筆者の考えです。
ファイルのダウンロードをブラウザに任せることは多くのメリットがあります。例えば、ダウンロードの中断や再開、キャンセル、エラー時のリトライなどを全部ブラウザのダウンロードUIに任せられるので、フロントエンドで何も実装しなくても機能豊富なダウンロードUXを提供できます。また、ダウンロードを発生させたタブが閉じられたとしても問題なくダウンロードを継続可能です。
Q. でもダウンロードの進捗状況を表示したい……
A. ブラウザのUIに表示されますよ。
Q. でもUXのために自前の進捗表示を用意したい……
A. ユーザーが使い慣れたブラウザのUIが使えるということが最高のUXですよ。
ということで、UXのことを考えるならブラウザのファイルダウンロード機能に任せるのがベストでしょう。
しかし、これだけだと記事の内容が薄いので、この方法が何らかの事情で使えない場合のことも考えます。例えば、認証の方式の都合上で単なるナビゲーションができない場合などです。
APIのURLにナビゲーションさせる方法を使えない場合に行われがちな方法としては、fetch(またはXHR)でAPIからデータをダウンロードし、それをフロントエンドのバッファに保持して、ダウンロード完了したらそれをファイルとしてブラウザに送る方法があります。
ここでは要件上、ダウンロードの進捗状況を表示しなければならないとしましょう。この場合、fetchのダウンロードがストリーミングで行われることが利用できます。
!以降のサンプル実装では、簡単のためエラーハンドリングは省略しています。
const response=awaitfetch('/api/csv');// responseが得られた時点ではまだレスポンスヘッダの受信が完了しただけで、本文の受信はこれから// 受信データを保存するバッファを用意const resultBuffer=newArrayBuffer(0,{maxByteLength:100*1024**2});const result=newUint8Array(resultBuffer)let offset=0;// bodyはReadableStreamオブジェクトであるconst body= response.body;// 受信したチャンクごとに処理するforawait(const chunkof body){ resultBuffer.resize(offset+ chunk.length); result.set(chunk, offset); offset+= chunk.length;// 進捗表示console.log(`${offset}バイト受信済`);}// ダウンロード完了したら、バッファをBlobに変換して、// URLを発行してブラウザにダウンロードさせるconst blob=newBlob([resultBuffer.transferToFixedLength()],{type: response.headers.get('Content-Type')});const url=URL.createObjectURL(blob);const a=document.createElement('a');a.download='data.csv';a.href= url;a.click();この例では、fetchのresponse.bodyがReadableStreamオブジェクトであることを利用して、ダウンロードの進捗状況を表示しています。ReadableStreamオブジェクトはこのようにfor-await-of構文でデータを受信したそばから処理できます。今回はconsole.logでこれまでに受信したバイト数を表示しています。
もしあらかじめファイルサイズが判明している場合は、それを何らかの形で取得しておけばパーセンテージも表示できるでしょう。
!一応レスポンスのContent-Lengthヘッダを取得することはできますが、これはレスポンスが圧縮されていた場合には当てにならないので注意しましょう。この場合、Content-Lengthの値は圧縮後のサイズを示している一方、上記のコードのようにfetchで取得した受信データは伸長された状態で取得されるため、正しく受信割合を計算できません。
ちなみに、この例では書き込み先のArrayBuffer (resultBuffer) を都度リサイズして保存領域を確保しています。これはES2024の新機能です。また、リサイズ可能なArrayBufferからBlobを作ることができないので、transferToFixedLengthメソッドを使ってリサイズできないArrayBufferに変換しています。こちらもES2024の新機能です。
細部は異なるかもしれませんが、以上のようなやり方でファイルダウンロードを実装した経験がある方も多いのではないでしょうか。
しかし、せっかく解説したものの、筆者が思うに、これは微妙な方法です。
特に、やはりブラウザのダウンロードUIが使えないことが一番のデメリットです。この方法でも一応最終的にはブラウザのダウンロードUIを介してファイルがダウンロードされることにはなりますが、「タブ内で実際のダウンロードが進行し、完了したらダウンロードUIにファイルが表示される」(ダウンロードUI上は一瞬でダウンロードされたように見える)という点で微妙です。
また、ダウンロードの最中、データをメモリ上に保持しなければならない点も良くありません。データ量が多い場合でもデータの全体をメモリ上に乗せる必要があるため、ネイティブなファイルダウンロードに比べてメモリ使用量が悪化する恐れがあります。
上で紹介した方法では、fetchの結果としてReadableStreamオブジェクトを得ていましたが、それをブラウザにダウンロードさせるためにBlobに変換する必要があるため、ストリーミングを活かせずに一旦全データをメモリ上に保持しなければなりませんでした。
これを改善して、ReadableStreamを直接ブラウザのダウンロードUIに接続させたいですね。そうすれば、クライアント上でReadableStreamを処理しつつ、ブラウザのダウンロードUIもいい感じに動くはずです。
実は、これを実現するためにService Workerが利用できます。しかし、これは荒業とも言える方法です。
すなわち、ブラウザのダウンロードUIというのは、URLからファイルをダウンロードするときに動作します。そして、Service Workerは、ブラウザがリクエストを送信するときに、そのリクエストをフックして自分でレスポンスを返すことができます。これを利用して、Service WorkerがハンドルするURLからファイルをダウンロードさせることが考えられますね。
つまり、手元にReadableStreamがある場合、それを何とかしてService Workerに送ります。Service Workerはダウンロード用のURLからそのデータをオウム返しします。ブラウザをそのURLにナビゲーションさせると、ブラウザのダウンロードUIが動作して、ダウンロードが始まります。
この仕組みであれば、クライアントで作成されたデータをダウンロードUIに接続させてダウンロードすることもできます。
筆者は記事のためとはいえこれのサンプル用意するの大変だなあと思っていたのですが、探してみたところまさにこれをやってくれるライブラリがありました。ここに書かれていることを試したい場合はこのライブラリのサンプルを見てみてください。
https://github.com/jimmywarting/StreamSaver.js
ところで、上記のライブラリのREADMEを見に行くと、今どきはFile System API(およびその拡張であるFile System Access API)があるからこのライブラリは必要なくなっていくだろうと書かれています。ということで、File System APIを使う方法を見てみましょう。
!ここで取り扱うFile System Access APIについては、Google Chromeに実装されているものの、FirefoxおよびSafariからは反対されています。そのため、一応紹介しますが、このままではあまり将来性が無さそうな仕様であることに注意してください。
また、File System Access APIを使う以降のサンプルはChromeでしか動作しません。
具体的な実装はこのようになります。
// ユーザーに保存先を選択してもらうconst handle=awaitshowSaveFilePicker({suggestedName:"data.csv",types:[{description:"CSVファイル",accept:{"text/csv":[".csv"],},},],});const response=awaitfetch('/');const body= response.body;let offset=0;const writable=await handle.createWritable();forawait(const chunkof body){// ファイルに書き込むawait writable.write(chunk); offset+= chunk.length;// 進捗表示console.log(`${offset}バイト受信済`);}// ファイルを閉じるawait writable.close();要するに、File System Access APIに由来するshowSaveFilePickerという関数を使ってユーザーに保存先を選択してもらいます。
そうするとFileSystemFileHandleオブジェクトが得られるので、それに対して書き込みを行うことで、ユーザーが選択したファイルへのダウンロードができます。
この方法では、ストリーミングを活かしつつユーザーの手元にファイルを保存するという目的は達成できるものの、ブラウザのダウンロードUIには何も表示されません。そもそもダウンロードではないからですね。
そのため、前述のUXという観点では結局おすすめできません。File System Access APIのissueにはダウンロードUIと接続したいという提案があります(issueを建てたのは前述のライブラリを作成した人です)が、そこまで興味を持たれていないようです。
今回は、APIから得られたデータをファイルとしてダウンロードさせる方法について考察しました。
やはり、ブラウザのダウンロードUIを使うのが一番シンプルで望ましい方法であるというのが筆者の考えです。しかし、そのためにはダウンロード用のURLにナビゲーションできるようにAPIを作る必要があります。サーバーサイドも交えて、これができるように設計するのが望ましいでしょう。
どうしてもそれができない場合には、最善のUXを諦めるか、あるいは(ライブラリがあるとはいえ)Service Workerを持ち出す大がかりな方法を使う必要があります。
ファイルシステムというのはどうしても高いセキュリティが求められる領域ですから、自由なアクセスには制限がかかります。Web標準の発展という観点から見てもなるべくブラウザに任せるのが良さそうです。
この記事では、バックエンドによって実装されHTTPエンドポイントとして公開されているものを指します。これをWeb APIなどと呼ぶ流派もありますが、単にAPIと言っても伝わるので、この記事ではAPIと呼びます。↩︎
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。