2025/03/28
LaravelでぺけコレクションというNAS的アプリを作ってるんだけど、100GBとかある動画ファイルのアップロードが意外と難しかった。
私はtus.ioを使ってアプローチしたのでその方法を解説する。
また、具体的な実装はペケコレ自体がOSSなので、そちらを見ていただければと思う。
大きいファイルのアップロードは難しい
Laravelで100GBのファイルをアップロードできるようにしたいときどうするだろうか。
php.iniで
upload_max_filesize=100G
post_max_size=100G
とするといけそうだが、以下の問題がある。
- メモリを莫大に食う
- 1回の通信に時間がかかる
- 途中でアップロードに失敗すると、また1から再アップロードが必要
これ等を回避する為、一般的にファイルを分割したアップロード方法が採用される。
そんな分割アップロードをサポートするのがtus.ioというプロトコルである。
tus.ioとは
tusは再開可能なファイルアップロード…つまりアップロードの中断・再開が可能なファイルアップロードのHTTPプロトコル。
tus.ioを使うと100GBのファイルでも良い感じに分割してアップロードできる。
tus自体はプロトコルなので、このtusプロトコルを使ったライブラリが沢山存在する。
その中にはankitpokhrel/tus-phpというPHP用のライブラリもあるんだけど、今回は公式が提供するtusdとtus-js-clientを利用する。
PHPのライブラリを使わない理由は、最近メンテがされていないのと、公式のものでも十分対応できそうだったから。
Laravelとtusの組み合わせ方
tus自体はLaravel・PHPとシナジーがあるわけでは無い。
ただ、tusのサーバーであるtusdはHTTPフックを用いた通信が可能で、特定のタイミングでLaravelと通信ができる。
フックの公式ドキュメントの通り、ファイルアップロード前やアップロード後にフックがあるので、認証等の前処理とか、圧縮やDB保存等の後処理もできるよっていう感じ。
つまり、tus.io + Laravelを使ったアップロードは以下のような流れになる
- ブラウザからtus-js-clientを使ってtusdへファイルアップロード開始
- tusdが特定のタイミングでLaravelへHTTPフックを送信
- LaravelがHTTPフックを元に処理
Dockerでtusdを実装する
tusdはtusのバックエンド、つまりサーバー。
フロントからtus-js-clientを使いこのサーバーに対しアップロードリクエストを送る。
Dockerfileは作らず、docker-compose.yamlで直接imageを取ってる。
私の実装を一般化したのが以下。
tus:
container_name: tus-container
image: tusproject/tusd:latest
ports:
- "1080:1080"
volumes:
- ../storage/app/public:/public
networks:
- backend
command:
[
"--port=1080",
"--hooks-http=http://laravel/api/tusd-hooks",
"--hooks-http-forward-headers=X-CSRF-TOKEN,Cookie",
"--upload-dir=/public",
"--base-path=/uploads",
"--cors-allow-origin=http://127.0.0.1",
"--cors-allow-credentials",
"--cors-allow-headers=X-CSRF-TOKEN"
]
working_dir: /public
各オプションの説明を
--port=1080
デフォが1080ぽいので、明示的に1080にしている。--hooks-http=http://laravel/api/tusd-hooks
HTTPフックの送信先。つまり、Laravelへのエンドポイント。
私は/api/tusd-hooksにした。--hooks-http-forward-headers=X-CSRF-TOKEN,Cookie
HTTPフックを送るとき、HTTPヘッダーを転送する設定。
つまり、「ブラウザ→tusd→Laravel」のように通信が行われるけど、ブラウザ→tusdについてたヘッダーの値をLaravelに転送できる。CSRFとユーザー認証のためにこの2つを設定。--upload-dir=/public
アップロードされるディレクトリの設定。--base-path=/uploads
アップロードするエンドポイントの設定。
これだと、http://127.0.0.1:1080/uploads
がエンドポイントになる感じ。--cors-allow-origin=http://127.0.0.1
tusdコンテナとLaravelサーバーはポートが違うのでCORSにひっかかる。
そのため、LaravelサーバーのURLをCORS-Originで許可する必要あり。--cors-allow-credentials
これをつけないとCORSのCookieを読み取らない--cors-allow-headers=X-CSRF-TOKEN
Cookieはcredentialsで許可しているが、それ以外のCORSヘッダーで読み取り許可するもの。
これでdocker compose up
してもらえば127.0.0.1:1080
でtusdへアクセス可能に。
Laravelの実装
各実装の具体的なものはペケコレを見てもらう感じで、ここでは抽象的な解説をする。
フロントエンドの実装
tus-js-clientを使ってtusdと通信をする。
tus-js-client自体は
npm install tus-js-client
とかでインストール。
フロントについて詳しくはtus-js-clientのドキュメントを参照
tus-js-clientでは以下のようにfileオブジェクトを引数にtus.Uploadでアップロード処理を書く。
import * as tus from "tus-js-client";
const upload = new tus.Upload(file, {
endpoint: "http://127.0.0.1:1080/uploads/",
// エラー時に何ミリ秒後リトライするか。複数個指定可能
retryDelays: [2000],
// サーバーへ自由に渡せるデータ。ここではファイル名とmimeTypeを渡している。
metadata: {
filename: file.name,
mimetype: file.type,
},
// リクエスト送信前に、引数に送る予定のリクエストを取るメソッド。
onBeforeRequest: function (req) {
var xhr = req.getUnderlyingObject()
// withCredentialsを有効にしてCookieを送っている
xhr.withCredentials = true
// CSRF-TOKENもヘッダーに付与してる
xhr.setRequestHeader("X-CSRF-TOKEN", csrfToken);
},
onError: (error) => {
console.error("アップロード失敗:", error);
},
onSuccess: () => {
console.log("アップロード完了:", upload.file.name);
},
});
upload.start();
これをvueやreactやbladeと組み合わせればアップロード機構は完成。
route
api.phpでルート設定。
中身は以下の様な感じ
Route::post('tusd-hooks', [MediaFileController::class, 'tusdHooks'])
->middleware(["web", "auth"])
->name('tusd-hooks');
認証方法はsanctumではなく、普通にweb認証を実装。
Cookieを使って認証するのでこれで問題なく認証できる。
Request
一般的なpostリクエストと同じくバリデーションが可能。
tusは初期設定だと1ファイルのアップロードあたり
- pre-create
- post-create
- post-receive
- post-finish
- post-terminate (アップロードキャンセル時のみなので、今回は使わないフック)
の計5つのフックを送ってくる。
そのため、一応簡易的にバリデーションをすると以下のような感じ。
<?php
namespace App\Http\Requests\MediaFile;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TusdHookRequest extends FormRequest
{
public function authorize(): bool
{
return \Auth::check();
}
public function rules(): array
{
return [
"Type" => [
"required",
"string",
Rule::in([
"pre-create",
"post-create",
"post-receive",
"post-finish",
"post-terminate"
]),
]
];
}
}
コントローラー
tusのフック
tus.ioのフックには、レスポンス帰ってくるまで処理をブロックするフックとブロックしないフックがある。
また、順序が定まっているフックと定まっていないフックもある。
tus.ioのドキュメントを引用すると
フック名 | ブロック | トリガーのタイミング | デフォルト |
---|---|---|---|
pre-create | ○ | 新しいアップロードが作成される前 | ○ |
post-create | ✕ | 新しいアップロードが作成された後 | ○ |
post-receive | ✕ | データ送信中に定期的に | ○ |
pre-finish | ○ | 全データ受信後、レスポンス送信前 | ✕ |
post-finish | ✕ | 全データ受信後、レスポンス送信後 | ○ |
pre-terminate | ○ | アップロードが終了(削除)される直前 | ✕ |
post-terminate | ✕ | アップロードが終了(削除)された後 | ○ |
という感じ。
デフォルトがバツなのは、設定で有効にしないと送られてこないやつ。
めっちゃ大事なのはpre-createフックで、このフックに対し200台以外のステータス、または"RejectUpload" => true
をレスポンスするとそのアップロードはキャンセルされるし、レスポンスが来るまでアップロード処理は開始されない。
しかも、必ずpre-createは一番最初のフックとして送信されてくる。
つまり、これでバリデーション処理が可能。
前処理の実装
簡易的にだけど、以下のように実装できる。
public function tusdHooks(TusdHookRequest $request)
{
$type = $request->input('Type');
switch ($type) {
case "pre-create":
$isValidate = validation($request);
if(! $isValidate) {
return response()->json([
"HTTPResponse" => [
"StatusCode" => 403,
"Body" => json_encode(["message" => "バリデーションエラーだよ"]),
"Header" => [
"Content-Type" => "application/json"
]
],
"RejectUpload" => true
], 200);
}
// ChangeFileInfo.Storage.Pathを指定するとそのパスに保存してくれる
return response()->json([
"status" => 200,
"ChangeFileInfo" => [
"Storage" => [
"Path" => "hoge/hoge.mp4",
]
],
]);
case "post-create":
return response()->json(["status" => 200]);
case "post-receive":
return response()->json(["status" => 200]);
case "post-finish":
// 後で実装
case "post-terminate":
return response()->json(["status" => 200]);
}
return response()->json(["status" => 500]);
}
後処理の実装
post-finishフックが来たらアップロードが完了したということなので、このフックで後処理を実装する。
やることは主に以下
- tusがアップロードで使ったinfoファイルの削除
- DBにファイル情報を保存
一応簡易的に実装すると以下。
かなり簡易的なので、ファイル削除とかは実際もっと慎重にやったほうが良い。
public function tusdHooks(TusdHookRequest $request)
{
$type = $request->input('Type');
switch ($type) {
case "pre-create":
// 省略
case "post-create":
return response()->json(["status" => 200]);
case "post-receive":
return response()->json(["status" => 200]);
case "post-finish":
$filePath = $request->input("Event.Upload.Storage.Path");
$infoPath = $request->input("Event.Upload.Storage.InfoPath");
Storage::delete($infoPath);
MediaFile::create(["filePath" => $filePath]):
return response()->json(["status" => 200]);
case "post-terminate":
return response()->json(["status" => 200]);
}
return response()->json(["status" => 500]);
}
実際に100GBを送ってみる
今回はffmpegで以下のように同じ画像をループさせて出来た無圧縮104.3GB動画をアップロードする。
ffmpeg -loop 1 -i sample.png \
-vf "scale=3840:2160,format=yuv420p" \
-t 360 -c:v rawvideo -y output.avi
実際のアップロード中の画面が以下のような感じで、全く問題なくアップロードできるし、中断・再開も可能。
アップロード後の後処理も問題なくでき、ファイルもアップロードされていた。いいね。
おわりに
そんな感じでLaravelとtus.ioを組み合わせたアップロード機能を作ったよというお話でした。
バリデーションとか認証関係が少し厄介で、本番環境で動かすとなるともう少し工夫は必要なのかなと言う感じ。
それでは。