LoginSignup
1
2

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

Posted at

Vue.jsLaravelによるSPAを構築しました。

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

実装方法

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

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

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

Vue.jsの実装

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

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

const upload = () => {

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

uploadChunks()

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

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➀

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

uploadChunks➁

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

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


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

uploadChunks➂

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しています。

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

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

Laravelの実装

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

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

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①

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クラスを利用し、受け取ったファイルを一時領域に格納しています。

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

upload②

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③

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

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

mergeChunks

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を超える大きいサイズを扱うのは初めてで何かと苦労しました。

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

それでは。

1
2
1

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
1
2