使用 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 来完成这个特定的任务,原因如下:
- Laravel 附带了 Artisan,这使得创建命令行任务变得轻而易举。 对于不了解 Artisan 的人,它是 Laravel 中包含的命令行界面,由强大的 Symfony 控制台组件驱动
- Laravel 有 Eloquent ORM 用于将我们的 Excel 数据映射到表格列
- 它维护得很好,并且有非常详尽的文档
- Laravel 100% 为 PHP 7 做好了准备; 事实上,Homestead 盒子已经运行 PHP 7
虽然我选择使用 Laravel,但本教程的概念和代码可以合并到任何也使用Symfony/Process
组件的框架中(您可以使用composer require symfony/process
通过 composer 安装它)。
首先,启动基于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_imported
和total_rows
。 如果我们想向用户显示进度,这两个变量将允许我们计算并交付完成的百分比。
首先运行php artisan make:migration CreateFlagTable
和php 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 Flag
和php 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 脚本。
导入代码
现在让我们编写一个处理导入的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(); } }
递归进度通知系统
让我们继续我们项目的前端部分,用户通知。 我们可以将 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);
回到服务器上,添加一个名为status
的GET
路由,它将调用一个方法,该方法将导入任务的当前状态报告为已完成,或者从 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 作业延期
当数据检索对时间不敏感时,另一种方法是稍后在服务器空闲时处理导入; 说,在午夜。 这可以使用以所需时间间隔执行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
你的经验是什么?
在类似情况下,您还有其他建议可以进一步提高性能和用户体验吗? 我很想知道你是怎么对付他们的。