Manejo de tareas intensivas con Laravel
Publicado: 2022-03-11Cuando se trata de tareas intensivas en recursos que consumen mucho tiempo, la mayoría de los desarrolladores de PHP se sienten tentados a elegir la "ruta de pirateo rápido". ¡No lo niegues! Todos hemos usado ini_set('max_execution_time', HUGE_INT);
antes, pero no tiene por qué ser así.
En el tutorial de hoy, demuestro cómo se puede mejorar la experiencia del usuario de una aplicación (con un esfuerzo mínimo del desarrollador) al separar las tareas de ejecución prolongada del flujo de solicitud principal utilizando las soluciones de desarrollo de Laravel. Al hacer uso de la capacidad de PHP para generar procesos separados que se ejecutan en segundo plano, el script principal responderá más rápido a la acción del usuario. Por lo tanto, gestiona mejor las expectativas de los usuarios en lugar de hacerlos esperar años (sin comentarios) para que finalice una solicitud.
Aplaza las tareas PHP de larga ejecución, no esperes.
El concepto base de este tutorial es el aplazamiento; tomar tareas que se ejecutan durante demasiado tiempo (según los estándares de Internet) y en su lugar diferir la ejecución en un proceso separado que se ejecuta independientemente de la solicitud. Este aplazamiento nos permite implementar un sistema de notificación que muestra al usuario el estado de la tarea (por ejemplo, se han importado X número de filas de Y) y alerta al usuario cuando el trabajo está terminado.
Nuestro tutorial se basa en un escenario de la vida real que estoy seguro de que ha encontrado antes: tomar datos de enormes hojas de cálculo de Excel y enviarlos a una base de datos de aplicaciones web. El proyecto completo está disponible en mi github.
Bootstrapping con Laravel
Usaremos "laravel/framework": "5.2.*"
y "maatwebsite/excel": "~2.1.0"
; un buen envoltorio para el paquete phpoffice/phpexcel
.
Elegí usar Laravel para esta tarea en particular por las siguientes razones:
- Laravel viene con Artisan, lo que hace que la creación de tareas de línea de comandos sea muy sencilla. Para aquellos que no conocen Artisan, es la interfaz de línea de comandos incluida en Laravel, impulsada por el poderoso componente Symfony Console.
- Laravel tiene el ORM Eloquent para mapear nuestros datos de Excel a las columnas de la tabla
- Está bien mantenido y tiene una documentación muy completa.
- Laravel está 100 por ciento listo para PHP 7; de hecho, la caja de Homestead ya ejecuta PHP 7
Si bien elijo ir con Laravel, el concepto y el código de este tutorial se pueden incorporar en cualquier marco que también use el componente Symfony/Process
(que puedes instalar a través de composer usando composer require symfony/process
).
Para comenzar, encienda su caja vagabunda basada en Homestead
(el estándar al desarrollar aplicaciones basadas en Laravel en estos días). Si no tiene configurado Homestead, la documentación oficial proporciona una guía completa paso a paso.
Con Homestead instalado, deberá modificar Homestead.yaml
antes de iniciar su cuadro vagabundo para hacer dos cosas: Asigne su carpeta de desarrollo local a una carpeta dentro de la máquina virtual Aprovisione automáticamente NGINX para acceder a una URL, como http://heavyimporter.app
, cargará su nuevo proyecto.
Así es como se ve mi archivo de configuración:
folders: - map: ~/public_html/toptal to: /home/vagrant/toptal sites: - map: heavyimporter.app to: /home/vagrant/toptal/heavyimporter/public databases: - heavyimporter
Ahora, guarde el archivo y ejecute vagrant up && vagrant provision
, que inicia la máquina virtual y la configura en consecuencia. Si todo salió bien, ahora puede iniciar sesión en su máquina virtual con vagrant ssh
y comenzar un nuevo proyecto de Laravel. (Si todo no salió bien, consulte la documentación de Vagrant de Hashicorp para obtener ayuda).
cd /home/vagrant/toptal && composer create-project --prefer-dist laravel/laravel heavyimporter
Después de crear el proyecto, deberá configurar algunas variables de configuración editando el archivo .env
en la carpeta de inicio. También debe asegurar su instalación ejecutando php artisan key:generate
.
Así es como se ven las partes relevantes del archivo .env en mi extremo:
APP_ENV=local APP_DEBUG=true APP_KEY=*** DB_HOST=127.0.0.1 DB_DATABASE=heavyimporter DB_USERNAME=homestead DB_PASSWORD=*****
Ahora agregue el paquete maatwebsite/excel
ejecutando composer require maatwebsite/excel:~2.1.0
.
También debe agregar el proveedor de servicios y la fachada/alias en su archivo config/app.php
.
Los proveedores de servicios son el núcleo de una aplicación Laravel; todo en Laravel se inicia a través de un proveedor de servicios, mientras que las fachadas son interfaces estáticas simples que permiten un acceso más fácil a esos proveedores de servicios. En otras palabras, en lugar de acceder a la base de datos (un proveedor de servicios) con Illuminate\Database\DatabaseManager... puede simplemente usar DB::staticmethod().
Para nosotros, nuestro proveedor de servicios es Maatwebsite\Excel\ExcelServiceProvider
y nuestra fachada es 'Excel'=>'Maatwebsite\Excel\Facades\Excel'
.
app.php
ahora debería verse así:
//... 'providers' => [ //... Maatwebsite\Excel\ExcelServiceProvider::class ], 'aliases' => [ //... 'Excel'=>'Maatwebsite\Excel\Facades\Excel' ]
Configuración de la base de datos con PHP Artisan
Configuremos nuestras migraciones de base de datos para dos tablas. Una tabla contiene una bandera con el estado de la importación, a la que llamaremos flag_table
, y la que tiene los datos reales de Excel, data
.
Si tiene la intención de incluir un indicador de progreso para rastrear el estado de la tarea de importación, debe agregar dos columnas más a flag_table
: rows_imported
y total_rows
. Estas dos variables nos permitirán calcular y entregar el porcentaje completado en el caso de que queramos mostrar el progreso al usuario.
Primero ejecute php artisan make:migration CreateFlagTable
y php artisan make:migration CreateDataTable
crafty make:migration CreateDataTable para crear realmente estas tablas. Luego, abra los archivos recién creados desde la base de database/migrations
y complete los métodos arriba y abajo con la estructura de la tabla.
//...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'); }
Antes de escribir el código de importación, creemos modelos vacíos para las tablas de nuestra base de datos. Esto se logra a través de Artisan al ejecutar dos comandos simples: php artisan make:model Flag
y php artisan make:model Data
, luego yendo a cada archivo recién creado y agregando el nombre de la tabla como una propiedad protegida de esa clase, así:
//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 }
Enrutamiento
Las rutas son los ojos de una aplicación Laravel; observan la solicitud HTTP y la señalan al controlador adecuado. Dicho esto, primero, necesitamos una ruta POST que asigne la tarea de cargar nuestro archivo de Excel al método de import
en el controlador. El archivo se cargará en algún lugar del servidor para que podamos obtenerlo más tarde cuando ejecutemos la tarea de la línea de comandos. Asegúrese de colocar todas sus rutas (incluso la predeterminada) en el grupo de rutas de middleware web
para que se beneficie del estado de la sesión y la protección CSRF. El archivo de rutas se verá así:
Route::group(['middleware' => ['web']], function () { //homepage Route::get('/', ['as'=>'home', function () { return view('welcome'); }]); //upload route Route::post('/import', ['as'=>'import', 'uses'=>'Controller@import']); });
La lógica de la tarea
Ahora dirijamos nuestra atención al controlador principal, que mantendrá el núcleo de nuestra lógica en un método que es responsable de lo siguiente:
- Haciendo las validaciones necesarias relacionadas con el tipo de archivo que se está subiendo
- Cargar el archivo en el servidor y agregar una entrada en
flag_table
(que se actualizará mediante el proceso de línea de comando una vez que se ejecute la tarea con el número total de filas y el estado actual de la carga) - Iniciar el proceso de importación (que llamará a la tarea Artisan) y luego regresar para informar al usuario que el proceso se ha iniciado
Este es el código del controlador 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')); }
Las líneas anteriores relacionadas con el proceso hacen algo realmente genial. Usan el paquete symfony/process
para generar un proceso en un subproceso separado, independientemente de la solicitud. Esto significa que la secuencia de comandos en ejecución no esperará a que finalice la importación, sino que redirigirá con un mensaje al usuario para que espere hasta que se complete la importación. De esta manera, puede mostrar un mensaje de estado de "importación pendiente" al usuario. Alternativamente, puede enviar solicitudes Ajax cada X segundos para actualizar el estado.
Usando solo PHP estándar, se puede lograr el mismo efecto con el siguiente código, pero, por supuesto, esto se basa en exec
, que está deshabilitado de forma predeterminada, en muchos casos.
function somefunction() { exec("php dosomething.php > /dev/null &"); //do something else without waiting for the above to finish }
Las funcionalidades que brinda symfony/process
son más extensas que un simple exec, por lo que si no está utilizando el paquete Symphony, puede modificar aún más el script PHP después de echar un vistazo al código fuente del paquete Symphony.
El código de importación
Ahora vamos a escribir un archivo de comando php artisan
que maneje la importación. Comience creando el archivo de clase de comando: php artisan make:console ImportManager
, luego haga referencia a él en la propiedad $commands
en /app/console/Kernel.php
, así:
protected $commands = [ Commands\ImportManager::class, ];
Ejecutar el comando artesanal creará un archivo llamado ImportManager.php
en la carpeta /app/Console/Commands
. Escribiremos nuestro código como parte del método handle()
.
Nuestro código de importación primero actualizará flag_table
con el número total de filas que se importarán, luego iterará a través de cada fila de Excel, la insertará en la base de datos y actualizará el estado.
Para evitar problemas de falta de memoria con archivos de Excel excepcionalmente grandes, es una buena idea procesar fragmentos pequeños del conjunto de datos respectivo en lugar de miles de filas a la vez; una proposición que causaría muchos problemas, no solo problemas de memoria.
Para este ejemplo basado en Excel, adaptaremos el método ImportManager::handle()
para obtener solo un pequeño conjunto de filas hasta que se haya importado toda la hoja. Esto ayuda a realizar un seguimiento del progreso de la tarea; después de que se procesa cada fragmento, actualizamos flag_table
incrementando la columna imported_rows
con el tamaño del fragmento.
Nota: No hay necesidad de paginar porque Maatwebsite\Excel
lo maneja por usted como se describe en la documentación de Laravel.
Así es como se ve la clase ImportManager final:
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(); } }
Sistema de notificación de progreso recursivo
Pasemos a la parte frontal de nuestro proyecto, la notificación al usuario. Podemos enviar solicitudes Ajax a una ruta de informe de estado en nuestra aplicación para notificar al usuario sobre el progreso o alertarlo cuando finalice la importación.
Aquí hay un script jQuery simple que enviará solicitudes al servidor hasta que reciba un mensaje que indique que el trabajo está hecho:
(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);
De vuelta en el servidor, agregue una ruta GET
llamada status
, que llamará a un método que informa el estado actual de la tarea de importación como completada o la cantidad de filas importadas de 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]); } } ...
Aplazamiento del trabajo de Cron
Otro enfoque, cuando la recuperación de datos no es sensible al tiempo, es manejar la importación en un momento posterior cuando el servidor está inactivo; decir, a medianoche. Esto se puede hacer usando trabajos cron que ejecutan el comando php artisan import:excelfile
en el intervalo de tiempo deseado.
En servidores Ubuntu, es tan simple como esto:
crontab -e #and add this line @midnight cd path/to/project && /usr/bin/php artisan import:excelfile >> /my/log/folder/import.log
¿Cuál es tu experiencia?
¿Tiene otras sugerencias para mejorar aún más el rendimiento y la experiencia del usuario en casos similares? Estaría ansioso por saber cómo has tratado con ellos.