Bewältigung intensiver Aufgaben mit Laravel
Veröffentlicht: 2022-03-11Wenn es um zeitraubende, ressourcenintensive Aufgaben geht, sind die meisten PHP-Entwickler versucht, den „schnellen Hack-Weg“ zu wählen. Leugne es nicht! Wir haben alle ini_set('max_execution_time', HUGE_INT); vorher, aber es muss nicht so sein.
Im heutigen Tutorial zeige ich, wie die Benutzererfahrung einer Anwendung (mit minimalem Entwickleraufwand) verbessert werden kann, indem lang andauernde Aufgaben mithilfe von Laravel-Entwicklungslösungen vom Hauptanforderungsfluss getrennt werden. Durch die Nutzung der Fähigkeit von PHP, separate Prozesse zu erzeugen, die im Hintergrund ausgeführt werden, reagiert das Hauptskript schneller auf Benutzeraktionen. Dadurch werden die Erwartungen der Benutzer besser verwaltet, anstatt sie ewig (ohne Feedback) warten zu lassen, bis eine Anfrage abgeschlossen ist.
Verschieben Sie lange laufende PHP-Aufgaben, warten Sie nicht.
Das Grundkonzept dieses Tutorials ist Aufschub; Aufgaben übernehmen, die (nach Internetstandards) zu lange laufen, und stattdessen die Ausführung in einen separaten Prozess verschieben, der unabhängig von der Anforderung ausgeführt wird. Diese Verzögerung ermöglicht es uns, ein Benachrichtigungssystem zu implementieren, das dem Benutzer den Status der Aufgabe anzeigt (z. B. X Zeilen von Y wurden importiert) und den Benutzer benachrichtigt, wenn die Aufgabe erledigt ist.
Unser Tutorial basiert auf einem realen Szenario, das Ihnen sicherlich schon einmal begegnet ist: Daten aus riesigen Excel-Tabellen nehmen und in eine Webanwendungsdatenbank übertragen. Das vollständige Projekt ist auf meinem Github verfügbar.
Bootstrapping mit Laravel
Wir verwenden "laravel/framework": "5.2.*" und "maatwebsite/excel": "~2.1.0" ; ein schöner Wrapper für das phpoffice/phpexcel Paket.
Ich habe mich aus folgenden Gründen für Laravel für diese spezielle Aufgabe entschieden:
- Laravel wird mit Artisan geliefert, was das Erstellen von Befehlszeilenaufgaben zum Kinderspiel macht. Für diejenigen, die Artisan nicht kennen, es ist die in Laravel enthaltene Befehlszeilenschnittstelle, die von der leistungsstarken Komponente Symfony Console gesteuert wird
- Laravel hat das Eloquent ORM für die Zuordnung unserer Excel-Daten zu Tabellenspalten
- Es ist gut gepflegt und hat eine sehr gründliche Dokumentation
- Laravel ist zu 100 Prozent bereit für PHP 7; Tatsächlich läuft auf der Homestead-Box bereits PHP 7
Während ich mich für Laravel entscheide, können das Konzept und der Code dieses Tutorials in jedes Framework integriert werden, das auch die Symfony/Process -Komponente verwendet (die Sie über den Composer mit der Verwendung von composer require symfony/process installieren können).
Starten Sie zunächst Ihre Vagrant-Box basierend auf Homestead (der Standard bei der Entwicklung von Laravel-basierten Anwendungen heutzutage). Wenn Sie Homestead nicht eingerichtet haben, bietet die offizielle Dokumentation eine gründliche Schritt-für-Schritt-Anleitung.
Wenn Homestead installiert ist, müssen Sie Homestead.yaml ändern, bevor Sie Ihre Vagrant-Box starten, um zwei Dinge zu tun: Ordnen Sie Ihren lokalen Entwicklungsordner einem Ordner innerhalb der virtuellen Maschine zu. Stellen Sie NGINX automatisch bereit, damit auf eine URL wie http://heavyimporter.app , lädt Ihr neues Projekt.
So sieht meine Konfigurationsdatei aus:
folders: - map: ~/public_html/toptal to: /home/vagrant/toptal sites: - map: heavyimporter.app to: /home/vagrant/toptal/heavyimporter/public databases: - heavyimporter Speichern Sie nun die Datei und führen Sie vagrant up && vagrant provision aus, wodurch die VM gestartet und entsprechend konfiguriert wird. Wenn alles gut gegangen ist, können Sie sich jetzt mit vagrant ssh in Ihre virtuelle Maschine einloggen und ein neues Laravel-Projekt starten. (Wenn nicht alles gut gelaufen ist, finden Sie Hilfe in der Vagrant-Dokumentation von Hashicorp.)
cd /home/vagrant/toptal && composer create-project --prefer-dist laravel/laravel heavyimporter Nach dem Erstellen des Projekts müssen Sie einige Konfigurationsvariablen einrichten, indem Sie die .env -Datei im Home-Ordner bearbeiten. Sie sollten Ihre Installation auch sichern, indem Sie php artisan key:generate ausführen.
So sehen die relevanten Teile der .env-Datei bei mir aus:
APP_ENV=local APP_DEBUG=true APP_KEY=*** DB_HOST=127.0.0.1 DB_DATABASE=heavyimporter DB_USERNAME=homestead DB_PASSWORD=***** Fügen Sie nun das Paket maatwebsite/excel hinzu, indem composer require maatwebsite/excel:~2.1.0 ausführen.
Sie müssen auch den Dienstanbieter und die Fassade/den Alias in Ihrer Datei config/app.php .
Dienstanbieter sind der Kern einer Laravel-Anwendung; Alles in Laravel wird durch einen Dienstanbieter gebootstrapped, während Fassaden einfache statische Schnittstellen sind, die einen einfacheren Zugriff auf diese Dienstanbieter ermöglichen. Mit anderen Worten, anstatt mit Illuminate\Database\DatabaseManager … auf die Datenbank (einen Dienstanbieter) zuzugreifen, können Sie einfach DB::staticmethod() verwenden.
Für uns ist unser Dienstanbieter Maatwebsite\Excel\ExcelServiceProvider und unsere Fassade ist 'Excel'=>'Maatwebsite\Excel\Facades\Excel' .
app.php sollte nun so aussehen:
//... 'providers' => [ //... Maatwebsite\Excel\ExcelServiceProvider::class ], 'aliases' => [ //... 'Excel'=>'Maatwebsite\Excel\Facades\Excel' ]Einrichten der Datenbank mit PHP Artisan
Lassen Sie uns unsere Datenbankmigrationen für zwei Tabellen einrichten. Eine Tabelle enthält ein Flag mit dem Status des Imports, das wir flag_table nennen, und diejenige, die die eigentlichen Excel-Daten enthält, data .
Wenn Sie beabsichtigen, eine Fortschrittsanzeige einzufügen, um den Status der Importaufgabe zu verfolgen, müssen Sie der flag_table zwei weitere Spalten hinzufügen: rows_imported und total_rows . Diese beiden Variablen ermöglichen es uns, den abgeschlossenen Prozentsatz zu berechnen und bereitzustellen, falls wir dem Benutzer den Fortschritt anzeigen möchten.
Führen Sie zuerst php artisan make:migration CreateFlagTable und php artisan make:migration CreateDataTable aus, um diese Tabellen tatsächlich zu erstellen. Öffnen Sie dann die neu erstellten Dateien aus database/migrations und füllen Sie die Up- und Down-Methoden mit der Tabellenstruktur.
//...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'); } Bevor wir den eigentlichen Importcode schreiben, erstellen wir leere Modelle für unsere Datenbanktabellen. Dies wird durch Artisan erreicht, indem zwei einfache Befehle ausgeführt werden: php artisan make:model Flag und php artisan make:model Data , dann in jede neu erstellte Datei gehen und den Tabellennamen als geschützte Eigenschaft dieser Klasse hinzufügen, wie folgt:
//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 }Routing
Routen sind die Augen einer Laravel-Anwendung; Sie beobachten die HTTP-Anfrage und leiten sie an den richtigen Controller weiter. Allerdings benötigen wir zunächst eine POST-Route, die der import -Methode im Controller die Aufgabe zuweist, unsere Excel-Datei hochzuladen. Die Datei wird irgendwo auf den Server hochgeladen, damit wir sie später abrufen können, wenn wir die Befehlszeilenaufgabe ausführen. Achten Sie darauf, alle Ihre Routen (auch die Standardroute) in der web Middleware-Routengruppe zu platzieren, damit Sie vom Sitzungsstatus und CSRF-Schutz profitieren. Die Routendatei sieht folgendermaßen aus:
Route::group(['middleware' => ['web']], function () { //homepage Route::get('/', ['as'=>'home', function () { return view('welcome'); }]); //upload route Route::post('/import', ['as'=>'import', 'uses'=>'Controller@import']); });Die Aufgabenlogik
Wenden wir uns nun dem Hauptcontroller zu, der den Kern unserer Logik in einer Methode enthält, die für Folgendes verantwortlich ist:
- Durchführung der erforderlichen Validierungen in Bezug auf den hochzuladenden Dateityp
- Hochladen der Datei auf den Server und Hinzufügen eines Eintrags in die
flag_table(die vom Befehlszeilenprozess aktualisiert wird, sobald die Aufgabe mit der Gesamtzahl der Zeilen und dem aktuellen Status des Uploads ausgeführt wird) - Starten Sie den Importprozess (der die Artisan-Aufgabe aufruft) und kehren Sie dann zurück, um den Benutzer darüber zu informieren, dass der Prozess initiiert wurde
Dies ist der Code für den Hauptcontroller:

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')); } Die obigen prozessbezogenen Zeilen machen etwas wirklich Cooles. Sie verwenden das symfony/process -Paket, um unabhängig von der Anfrage einen Prozess in einem separaten Thread zu erzeugen. Das bedeutet, dass das laufende Skript nicht auf den Abschluss des Imports wartet, sondern stattdessen mit einer Nachricht an den Benutzer umleitet, dass er warten soll, bis der Import abgeschlossen ist. Auf diese Weise können Sie dem Benutzer eine Statusmeldung „Import ausstehend“ anzeigen. Alternativ können Sie alle X Sekunden Ajax-Anfragen senden, um den Status zu aktualisieren.
Wenn Sie nur Vanilla-PHP verwenden, kann der gleiche Effekt mit dem folgenden Code erzielt werden, aber dies hängt natürlich in vielen Fällen von exec ab, das standardmäßig deaktiviert ist.
function somefunction() { exec("php dosomething.php > /dev/null &"); //do something else without waiting for the above to finish } Die Funktionalitäten, die symfony/process bietet, sind umfangreicher als eine einfache Ausführung. Wenn Sie also nicht das symphony-Paket verwenden, können Sie das PHP-Skript weiter optimieren, nachdem Sie sich den Quellcode des Symphony-Pakets angesehen haben.
Der Importcode
Lassen Sie uns nun eine php artisan -Befehlsdatei schreiben, die den Import übernimmt. Beginnen Sie mit dem Erstellen der Befehlsklassendatei: php artisan make:console ImportManager , und verweisen Sie dann in der Eigenschaft $commands in /app/console/Kernel.php wie folgt darauf:
protected $commands = [ Commands\ImportManager::class, ]; Durch Ausführen des artisan-Befehls wird eine Datei mit dem Namen ImportManager.php im Ordner /app/Console/Commands erstellt. Wir werden unseren Code als Teil der Methode handle() schreiben.
Unser Importcode aktualisiert zuerst die flag_table mit der Gesamtzahl der zu importierenden Zeilen, dann iteriert er durch jede Excel-Zeile, fügt sie in die Datenbank ein und aktualisiert den Status.
Um Speicherprobleme bei außergewöhnlich großen Excel-Dateien zu vermeiden, ist es eine gute Idee, mundgerechte Teile des jeweiligen Datensatzes anstelle von Tausenden von Zeilen auf einmal zu verarbeiten. ein Vorschlag, der viele Probleme verursachen würde, nicht nur Speicherprobleme.
Für dieses Excel-basierte Beispiel passen wir die ImportManager::handle() Methode an, um nur einen kleinen Satz von Zeilen abzurufen, bis das gesamte Blatt importiert wurde. Dies hilft bei der Verfolgung des Aufgabenfortschritts; Nachdem jeder Chunk verarbeitet wurde, aktualisieren wir die flag_table , indem wir die Spalte „ imported_rows “ um die Größe des Chunks erhöhen.
Hinweis: Es ist nicht erforderlich, Seiten zu paginieren, da Maatwebsite\Excel für Sie übernimmt, wie in der Dokumentation von Laravel beschrieben.
So sieht die endgültige ImportManager-Klasse aus:
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(); } }Rekursives Fortschrittsbenachrichtigungssystem
Kommen wir zum Frontend-Teil unseres Projekts, der Benutzerbenachrichtigung. Wir können Ajax-Anforderungen an eine Statusberichtsroute in unserer Anwendung senden, um den Benutzer über den Fortschritt zu informieren oder ihn zu benachrichtigen, wenn der Import abgeschlossen ist.
Hier ist ein einfaches jQuery-Skript, das Anfragen an den Server sendet, bis er eine Nachricht erhält, dass der Job erledigt ist:
(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); Zurück auf dem Server fügen Sie eine GET -Route namens status hinzu, die eine Methode aufruft, die den aktuellen Status der Importaufgabe entweder als erledigt oder als Anzahl der aus X importierten Zeilen meldet.
//...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-Aufschub
Wenn der Datenabruf nicht zeitkritisch ist, besteht ein anderer Ansatz darin, den Import zu einem späteren Zeitpunkt durchzuführen, wenn der Server im Leerlauf ist; sagen wir um Mitternacht. Dies kann mit Cron-Jobs erfolgen, die den Befehl php artisan import:excelfile im gewünschten Zeitintervall ausführen.
Auf Ubuntu-Servern ist es so einfach:
crontab -e #and add this line @midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.logWas ist Ihre Erfahrung?
Haben Sie weitere Vorschläge zur weiteren Verbesserung der Leistung und Benutzererfahrung in ähnlichen Fällen? Mich würde interessieren, wie Sie damit umgegangen sind.
