ブラウザの上限を超えたサイズのファイルを送るときに結構苦労したので備忘録として残す。
一応暫定対応という形で実装したが、アップロードに関して知見が浅く、より良い方法があればぜひコメントで教えていただきたい。
考え所
クライアントから、サーバーに大きなファイルをアップロードするにあたって留意した事項は下記の通り。
- アップロード後すぐに閲覧可能
- サーバーのストレージを圧迫しない
- アップロード中のメモリ使用量
- アップロード中のリクエスト制限
- ブラウザの上限に引っかからない
1に関しては大きいファイルはS3においておきたいが、クライアントから直接アップロードさせるわけにもいかず、一旦アプリケーションサーバーを経由してS3におく必要があったため、S3から参照させるまで待たせると、クライアント→アプリケーションサーバー→S3
で単純に考えれば2倍時間がかってしまう。
2は1を回避し、アプリケーションサーバーでファイルを参照させるとなると、かなり大きなストレージを積んだサーバーにしなければいけいない。
3はチャンクの大きさと同時接続数によってはアプリケーションサーバーのメモリがカツカツになる。
4に関しては、マルチパートで送るため、チャンクサイズによってはリクエスト上限に引っかかる
構成と処理の流れ
アプリケーションサーバーへのアップロード
サーバーからクラウドストレージへのアップロード
アプリケーションサーバーへのリクエスト圧迫と、メモリ圧迫を回避するために、大容量ファイルをアップロードするようのEC2インスタンス(ファイル管理サーバー)を立てておく。
クライアントからファイルサーバーへflowjsでアップロード、Laravelだとstorageディレクトリからpublicディレクトリへシンボリックリンクを貼れるので、そこからホスティング。ただし参照先をStorageディレクトリ内部でマウントしたEFSにする。
ファイルサーバーのクーロンでstorage内部のファイルをawsのライブラリを使用してS3にマルチパートアップロードし、参照をアプリケーションサーバーからS3に切り替え、EFSのファイルを削除する。
flowjs
uploadメソッドは親からrefで参照してコールする、一応Vuexでアップロード進捗の管理
<template>
<div>
<v-file-input
v-model="files"
prepend-icon="mdi-camera"
multiple
show-size
counter
accept="image/*,video/*"
>
<template v-slot:selection="selectionObj">
<v-chip
small
label
close
color="primary"
@click:close="remove(selectionObj)"
>
{{ selectionObj.text }}
</v-chip>
</template>
</v-file-input>
</div>
</template>
<script>
import Flow from '@flowjs/flow.js'
import {mapGetters} from 'vuex'
export default {
name: "Uploader",
data: () => ({
files: [],
flow: {},
assetPath: null,
}),
computed: {
...mapGetters({
token: 'auth/token'
}),
},
mounted() {
this.setUpFlow()
},
methods: {
setUpFlow() {
this.flow = new Flow({
target: Laravel.fileManagementUrl + `/api/${this.$route.params.id}/file/chunked/upload`,
chunkSize: 15 * 1024 * 1024, //15MB chunk
forceChunkSize: true, //必ず15mb以下
headers: {
Authorization: 'Bearer ' + this.token //ヘッダーに認証情報追加
},
allowDuplicateUploads: true, //同名ファイルのアップロード許可
maxChunkRetries: 3, //チャンクアップロードに失敗した時のリトライ回数上限
})
this.$emit('isSupported', this.flow.support)
this.flow.on('fileAdded', (file, event) => {
file.name = this.generateID() + '_' + file.name
})
this.flow.on('fileSuccess', (file, message) => {
let files = this.$store.getters['upload/files']
files = files.map(f => {
if (file.name === f.name) {
f.uploaded = true
}
return f
})
this.$store.dispatch('upload/setFiles', files)
});
this.flow.on('progress', () => {
this.$store.dispatch('upload/setUploadedSize', this.flow.sizeUploaded())
this.$store.dispatch('upload/setRemainingTime', this.flow.timeRemaining())
this.$store.dispatch('upload/setUploadProgress', Math.ceil(this.flow.progress() * 100))
})
this.flow.on('complete', () => {
this.$store.dispatch('upload/setCompletedStatus', true)
setTimeout(() => {
this.$store.dispatch('upload/initialize')
}, 3000)
})
},
upload() {
if (this.$store.getters['upload/status']) {
this.$toast.info('他のファイルをアップロード中です')
return false
}
if (this.files.length) {
this.flow.addFiles(this.files)
let fileNames = this.flow.files.map(flowFile => flowFile.name)
this.setUploadStore()
this.flow.upload()
return fileNames
} else {
return []
}
},
setUploadStore() {
this.$store.dispatch('upload/initialize')
let files = this.flow.files.map(flowFile => {
return {
name: flowFile.name,
uploaded: false
}
})
this.$store.dispatch('upload/setFiles', files)
this.$store.dispatch('upload/setTotalUploadSize', this.flow.getSize())
this.$store.dispatch('upload/setUploadedSize', 0)
this.$store.dispatch('upload/setRemainingTime', 0)
this.$store.dispatch('upload/setUploadProgress', 0)
this.$store.dispatch('upload/setStatus', true)
},
generateID() {
return Math.random().toString(36).substr(2, 9)
},
remove(selectionObj) {
this.files = this.files.filter(i => i !== selectionObj.file)
},
}
}
</script>
<style scoped>
</style>
flow-php-server
Route::group(['middleware' => ['jwt']], function () {
//GET POST両方受ける(CORSは良きように設定。今回はLaravel6系だとOPTIONリクエストをミドルウェアでキャッチできないのでLaravel7系のCORS設定を利用)
Route::match(['GET', 'POST'], '{id}/file/chunked/upload', 'UploadController@chunkedUpload');
});
<?php
namespace App\Http\Controllers;
use Flow\File;
use Flow;
class UploadController extends Controller
{
public function chunkedUpload()
{
//デフォのコードだと動かないのでカスタマイズ
$config = new Flow\Config();
$config->setTempDir(storage_path().'/tmp/chunk/');
$request = new Flow\Request();
$upload_folder = storage_path().'/app/public/tmp/efs/';
$upload_file_name = $request->getFileName();
$upload_path = $upload_folder.$upload_file_name;
$file = new File($config);
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
if ( ! $file->checkChunk() ) {
return response('',204);
}
} else {
if ($file->validateChunk()) {
$file->saveChunk();
} else {
return response('', 400);
}
}
if ($file->validateFile() && $file->save($upload_path)) {
return response('File upload was completed', 200);
}
}
}
S3にアップロード
Laravelでコマンド生成タスクスケジューラーに追加
public function handle()
{
//pub key sec keyとかはenvから勝手に読んでくれる優れもの
$s3Client = new S3Client([
'region' => env('AWS_DEFAULT_REGION'),
'version' => 'latest'
]);
$local_files = Storage::files('public/tmp/efs');
foreach ($local_files as $local_file_path) {
$file_path_info = pathinfo($local_file_path);
$source = storage_path().'/app/public/tmp/efs/'.$file_path_info['basename'];
$s3_file_path = $this->createS3FilePath($file_path_info);
//multi part upload
$uploader = new MultipartUploader($s3Client, $source, [
'bucket' => env('AWS_BUCKET'),
'key' => $s3_file_path, //ややこしいがファイルのフルパス
]);
try {
$result = $uploader->upload();
if ($result['ObjectURL']) {
Storage::disk('local')->delete('public/tmp/efs/'.$file_path_info['basename']);
echo "Upload complete: {$result['ObjectURL']}\n";
}
} catch (MultipartUploadException $e) {
echo $e->getMessage() . "\n";
}
}
}
まとめ
flowjsがまあまあ癖があり動くまで結構大変だった。そもそもマルチパートアップロードの情報があまり見つからず、普通はどのようにアップロードしてるのか気になったが、そもそも大容量ファイルは基本受け付けないのかもしれない。