Vue.js
とLaravel
による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()
でキーに値をセットし、
start
とchunkIndex
を更新しています。
※ 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
しています。
formData
をPOST
するのでヘッダーに"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を超える大きいサイズを扱うのは初めてで何かと苦労しました。
改善点等、コメントいただければ幸いです。
それでは。