使用 Laravel 處理密集型任務

已發表: 2022-03-11

在處理耗時的資源密集型任務時,大多數 PHP 開發人員都傾向於選擇“快速破解路線”。 不要否認! 我們都用過ini_set('max_execution_time', HUGE_INT); 以前,但不一定要這樣。

在今天的教程中,我將演示如何通過使用 Laravel 開發解決方案將長時間運行的任務與主請求流分離來改善應用程序的用戶體驗(只需最少的開發人員工作量)。 通過利用 PHP 生成在後台運行的單獨進程的能力,主腳本將更快地響應用戶操作。 因此,它可以更好地管理用戶期望,而不是讓他們等待很長時間(沒有反饋)才能完成請求。

推遲長時間運行的 PHP 任務,不要等待。

本教程的基本概念是延期; 執行運行時間過長的任務(按照 Internet 標準),而是將執行推遲到獨立於請求運行的單獨進程中。 這種延遲允許我們實現一個通知系統,向用戶顯示任務的狀態(例如,Y 中的 X 行已被導入)並在工作完成時提醒用戶。

我們的教程基於我相信您之前遇到過的真實場景:從巨大的 Excel 電子表格中獲取數據並將其推送到 Web 應用程序數據庫中。 完整的項目可以在我的 github 上找到。

不要讓您的用戶坐等長時間運行的任務。 推遲。

使用 Laravel 引導

我們將使用"laravel/framework": "5.2.*""maatwebsite/excel": "~2.1.0" ; 一個很好的phpoffice/phpexcel包的包裝器。

我選擇使用 Laravel 來完成這個特定的任務,原因如下:

  1. Laravel 附帶了 Artisan,這使得創建命令行任務變得輕而易舉。 對於不了解 Artisan 的人,它是 Laravel 中包含的命令行界面,由強大的 Symfony 控制台組件驅動
  2. Laravel 有 Eloquent ORM 用於將我們的 Excel 數據映射到表格列
  3. 它維護得很好,並且有非常詳盡的文檔
  4. Laravel 100% 為 PHP 7 做好了準備; 事實上,Homestead 盒子已經運行 PHP 7

雖然我選擇使用 Laravel,但本教程的概念和代碼可以合併到任何也使用Symfony/Process組件的框架中(您可以使用composer require symfony/process通過 composer 安裝它)。

相關:為什麼我決定擁抱 Laravel

首先,啟動基於Homestead (目前開發基於 Laravel 的應用程序的標準)的 vagrant box。 如果您沒有設置 Homestead,官方文檔會提供詳盡的分步指南。

安裝 Homestead 後,您需要在啟動 vagrant box 之前修改Homestead.yaml以做兩件事: 將本地開發文件夾映射到虛擬機內的文件夾 自動配置 NGINX 以便訪問 URL,例如http://heavyimporter.app ,將加載您的新項目。

這是我的配置文件的樣子:

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

現在,保存文件並運行vagrant up && vagrant provision ,它會啟動 VM 並進行相應的配置。 如果一切順利,您現在可以使用vagrant ssh登錄到您的虛擬機,並開始一個新的 Laravel 項目。 (如果一切不順利,請參閱 Hashicorp 的 Vagrant 文檔尋求幫助。)

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

創建項目後,您需要通過編輯主文件夾中的.env文件來設置一些配置變量。您還應該通過運行php artisan key:generate來保護您的安裝。

這是 .env 文件的相關部分在我這邊的樣子:

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

現在通過執行composer require maatwebsite/excel:~2.1.0添加maatwebsite/excel包。

您還需要在config/app.php文件中添加服務提供者和外觀/別名。

服務提供者是 Laravel 應用程序的核心; Laravel 中的一切都是通過服務提供者引導的,而外觀是簡單的靜態接口,可以更輕鬆地訪問這些服務提供者。 換句話說,不用使用 Illuminate\Database\DatabaseManager 來訪問數據庫(服務提供者)……你可以只使用 DB::staticmethod()。

對我們來說,我們的服務提供商是Maatwebsite\Excel\ExcelServiceProvider ,我們的外觀是'Excel'=>'Maatwebsite\Excel\Facades\Excel'

app.php現在應該如下所示:

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

使用 PHP Artisan 設置數據庫

讓我們為兩個表設置數據庫遷移。 一個表包含一個帶有導入狀態的標誌,我們將其稱為flag_table ,另一個表包含實際的 Excel 數據data

如果您打算包含一個進度指示器來跟踪導入任務的狀態,則需要在flag_table中再添加兩列: rows_importedtotal_rows 。 如果我們想向用戶顯示進度,這兩個變量將允許我們計算並交付完成的百分比。

首先運行php artisan make:migration CreateFlagTablephp artisan make:migration CreateDataTable來實際創建這些表。 然後,從database/migrations打開新創建的文件,並用表結構填充 up 和 down 方法。

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

在我們實際編寫導入代碼之前,讓我們為我們的數據庫表創建空模型。 這是通過 Artisan 通過運行兩個簡單命令來實現的: php artisan make:model Flagphp artisan make:model Data ,然後進入每個新創建的文件並將表名添加為該類的受保護屬性,如下所示:

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

路由

路由是 Laravel 應用程序的眼睛; 他們觀察 HTTP 請求並將其指向正確的控制器。 話雖如此,首先,我們需要一個 POST 路由,它將上傳 Excel 文件的任務分配給控制器中的import方法。 該文件將上傳到服務器上的某個位置,以便我們稍後在執行命令行任務時獲取它。 確保將所有路由(甚至是默認路由)放入web中間件路由組,以便您從會話狀態和 CSRF 保護中受益。 路由文件將如下所示:

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

任務邏輯

現在讓我們把注意力轉向主控制器,它將把我們邏輯的核心放在一個負責以下的方法中:

  • 對上傳的文件類型進行必要的驗證
  • 將文件上傳到服務器並在flag_table中添加一個條目(一旦任務執行,命令行進程將使用總行數和上傳的當前狀態更新該條目)
  • 啟動導入過程(將調用 Artisan 任務)然後返回讓用戶知道該過程已啟動

這是主控制器的代碼:

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

上面與流程相關的行做了一些非常酷的事情。 他們使用symfony/process包在單獨的線程上生成一個進程,獨立於請求。 這意味著正在運行的腳本不會等待導入完成,而是會重定向一條消息給用戶,讓用戶等待導入完成。 這樣,您可以向用戶顯示“導入未決”狀態消息。 或者,您可以每 X 秒發送一次 Ajax 請求以更新狀態。

僅使用 vanilla PHP,可以使用以下代碼實現相同的效果,但這當然依賴於exec ,在許多情況下默認禁用。

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

symfony/process提供的功能比簡單的 exec 更廣泛,所以如果您不使用 symphony 包,您可以在查看 Symphony 包源代碼後進一步調整 PHP 腳本。

使用 Symfony 包,您可以在獨立於請求的單獨線程上生成 PHP 進程。

導入代碼

現在讓我們編寫一個處理導入的php artisan命令文件。 首先創建命令類文件: php artisan make:console ImportManager ,然後在/app/console/Kernel.php$commands屬性中引用它,如下所示:

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

運行 artisan 命令將在/app/Console/Commands文件夾中創建一個名為ImportManager.php的文件。 我們將編寫代碼作為handle()方法的一部分。

我們的導入代碼將首先用要導入的總行數更新flag_table ,然後遍歷 Excel 的每一行,將其插入數據庫,並更新狀態。

為了避免異常大的 Excel 文件出現內存不足問題,最好同時處理相應數據集的小塊而不是數千行; 一個會引起很多問題的命題,而不僅僅是記憶問題。

對於這個基於 Excel 的示例,我們將調整ImportManager::handle()方法以僅獲取一小組行,直到整個工作表都被導入。 這有助於跟踪任務進度; 在處理完每個塊之後,我們通過將imported_rows列增加塊的大小來更新flag_table

注意:不需要分頁,因為Maatwebsite\Excel會按照 Laravel 文檔中的說明為您處理。

下面是最終 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(); } }
相關:聘請前 3% 的自由 Laravel 開發人員。

遞歸進度通知系統

讓我們繼續我們項目的前端部分,用戶通知。 我們可以將 Ajax 請求發送到應用程序中的狀態報告路由,以通知用戶進度或在導入完成時提醒他們。

這是一個簡單的 jQuery 腳本,它將向服務器發送請求,直到它收到一條表明工作完成的消息:

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

回到服務器上,添加一個名為statusGET路由,它將調用一個方法,該方法將導入任務的當前狀態報告為已完成,或者從 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]); } } ... 

將 Ajax 請求發送到狀態報告路由以通知用戶進度。

將 Ajax 請求發送到狀態報告路由以通知用戶進度。

Cron 作業延期

當數據檢索對時間不敏感時,另一種方法是稍後在服務器空閒時處理導入; 說,在午夜。 這可以使用以所需時間間隔執行php artisan import:excelfile命令的 cron 作業來完成。

在 Ubuntu 服務器上,就這麼簡單:

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

你的經驗是什麼?

在類似情況下,您還有其他建議可以進一步提高性能和用戶體驗嗎? 我很想知道你是怎麼對付他們的。

相關:完整的用戶身份驗證和訪問控制 - Laravel Passport 教程,Pt。 1