Obsługa intensywnych zadań z Laravel

Opublikowany: 2022-03-11

Większość programistów PHP, mając do czynienia z czasochłonnymi zadaniami wymagającymi dużej ilości zasobów, ma pokusę wybrania „szybkiego hackowania”. Nie zaprzeczaj! Wszyscy używaliśmy ini_set('max_execution_time', HUGE_INT); wcześniej, ale nie musi tak być.

W dzisiejszym samouczku pokazuję, jak można poprawić wrażenia użytkownika aplikacji (przy minimalnym nakładzie pracy programisty) poprzez oddzielenie długotrwałych zadań od głównego przepływu żądań przy użyciu rozwiązań programistycznych Laravel. Wykorzystując zdolność PHP do tworzenia oddzielnych procesów działających w tle, główny skrypt będzie szybciej reagował na działania użytkownika. Dzięki temu lepiej zarządza oczekiwaniami użytkowników, zamiast zmuszać ich do czekania wiekami (bez informacji zwrotnej) na zakończenie żądania.

Odkładaj długo działające zadania PHP, nie czekaj.

Podstawową koncepcją tego samouczka jest odroczenie; przejmowanie zadań, które działają zbyt długo (jak na standardy internetowe) i zamiast tego odraczanie ich wykonania do oddzielnego procesu, który działa niezależnie od żądania. To odroczenie pozwala nam zaimplementować system powiadomień, który pokazuje użytkownikowi status zadania (na przykład zaimportowano X wierszy z Y) i ostrzega użytkownika, gdy zadanie jest wykonane.

Nasz samouczek opiera się na prawdziwym scenariuszu, z którym na pewno już się spotkałeś: Pobieranie danych z ogromnych arkuszy kalkulacyjnych Excela i umieszczanie ich w bazie danych aplikacji internetowych. Cały projekt jest dostępny na moim githubie.

Nie zmuszaj swoich użytkowników do siedzenia i czekania na długotrwałe zadanie. Odraczać.

Bootstrapping z Laravel

Będziemy używać "laravel/framework": "5.2.*" i "maatwebsite/excel": "~2.1.0" ; ładne opakowanie dla pakietu phpoffice/phpexcel .

Wybrałem Laravela do tego konkretnego zadania z następujących powodów:

  1. Laravel jest dostarczany z Artisan, co sprawia, że ​​tworzenie zadań wiersza poleceń jest dziecinnie proste. Dla tych, którzy nie znają Artisana, jest to interfejs wiersza poleceń zawarty w Laravel, napędzany przez potężny komponent Symfony Console
  2. Laravel ma Eloquent ORM do mapowania naszych danych Excela na kolumny tabeli
  3. Jest zadbany i posiada bardzo dokładną dokumentację
  4. Laravel jest w 100 procentach gotowy na PHP 7; w rzeczywistości na Homestead działa już PHP 7

Chociaż zdecyduję się na Laravela, koncepcja i kod tego samouczka można włączyć do dowolnego frameworka, który również używa komponentu Symfony/Process (który można zainstalować za pomocą kompozytora przy użyciu composer require symfony/process ).

Powiązane: Dlaczego zdecydowałem się objąć Laravel

Na początek odpal swoją włóczęgę opartą na Homestead (standard podczas tworzenia aplikacji opartych na Laravel w dzisiejszych czasach). Jeśli nie masz skonfigurowanego Homestead, oficjalna dokumentacja zawiera dokładny przewodnik krok po kroku.

Po zainstalowaniu Homestead, będziesz musiał zmodyfikować Homestead.yaml przed uruchomieniem swojego włóczęgi, aby wykonać dwie rzeczy: Zmapować lokalny folder programistyczny do folderu wewnątrz maszyny wirtualnej Automatycznie udostępnić NGINX, aby uzyskać dostęp do adresu URL, takiego jak http://heavyimporter.app , załaduje nowy projekt.

Oto jak wygląda mój plik konfiguracyjny:

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

Teraz zapisz plik i uruchom vagrant up && vagrant provision , który uruchomi maszynę wirtualną i odpowiednio ją skonfiguruje. Jeśli wszystko poszło dobrze, możesz teraz zalogować się na swoją maszynę wirtualną za pomocą vagrant ssh i rozpocząć nowy projekt Laravel. (Jeśli wszystko nie poszło dobrze, poszukaj pomocy w dokumentacji Hashicorp's Vagrant.)

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

Po utworzeniu projektu będziesz musiał ustawić kilka zmiennych konfiguracyjnych, edytując plik .env w katalogu domowym. Powinieneś również zabezpieczyć swoją instalację, uruchamiając php artisan key:generate .

Oto, jak po mojej stronie wyglądają odpowiednie części pliku .env:

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

Teraz dodaj pakiet maatwebsite/excel , uruchamiając composer require maatwebsite/excel:~2.1.0 .

Musisz również dodać dostawcę usług i fasadę/alias w pliku config/app.php .

Dostawcy usług są rdzeniem aplikacji Laravel; wszystko w Laravelu jest uruchamiane przez dostawcę usług, podczas gdy fasady są prostymi, statycznymi interfejsami, które umożliwiają łatwiejszy dostęp do tych usługodawców. Innymi słowy, zamiast dostępu do bazy danych (dostawcy usług) za pomocą Illuminate\Database\DatabaseManager… możesz po prostu użyć DB::staticmethod().

Naszym dostawcą usług jest Maatwebsite\Excel\ExcelServiceProvider , a nasza fasada to 'Excel'=>'Maatwebsite\Excel\Facades\Excel' .

app.php powinien teraz wyglądać tak:

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

Konfigurowanie bazy danych za pomocą PHP Artisan

Skonfigurujmy migracje bazy danych dla dwóch tabel. Jedna tabela zawiera flagę ze statusem importu, którą nazwiemy flag_table , oraz ta, która zawiera rzeczywiste dane Excela, data .

Jeśli zamierzasz dołączyć wskaźnik postępu do śledzenia stanu zadania importu, musisz dodać dwie dodatkowe kolumny do flag_table : rows_imported i total_rows . Te dwie zmienne pozwolą nam obliczyć i dostarczyć procent ukończonych w przypadku, gdy chcemy pokazać postęp użytkownikowi.

Najpierw uruchom php artisan make:migration CreateFlagTable i php artisan make:migration CreateDataTable , aby faktycznie utworzyć te tabele. Następnie otwórz nowo utworzone pliki z database/migrations i wypełnij metody up i down strukturą tabeli.

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

Zanim faktycznie napiszemy kod importu, utwórzmy puste modele dla naszych tabel bazy danych. Osiąga się to za pomocą programu Artisan, uruchamiając dwa proste polecenia: php artisan make:model Flag i php artisan make:model Data , a następnie przechodząc do każdego nowo utworzonego pliku i dodając nazwę tabeli jako chronioną właściwość tej klasy, na przykład:

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

Rozgromienie

Trasy są oczami aplikacji Laravel; obserwują żądanie HTTP i kierują je do odpowiedniego kontrolera. Biorąc to pod uwagę, najpierw potrzebujemy trasy POST, która przypisuje zadanie przesłania naszego pliku Excel do metody import w kontrolerze. Plik zostanie wgrany gdzieś na serwer, abyśmy mogli go pobrać później, gdy wykonamy zadanie wiersza poleceń. Pamiętaj, aby umieścić wszystkie swoje trasy (nawet domyślną) w grupie tras web oprogramowania pośredniczącego, aby korzystać ze stanu sesji i ochrony CSRF. Plik tras będzie wyglądał tak:

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

Logika zadań

Zwróćmy teraz uwagę na główny kontroler, który będzie zawierał rdzeń naszej logiki w metodzie odpowiedzialnej za:

  • Dokonywanie niezbędnych walidacji związanych z przesyłanym typem pliku
  • Przesłanie pliku na serwer i dodanie wpisu do flag_table (który zostanie zaktualizowany przez proces wiersza poleceń po wykonaniu zadania z łączną liczbą wierszy i aktualnym stanem przesyłania)
  • Rozpoczęcie procesu importu (który wywoła zadanie Artisan), a następnie powrót, aby poinformować użytkownika, że ​​proces został rozpoczęty

To jest kod głównego kontrolera:

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

Powyższe linie związane z procesem robią coś naprawdę fajnego. Używają pakietu symfony/process do tworzenia procesu w osobnym wątku, niezależnie od żądania. Oznacza to, że działający skrypt nie będzie czekał na zakończenie importu, ale zamiast tego przekieruje z komunikatem do użytkownika, aby poczekał na zakończenie importu. W ten sposób możesz wyświetlić użytkownikowi komunikat o stanie „oczekujący na import”. Alternatywnie możesz wysyłać żądania Ajax co X sekund, aby zaktualizować status.

Używając tylko waniliowego PHP, ten sam efekt można osiągnąć za pomocą poniższego kodu, ale oczywiście opiera się to na exec , który w wielu przypadkach jest domyślnie wyłączony.

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

Funkcjonalności oferowane przez symfony/process są bardziej rozbudowane niż prosty exec, więc jeśli nie używasz pakietu symphony, możesz dalej modyfikować skrypt PHP po zapoznaniu się z kodem źródłowym pakietu Symphony.

Korzystając z pakietu Symfony, możesz utworzyć proces PHP w osobnym wątku, niezależnie od żądania.

Kod importowy

Teraz napiszmy plik poleceń php artisan , który obsługuje import. Zacznij od utworzenia pliku klasy poleceń: php artisan make:console ImportManager , a następnie odnieś się do niego we właściwości $commands w /app/console/Kernel.php , w następujący sposób:

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

Uruchomienie polecenia artisan spowoduje utworzenie pliku o nazwie ImportManager.php w folderze /app/Console/Commands . Nasz kod napiszemy jako część metody handle() .

Nasz kod importu najpierw zaktualizuje flag_table o łączną liczbę wierszy do zaimportowania, a następnie przejdzie przez każdy wiersz programu Excel, wstawi go do bazy danych i zaktualizuje status.

Aby uniknąć problemów z brakiem pamięci w przypadku wyjątkowo dużych plików Excela, dobrym pomysłem jest przetwarzanie niewielkich fragmentów odpowiedniego zestawu danych zamiast tysięcy wierszy naraz; propozycja, która spowodowałaby wiele problemów, nie tylko problemy z pamięcią.

W tym przykładzie opartym na Excelu dostosujemy ImportManager::handle() tak, aby pobierała tylko mały zestaw wierszy, dopóki cały arkusz nie zostanie zaimportowany. Pomaga to w śledzeniu postępu zadania; po przetworzeniu każdej porcji aktualizujemy flag_table , zwiększając kolumnę imported_rows o rozmiar porcji.

Uwaga: nie ma potrzeby stronicowania, ponieważ Maatwebsite\Excel obsługuje to za Ciebie, jak opisano w dokumentacji Laravela.

Oto jak wygląda końcowa klasa 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(); } }
Powiązane: Zatrudnij najlepszych 3% niezależnych programistów Laravel.

Rekurencyjny system powiadamiania o postępach

Przejdźmy do frontendowej części naszego projektu, czyli powiadamiania użytkowników. Możemy wysyłać żądania Ajax do trasy raportującej status w naszej aplikacji, aby powiadomić użytkownika o postępie lub ostrzec go, gdy import się zakończy.

Oto prosty skrypt jQuery, który będzie wysyłał żądania do serwera, dopóki nie otrzyma komunikatu o wykonaniu zadania:

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

Z powrotem na serwerze dodaj trasę GET o nazwie status , która wywoła metodę zgłaszającą bieżący stan zadania importu jako wykonane lub liczbę wierszy zaimportowanych z 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]); } } ... 

Wysyłaj żądania Ajax do trasy raportowania stanu, aby powiadomić użytkownika o postępie.

Wysyłaj żądania Ajax do trasy raportowania stanu, aby powiadomić użytkownika o postępie.

Odroczenie pracy Cron

Innym podejściem, gdy pobieranie danych nie jest zależne od czasu, jest obsługa importu w późniejszym czasie, gdy serwer jest bezczynny; powiedzmy o północy. Można to zrobić za pomocą zadań cron, które wykonują polecenie php artisan import:excelfile w żądanym przedziale czasu.

Na serwerach Ubuntu jest to tak proste:

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

Jakie masz doświadczenie?

Czy masz inne sugestie dotyczące dalszej poprawy wydajności i wygody użytkownika w podobnych przypadkach? Chciałbym wiedzieć, jak sobie z nimi poradziłeś.

Powiązane: Pełne uwierzytelnianie użytkownika i kontrola dostępu – samouczek dotyczący paszportu Laravel, Pt. 1