Gestionarea sarcinilor intensive cu Laravel

Publicat: 2022-03-11

Atunci când se ocupă de sarcini consumatoare de resurse, majoritatea dezvoltatorilor PHP sunt tentați să aleagă „ruta de hack rapid”. Nu nega! Cu toții am folosit ini_set('max_execution_time', HUGE_INT); înainte, dar nu trebuie să fie așa.

În tutorialul de astăzi, demonstrez cum experiența utilizatorului unei aplicații poate fi îmbunătățită (cu un efort minim de dezvoltator) prin separarea sarcinilor de lungă durată de fluxul principal de cereri folosind soluțiile de dezvoltare Laravel. Utilizând capacitatea PHP de a genera procese separate care rulează în fundal, scriptul principal va răspunde mai rapid la acțiunea utilizatorului. Astfel, gestionează mai bine așteptările utilizatorilor, în loc să-i facă să aștepte de ani de zile (fără feedback) până la finalizarea unei cereri.

Amânați sarcinile PHP care rulează îndelungat, nu așteptați.

Conceptul de bază al acestui tutorial este amânarea; preluarea sarcinilor care rulează prea mult timp (după standardele de internet) și, în schimb, amânarea execuției într-un proces separat care rulează independent de cerere. Această amânare ne permite să implementăm un sistem de notificare care arată utilizatorului starea sarcinii (au fost importate un număr X de rânduri din Y, de exemplu) și să avertizeze utilizatorul când lucrarea este terminată.

Tutorialul nostru se bazează pe un scenariu din viața reală pe care sunt sigur că l-ați mai întâlnit: luarea datelor din foi de calcul Excel uriașe și introducerea lor într-o bază de date a aplicației web. Proiectul complet este disponibil pe github-ul meu.

Nu faceți utilizatorii să stea și să aștepte la o sarcină de lungă durată. Amâna.

Bootstrapping cu Laravel

Vom folosi "laravel/framework": "5.2.*" și "maatwebsite/excel": "~2.1.0" ; un înveliș frumos pentru pachetul phpoffice/phpexcel .

Am ales să folosesc Laravel pentru această sarcină specială din următoarele motive:

  1. Laravel vine cu Artisan, ceea ce face ca crearea de sarcini în linia de comandă să fie o ușoară. Pentru cei care nu cunosc Artisan, este interfața de linie de comandă inclusă în Laravel, condusă de puternica componentă Symfony Console
  2. Laravel are ORM Elocvent pentru maparea datelor noastre Excel pe coloanele tabelului
  3. Este bine intretinuta si are o documentatie foarte amanuntita
  4. Laravel este 100% pregătit pentru PHP 7; de fapt, caseta Homestead rulează deja PHP 7

În timp ce aleg să merg cu Laravel, conceptul și codul acestui tutorial pot fi încorporate în orice cadru care folosește și componenta Symfony/Process (pe care o puteți instala prin compozitor folosind composer require symfony/process ).

Înrudit : De ce m-am decis să o îmbrățișez pe Laravel

Pentru început, porniți-vă cutia vagabondă bazată pe Homestead (standardul atunci când dezvoltați aplicații bazate pe Laravel în zilele noastre). Dacă nu aveți configurat Homestead, documentația oficială oferă un ghid amănunțit pas cu pas.

Cu Homestead instalat, va trebui să modificați Homestead.yaml înainte de a vă porni caseta vagabondă pentru a face două lucruri: Mapați folderul de dezvoltare locală într-un folder din interiorul mașinii virtuale Asigurați automat NGINX, astfel încât accesarea unei adrese URL, cum ar fi http://heavyimporter.app , vă va încărca noul proiect.

Iată cum arată fișierul meu de configurare:

 folders: - map: ~/public_html/toptal to: /home/vagrant/toptal sites: - map: heavyimporter.app to: /home/vagrant/toptal/heavyimporter/public databases: - heavyimporter

Acum, salvați fișierul și rulați vagrant up && vagrant provision , care pornește VM-ul și îl configurează în consecință. Dacă totul a mers bine, acum vă puteți conecta la mașina virtuală cu vagrant ssh și puteți începe un nou proiect Laravel. (Dacă totul nu a mers bine, consultați documentația Vagrant de la Hashicorp pentru ajutor.)

 cd /home/vagrant/toptal && composer create-project --prefer-dist laravel/laravel heavyimporter

După crearea proiectului, va trebui să setați unele variabile de configurare prin editarea fișierului .env din folderul de start. Ar trebui să vă asigurați instalarea rulând php artisan key:generate .

Iată cum arată părțile relevante ale fișierului .env din partea mea:

 APP_ENV=local APP_DEBUG=true APP_KEY=*** DB_HOST=127.0.0.1 DB_DATABASE=heavyimporter DB_USERNAME=homestead DB_PASSWORD=*****

Acum adăugați pachetul maatwebsite/excel executând composer require maatwebsite/excel:~2.1.0 .

De asemenea, trebuie să adăugați furnizorul de servicii și fațada/alias-ul în fișierul config/app.php .

Furnizorii de servicii sunt nucleul unei aplicații Laravel; totul în Laravel este bootstrap printr-un furnizor de servicii, în timp ce fațadele sunt simple interfețe statice care permit accesul mai ușor la acești furnizori de servicii. Cu alte cuvinte, în loc să accesați baza de date (un furnizor de servicii) cu Illuminate\Database\DatabaseManager... puteți utiliza doar DB::staticmethod().

Pentru noi, furnizorul nostru de servicii este Maatwebsite\Excel\ExcelServiceProvider , iar fațada noastră este 'Excel'=>'Maatwebsite\Excel\Facades\Excel' .

app.php ar trebui să arate acum astfel:

 //... 'providers' => [ //... Maatwebsite\Excel\ExcelServiceProvider::class ], 'aliases' => [ //... 'Excel'=>'Maatwebsite\Excel\Facades\Excel' ]

Configurarea bazei de date cu PHP Artisan

Să configuram migrarea bazei de date pentru două tabele. Un tabel conține un steag cu starea importului, pe care îl vom numi flag_table , iar cel care are datele reale Excel, data .

Dacă intenționați să includeți un indicator de progres pentru a urmări starea sarcinii de import, trebuie să adăugați încă două coloane la flag_table : rows_imported și total_rows . Aceste două variabile ne vor permite să calculăm și să livrăm procentul finalizat în cazul în care dorim să arătăm progresul utilizatorului.

Mai întâi rulați php artisan make:migration CreateFlagTable și php artisan make:migration CreateDataTable pentru a crea efectiv aceste tabele. Apoi, deschideți fișierele nou create din database/migrations și completați metodele sus și jos cu structura tabelului.

 //...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'); }

Înainte de a scrie codul de import, să creăm modele goale pentru tabelele bazei de date. Acest lucru se realizează prin Artisan prin rularea a două comenzi simple: php artisan make:model Flag și php artisan make:model Data , apoi accesând fiecare fișier nou creat și adăugând numele tabelului ca proprietate protejată a acelei clase, astfel:

 //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 }

Dirijare

Rutele sunt ochii unei aplicații Laravel; ei observă solicitarea HTTP și o direcționează către controlerul corespunzător. Acestea fiind spuse, mai întâi, avem nevoie de o rută POST care atribuie sarcina de a încărca fișierul nostru Excel la metoda de import în controler. Fișierul va fi încărcat undeva pe server, astfel încât să îl putem prelua mai târziu când executăm sarcina de linie de comandă. Asigurați-vă că plasați toate rutele (chiar și cea implicită) în grupul de rute web middleware, astfel încât să beneficiați de starea sesiunii și de protecție CSRF. Fișierul rute va arăta astfel:

 Route::group(['middleware' => ['web']], function () { //homepage Route::get('/', ['as'=>'home', function () { return view('welcome'); }]); //upload route Route::post('/import', ['as'=>'import', 'uses'=>'Controller@import']); });

Logica sarcinii

Acum să ne îndreptăm atenția către controlerul principal, care va deține miezul logicii noastre într-o metodă care este responsabilă pentru următoarele:

  • Efectuarea validărilor necesare legate de tipul de fișier care este încărcat
  • Încărcarea fișierului pe server și adăugarea unei intrări în flag_table (care va fi actualizată de procesul de linie de comandă odată ce sarcina se execută cu numărul total de rânduri și starea curentă a încărcării)
  • Pornirea procesului de import (care va apela sarcina Artisan), apoi revenirea pentru a anunța utilizatorul că procesul a fost inițiat

Acesta este codul pentru controlerul 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')); }

Liniile legate de proces de mai sus fac ceva foarte grozav. Ei folosesc pachetul symfony/process pentru a genera un proces pe un fir separat, independent de cerere. Aceasta înseamnă că scriptul care rulează nu va aștepta să se termine importul, ci va redirecționa cu un mesaj către utilizator pentru a aștepta până la finalizarea importului. În acest fel, puteți afișa utilizatorului un mesaj de stare „import în așteptare”. Alternativ, puteți trimite solicitări Ajax la fiecare X secunde pentru a actualiza starea.

Folosind numai vanilla PHP, același efect poate fi obținut cu următorul cod, dar, desigur, acesta se bazează pe exec , care este dezactivat implicit, în multe cazuri.

 function somefunction() { exec("php dosomething.php > /dev/null &"); //do something else without waiting for the above to finish }

Funcționalitățile pe care le oferă symfony/process sunt mai extinse decât un simplu exec, așa că dacă nu utilizați pachetul symphony, puteți modifica mai mult scriptul PHP după ce vă uitați la codul sursă al pachetului Symphony.

Folosind pachetul Symfony, puteți genera un proces PHP pe un fir separat, independent de cerere.

Codul de import

Acum să scriem un fișier de comandă php artisan care se ocupă de import. Începeți prin a crea fișierul clasei de comandă: php artisan make:console ImportManager , apoi faceți referire la el în proprietatea $commands din /app/console/Kernel.php , astfel:

 protected $commands = [ Commands\ImportManager::class, ];

Rularea comenzii artizanale va crea un fișier numit ImportManager.php în folderul /app/Console/Commands . Vom scrie codul nostru ca parte a metodei handle() .

Codul nostru de import va actualiza mai întâi flag_table cu numărul total de rânduri care urmează să fie importate, apoi va itera prin fiecare rând Excel, îl va insera în baza de date și va actualiza starea.

Pentru a evita problemele de memorie cu fișiere Excel excepțional de mari, este o idee bună să procesați bucăți de dimensiuni mici din setul de date respectiv în loc de mii de rânduri simultan; o propunere care ar cauza o mulțime de probleme, nu doar probleme de memorie.

Pentru acest exemplu bazat pe Excel, vom adapta metoda ImportManager::handle() pentru a prelua doar un set mic de rânduri până când întreaga foaie a fost importată. Acest lucru ajută la urmărirea progresului sarcinii; după ce fiecare bucată este procesată, actualizăm flag_table incrementând coloana imported_rows cu dimensiunea fragmentului.

Notă: Nu este nevoie să paginați deoarece Maatwebsite\Excel se ocupă de asta pentru dvs., așa cum este descris în documentația lui Laravel.

Iată cum arată clasa finală 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(); } }
Înrudit : Angajați primii 3% dintre dezvoltatorii independenți Laravel.

Sistem recursiv de notificare a progresului

Să trecem la partea frontală a proiectului nostru, notificarea utilizatorului. Putem trimite solicitări Ajax către o rută de raportare a stării din aplicația noastră pentru a notifica utilizatorul despre progres sau pentru a-l avertiza când importul este finalizat.

Iată un script jQuery simplu care va trimite solicitări către server până când primește un mesaj care spune că lucrarea este terminată:

 (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);

Înapoi pe server, adăugați o rută GET numită status , care va apela o metodă care raportează starea curentă a sarcinii de import, fie ca fiind finalizată, fie numărul de rânduri importate din 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]); } } ... 

Trimiteți cereri Ajax către o rută de raportare a stării pentru a notifica utilizatorul despre progres.

Trimiteți cereri Ajax către o rută de raportare a stării pentru a notifica utilizatorul despre progres.

Amânarea jobului Cron

O altă abordare, atunci când recuperarea datelor nu este sensibilă la timp, este de a gestiona importul la un moment ulterior, când serverul este inactiv; să zicem, la miezul nopții. Acest lucru se poate face folosind joburi cron care execută comanda php artisan import:excelfile la intervalul de timp dorit.

Pe serverele Ubuntu, este la fel de simplu ca acesta:

 crontab -e #and add this line @midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.log

Care este experiența ta?

Aveți alte sugestii pentru îmbunătățirea în continuare a performanței și a experienței utilizatorului în cazuri similare? Aș fi nerăbdător să știu cum te-ai descurcat cu ei.

Înrudit : Autentificare completă a utilizatorului și control al accesului – Un tutorial pentru pașaport Laravel, Pt. 1