Lidando com Tarefas Intensivas com Laravel

Publicados: 2022-03-11

Ao lidar com tarefas demoradas que consomem muitos recursos, a maioria dos desenvolvedores PHP é tentada a escolher a “rota de hacking rápida”. Não negue! Todos nós usamos ini_set('max_execution_time', HUGE_INT); antes, mas não precisa ser assim.

No tutorial de hoje, demonstro como a experiência do usuário de um aplicativo pode ser melhorada (com esforço mínimo do desenvolvedor) separando tarefas de longa duração do fluxo de solicitação principal usando soluções de desenvolvimento Laravel. Ao fazer uso da capacidade do PHP de gerar processos separados que são executados em segundo plano, o script principal responderá mais rapidamente à ação do usuário. Assim, ele gerencia melhor as expectativas do usuário em vez de fazê-los esperar por muito tempo (sem feedback) para que uma solicitação seja concluída.

Adie tarefas PHP de longa duração, não espere.

O conceito base deste tutorial é adiamento; pegando tarefas que são executadas por muito tempo (pelos padrões da Internet) e, em vez disso, adiando a execução para um processo separado que é executado independentemente da solicitação. Esse adiamento nos permite implementar um sistema de notificação que mostra ao usuário o status da tarefa (X número de linhas de Y foram importadas, por exemplo) e alerta o usuário quando o trabalho é concluído.

Nosso tutorial é baseado em um cenário da vida real que tenho certeza que você já encontrou antes: Pegar dados de enormes planilhas do Excel e colocá-los em um banco de dados de aplicativos da web. O projeto completo está disponível no meu github.

Não faça seus usuários sentarem e esperarem em uma tarefa de longa duração. Adiar.

Bootstrap com Laravel

Estaremos usando "laravel/framework": "5.2.*" e "maatwebsite/excel": "~2.1.0" ; um bom wrapper para o pacote phpoffice/phpexcel .

Eu escolhi usar o Laravel para esta tarefa em particular pelos seguintes motivos:

  1. O Laravel vem com o Artisan, o que facilita muito a criação de tarefas de linha de comando. Para quem não conhece Artisan, é a interface de linha de comando incluída no Laravel, acionada pelo poderoso componente Symfony Console
  2. Laravel tem o Eloquent ORM para mapear nossos dados do Excel para colunas da tabela
  3. Está bem conservado e tem uma documentação muito completa
  4. Laravel está 100% pronto para PHP 7; na verdade, a caixa Homestead já roda o PHP 7

Embora eu opte por usar o Laravel, o conceito e o código deste tutorial podem ser incorporados a qualquer framework que também use o componente Symfony/Process (que você pode instalar via composer usando composer require symfony/process ).

Relacionado: Por que decidi abraçar o Laravel

Para começar, abra sua caixa vagrant baseada em Homestead (o padrão ao desenvolver aplicativos baseados em Laravel nos dias de hoje). Se você não tiver o Homestead configurado, a documentação oficial fornece um guia passo a passo completo.

Com o Homestead instalado, você precisará modificar o Homestead.yaml antes de iniciar sua caixa vagrant para fazer duas coisas: Mapear sua pasta de desenvolvimento local para uma pasta dentro da máquina virtual Provisionar automaticamente o NGINX para acessar uma URL, como http://heavyimporter.app , carregará seu novo projeto.

Veja como está meu arquivo de configuração:

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

Agora, salve o arquivo e execute vagrant up && vagrant provision , que inicia a VM e a configura de acordo. Se tudo correu bem, agora você pode fazer login em sua máquina virtual com vagrant ssh e iniciar um novo projeto Laravel. (Se tudo não correu bem, consulte a documentação do Vagrant da Hashicorp para obter ajuda.)

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

Após criar o projeto, você precisará configurar algumas variáveis ​​de configuração editando o arquivo .env na pasta home. Você também deve proteger sua instalação executando php artisan key:generate .

Veja como são as partes relevantes do arquivo .env do meu lado:

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

Agora adicione o pacote maatwebsite/excel executando composer require maatwebsite/excel:~2.1.0 .

Você também precisa adicionar o provedor de serviços e a fachada/alias em seu config/app.php .

Os provedores de serviços são o núcleo de um aplicativo Laravel; tudo no Laravel é inicializado por meio de um provedor de serviços, enquanto as fachadas são interfaces estáticas simples que permitem acesso mais fácil a esses provedores de serviços. Em outras palavras, ao invés de acessar o banco de dados (um provedor de serviços) com Illuminate\Database\DatabaseManager… você pode simplesmente usar DB::staticmethod().

Para nós, nosso provedor de serviços é Maatwebsite\Excel\ExcelServiceProvider e nossa fachada é 'Excel'=>'Maatwebsite\Excel\Facades\Excel' .

app.php agora deve ficar assim:

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

Configurando o banco de dados com PHP Artisan

Vamos configurar nossas migrações de banco de dados para duas tabelas. Uma tabela contém um sinalizador com o status da importação, que chamaremos de flag_table , e aquela que contém os dados reais do Excel, data .

Se você pretende incluir um indicador de progresso para rastrear o status da tarefa de importação, será necessário adicionar mais duas colunas à flag_table : rows_imported e total_rows . Essas duas variáveis ​​nos permitirão calcular e entregar a porcentagem concluída no caso de querermos mostrar o progresso ao usuário.

Primeiro execute php artisan make:migration CreateFlagTable e php artisan make:migration CreateDataTable para realmente criar essas tabelas. Em seguida, abra os arquivos recém-criados do banco de database/migrations e preencha os métodos up e down com a estrutura da tabela.

 //...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 realmente escrevermos o código de importação, vamos criar modelos vazios para nossas tabelas de banco de dados. Isso é conseguido através do Artisan executando dois comandos simples: php artisan make:model Flag e php artisan make:model Data , então entrando em cada arquivo recém-criado e adicionando o nome da tabela como uma propriedade protegida dessa classe, assim:

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

Roteamento

Rotas são os olhos de um aplicativo Laravel; eles observam a solicitação HTTP e a apontam para o controlador apropriado. Dito isto, primeiro, precisamos de uma rota POST que atribua a tarefa de carregar nosso arquivo Excel ao método de import no controlador. O arquivo será carregado em algum lugar no servidor para que possamos pegá-lo mais tarde quando executarmos a tarefa de linha de comando. Certifique-se de colocar todas as suas rotas (mesmo a padrão) no grupo de rotas de middleware da web para que você se beneficie do estado da sessão e da proteção CSRF. O arquivo de rotas ficará assim:

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

A lógica de tarefas

Agora vamos voltar nossa atenção para o controlador principal, que irá manter o núcleo da nossa lógica em um método que é responsável pelo seguinte:

  • Fazendo as validações necessárias relacionadas ao tipo de arquivo que está sendo carregado
  • Fazendo o upload do arquivo para o servidor e adicionando uma entrada na flag_table (que será atualizada pelo processo de linha de comando assim que a tarefa for executada com o número total de linhas e o status atual do upload)
  • Iniciar o processo de importação (que chamará a tarefa Artisan) e retornar para informar ao usuário que o processo foi iniciado

Este é o código para o 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')); }

As linhas relacionadas ao processo acima fazem algo muito legal. Eles usam o pacote symfony/process para gerar um processo em uma thread separada, independentemente da solicitação. Isso significa que o script em execução não aguardará a conclusão da importação, mas redirecionará com uma mensagem para o usuário aguardar até que a importação seja concluída. Dessa forma, você pode exibir uma mensagem de status de “importação pendente” para o usuário. Como alternativa, você pode enviar solicitações Ajax a cada X segundos para atualizar o status.

Usando apenas o PHP vanilla, o mesmo efeito pode ser obtido com o código a seguir, mas é claro que isso depende do exec , que está desabilitado por padrão, em muitos casos.

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

As funcionalidades que o symfony/process oferece são mais extensas do que um simples exec, então se você não estiver usando o pacote symphony, você pode ajustar o script PHP depois de dar uma olhada no código fonte do pacote Symphony.

Usando o pacote Symfony, você pode gerar um processo PHP em uma thread separada, independentemente da solicitação.

O código de importação

Agora vamos escrever um arquivo de comando php artisan que lida com a importação. Comece criando o arquivo de classe de comando: php artisan make:console ImportManager , então faça referência a ele na propriedade $commands em /app/console/Kernel.php , assim:

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

A execução do comando artisan criará um arquivo chamado ImportManager.php na pasta /app/Console/Commands . Vamos escrever nosso código como parte do método handle() .

Nosso código de importação primeiro atualizará a flag_table com o número total de linhas a serem importadas, depois iterará por cada linha do Excel, inserirá no banco de dados e atualizará o status.

Para evitar problemas de falta de memória com arquivos do Excel excepcionalmente grandes, é uma boa ideia processar pedaços pequenos do respectivo conjunto de dados em vez de milhares de linhas de uma vez; uma proposta que causaria muitos problemas, não apenas problemas de memória.

Para este exemplo baseado em Excel, adaptaremos o ImportManager::handle() para buscar apenas um pequeno conjunto de linhas até que toda a planilha tenha sido importada. Isso ajuda a acompanhar o progresso da tarefa; depois que cada fragmento é processado, atualizamos a flag_table incrementando imported_rows coluna import_rows com o tamanho do fragmento.

Nota: Não há necessidade de paginar porque Maatwebsite\Excel trata disso para você conforme descrito na documentação do Laravel.

Veja como é a classe final do 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(); } }
Relacionado: Contrate os 3% melhores desenvolvedores freelance de Laravel.

Sistema de Notificação de Progresso Recursivo

Vamos passar para a parte de front-end do nosso projeto, a notificação do usuário. Podemos enviar solicitações Ajax para uma rota de relatório de status em nosso aplicativo para notificar o usuário sobre o andamento ou alertá-lo quando a importação for concluída.

Aqui está um script jQuery simples que enviará solicitações ao servidor até receber uma mensagem informando que o trabalho foi concluído:

 (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 volta ao servidor, adicione uma rota GET chamada status , que chamará um método que informa o status atual da tarefa de importação como concluída ou o número de linhas 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]); } } ... 

Envie solicitações Ajax para uma rota de relatório de status para notificar o usuário sobre o andamento.

Envie solicitações Ajax para uma rota de relatório de status para notificar o usuário sobre o andamento.

Adiamento do Cron Job

Outra abordagem, quando a recuperação de dados não é sensível ao tempo, é manipular a importação posteriormente, quando o servidor estiver ocioso; digamos, à meia-noite. Isso pode ser feito usando cron jobs que executam o comando php artisan import:excelfile no intervalo de tempo desejado.

Nos servidores Ubuntu, é tão simples quanto isto:

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

Qual é a sua experiência?

Você tem outras sugestões para melhorar ainda mais o desempenho e a experiência do usuário em casos semelhantes? Eu estaria ansioso para saber como você lidou com eles.

Relacionado: Autenticação completa do usuário e controle de acesso – Um tutorial do Laravel Passport, Pt. 1