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を使用することにしました。

  1. LaravelにはArtisanが付属しているため、コマンドラインタスクを簡単に作成できます。 Artisanを知らない人のために、それは強力なSymfonyコンソールコンポーネントによって駆動されるLaravelに含まれているコマンドラインインターフェースです
  2. Laravelには、Excelデータをテーブル列にマッピングするためのEloquentORMがあります
  3. それはよく維持されており、非常に徹底的なドキュメントがあります
  4. LaravelはPHP7に100%対応しています。 実際、HomesteadボックスはすでにPHP7を実行しています

私はLaravelを選択しましたが、このチュートリアルのコンセプトとコードは、 Symfony/Processコンポーネント( composer require symfony/processを使用してcomposer経由でインストールできます)を使用する任意のフレームワークに組み込むことができます。

関連: Laravelを採用することにした理由

まず、 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_importedtotal_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 Flagphp 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スクリプトをさらに微調整できます。

Symfonyパッケージを使用すると、リクエストとは関係なく、別のスレッドで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(); } }
関連:フリーランスのLaravel開発者の上位3%を雇います。

再帰的進捗通知システム

プロジェクトのフロントエンド部分であるユーザー通知に移りましょう。 アプリケーションのステータスレポートルートに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]); } } ... 

Ajaxリクエストをステータスレポートルートに送信して、進行状況をユーザーに通知します。

Ajaxリクエストをステータスレポートルートに送信して、進行状況をユーザーに通知します。

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

あなたの経験は何ですか?

同様のケースでパフォーマンスとユーザーエクスペリエンスをさらに改善するための他の提案はありますか? 私はあなたがそれらにどのように対処したかを知りたいと思います。

関連:完全なユーザー認証とアクセス制御– Laravel Passportチュートリアル、Pt。 1