なんの記事?
署名付きURLを使ってフロントエンドアプリ(React)のファイルをダウンロードする機能を作ったら署名の有効期限で色々困ったことがあったので困ったこととその解決方法についてメモ。
この記事では署名付きURLの発行の話は書かない。発行された署名付きURLを使う側について書く。
後半部分に書いたがこちらの方式のほうがスマートだと思う。この記事ではベストではないが制約があるなかで選択した別方式について書く。
モチベーションとか問題解決の経緯をスキップしてダウンロードの実装方法を見たい場合はサンプルコードへ。
署名付きURLを使うモチベーション
- ファイルアップロードにかかる時間を減らしたい
- バックエンドアプリによってファイルのGET/PUTの認可制御を行いたい(ストレージサービスのみで実現可能な認可制御より複雑な条件による制御をおこないたい)
発生した問題
ダウンロードのアクションまでに署名の有効期限が切れる
認可制御のためダウンロードに署名付きURLを利用する。
アップロードしたファイルを画面からダウンロードさせたいが、署名の有効期限が短いとユーザーがダウンロードのアクションを起こす前に署名の有効期限が切れてしまう。
具体例
imgタグで署名付きURLを利用する場合、ページの読み込みが完了した時点でファイルのダウンロードが開始するいるため署名の有効期限が切れる心配はほぼなし。(loadイベントまでにファイルのダウンロードが開始している)
<img src='署名付きURL'>
aタグで署名付きURLを利用する場合、ユーザーがこのリンクをクリックしてダウンロードのアクションを起こすまでダウンロードが開始されない。ユーザーがリンクをクリックするまでの時間次第で署名の有効期限が切れてしまう。
<a href='署名付きURL' download>
解決策
画面を初期表示したタイミングでダウンロードを済ませることで有効期限切れを回避する。
ダウンロードしたファイルはブラウザに保存する。
どこに保存するのか
ブラウザのメモリ上(Blobオブジェクトにしてブラウザのメモリに一時保存)⭕
- ブラウザのJavaScriptエンジンヒープ領域。ざっくり手元のchromeでjsHeapSizeLimitを見ると約2GBの領域が確保されている。これだけあれば少々のファイルをダウンロードして保持していても問題なさそう
cookie、localStorage❌
- cookieに保存できるサイズは最大4kbまでなのでファイル保存先には不適切
- そもそもウィンドウやタブを閉じても保持するような情報じゃないのでcookie、localStorageは不適切
sessionStorage❌
- 1オリジン5MBまでなので、これもファイル保存先にするには明らかにサイズが足りず不適切
サンプルコード
画面を初期表示したタイミングで署名付きURLを使って複数のファイルをダウンロードしてaタグでダウンロードリンクを表示するサンプルコード。
ファイルダウンロード用関数
const contentDownload = async (params: {
signedUrl: string,
fileName: string,
onSuccess: (localUrl: string) => void,
onError?: (e: any) => void,
onFinal?: () => void,
}) => {
try {
const response = await axios({
url: params.signedUrl,
method: 'GET',
responseType: 'blob',
})
const blob = new Blob([response.data], {
type: response.data.type
});
const url = URL.createObjectURL(blob);
// File オブジェクトを作りたい場合は↓
// const file = new File([response.data], params.fileName, {type: response.data.type});
params.onSuccess(url);
} catch (e) {
if (params.onError) {
params.onError(e);
}
} finally {
if (params.onFinal) {
params.onFinal();
}
}
}
関数呼び出し
// ダウンロードするファイルの型定義
type Content = {
// fileNameはバックエンドアプリで管理している情報を取得して使う
fileName: string,
// localURLはダウンロードしたファイルから作成する
localURL: string,
}
const [contents, setContents] = useState<Content[]>([]);
const downloadContents = useCallback(async () => {
// バックエンドAPIから取得したファイルに関するデータbackEndData
if (backEndData) {
// 逐次ダウンロードすると署名の有効期限が切れる可能性があるのでPromise.allで並列処理が必要
await Promise.all(
contents.map((content) => {
return contentDownload({
signedUrl: backEndData.signedUrl,
fileName: backEndData.name,
onSuccess: (localUrl) => {
const newContent = {
fileName: backEndData.name,
localUrl,
};
// stateに保存しておく
setContents(prevContents => Array.from(prevContents).concat(newContent));
},
});
}));
}
}, [contents]);
useEffect(() => {
setContents();
downloadContents();
}, [downloadContents]);
// 中略
return (
{contents.map(content => {
return <a href={content.localUrl} download={content.fileName}>{content.fileName}</a>
})}
);
課題感
長時間ブラウザのメモリを占領してしまう
少なくともユーザーが画面を閉じるか遷移するまでの長時間、ブラウザのメモリをファイルのような大きなデータで埋めたくない。
ブラウザのJavaScriptエンジンヒープ領域がどういうロジックで決定されているかの資料が見つからないが、せいぜい2〜3GBだと思う。数百MBのファイルダウンロードがある場合この方式でメモリに抱えさせるとメモリを枯渇させるかもという懸念もある。
ダウンロードのタイミングで署名付きurlを取得する方式
ユーザーがダウンロードのアクション起こしたタイミングでGETのための署名付きURLを取得すればブラウザにBlobを長い間持たセル必要はなく、保存先の課題がなくなる。
ただしその方式を選択肢た場合、ファイルを特定するキーを元にユーザーがファイルをダウンロードする権限を持っているか判断し、署名付きURLを返すようなAPIがバックエンドアプリに必要になる。今回はそういったAPIの作成が難しい背景もあってブラウザのメモリにファイルを保持する方式を選択した。
まとめ
こういった面倒ごとがあるのを念頭に置いて署名付きURL使う選択をしようねという話。
もっと良いやり方があればコメントで教えてください!
参照
https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html
https://www.w3.org/TR/FileAPI/#url-model
https://www.w3.org/TR/FileAPI/#lifeTime
https://developer.mozilla.org/ja/docs/Web/API/Blob
https://ja.javascript.info/blob