Обработка интенсивных задач с Laravel

Опубликовано: 2022-03-11

Имея дело с трудоемкими ресурсоемкими задачами, большинство PHP-разработчиков склонны выбирать «быстрый путь взлома». Не отрицай этого! Мы все использовали ini_set('max_execution_time', HUGE_INT); раньше, но так быть не должно.

В сегодняшнем руководстве я показываю, как можно улучшить взаимодействие с пользователем приложения (с минимальными усилиями разработчика), отделив длительные задачи от основного потока запросов с помощью решений для разработки Laravel. Используя возможность PHP создавать отдельные процессы, работающие в фоновом режиме, основной сценарий будет быстрее реагировать на действия пользователя. Таким образом, он лучше управляет ожиданиями пользователей, вместо того, чтобы заставлять их ждать целую вечность (без обратной связи) завершения запроса.

Отложите длительные задачи PHP, не ждите.

Базовая концепция этого руководства — отсрочка; брать задачи, которые выполняются слишком долго (по стандартам Интернета), и вместо этого откладывать выполнение в отдельный процесс, который выполняется независимо от запроса. Эта отсрочка позволяет нам реализовать систему уведомлений, которая показывает пользователю статус задачи (например, было импортировано X строк из Y) и предупреждает пользователя, когда задача выполнена.

Наше руководство основано на сценарии из реальной жизни, с которым, я уверен, вы сталкивались раньше: получение данных из огромных электронных таблиц Excel и передача их в базу данных веб-приложения. Полный проект доступен на моем github.

Не заставляйте своих пользователей сидеть и ждать долго выполняющейся задачи. Отложить.

Начальная загрузка с Laravel

Мы будем использовать "laravel/framework": "5.2.*" и "maatwebsite/excel": "~2.1.0" ; хорошая оболочка для phpoffice/phpexcel .

Я решил использовать Laravel для этой конкретной задачи по следующим причинам:

  1. Laravel поставляется с Artisan, что упрощает создание задач командной строки. Для тех, кто не знает Artisan, это интерфейс командной строки, включенный в Laravel, управляемый мощным компонентом Symfony Console.
  2. В Laravel есть Eloquent ORM для сопоставления данных Excel со столбцами таблицы.
  3. Он хорошо поддерживается и имеет очень подробную документацию.
  4. Laravel на 100% готов к PHP 7; на самом деле коробка Homestead уже работает под управлением PHP 7

Хотя я выбираю Laravel, концепция и код этого руководства могут быть включены в любой фреймворк, который также использует компонент Symfony/Process (который вы можете установить через композитор, используя composer require symfony/process ).

Связанный: Почему я решил принять Laravel

Для начала запустите свой vagrant box на основе Homestead (стандарт при разработке приложений на основе Laravel в наши дни). Если у вас не настроен Homestead, официальная документация содержит подробное пошаговое руководство.

С установленным Homestead вам нужно будет изменить Homestead.yaml перед запуском вашего vagrant box, чтобы сделать две вещи: Сопоставить локальную папку разработки с папкой внутри виртуальной машины. Автоматически подготовить 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 , который запустит виртуальную машину и настроит ее соответствующим образом. Если все прошло хорошо, теперь вы можете войти в свою виртуальную машину с помощью 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=*****

Теперь добавьте пакет maatwebsite/excel , выполнив composer require maatwebsite/excel:~2.1.0 .

Вам также необходимо добавить поставщика услуг и фасад/псевдоним в файле 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 для порождения процесса в отдельном потоке, независимо от запроса. Это означает, что запущенный сценарий не будет ждать завершения импорта, а вместо этого перенаправит пользователя с сообщением о необходимости дождаться завершения импорта. Таким образом, вы можете отобразить пользователю сообщение о статусе «ожидание импорта». Кроме того, вы можете отправлять Ajax-запросы каждые X секунд для обновления статуса.

Используя только обычный PHP, тот же эффект может быть достигнут с помощью следующего кода, но, конечно, это зависит от exec , который во многих случаях отключен по умолчанию.

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

Функциональность, которую дает symfony/process , более обширна, чем простой exec, поэтому, если вы не используете пакет symphony, вы можете дополнительно настроить PHP-скрипт после просмотра исходного кода пакета Symphony.

Используя пакет Symfony, вы можете создать процесс PHP в отдельном потоке, независимо от запроса.

Код импорта

Теперь давайте напишем командный файл php artisan , который обрабатывает импорт. Начните с создания файла класса команд: php artisan make:console ImportManager , затем укажите его в свойстве $commands в /app/console/Kernel.php , например:

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

Запуск команды artisan создаст файл с именем ImportManager.php в папке /app/Console/Commands . Мы напишем наш код как часть метода handle() .

Наш код импорта сначала обновит flag_table общим количеством строк, которые нужно импортировать, затем он будет перебирать каждую строку Excel, вставлять ее в базу данных и обновлять статус.

Чтобы избежать проблем нехватки памяти с исключительно большими файлами Excel, рекомендуется обрабатывать фрагменты соответствующего набора данных размером с укус, а не тысячи строк одновременно; предложение, которое вызовет множество проблем, а не только проблемы с памятью.

Для этого примера на основе Excel мы адаптируем метод ImportManager::handle() для выборки только небольшого набора строк, пока не будет импортирован весь лист. Это помогает отслеживать ход выполнения задачи; после обработки каждого чанка мы обновляем flag_table , увеличивая столбец imported_rows на размер чанка.

Примечание. Нет необходимости разбивать на страницы, потому что 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);

Вернувшись на сервер, добавьте маршрут GET с именем status , который вызовет метод, сообщающий о текущем статусе задачи импорта как о выполненной или о количестве строк, импортированных из 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

Другой подход, когда извлечение данных не зависит от времени, состоит в том, чтобы выполнить импорт позже, когда сервер простаивает; скажем, в полночь. Это можно сделать с помощью заданий 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

Каков ваш опыт?

Есть ли у вас другие предложения по дальнейшему улучшению производительности и удобства пользователей в подобных случаях? Мне очень хотелось бы узнать, как вы с ними справились.

Связанный: Полная аутентификация пользователя и контроль доступа — Учебное пособие по паспорту Laravel, Pt. 1