Gestion des tâches intensives avec Laravel
Publié: 2022-03-11Lorsqu'il s'agit de tâches chronophages et gourmandes en ressources, la plupart des développeurs PHP sont tentés de choisir la "route de piratage rapide". Ne le niez pas ! Nous avons tous utilisé ini_set('max_execution_time', HUGE_INT);
avant, mais il n'a pas à être de cette façon.
Dans le didacticiel d'aujourd'hui, je montre comment l'expérience utilisateur d'une application peut être améliorée (avec un effort minimal du développeur) en séparant les tâches de longue durée du flux de requêtes principal à l'aide des solutions de développement Laravel. En utilisant la capacité de PHP à générer des processus séparés qui s'exécutent en arrière-plan, le script principal répondra plus rapidement à l'action de l'utilisateur. Ainsi, il gère mieux les attentes des utilisateurs au lieu de les faire attendre des lustres (sans retour) pour qu'une requête se termine.
Différez les tâches PHP de longue durée, n'attendez pas.
Le concept de base de ce didacticiel est l'ajournement ; prenant des tâches qui s'exécutent trop longtemps (selon les normes Internet) et reportant à la place l'exécution dans un processus séparé qui s'exécute indépendamment de la demande. Ce report nous permet de mettre en place un système de notification qui indique à l'utilisateur l'état de la tâche (X nombre de lignes sur Y ont été importées, par exemple) et alerte l'utilisateur lorsque la tâche est terminée.
Notre didacticiel est basé sur un scénario réel que vous avez sûrement déjà rencontré : prendre des données à partir d'énormes feuilles de calcul Excel et les insérer dans une base de données d'application Web. Le projet complet est disponible sur mon github.
Amorcer avec Laravel
Nous utiliserons "laravel/framework": "5.2.*"
et "maatwebsite/excel": "~2.1.0"
; un joli wrapper pour le phpoffice/phpexcel
.
J'ai choisi d'utiliser Laravel pour cette tâche particulière pour les raisons suivantes :
- Laravel est livré avec Artisan, ce qui facilite la création de tâches en ligne de commande. Pour ceux qui ne connaissent pas Artisan, c'est l'interface de ligne de commande incluse dans Laravel, pilotée par le puissant composant Symfony Console
- Laravel a l'ORM Eloquent pour mapper nos données Excel aux colonnes de table
- Il est bien entretenu et il a une documentation très complète
- Laravel est prêt à 100 % pour PHP 7 ; en fait, la box Homestead exécute déjà PHP 7
Alors que je choisis d'aller avec Laravel, le concept et le code de ce tutoriel peuvent être incorporés dans n'importe quel framework qui utilise également le composant Symfony/Process
(que vous pouvez installer via composer en utilisant composer require symfony/process
).
Pour commencer, lancez votre boîte vagabonde basée sur Homestead
(la norme lors du développement d'applications basées sur Laravel de nos jours). Si vous n'avez pas configuré Homestead, la documentation officielle fournit un guide détaillé étape par étape.
Une fois Homestead installé, vous devrez modifier Homestead.yaml
avant de démarrer votre boîte de vagabondage afin de faire deux choses : Mapper votre dossier de développement local sur un dossier à l'intérieur de la machine virtuelle Provisionner automatiquement NGINX afin d'accéder à une URL, telle que http://heavyimporter.app
, chargera votre nouveau projet.
Voici à quoi ressemble mon fichier de configuration :
folders: - map: ~/public_html/toptal to: /home/vagrant/toptal sites: - map: heavyimporter.app to: /home/vagrant/toptal/heavyimporter/public databases: - heavyimporter
Maintenant, enregistrez le fichier et exécutez vagrant up && vagrant provision
, qui démarre la machine virtuelle et la configure en conséquence. Si tout s'est bien passé, vous pouvez maintenant vous connecter à votre machine virtuelle avec vagrant ssh
et démarrer un nouveau projet Laravel. (Si tout ne s'est pas bien passé, reportez-vous à la documentation Vagrant de Hashicorp pour obtenir de l'aide.)
cd /home/vagrant/toptal && composer create-project --prefer-dist laravel/laravel heavyimporter
Après avoir créé le projet, vous devrez configurer certaines variables de configuration en éditant le fichier .env
dans le dossier home. Vous devez également sécuriser votre installation en exécutant php artisan key:generate
.
Voici à quoi ressemblent les parties pertinentes du fichier .env de mon côté :
APP_ENV=local APP_DEBUG=true APP_KEY=*** DB_HOST=127.0.0.1 DB_DATABASE=heavyimporter DB_USERNAME=homestead DB_PASSWORD=*****
Ajoutez maintenant le maatwebsite/excel
en exécutant composer require maatwebsite/excel:~2.1.0
.
Vous devez également ajouter le fournisseur de services et la façade/alias dans votre fichier config/app.php
.
Les fournisseurs de services sont au cœur d'une application Laravel ; tout dans Laravel est amorcé via un fournisseur de services, tandis que les façades sont de simples interfaces statiques qui permettent un accès plus facile à ces fournisseurs de services. En d'autres termes, au lieu d'accéder à la base de données (un fournisseur de services) avec Illuminate\Database\DatabaseManager… vous pouvez simplement utiliser DB::staticmethod().
Pour nous, notre fournisseur de services est Maatwebsite\Excel\ExcelServiceProvider
et notre façade est 'Excel'=>'Maatwebsite\Excel\Facades\Excel'
.
app.php
devrait maintenant ressembler à ceci :
//... 'providers' => [ //... Maatwebsite\Excel\ExcelServiceProvider::class ], 'aliases' => [ //... 'Excel'=>'Maatwebsite\Excel\Facades\Excel' ]
Configurer la base de données avec PHP Artisan
Configurons nos migrations de base de données pour deux tables. Une table contient un indicateur avec le statut de l'importation, que nous appellerons flag_table
, et celle qui contient les données Excel réelles, data
.
Si vous avez l'intention d'inclure un indicateur de progression pour suivre l'état de la tâche d'importation, vous devez ajouter deux colonnes supplémentaires à flag_table
: rows_imported
et total_rows
. Ces deux variables nous permettront de calculer et de livrer le pourcentage complété dans le cas où nous voulons montrer la progression à l'utilisateur.
Commencez par exécuter php artisan make:migration CreateFlagTable
et php artisan make:migration CreateDataTable
pour créer réellement ces tables. Ensuite, ouvrez les fichiers nouvellement créés à partir de la base de database/migrations
et remplissez les méthodes up et down avec la structure de la table.
//...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'); }
Avant d'écrire le code d'importation, créons des modèles vides pour nos tables de base de données. Ceci est réalisé via Artisan en exécutant deux commandes simples : php artisan make:model Flag
et php artisan make:model Data
, puis en entrant dans chaque fichier nouvellement créé et en ajoutant le nom de la table en tant que propriété protégée de cette classe, comme ceci :
//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 }
Routage
Les routes sont les yeux d'une application Laravel ; ils observent la requête HTTP et la pointent vers le contrôleur approprié. Cela étant dit, nous avons d'abord besoin d'une route POST qui attribue la tâche de télécharger notre fichier Excel à la méthode d' import
dans le contrôleur. Le fichier sera téléchargé quelque part sur le serveur afin que nous puissions le récupérer plus tard lorsque nous exécuterons la tâche de ligne de commande. Assurez-vous de placer toutes vos routes (même celle par défaut) dans le groupe de routes du middleware web
afin de bénéficier de l'état de la session et de la protection CSRF. Le fichier de routes ressemblera à ceci :
Route::group(['middleware' => ['web']], function () { //homepage Route::get('/', ['as'=>'home', function () { return view('welcome'); }]); //upload route Route::post('/import', ['as'=>'import', 'uses'=>'Controller@import']); });
La logique de tâche
Tournons maintenant notre attention vers le contrôleur principal, qui contiendra le cœur de notre logique dans une méthode qui est responsable de ce qui suit :
- Effectuer les validations nécessaires liées au type de fichier en cours de téléchargement
- Téléchargement du fichier sur le serveur et ajout d'une entrée dans le
flag_table
(qui sera mis à jour par le processus de ligne de commande une fois la tâche exécutée avec le nombre total de lignes et l'état actuel du téléchargement) - Démarrage du processus d'importation (qui appellera la tâche Artisan) puis retour pour informer l'utilisateur que le processus a été lancé
Voici le code du contrôleur principal :

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')); }
Les lignes liées aux processus ci-dessus font quelque chose de vraiment cool. Ils utilisent le package symfony/process
pour générer un processus sur un thread séparé, indépendamment de la requête. Cela signifie que le script en cours d'exécution n'attendra pas la fin de l'importation, mais qu'il redirigera avec un message à l'utilisateur pour qu'il attende que l'importation soit terminée. De cette façon, vous pouvez afficher un message d'état "importation en attente" à l'utilisateur. Alternativement, vous pouvez envoyer des requêtes Ajax toutes les X secondes pour mettre à jour le statut.
En utilisant uniquement du PHP vanille, le même effet peut être obtenu avec le code suivant, mais cela repose bien sûr sur exec
, qui est désactivé par défaut, dans de nombreux cas.
function somefunction() { exec("php dosomething.php > /dev/null &"); //do something else without waiting for the above to finish }
Les fonctionnalités symfony/process
sont plus étendues qu'un simple exécutable, donc si vous n'utilisez pas le package symphony, vous pouvez modifier davantage le script PHP après avoir consulté le code source du package Symphony.
Le code d'importation
Écrivons maintenant un fichier de commande php artisan
qui gère l'importation. Commencez par créer le fichier de classe de commandes : php artisan make:console ImportManager
, puis référencez-le dans la propriété $commands
de /app/console/Kernel.php
, comme ceci :
protected $commands = [ Commands\ImportManager::class, ];
L'exécution de la commande artisan créera un fichier nommé ImportManager.php
dans le dossier /app/Console/Commands
. Nous allons écrire notre code dans le cadre de la méthode handle()
.
Notre code d'importation mettra d'abord à jour le flag_table
avec le nombre total de lignes à importer, puis il parcourra chaque ligne Excel, l'insérera dans la base de données et mettra à jour le statut.
Pour éviter les problèmes de mémoire insuffisante avec des fichiers Excel exceptionnellement volumineux, c'est une bonne idée de traiter des petits morceaux de l'ensemble de données respectif au lieu de milliers de lignes à la fois ; une proposition qui causerait beaucoup de problèmes, pas seulement des problèmes de mémoire.
Pour cet exemple basé sur Excel, nous adapterons la ImportManager::handle()
pour récupérer uniquement un petit ensemble de lignes jusqu'à ce que la feuille entière ait été importée. Cela aide à suivre la progression de la tâche ; après le traitement de chaque bloc, nous mettons à jour flag_table
en incrémentant la colonne imported_rows
avec la taille du bloc.
Remarque : Il n'est pas nécessaire de paginer car Maatwebsite\Excel
gère cela pour vous comme décrit dans la documentation de Laravel.
Voici à quoi ressemble la classe ImportManager finale :
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(); } }
Système de notification de progression récursif
Passons à la partie frontale de notre projet, la notification utilisateur. Nous pouvons envoyer des requêtes Ajax à une route de rapport d'état dans notre application pour informer l'utilisateur de la progression ou l'alerter lorsque l'importation est terminée.
Voici un simple script jQuery qui enverra des requêtes au serveur jusqu'à ce qu'il reçoive un message indiquant que le travail est terminé :
(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);
De retour sur le serveur, ajoutez une route GET
appelée status
, qui appellera une méthode qui signale l'état actuel de la tâche d'importation comme étant terminée ou le nombre de lignes importées depuis 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]); } } ...
Ajournement de tâche Cron
Une autre approche, lorsque la récupération des données n'est pas sensible au temps, consiste à gérer l'importation ultérieurement lorsque le serveur est inactif ; disons, à minuit. Cela peut être fait à l'aide de tâches cron qui exécutent la commande php artisan import:excelfile
à l'intervalle de temps souhaité.
Sur les serveurs Ubuntu, c'est aussi simple que ça :
crontab -e #and add this line @midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.log
Quelle est votre expérience ?
Avez-vous d'autres suggestions pour améliorer encore les performances et l'expérience utilisateur dans des cas similaires ? J'aimerais savoir comment vous les avez traités.