本文概述
在处理耗时的资源密集型任务时, 大多数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 Console组件驱动
- Laravel具有Eloquent ORM, 用于将我们的Excel数据映射到表列
- 它维护得很好并且有非常详尽的文档
- Laravel已为PHP 7做好了100%的准备;实际上, Homestead盒已经运行了PHP 7
当我选择使用Laravel时, 本教程的概念和代码可以合并到任何使用Symfony / Process组件的框架中(你可以使用composer require symfony / process通过composer安装该组件)。
相关:为什么我决定拥抱Laravel
首先, 基于Homestead(这是近来开发基于Laravel的应用程序的标准)启动你的无所事事的盒子。如果你没有设置Homestead, 则官方文档会提供详尽的分步指南。
安装了Homestead后, 你需要在启动游民游箱之前修改Homestead.yaml, 以执行以下两项操作:将本地开发文件夹映射到虚拟机内部的文件夹自动配置NGINX, 以便访问URL, 例如http:// heavyimporter.app, 将加载你的新项目。
我的配置文件如下所示:
folders:
- map: ~/public_html/srcmini
to: /home/vagrant/srcmini
sites:
- map: heavyimporter.app
to: /home/vagrant/srcmini/heavyimporter/public
databases:
- heavyimporter
现在, 保存文件并运行” vagrant up && vagrant设置”, 这将启动VM并进行相应的配置。如果一切顺利, 你现在可以使用vagrant ssh登录到虚拟机, 并启动一个新的Laravel项目。 (如果一切都不顺利, 请参阅Hashicorp的Vagrant文档以获取帮助。)
cd /home/vagrant/srcmini && 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文件中添加服务提供商和Facade / alias。
服务提供者是Laravel应用程序的核心。 Laravel中的所有内容都通过服务提供商进行引导, 而Facades是简单的静态接口, 可以更轻松地访问那些服务提供商。换句话说, 除了使用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来实际创建这些表。然后, 从数据库/迁移中打开新创建的文件, 并使用表结构填充上下方法。
//...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'=>'[email protected]']);
});
任务逻辑
现在, 我们将注意力转移到主控制器上, 该主控制器将采用一种负责以下工作的方法来控制逻辑的核心:
- 对要上传的文件类型进行必要的验证
- 将文件上载到服务器, 并在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请求以更新状态。
仅使用原始PHP, 可以通过以下代码实现相同的效果, 但是当然, 这取决于exec, 在许多情况下, 默认情况下禁用了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 / process`包, 你可以独立于请求而在单独的线程上生成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()方法进行调整, 使其仅获取一小组行, 直到导入了整个图纸为止。这有助于跟踪任务进度;在处理完每个块之后, 我们通过增加import_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();
}
}
相关:雇用自由职业Laravel开发人员的前3%。
递归进度通知系统
让我们继续进行项目的前端部分, 即用户通知。我们可以将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'=>'[email protected]']);
//...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请求发送到状态报告路由, 以通知用户进度。
Cron工作延缓
当数据检索对时间不敏感时, 另一种方法是在服务器空闲时稍后处理导入。例如, 在午夜。可以使用cron作业在所需的时间间隔执行php artisan import:excelfile命令来完成。
在Ubuntu服务器上, 它很简单:
crontab -e
#and add this line
@midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.log
你的经验是什么?
在类似情况下, 你还有其他建议来进一步改善性能和用户体验吗?我很想知道你是如何处理他们的。
评论前必须登录!
注册