Laravelで集中的なタスクを処理する
公開: 2022-03-11時間のかかるリソースを大量に消費するタスクを処理する場合、ほとんどのPHP開発者は「クイックハックルート」を選択したくなります。 それを否定しないでください! 私たちは皆ini_set('max_execution_time', HUGE_INT);
を使用しました。 以前は、このようにする必要はありません。
今日のチュートリアルでは、Laravel開発ソリューションを使用して、実行時間の長いタスクをメインのリクエストフローから分離することにより、アプリケーションのユーザーエクスペリエンスを(最小限の開発者の労力で)改善する方法を示します。 バックグラウンドで実行される個別のプロセスを生成するPHPの機能を利用することにより、メインスクリプトはユーザーのアクションにより速く応答します。 これにより、ユーザーがリクエストの終了を(フィードバックなしで)何年も待たされるのではなく、ユーザーの期待をより適切に管理できます。
長時間実行されるPHPタスクを延期し、待たないでください。
このチュートリアルの基本概念は延期です。 (インターネット標準で)長時間実行されるタスクを取得し、代わりに、要求とは独立して実行される別のプロセスに実行を延期します。 この延期により、ユーザーにタスクのステータス(たとえば、YからX行がインポートされた)を表示し、ジョブが完了したときにユーザーに警告する通知システムを実装できます。
私たちのチュートリアルは、あなたが以前に遭遇したと確信している実際のシナリオに基づいています。巨大なExcelスプレッドシートからデータを取得し、それをWebアプリケーションデータベースにプッシュします。 プロジェクト全体は私のgithubで入手できます。
Laravelによるブートストラップ
"laravel/framework": "5.2.*"
と"maatwebsite/excel": "~2.1.0"
を使用します。 phpoffice/phpexcel
パッケージの優れたラッパー。
次の理由から、この特定のタスクにLaravelを使用することにしました。
- LaravelにはArtisanが付属しているため、コマンドラインタスクを簡単に作成できます。 Artisanを知らない人のために、それは強力なSymfonyコンソールコンポーネントによって駆動されるLaravelに含まれているコマンドラインインターフェースです
- Laravelには、Excelデータをテーブル列にマッピングするためのEloquentORMがあります
- それはよく維持されており、非常に徹底的なドキュメントがあります
- LaravelはPHP7に100%対応しています。 実際、HomesteadボックスはすでにPHP7を実行しています
私はLaravelを選択しましたが、このチュートリアルのコンセプトとコードは、 Symfony/Process
コンポーネント( composer require symfony/process
を使用してcomposer経由でインストールできます)を使用する任意のフレームワークに組み込むことができます。
まず、 Homestead
(最近のLaravelベースのアプリケーションを開発する際の標準)に基づいてVagrantボックスを起動します。 Homesteadをセットアップしていない場合は、公式ドキュメントに完全なステップバイステップガイドが記載されています。
Homesteadがインストールされている場合、2つのことを行うには、vagrantボックスを開始する前にHomestead.yaml
を変更する必要があります。ローカル開発フォルダーを仮想マシン内のフォルダーにマップするNGINXを自動的にプロビジョニングして、 http://heavyimporter.app
などのURLにアクセスできるようにします。 http://heavyimporter.app
は、新しいプロジェクトをロードします。
構成ファイルは次のようになります。
folders: - map: ~/public_html/toptal to: /home/vagrant/toptal sites: - map: heavyimporter.app to: /home/vagrant/toptal/heavyimporter/public databases: - heavyimporter
次に、ファイルを保存し、 vagrant up && vagrant provision
を実行します。これにより、VMが起動し、それに応じて構成されます。 すべてがうまくいけば、 vagrant ssh
を使用して仮想マシンにログインし、新しいLaravelプロジェクトを開始できます。 (すべてがうまくいかなかった場合は、HashicorpのVagrantドキュメントを参照してください。)
cd /home/vagrant/toptal && composer create-project --prefer-dist laravel/laravel heavyimporter
プロジェクトを作成したら、ホームフォルダーの.env
ファイルを編集して、いくつかの構成変数を設定する必要があります。また、 php artisan key:generate
を実行してインストールを保護する必要があります。
私の側では、.envファイルの関連部分は次のようになります。
APP_ENV=local APP_DEBUG=true APP_KEY=*** DB_HOST=127.0.0.1 DB_DATABASE=heavyimporter DB_USERNAME=homestead DB_PASSWORD=*****
次に、 composer require maatwebsite/excel:~2.1.0
を実行して、 maatwebsite/excel
パッケージを追加します。
また、 config/app.php
ファイルにサービスプロバイダーとfacade/aliasを追加する必要があります。
サービスプロバイダーはLaravelアプリケーションの中核です。 Laravelのすべては、サービスプロバイダーを介してブートストラップされますが、ファサードは、これらのサービスプロバイダーへのアクセスを容易にするシンプルな静的インターフェイスです。 つまり、Illuminate \ Database \ DatabaseManagerを使用してデータベース(サービスプロバイダー)にアクセスする代わりに、DB :: staticmethod()を使用できます。
私たちの場合、サービスプロバイダーはMaatwebsite\Excel\ExcelServiceProvider
であり、ファサードは'Excel'=>'Maatwebsite\Excel\Facades\Excel'
です。
app.php
は次のようになります。
//... 'providers' => [ //... Maatwebsite\Excel\ExcelServiceProvider::class ], 'aliases' => [ //... 'Excel'=>'Maatwebsite\Excel\Facades\Excel' ]
PHPArtisanを使用したデータベースのセットアップ
2つのテーブルのデータベース移行を設定しましょう。 1つのテーブルには、インポートのステータスを示すフラグが保持されます。これをflag_table
と呼び、実際のExcelデータであるdata
を保持するテーブルです。
インポートタスクのステータスを追跡するために進行状況インジケーターを含める場合は、 flag_table
にさらに2つの列rows_imported
とtotal_rows
を追加する必要があります。 これらの2つの変数により、ユーザーに進捗状況を表示したい場合に完了したパーセンテージを計算して提供できます。
最初にphpartisanmake:migrationCreateFlagTableとphp artisan make:migration CreateFlagTable
php artisan make:migration CreateDataTable
を実行して、これらのテーブルを実際に作成します。 次に、 database/migrations
から新しく作成されたファイルを開き、upメソッドとdownメソッドにテーブル構造を入力します。
//...CreateFlagTable.php class CreateFlagTable extends Migration { public function up() { Schema::create('flag_table', function (Blueprint $table) { $table->increments('id'); $table->string('file_name')->unique(); $table->boolean('imported'); $table->integer('rows_imported'); $table->integer('total_rows'); $table->timestamps(); }); } public function down() { Schema::drop('flag_table'); } //...CreateDataTable.php class CreateDataTable extends Migration { public function up() { Schema::create('data', function (Blueprint $table) { $table->increments('id'); $table->string('A', 20); $table->string('B', 20); }); } public function down() { Schema::drop('data'); }
実際にインポートコードを作成する前に、データベーステーブルの空のモデルを作成しましょう。 これは、Artisanを介して2つの簡単なコマンドを実行することで実現されます: php artisan make:model Flag
とphp artisan make:model Data
次に、新しく作成された各ファイルに移動し、次のようにそのクラスの保護されたプロパティとしてテーブル名を追加します。
//file: app/Flag.php namespace App; use Illuminate\Database\Eloquent\Model; class Flag extends Model { protected $table = 'flag_table'; protected $guarded = []; //this will give us the ability to mass assign properties to the model } //... //file app/Data.php //... class Data extends Model { protected $table = 'data'; protected $guarded = []; protected $timestamps = false; //disable time stamps for this }
ルーティング
ルートはLaravelアプリケーションの目です。 彼らはHTTPリクエストを監視し、それを適切なコントローラーにポイントします。 そうは言っても、最初に、Excelファイルをアップロードするタスクをコントローラーのimport
メソッドに割り当てるPOSTルートが必要です。 ファイルはサーバーのどこかにアップロードされるので、後でコマンドラインタスクを実行するときにファイルを取得できます。 セッション状態とCSRF保護の恩恵を受けるために、必ずすべてのルート(デフォルトのルートも含む)をweb
ミドルウェアルートグループに配置してください。 ルートファイルは次のようになります。
Route::group(['middleware' => ['web']], function () { //homepage Route::get('/', ['as'=>'home', function () { return view('welcome'); }]); //upload route Route::post('/import', ['as'=>'import', 'uses'=>'Controller@import']); });
タスクロジック
次に、メインコントローラーに注目しましょう。メインコントローラーは、次の機能を担うメソッドでロジックのコアを保持します。
- アップロードされるファイルタイプに関連する必要な検証を行う
- ファイルをサーバーにアップロードし、
flag_table
にエントリを追加します(タスクが実行されると、行の総数とアップロードの現在のステータスでコマンドラインプロセスによって更新されます) - インポートプロセス(Artisanタスクを呼び出します)を開始してから、プロセスが開始されたことをユーザーに通知するために戻ります
これはメインコントローラーのコードです。
namespace App\Http\Controllers; //... use Maatwebsite\Excel\Facades\Excel; use Symfony\Component\Process\Process as Process; use Symfony\Component\Process\Exception\ProcessFailedException; use Illuminate\Http\Request; use Validator; use Redirect; use Config; use Session; use DB; use App\Flag; //... public function import(Request $request) { $excel_file = $request->file('excel_file'); $validator = Validator::make($request->all(), [ 'excel_file' => 'required' ]); $validator->after(function($validator) use ($excel_file) { if ($excel_file->guessClientExtension()!=='xlsx') { $validator->errors()->add('field', 'File type is invalid - only xlsx is allowed'); } }); if ($validator->fails()) { return Redirect::to(route('home')) ->withErrors($validator); } try { $fname = md5(rand()) . '.xlsx'; $full_path = Config::get('filesystems.disks.local.root'); $excel_file->move( $full_path, $fname ); $flag_table = Flag::firstOrNew(['file_name'=>$fname]); $flag_table->imported = 0; //file was not imported $flag_table->save(); }catch(\Exception $e){ return Redirect::to(route('home')) ->withErrors($e->getMessage()); //don't use this in production ok ? } //and now the interesting part $process = new Process('php ../artisan import:excelfile'); $process->start(); Session::flash('message', 'Hold on tight. Your file is being processed'); return Redirect::to(route('home')); }
上記のプロセス関連の行は、本当にクールなことをします。 彼らはsymfony/process
パッケージを使用して、リクエストとは関係なく、別のスレッドでプロセスを生成します。 これは、実行中のスクリプトがインポートの完了を待機せず、代わりに、インポートが完了するまで待機するようにユーザーにメッセージを送信してリダイレクトすることを意味します。 このようにして、「インポート保留中」ステータスメッセージをユーザーに表示できます。 または、X秒ごとにAjaxリクエストを送信して、ステータスを更新することもできます。

バニラPHPのみを使用すると、次のコードでも同じ効果が得られますが、もちろん、これはexec
に依存します。これは、多くの場合、デフォルトで無効になっています。
function somefunction() { exec("php dosomething.php > /dev/null &"); //do something else without waiting for the above to finish }
symfony/process
が提供する機能は、単純なexecよりも広範囲にわたるため、symphonyパッケージを使用していない場合は、Symphonyパッケージのソースコードを確認した後、PHPスクリプトをさらに微調整できます。
インポートコード
次に、インポートを処理するphp artisan
コマンドファイルを作成しましょう。 コマンドクラスファイルを作成することから始めます: php artisan make:console ImportManager
ImportManager次に、次のように/app/console/Kernel.php
の$commands
プロパティでそれを参照します。
protected $commands = [ Commands\ImportManager::class, ];
artisanコマンドを実行すると、 /app/Console/Commands
フォルダーにImportManager.php
という名前のファイルが作成されます。 handle()
メソッドの一部としてコードを記述します。
インポートコードは、最初にインポートする行の総数でflag_table
を更新し、次に各Excel行を繰り返し処理してデータベースに挿入し、ステータスを更新します。
非常に大きなExcelファイルでのメモリ不足の問題を回避するには、一度に数千行ではなく、それぞれのデータセットのバイトサイズのチャンクを処理することをお勧めします。 メモリの問題だけでなく、多くの問題を引き起こす可能性のある提案。
このExcelベースの例では、 ImportManager::handle()
メソッドを適応させて、シート全体がインポートされるまで、行の小さなセットのみをフェッチします。 これは、タスクの進行状況を追跡するのに役立ちます。 各チャンクが処理された後、 imported_rows
列をチャンクのサイズでインクリメントすることにより、 flag_table
を更新します。
注:Laravelのドキュメントで説明されているように、 Maatwebsite\Excel
がページ付けを処理するため、ページ付けする必要はありません。
最終的なImportManagerクラスは次のようになります。
namespace App\Console\Commands; use Illuminate\Console\Command; use DB; use Validator; use Config; use Maatwebsite\Excel\Facades\Excel; use App\Flag; class ImportManager extends Command { protected $signature = 'import:excelfile'; protected $description = 'This imports an excel file'; protected $chunkSize = 100; public function handle() { $file = Flag::where('imported','=','0') ->orderBy('created_at', 'DESC') ->first(); $file_path = Config::get('filesystems.disks.local.root') . '/' .$file->file_name; // let's first count the total number of rows Excel::load($file_path, function($reader) use($file) { $objWorksheet = $reader->getActiveSheet(); $file->total_rows = $objWorksheet->getHighestRow() - 1; //exclude the heading $file->save(); }); //now let's import the rows, one by one while keeping track of the progress Excel::filter('chunk') ->selectSheetsByIndex(0) ->load($file_path) ->chunk($this->chunkSize, function($result) use ($file) { $rows = $result->toArray(); //let's do more processing (change values in cells) here as needed $counter = 0; foreach ($rows as $k => $row) { foreach ($row as $c => $cell) { $rows[$k][$c] = $cell . ':)'; //altered value :) } DB::table('data')->insert( $rows[$k] ); $counter++; } $file = $file->fresh(); //reload from the database $file->rows_imported = $file->rows_imported + $counter; $file->save(); } ); $file->imported =1; $file->save(); } }
再帰的進捗通知システム
プロジェクトのフロントエンド部分であるユーザー通知に移りましょう。 アプリケーションのステータスレポートルートにAjaxリクエストを送信して、進行状況をユーザーに通知したり、インポートが完了したときにユーザーにアラートを送信したりできます。
これは、ジョブが完了したことを示すメッセージを受信するまでサーバーにリクエストを送信する単純なjQueryスクリプトです。
(function($){ 'use strict'; function statusUpdater() { $.ajax({ 'url': THE_ROUTE_TO_THE_SCRIPT, }).done(function(r) { if(r.msg==='done') { console.log( "The import is completed. Your data is now available for viewing ... " ); } else { //get the total number of imported rows console.log("Status is: " + r.msg); console.log( "The job is not yet done... Hold your horses, it takes a while :)" ); statusUpdater(); } }) .fail(function() { console.log( "An error has occurred... We could ask Neo about what happened, but he's taken the red pill and he's at home sleeping" ); }); } statusUpdater(); })(jQuery);
サーバーに戻り、 status
というGET
ルートを追加します。これにより、インポートタスクの現在のステータスが完了したか、Xからインポートされた行数を報告するメソッドが呼び出されます。
//...routes.php Route::get('/status', ['as'=>'status', 'uses'=>'Controller@status']); //...controller.php ... public function status(Request $request) { $flag_table = DB::table('flag_table') ->orderBy('created_at', 'desc') ->first(); if(empty($flag)) { return response()->json(['msg' => 'done']); //nothing to do } if($flag_table->imported === 1) { return response()->json(['msg' => 'done']); } else { $status = $flag_table->rows_imported . ' excel rows have been imported out of a total of ' . $flag_table->total_rows; return response()->json(['msg' => $status]); } } ...
cronジョブの延期
データ取得が時間に敏感でない場合の別のアプローチは、サーバーがアイドル状態のときに後でインポートを処理することです。 たとえば、真夜中に。 これは、必要な時間間隔でphp artisan import:excelfile
コマンドを実行するcronジョブを使用して実行できます。
Ubuntuサーバーでは、次のように簡単です。
crontab -e #and add this line @midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.log
あなたの経験は何ですか?
同様のケースでパフォーマンスとユーザーエクスペリエンスをさらに改善するための他の提案はありますか? 私はあなたがそれらにどのように対処したかを知りたいと思います。