ブラウザからS3に巨大なファイルを低メモリで送りつけるアレ
ブラウザから8GB以上の巨大なファイルや多量のファイルをS3に送りつけるには、MultiPart Uploadの機能を使っていく必要がある。
具体的には、データを5MB以上のchunkに分け、分割して送りつけることになる。
データが8GBいかないケースでも、ファイル読み込み量を分割できる機構が使えるので、メモリにも優しい。
それなりに使う場面はありそう。
1.とりあえずドキュメント通り実装して徳を積む
マルチパートアップロードの概要
Multipart Upload API を使用したオブジェクトのアップロード
(1) S3のCORS設定
- ExposeHeader => ETag を指定する。
- AllowHeader
<AllowedHeader>Authorization</AllowedHeader>
<AllowedHeader>Content-Type</AllowedHeader>
<AllowedHeader>User-Agent</AllowedHeader>
<AllowedHeader>x-amz-date</AllowedHeader>
<AllowedHeader>x-amz-user-agent</AllowedHeader>
<AllowedHeader>x-amz-storage-class</AllowedHeader>
<AllowedHeader>x-amz-acl</AllowedHeader>
この辺を設定する。PUT権とかはあとで動的に付与する。
(2) ブラウザのfile api
ブラウザのfile api でファイル名やbyte数取得。
この段階ではFileReaderによるメモリ上への展開は行わないこと。
余裕があればWebWorkerで別スレッド立ててUIスレッドへの影響範囲を小さくする。
(3) サーバーから署名付きURL取得
ブラウザからのsignedURL発行要求を認可,返却。
S3のbucketやオブジェクトに対し、PUTやHEADリクエストが通るようにする。
この辺までは割といつも通り。
(4) S3::createMultipartUpload
S3の該当keyに対して createMultipartUpload 要求。uploadIdを取得。
要するに「今から分割ファイルでアップロードするぜ?」という宣言みたいなの。
このuploadIdは今後のリクエストすべてに同梱することとなる。
(5) 分割したblobを送りつける S3::uploadPart
FileReaderにいきなり巨大なfileオブジェクト食わすと多分2GBあたりでブラウザが音も立てず死ぬ。
chunk送信用のループ内でFile::slice(startByte , endByte)してblob化して都度捨てるのがミソ
この部分しかメモリ上に乗らないようになる。これが可能なのよく知らなかった。
並列アップロード数のロジックとかchunkごとの大きさ(5MB以上)とかは様子見ながら好きにカスタムしたらいいんじゃないかな。
この時帰ってくるETag値と、送ったPartNumber値はこういう形式でとっておく。
const multipartMap= { Parts: [] };
//...ループ内
multipartMap.Parts[partNumber - 1] = {
ETag: 返ってきたETag値,
PartNumber: partNumber
};
(6) 完了 S3::completeMultipartUpload
すべて送り終わったら、completeMultipartUploadリクエストを送る。
この時、先ほどのmultipartMapを同梱して送りつける。
これで、初めてS3のバケット上にオブジェクトができる。
Code
4-6まではこんな感じになります。今回はaws-sdk使用。全部Promise返すモードがいつの間にかついてて良い。
(並列アップロードのロジック、chunkごとの大きさ指定(5MB以上)とかエラー処理は記事のためあえて省いてる。)
const upload = async (s3 , s3Params , file)=>{
const mime = Mime.lookup(file.name);
const multiPartParams = s3Params.ContentType ? s3Params : {ContentType : mime , ...s3Params};
const allSize = file.size;
const partSize = 1024 * 1024 * 5; // 5MB/chunk
const multipartMap = {
Parts: []
};
/* (4) */
const multiPartUploadResult = await s3.createMultipartUpload(multiPartParams).promise();
const uploadId = multiPartUploadResult.UploadId;
/* (5) */
let partNum = 0;
const {ContentType , ...otherParams} = multiPartParams;
for (let rangeStart = 0; rangeStart < allSize; rangeStart += partSize) {
partNum++;
const end = Math.min(rangeStart + partSize, allSize);
const sendData = await new Promise((resolve)=>{
let fileReader = new FileReader();
fileReader.onload = (event)=>{
const data = event.target.result;
let byte = new Uint8Array(data);
resolve(byte);
fileReader.abort();
};
const blob2 = this.file.slice(rangeStart , end);
fileReader.readAsArrayBuffer(blob2);
})
const progress = end / file.size;
console.log(`今,${progress * 100}%だよ`);
const partParams = {
Body: sendData,
PartNumber: String(partNum),
UploadId: uploadId,
...otherParams,
};
const partUpload = await s3.uploadPart(partParams).promise();
multipartMap.Parts[partNum - 1] = {
ETag: partUpload.ETag,
PartNumber: partNum
};
}
/* (6) */
const doneParams = {
...otherParams,
MultipartUpload: multipartMap,
UploadId: uploadId
};
await s3.completeMultipartUpload(doneParams)
.promise()
.then(()=> alert("Complete!!!!!!!!!!!!!"))
}
理屈上、fileObjectの参照が残ってれば,中断やキャンセル、断片ごとのリトライも可能なはず。
S3 Multipart Upload Request のタイムアウト設定を長めにしておいてもいいかも。
2.めんどくさいのでOSSを使う
上記までをライブラリ化しようと思ってたけど、ある意味当然のごとく既に存在した。。
仕様も非同期チェーンも特に覚えたくない人は、早くお家帰りたいのでこっちのありものを使う。
EvaporateJS
一時停止やキャンセル、リトライ、chunkサイズの指定や同時送信数も指定可能。
webWorker上の別スレッドで動かすモードはついてないみたいだけど、よくまとまっててすごい。
ただしPromise使うので、typescriptやbabel等のプリプロセッサ通さない人は、es6-promiseあたりのポリフィルを念のため入れること。
以上です。