ファイル分割アップロード

``Vue.js``と``Laravel``によるSPAを構築しました。

``Vue``から``Laravel``へのファイルアップロード機能を実装しており、
大きいサイズのファイルをアップロードする際に少し苦労したので、
その備忘録としてここに残します。

# 実装方法
初めは1つのファイルをフロントエンドからバックエンドへそのまま送る予定でした。

しかし、5GBほどのファイルをアップロードすると、通信環境によってはタイムアウトでエラーが発生し、設定値等を変更しても改善しませんでした。

なのでフロントから分割してアップロードし、
バックで統合、保存する方式で実装することに決めました。

# Vue.jsの実装
``<input>``は **multiple** に設定しているので複数選択可能です。
それぞれのファイル毎に``uploadChunks()``を呼び出します。

実際のソースでは``upload()``が呼び出されるとバリデーションチェック等を行っています。

```js
const upload = () => {

    /* バリデーションチェック等実装 */
    
    inputFileList.value.forEach((file) => {
        uploadChunks(file);
    })
}
```

<br>

## uploadChunks()
``uploadChunks()``の処理です。
ここでファイルを特定のサイズに分割してサーバーサイドにアップロードしています。

```JS
const uploadChunks = async(file) => {
    const url = "api/upload";
    const chunkSize = 1024 * 1024 * 50;
    const fileSize = file.size;
    const maxChunkIndex = Math.floor(fileSize/chunkSize) + (fileSize%chunkSize !== 0) - 1;
    const uniqueId = getUniqueId();

    let start = 0;
    let chunkIndex = 0;

    while(start < file.size) {
        const end = Math.min(start + chunkSize, file.size); 
        const chunk = file.slice(start, end);
        const formData = new FormData();

        formData.append('chunk', chunk);
        formData.append('chunkIndex', chunkIndex);
        formData.append('maxChunkIndex', maxChunkIndex);
        formData.append('fileName', file.name);
        formData.append('uniqueId', uniqueId);

        start += chunkSize;
        chunkIndex ++;

        try {
            const response = await axios
                .post(url, formData, {
                    headers: {
                        "content-type": "multipart/form-data",
                    }
                })

            const result = response.data;
            console.log(result);
            
        } catch(error){
            console.error(error);
            break;
        }
    }
}
```

それぞれ分割して説明します。

### uploadChunks➀

```JS
const uploadChunks = async(file) => {
    const url = "api/upload";
    const chunkSize = 1024 * 1024 * 50;
    const fileSize = file.size;
    const maxChunkIndex = Math.floor(fileSize/chunkSize) + (fileSize%chunkSize !== 0) - 1;
    const uniqueId = getUniqueId();

    let start = 0;
    let chunkIndex = 0;
```

ここでは変数・定数を宣言しています。

| 名前 | 説明 |
|:-:|:--|
| url  | APIのルート。|
| chunkSize  | 分割ファイルそれぞれのサイズ。 <br> 今回は50MBに設定。 |
| fileSize  | アップロードファイルのサイズ。 |
| maxChunkIndex  | 分割ファイルに対し、0から順にIndexを振っていく。 <br> その最大値。  |
| uniqueId  | Laravelでファイルに名前を付ける際に使用。  |
| start  | ファイル分割のスタート位置。  |
| chunkIndex  | 分割ファイルのIndex。 |


### uploadChunks➁

```JS
while(start < file.size) {
        const end = Math.min(start + chunkSize, file.size); 
        const chunk = file.slice(start, end);
        const formData = new FormData();

        formData.append('chunk', chunk);
        formData.append('chunkIndex', chunkIndex);
        formData.append('maxChunkIndex', maxChunkIndex);
        formData.append('fileName', file.name);
        formData.append('uniqueId', uniqueId);

        start += chunkSize;
        chunkIndex ++;

```

ここでも定数の宣言を行っています。

| 名前 | 説明 |
|:-:|:--|
| end  | 分割ファイルの区切り位置。 <br> 50MBずつ分割するが割り切れない場合、``Math.min()``でファイルの最大サイズの値を分割ファイルの区切り位置とする。|
| chunk  | 分割ファイル  |
| formData  | formDataオブジェクト  |

そして、``formData.append()``でキーに値をセットし、
``start``と``chunkIndex``を更新しています。

<br>

**※ start と end の補足**
例：225MBのファイルを50MBずつ送信する場合
　分割ファイル0: 0MB ~ 50MB
　分割ファイル1: 50MB ~ 100MB
　分割ファイル2: 100MB ~ 150MB
　分割ファイル3: 150MB ~ 200MB
　分割ファイル4: 200MB ~ Math.min(
250MB
, **225MB**)

### uploadChunks➂

```js
try {
    onst response = await axios
        .post(url, formData, {
            headers: {
                "content-type": "multipart/form-data",
            }
        })

    const result = response.data;
    console.log(result);
            
} catch(error){
    console.error(error);
    break;
}
```

ここで分割ファイル毎にLaravelへ``POST``しています。

``formData``を``POST``するのでヘッダーに``"content-type": "multipart/form-data"``を設定しています。

エラー/成功のハンドリングはご自由に。

# Laravelの実装
こちらでフロントから受け取ったファイルをハンドリングしています。
こちらも分けて説明していきます。

※バリデーションチェック等は省いています。
```php
class uploadController {

    public function upload(Request $request) {

        $result = [];
            
        try {
            $chunk = $request->file('chunk');
            $chunkIndex =  $request->input('chunkIndex');
            $maxChunkIndex = $request->input('maxChunkIndex');
            $originalFileName = str_replace([' ', '　'], '', $request->input('fileName')); //replace blank
            $uniqueId = $request->input('uniqueId');

            $chunkFileName = $uniqueId.'_'.$chunkIndex.'_'.$originalFileName;
            $uniqueFileName = $uniqueId.'_'.$originalFileName;

            $result = [];

            Storage::disk('temp')->putFileAs('', $chunk, $chunkFileName);

            $response = $this->mergeChunks($chunkFileName, $uniqueFileName, $chunk);

            if($response) {
                if($maxChunkIndex == $chunkIndex) {
                    $result = [
                        "status" => "200"
                        , "message" => "成功"
                        , "data" => [
                            'originalFileName' => $originalFileName
                            , 'fileName' => $uniqueFileName
                        ]
                    ];

                } else {
                    $result = [
                        "status" => "201"
                    ];
                }

            } else {
                $result = [
                    "status" => "500"
                    , "message" => "失敗"
                ];
            }

            Storage::disk('temp')->delete($chunkFileName);
            return response()->json($result, $result["status"]);

        } catch(\Exception $error) {
            Logger('失敗', ['error' => $error]);
            $result = [
                "status" => "500"
                , "message" => "失敗"
                , "errorMessage" => $error
            ];
            return response()->json($result, $result["status"]);
        }
    }

    public function mergeChunks($chunkFileName, $uniqueFileName, $chunk) {

        $searchTarget = Storage::disk('temp')->path($chunkFileName);
        $outputFilePath = Storage::disk('public')->path($uniqueFileName);
        $command = config('app.fileAppendCommand');
        $command2 = config('app.fileAppendCommand2');

        try {
            if(Storage::disk('public')->exists($uniqueFileName)) {
                exec("$command $outputFilePath $searchTarget $command2 $outputFilePath");
            } else {
                Storage::disk('public')->putFileAs('', $chunk, $uniqueFileName);
            }
            return true;
            
        } catch(\Exception $error) {
            Logger('失敗', ['error' => $error]);
            return false;
        }
    }
};
```

### upload①

```php
public function upload(Request $request) {

        $result = [];
            
        try {
            $chunk = $request->file('chunk');
            $chunkIndex =  $request->input('chunkIndex');
            $maxChunkIndex = $request->input('maxChunkIndex');
            $originalFileName = str_replace([' ', '　'], '', $request->input('fileName'));
            $uniqueId = $request->input('uniqueId');

            $chunkFileName = $uniqueId.'_'.$chunkIndex.'_'.$originalFileName;
            $uniqueFileName = $uniqueId.'_'.$originalFileName;

            $result = [];

            Storage::disk('temp')->putFileAs('', $chunk, $chunkFileName);

            $response = $this->mergeChunks($chunkFileName, $uniqueFileName, $chunk);
```

ここではリクエストで受け取った内容を取得しています。
``$originalFileName``は、ファイル名の空白を``str_replace``を使用して削除しています。

それぞれの宣言が終わったら、
[Storageクラス](https://readouble.com/laravel/10.x/ja/filesystem.html)を利用し、受け取ったファイルを一時領域に格納しています。

格納後、同じController内の``mergeChunks()``を呼び出しています。
（``mergeChunks()``の説明は後ほど）

### upload②

```php
if($response) {
    if($maxChunkIndex == $chunkIndex) {
        $result = [
            "status" => "200"
               , "message" => "成功"
               , "data" => [
                   'originalFileName' => $originalFileName
                   , 'fileName' => $uniqueFileName
               ]
        ];

    } else {
        $result = [
            "status" => "201"
        ];
    }

} else {
    $result = [
        "status" => "500"
        , "message" => "失敗"
    ];
}

Storage::disk('temp')->delete($chunkFileName);
return response()->json($result, $result["status"]);
```

``mergeChunks()``は論理値を返却します。

``true``の場合はファイル結合成功で200番台のステータスを``$result``にセットします。
``$chunkIndex``で判断し、最終ファイルだった場合は``200``、途中ファイルの場合は``201``。

``false``の場合はファイル結合失敗で``500``を``$result``にセットします。

セットしたら、一時領域に保存していた分割ファイルを削除します。
その後、``$result``をフロントに返却します。

### upload③
```php
} catch(\Exception $error) {
    Logger('添付ファイル配置失敗', ['error' => $error]);
    $result = [
        "status" => "500"
        , "message" => "失敗"
        , "errorMessage" => $error
    ];
    return response()->json($result, $result["status"]);
}
```

ここでは処理中にエラーが発生した場合のハンドリングを行っています。
ログに出力し、エラーコードをフロントに返却しているだけです。

### mergeChunks

```php
public function mergeChunks($chunkFileName, $uniqueFileName, $chunk) {

    $searchTarget = Storage::disk('temp')->path($chunkFileName);
    $outputFilePath = Storage::disk('public')->path($uniqueFileName);

    try {
        if(Storage::disk('public')->exists($uniqueFileName)) {
            exec("cat $outputFilePath $searchTarget > $outputFilePath");
        } else {
            Storage::disk('public')->putFileAs('', $chunk, $uniqueFileName);
        }
        return true;
            
    } catch(\Exception $error) {
        Logger('失敗', ['error' => $error]);
        return false;
    }
}
```

まず、一時保存領域にある分割ファイルのパスと、結合ファイル保存先のパスを宣言しています。

その後、ファイルのハンドリングを行います。

結合ファイル保存先に既にファイルが存在している場合
　→ 一時保存領域に格納しているフロントから受け取ったファイルと結合。

結合ファイル保存先に既にファイルが存在していない場合
　→ 結合ファイル保存先のパスに新たにファイルを作成。

処理に成功したら``true``を返し、エラーの場合は``false``を返します。


# おわりに
今までもファイルアップロード機能を開発した経験はありましたが、
5GBを超える大きいサイズを扱うのは初めてで何かと苦労しました。

改善点等、コメントいただければ幸いです。

それでは。