3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

vue+flowjs+laravelで大容量ファイルアップロードした時の構成

Last updated at Posted at 2020-07-19

ブラウザの上限を超えたサイズのファイルを送るときに結構苦労したので備忘録として残す。

一応暫定対応という形で実装したが、アップロードに関して知見が浅く、より良い方法があればぜひコメントで教えていただきたい。

考え所

クライアントから、サーバーに大きなファイルをアップロードするにあたって留意した事項は下記の通り。

  1. アップロード後すぐに閲覧可能
  2. サーバーのストレージを圧迫しない
  3. アップロード中のメモリ使用量
  4. アップロード中のリクエスト制限
  5. ブラウザの上限に引っかからない

1に関しては大きいファイルはS3においておきたいが、クライアントから直接アップロードさせるわけにもいかず、一旦アプリケーションサーバーを経由してS3におく必要があったため、S3から参照させるまで待たせると、クライアント→アプリケーションサーバー→S3で単純に考えれば2倍時間がかってしまう。

2は1を回避し、アプリケーションサーバーでファイルを参照させるとなると、かなり大きなストレージを積んだサーバーにしなければいけいない。

3はチャンクの大きさと同時接続数によってはアプリケーションサーバーのメモリがカツカツになる。

4に関しては、マルチパートで送るため、チャンクサイズによってはリクエスト上限に引っかかる

構成と処理の流れ

アプリケーションサーバーへのアップロード

サーバーからクラウドストレージへのアップロード

sample.jpg

アプリケーションサーバーへのリクエスト圧迫と、メモリ圧迫を回避するために、大容量ファイルをアップロードするようのEC2インスタンス(ファイル管理サーバー)を立てておく。

クライアントからファイルサーバーへflowjsでアップロード、Laravelだとstorageディレクトリからpublicディレクトリへシンボリックリンクを貼れるので、そこからホスティング。ただし参照先をStorageディレクトリ内部でマウントしたEFSにする。
ファイルサーバーのクーロンでstorage内部のファイルをawsのライブラリを使用してS3にマルチパートアップロードし、参照をアプリケーションサーバーからS3に切り替え、EFSのファイルを削除する。

flowjs

uploadメソッドは親からrefで参照してコールする、一応Vuexでアップロード進捗の管理

uploader.vue
<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

api.php
Route::group(['middleware' => ['jwt']], function () {
    //GET POST両方受ける(CORSは良きように設定。今回はLaravel6系だとOPTIONリクエストをミドルウェアでキャッチできないのでLaravel7系のCORS設定を利用)
    Route::match(['GET', 'POST'], '{id}/file/chunked/upload', 'UploadController@chunkedUpload');
});
uploader.php
<?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でコマンド生成タスクスケジューラーに追加

command.php
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がまあまあ癖があり動くまで結構大変だった。そもそもマルチパートアップロードの情報があまり見つからず、普通はどのようにアップロードしてるのか気になったが、そもそも大容量ファイルは基本受け付けないのかもしれない。

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?