Gestire attività intensive con Laravel
Pubblicato: 2022-03-11Quando si tratta di attività che richiedono molto tempo, la maggior parte degli sviluppatori PHP è tentata di scegliere il "percorso rapido di hacking". Non negarlo! Abbiamo usato tutti ini_set('max_execution_time', HUGE_INT);
prima, ma non deve essere così.
Nel tutorial di oggi, mostro come l'esperienza utente di un'applicazione può essere migliorata (con il minimo sforzo dello sviluppatore) separando le attività di lunga durata dal flusso di richieste principale utilizzando le soluzioni di sviluppo Laravel. Utilizzando la capacità di PHP di generare processi separati che vengono eseguiti in background, lo script principale risponderà più velocemente all'azione dell'utente. In tal modo, gestisce meglio le aspettative degli utenti invece di farli aspettare per anni (senza feedback) per il completamento di una richiesta.
Rinvia le attività PHP di lunga durata, non aspettare.
Il concetto di base di questo tutorial è il differimento; prendendo attività che vengono eseguite per troppo tempo (secondo gli standard Internet) e rinviando invece l'esecuzione in un processo separato che viene eseguito indipendentemente dalla richiesta. Questo differimento ci consente di implementare un sistema di notifica che mostra all'utente lo stato dell'attività (sono stati importati X numero di righe su Y, ad esempio) e avvisa l'utente quando il lavoro è terminato.
Il nostro tutorial si basa su uno scenario di vita reale che sono sicuro che hai già incontrato in precedenza: prendere dati da enormi fogli di calcolo Excel e inserirli in un database di applicazioni web. Il progetto completo è disponibile sul mio github.
Bootstrapping con Laravel
Useremo "laravel/framework": "5.2.*"
e "maatwebsite/excel": "~2.1.0"
; un bel wrapper per il pacchetto phpoffice/phpexcel
.
Ho scelto di utilizzare Laravel per questo particolare compito per i seguenti motivi:
- Laravel viene fornito con Artisan, che rende la creazione di attività da riga di comando un gioco da ragazzi. Per chi non conosce Artisan, è l'interfaccia a riga di comando inclusa in Laravel, gestita dal potente componente Symfony Console
- Laravel ha l'Eloquent ORM per mappare i nostri dati Excel sulle colonne della tabella
- È ben tenuto e ha una documentazione molto completa
- Laravel è pronto al 100% per PHP 7; infatti, la casella Homestead esegue già PHP 7
Anche se scelgo di andare con Laravel, il concetto e il codice di questo tutorial possono essere incorporati in qualsiasi framework che utilizzi anche il componente Symfony/Process
(che puoi installare tramite composer usando composer require symfony/process
).
Per iniziare, accendi la tua scatola vagabonda basata su Homestead
(lo standard quando si sviluppano applicazioni basate su Laravel in questi giorni). Se non hai configurato Homestead, la documentazione ufficiale fornisce una guida dettagliata passo passo.
Con Homestead installato, dovrai modificare Homestead.yaml
prima di avviare la tua casella vagabondo per fare due cose: Mappare la cartella di sviluppo locale in una cartella all'interno della macchina virtuale Effettuare automaticamente il provisioning di NGINX in modo che acceda a un URL, come http://heavyimporter.app
, caricherà il tuo nuovo progetto.
Ecco come appare il mio file di configurazione:
folders: - map: ~/public_html/toptal to: /home/vagrant/toptal sites: - map: heavyimporter.app to: /home/vagrant/toptal/heavyimporter/public databases: - heavyimporter
Ora salva il file ed esegui vagrant up && vagrant provision
, che avvia la VM e la configura di conseguenza. Se tutto è andato bene, ora puoi accedere alla tua macchina virtuale con vagrant ssh
e avviare un nuovo progetto Laravel. (Se tutto non è andato bene, fare riferimento alla documentazione Vagrant di Hashicorp per assistenza.)
cd /home/vagrant/toptal && composer create-project --prefer-dist laravel/laravel heavyimporter
Dopo aver creato il progetto, dovrai impostare alcune variabili di configurazione modificando il file .env
nella cartella home. Dovresti anche proteggere la tua installazione eseguendo php artisan key:generate
.
Ecco come appaiono le parti rilevanti del file .env da parte mia:
APP_ENV=local APP_DEBUG=true APP_KEY=*** DB_HOST=127.0.0.1 DB_DATABASE=heavyimporter DB_USERNAME=homestead DB_PASSWORD=*****
Ora aggiungi il pacchetto maatwebsite/excel
eseguendo composer require maatwebsite/excel:~2.1.0
.
È inoltre necessario aggiungere il provider di servizi e la facciata/alias nel file config/app.php
.
I fornitori di servizi sono il fulcro di un'applicazione Laravel; tutto in Laravel viene avviato tramite un fornitore di servizi, mentre le facciate sono semplici interfacce statiche che consentono un accesso più semplice a tali fornitori di servizi. In altre parole, invece di accedere al database (un fornitore di servizi) con Illuminate\Database\DatabaseManager... puoi semplicemente usare DB::staticmethod().
Per noi, il nostro fornitore di servizi è Maatwebsite\Excel\ExcelServiceProvider
e la nostra facciata è 'Excel'=>'Maatwebsite\Excel\Facades\Excel'
.
app.php
ora dovrebbe apparire così:
//... 'providers' => [ //... Maatwebsite\Excel\ExcelServiceProvider::class ], 'aliases' => [ //... 'Excel'=>'Maatwebsite\Excel\Facades\Excel' ]
Configurazione del database con PHP Artisan
Impostiamo le nostre migrazioni del database per due tabelle. Una tabella contiene un flag con lo stato dell'importazione, che chiameremo flag_table
, e quella che ha i dati Excel effettivi, data
.
Se intendi includere un indicatore di avanzamento per tenere traccia dello stato dell'attività di importazione, devi aggiungere altre due colonne alla flag_table
: rows_imported
e total_rows
. Queste due variabili ci permetteranno di calcolare e fornire la percentuale completata nel caso in cui vogliamo mostrare i progressi all'utente.
Prima esegui php artisan make:migration CreateFlagTable
e php artisan make:migration CreateDataTable
per creare effettivamente queste tabelle. Quindi, apri i file appena creati da database/migrations
e riempi i metodi su e giù con la struttura della tabella.
//...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'); }
Prima di scrivere effettivamente il codice di importazione, creiamo modelli vuoti per le nostre tabelle del database. Ciò si ottiene tramite Artisan eseguendo due semplici comandi: php artisan make:model Flag
e php artisan make:model Data
, quindi entrando in ogni file appena creato e aggiungendo il nome della tabella come proprietà protetta di quella classe, in questo modo:
//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 }
Instradamento
I percorsi sono gli occhi di un'applicazione Laravel; osservano la richiesta HTTP e la indirizzano al controller appropriato. Detto questo, in primo luogo, abbiamo bisogno di un percorso POST che assegni il compito di caricare il nostro file Excel al metodo di import
nel controller. Il file verrà caricato da qualche parte sul server in modo da poterlo prelevare in seguito quando eseguiremo l'attività della riga di comando. Assicurati di inserire tutti i tuoi percorsi (anche quello predefinito) nel gruppo di percorsi del middleware web
in modo da beneficiare dello stato della sessione e della protezione CSRF. Il file dei percorsi sarà simile a questo:
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 logica del compito
Ora rivolgiamo la nostra attenzione al controller principale, che conterrà il nucleo della nostra logica in un metodo responsabile di quanto segue:
- Effettuare le convalide necessarie relative al tipo di file che viene caricato
- Caricamento del file sul server e aggiunta di una voce nella
flag_table
(che verrà aggiornata dal processo della riga di comando una volta eseguita l'attività con il numero totale di righe e lo stato corrente del caricamento) - Avviando il processo di importazione (che chiamerà l'attività Artisan), quindi torna per far sapere all'utente che il processo è stato avviato
Questo è il codice per il controller principale:

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')); }
Le righe relative al processo sopra fanno qualcosa di veramente interessante. Usano il pacchetto symfony/process
per generare un processo su un thread separato, indipendentemente dalla richiesta. Ciò significa che lo script in esecuzione non attenderà il completamento dell'importazione, ma reindirizzerà con un messaggio all'utente di attendere il completamento dell'importazione. In questo modo è possibile visualizzare all'utente un messaggio di stato "importazione in sospeso". In alternativa, puoi inviare richieste Ajax ogni X secondi per aggiornare lo stato.
Usando solo il PHP vanilla, lo stesso effetto può essere ottenuto con il codice seguente, ma ovviamente questo si basa su exec
, che è disabilitato per impostazione predefinita, in molti casi.
function somefunction() { exec("php dosomething.php > /dev/null &"); //do something else without waiting for the above to finish }
Le funzionalità fornite da symfony/process
sono più estese di un semplice exec, quindi se non stai usando il pacchetto symphony, puoi modificare ulteriormente lo script PHP dopo aver dato un'occhiata al codice sorgente del pacchetto Symphony.
Il codice di importazione
Ora scriviamo un file di comando php artisan
che gestisce l'importazione. Inizia creando il file della classe di comando: php artisan make:console ImportManager
, quindi fai riferimento ad esso nella proprietà $commands
in /app/console/Kernel.php
, in questo modo:
protected $commands = [ Commands\ImportManager::class, ];
L'esecuzione del comando craft creerà un file denominato ImportManager.php
nella cartella /app/Console/Commands
. Scriveremo il nostro codice come parte del metodo handle()
.
Il nostro codice di importazione aggiornerà prima la flag_table
con il numero totale di righe da importare, quindi scorrerà ogni riga di Excel, la inserirà nel database e aggiornerà lo stato.
Per evitare problemi di memoria insufficiente con file Excel eccezionalmente grandi, è una buona idea elaborare piccoli frammenti del rispettivo set di dati anziché migliaia di righe contemporaneamente; una proposta che causerebbe molti problemi, non solo problemi di memoria.
Per questo esempio basato su Excel, adatteremo il ImportManager::handle()
per recuperare solo un piccolo insieme di righe fino a quando l'intero foglio non sarà stato importato. Questo aiuta a tenere traccia dell'avanzamento dell'attività; dopo che ogni blocco è stato elaborato, aggiorniamo flag_table
incrementando la colonna imported_rows
con la dimensione del blocco.
Nota: non è necessario impaginare perché Maatwebsite\Excel
lo gestisce per te come descritto nella documentazione di Laravel.
Ecco come appare la classe finale di 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(); } }
Sistema ricorsivo di notifica dei progressi
Passiamo alla parte front-end del nostro progetto, la notifica all'utente. Possiamo inviare richieste Ajax a un percorso di segnalazione dello stato nella nostra applicazione per notificare all'utente i progressi o avvisarlo al termine dell'importazione.
Ecco un semplice script jQuery che invierà richieste al server finché non riceve un messaggio che indica che il lavoro è terminato:
(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);
Di nuovo sul server, aggiungi una route GET
denominata status
, che chiamerà un metodo che segnala lo stato corrente dell'attività di importazione come completata o il numero di righe importate da 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 Job differimento
Un altro approccio, quando il recupero dei dati non è sensibile al tempo, consiste nel gestire l'importazione in un secondo momento quando il server è inattivo; diciamo, a mezzanotte. Questo può essere fatto usando i lavori cron che eseguono il comando php artisan import:excelfile
all'intervallo di tempo desiderato.
Sui server Ubuntu, è così semplice:
crontab -e #and add this line @midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.log
Qual è la tua esperienza?
Hai altri suggerimenti per migliorare ulteriormente le prestazioni e l'esperienza utente in casi simili? Sarei ansioso di sapere come li hai affrontati.