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

[ブラウザの上限を超えたサイズ](https://www.smartfile.com/blog/file-size-limits-for-uploads-through-web-browsers/)のファイルを送るときに結構苦労したので備忘録として残す。

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

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

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

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

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

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

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

# 構成と処理の流れ
アプリケーションサーバーへのアップロード

 * [flowjs](https://github.com/flowjs/flow.js)
 * [flow-php-server](https://github.com/flowjs/flow-php-server)
 * [AWS EFS](https://aws.amazon.com/jp/efs/)

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

 * [AWS SDK for PHP](https://docs.aws.amazon.com/ja_jp/sdk-for-php/v3/developer-guide/s3-multipart-upload.html) 

![sample.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209804/8374f4b5-95e8-ca74-c766-9f41f518bce5.jpeg)

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

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

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

```vue: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

```php:api.php
Route::group(['middleware' => ['jwt']], function () {
    //GET POST両方受ける(CORSは良きように設定。今回はLaravel6系だとOPTIONリクエストをミドルウェアでキャッチできないのでLaravel7系のCORS設定を利用)
    Route::match(['GET', 'POST'], '{id}/file/chunked/upload', 'UploadController@chunkedUpload');
});
```

```php: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でコマンド生成タスクスケジューラーに追加

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