CSV
Zugriff auf FTP Storage über Laravel 5.6
Der Zugriff auf einen FTP Server, sowie auf viele andere Speicherorte ist eine gängige Aufgabe in der Programmierung. Das PHP Framework Laravel bietet für den Zugriff auf viele unterschiedliche Speicherorte eine generische Zugriffsmethode. Amazons S3, der lokale Server-Speicher, FTP Server, etc. werden als disks behandelt. Laravel bietet diese Funktion dank des PHP Pakets Flysystem von Frank de Jonge an. Detailliertere Informationen zu allen Speichermethoden findet Ihr wie immer in der Dokumentation von Laravel.
In diesem Artikel betrachten wir das Szenario, dass es einen täglichen CSV Export in ein Verzeichnis eines FTP Servers gibt. Die Plattform soll diese Datei dann mit einem nächtlich geplanten Task vom FTP Server laden, überprüfen, ob die Datei bereits importiert wurde und wenn dies nicht der Fall ist, die Datei über die Task Queue importieren. Ich habe auch einen interessanten Artikel über die Laravel Task Queue und einen damit verbundenen CSV Import geschrieben, der für Dich ebenfalls interessant sein könnte.
Ich gehe in diesem Artikel nicht auf die Einrichtung eines kompletten Laravel Projektes ein. Eine gute Anleitung dazu findest Du ebenfalls in der Laravel Dokumentation zur Projekterstellung.
FTP Filesystem Konfiguration
Damit Du FTP als disk benutzen kannst, ist es zunächst wichtig Änderungen an der Konfigurationsdatei "config/filesystems.php" vorzunehmen. Der Array-Knoten "disks" muss dazu um folgenden Block "ftp" erweitert werden:
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
],
's3' => [
'driver' => 's3',
'key' => env('AWS_ACCESS_KEY_ID'),
'secret' => env('AWS_SECRET_ACCESS_KEY'),
'region' => env('AWS_DEFAULT_REGION'),
'bucket' => env('AWS_BUCKET'),
'url' => env('AWS_URL'),
],
'ftp' => [
'driver' => 'ftp',
'host' => 'ftp.example.com',
'username' => 'your-username',
'password' => 'your-password',
// Optional FTP Settings...
// 'port' => 21,
// 'root' => '',
// 'passive' => true,
// 'ssl' => true,
// 'timeout' => 30,
],
],
Du kannst nun die Zugangsdaten zum FTP Server in den Feldern "host", "username" und "password" hinterlegen. Entweder trägst Du diese direkt ein, oder Du hinterlegst diese in der ".env" Datei Deines Projektes und lädst die Daten wie im Knoten "s3" über die "env()" Funktion. Dies hat den Vorteil, dass Du je nach Umgebung andere Daten hinterlegen kannst, ohne jedesmal Änderungen am Quellcode durchführen zu müssen. Hier findest nochmal ein Beispiel mit env Variablen:
'ftp' => [
'driver' => 'ftp',
'host' => env('FTP_HOST', 'ftp.example.com'),
'username' => env('FTP_USERNAME', 'your-username'),
'password' => env('FTP_PASSWORD', 'your-password'),
// Optional FTP Settings...
// 'port' => env('FTP_PORT', 21),
// 'root' => env('FTP_ROOT', ''),
// 'passive' => env('FTP_MODE_PASSIVE', true),
// 'ssl' => env('FTP_SSL', true),
// 'timeout' => env('FTP_TIMEOUT', 30),
],
In der ".env" Datei musst Du diese Variablen jetzt natürlich auch noch hinterlegen. Füge diese am Ende (oder einer von Dir favorisierten Stelle) der Datei ein:
FTP_HOST=ftp.example.com FTP_USERNAME=your-username FTP_PASSWORD=your-password FTP_PORT=21 FTP_ROOT= FTP_MODE_PASSIVE=true FTP_SSL=true FTP_TIMEOUT=30 FTP_IMPORT_DIR="test_verzeichnis/csv_exporte"
Um später den Import-Pfad dynamisch anpassen zu können, hinterlege ich auch diesen als Environment Variable. Nun legen wir noch eine eigene Konfigurationsdatei an, deren Werte wir zum späteren Zeitpunkt mit der "config" Helper-Funktion auslesen können. Infos zu dieser Funktion findest Du im Abschnitt Helper Dokumentation von Laravel. Erstelle nun die Datei "ftpcsvimport.php" im Verzeichnis "config" mit folgendem Inhalt:
<?php
return [
/*
|--------------------------------------------------------------------------
| FTP Import-Verzeichnis
|--------------------------------------------------------------------------
|
| Das Verzeichnis ist das Basisverzeichnis für die zu importierenden
| Dateien.
|
*/
'importDirectory' => env('FTP_IMPORT_DIR', ''),
/*
|--------------------------------------------------------------------------
| Benötigte Spalten-Header
|--------------------------------------------------------------------------
|
| Die folgenden Spalten müssen für den Import bereitstehen.
|
*/
'headers' => [
'dataId',
'dataName',
'dataValue',
],
/*
|--------------------------------------------------------------------------
| Pflichtspalteninhalte
|--------------------------------------------------------------------------
|
| Die folgenden Spalten der CSV Datei müssen über einen validen Inhalt
| verfügen.
|
*/
'requiredColumns' => [
'dataId',
'dataName',
'dataValue',
],
];
Neben der Konfiguration für das Import-Verzeichnis wurden auch die Konfigurationen für den CSV Import bereits hinterlegt. An dieser Stelle musst Du Deine benötigten CSV Spalten-Header und Deine Pflichtspalten, also Spalten die über Inhalte verfügen müssen, hinterlegen. Ich verwende lediglich Beispieldaten zu Anschauungszwecken.
Damit Deine durchgeführte Konfiguration wirksam wird, musst Du zunächst den Laravel Cache neu befüllen. Das kannst Du ganz leicht über den folgenden Konsolenbefehl:
$ php artisan config:cache
Erstellung des CSV-Import Jobs
Die Konfiguration der disks ist abgeschlossen, aber wie bekommen wir nun unsere CSV Datei vom FTP Server? Wir müssen zunächst ein neues Eloquent Model samt Migration anlegen, um bereits importierte Dateien auszuschließen. Dazu führst Du folgenden Befehl im Terminal aus:
$ php artisan make:model ImportedFile -m
Dieser Befehl legt das neue Model "ImportedFile" im Ordner "app/ImportedFile.php" an. Durch das "-m" wird eine passende Datenbankmigration für die neue Tabelle "imported_files" im Ordner "database/migrations" angelegt. Der Name der Datei sollte folgendem Aufbau besitzen "2018_07_16_093346_create_imported_files_table.php". Selbstverständlich ist bei Dir der Zeitpunkt vor "create_imported_files_table.php" ein anderer als bei mir.
Öffne nun die Migrationsdatei und passe den Inhalt von Schema::create in der Funktion "up" folgendermaßen an:
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('imported_files', function (Blueprint $table) {
$table->increments('id');
$table->timestamps();
$table->string('file_name')
->unique();
});
}
Durch den "unique" Index muss der Dateiname der Importierten Datei eindeutig und einzigartig sein, und darf nicht nochmals importiert werden. Diese Property muss nun auch noch im Model implementiert werden. Dazu öffnest Du die Datei "app/ImportedFile.php" und fügst folgenden Inhalt ein:
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
/**
* @property string file_name
*/
class ImportedFile extends Model
{
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'file_name',
];
}
Zum Schluss musst Du die Migration noch durchführen, damit die Änderungen per "code first" in die Datenbank übertragen werden. Verwende dazu folgenden Befehl in der Konsole (Vergiss aber nicht zuvor die Datenbank Verbindungsvariablen in der .env Datei korrekt zu befüllen):
$ php artisan migrate
Jetzt können wir mit der Erstellung des Jobs fortfahren. Der Job wird später automatisch zu einem geplanten Zeitpunkt (task scheduling) "gequeued" und von einem freien konfigurierten Queue-Worker ausgeführt. Um einen Job anzulegen führst Du folgenden Befehl aus:
$ php artisan make:job ImportCsvFromFtpServer
Im Verzeichnis "app/Jobs" wurde nun die Job Datei "ImportCsvFromFtpServer.php" angelegt. Der CSV Import im Job wird später über das PHP Paket league/csv in der Version 9.0 durchgeführt. Dieses kannst Du über den PHP Abhängigkeitsmanager Composer mit folgendem Befehl installieren:
$ composer require league/csv:^9.0
Es steht Dir natürlich frei, ein anderes PHP Paket, oder zu einem späteren Zeitpunk eine aktuellere Version von league/csv zum Import zu verwenden, oder den Import bspw. über die PHP Funktion str_getcsv selbst zu implementieren. Ich habe mit dem Paket von thephpleague bisher gute Erfahrungen gemacht, da der Implementierungsaufwand um ein vielfaches geringer ist. Das zahlt sich nicht nur für Dich aus, sondern auch für Projektkosten Deines Kunden. Durch den "autoload" Knoten in der "league/csv" composer.json musst Du das Paket nicht in Deiner "app.php" Konfiguration hinterlegen.
Im Import Job ImportCsvFromFtpServer erstellst Du nun zunächst die privaten Properties für das Import Verzeichnis und den CSV Import und befüllst sie im Konstruktor:
/**
* @var array
*/
private $requiredHeaders;
/**
* @var array
*/
private $requiredColumns;
/**
* @var string
*/
private $importDirectory;
/**
* ImportCsvFromFtpServer constructor.
*/
public function __construct()
{
$this->requiredHeaders = config('ftpcsvimport.headers');
$this->requiredColumns = config('ftpcsvimport.requiredColumns');
$this->importDirectory = config('ftpcsvimport.importDirectory');
}
Nachdem im Konstruktor das Import-Verzeichnis und die Import-Konfigurationen ermittelt wurden, können wir uns mit dem Import beschäftigen. Aber wie importieren wir nun alle bisher nicht importierten Dateien? Um zu überprüfen, welche Dateien noch nicht importiert wurden, müssen wir erst einmal alle Dateinamen aus dem Import-Verzeichnis ermitteln. Dazu können wir auf unserer FTP disk die Methode "files" verwenden, der wir einen Verzeichnispfad übergeben und zwar den, den wir in der Environment Variable hinterlegt haben.
Danach werden alle Dateinamen der mit einer Schleife durchlaufen und es wird für jeden Dateinamen überprüft, ob er in der Datenbank existiert. Ist das nicht der Fall, so können wir ihn in eine Liste mit nicht importierten Dateien aufnehmen, die zuletzt geladen und importiert werden. In der Praxis sieht das nun folgendermaßen aus:
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
/** @var array $allFilesInDirectory */
$allFilesInDirectory = Storage::disk('ftp')
->files($this->importDirectory);
if(empty($allFilesInDirectory))
return;
/** @var array $filesToImport */
$filesToImport = [];
/** @var string $file */
foreach ($allFilesInDirectory as $file) {
if(ImportedFile::where('file_name', '=', $file)->exists())
continue;
$filesToImport[] = $file;
}
if(!empty($filesToImport))
$this->importFiles($filesToImport);
}
Am Ende der Funktion "handle" wird die Funktion "importFiles" aufgerufen. Diese ist zuständig für das Abarbeiten der Liste aller zu importierenden Dateien. Des weiteren beinhaltet sie die Verwaltung der Datenbank-Transaktion und das Error-Logging:
/**
* Bearbeitet eine Liste von zu importierenden CSV Dateien.
*
* @param array $filesToImport
*/
private function importFiles( $filesToImport )
{
try {
DB::beginTransaction();
/** @var string $file */
foreach ($filesToImport as $file) {
if (empty($file) || !Storage::disk('ftp')->exists($file))
continue;
if($this->importFile($file))
ImportedFile::create([
'file_name' => $file
]);
}
DB::commit();
} catch (PDOException $e) {
DB::rollBack();
Log::error('Während des automatischen CSV-Imports vom FTP-Server kam es zum PDO Fehler innerhalb der
Transaktion. Es wurde ein Rollback durchgeführt um den alten Stand der Datenbank wieder
herzustellen.');
Log::error(sprintf('FTP CSV-Import Fehlermeldung: %1$s', $e->getMessage()));
Log::error(sprintf('FTP CSV-Import Stacktrace: %1$s', $e->getTraceAsString()));
} catch (Exception $e) {
DB::rollBack();
Log::error('Während des automatischen CSV-Imports vom FTP-Server kam es zum allgemeinen Fehler innerhalb der
Transaktion. Es wurde ein Rollback durchgeführt um den alten Stand der Datenbank wieder
herzustellen.');
Log::error(sprintf('FTP CSV-Import Fehlermeldung: %1$s', $e->getMessage()));
Log::error(sprintf('FTP CSV-Import Stacktrace: %1$s', $e->getTraceAsString()));
}
}
Sollte während des Imports ein Fehler auftreten, so wird ein Rollback der Transaktion durchgeführt und die importierten Daten wieder entfernt. Nach dem Rollback erfolgt das Logging mit dem konfigurierten Treiber.
Innerhalb der Schleife kann man prüfen, ob die aktuelle Datei im Dateisystem existiert. Ist das der Fall, dann wird der Dateiname an die Funktion "importFile" übergeben, die dann mittels league/csv den CSV Import in die Datenbank durchführt. Wurde die Datei erfolgreich importiert, so gibt die Funktion ein "true" zurück und es wird ein neuer Eintrag in der Tabelle "imported_files" hinzugefügt.
Zu guter Letzt müssen wir nun noch die Funktion "importFile" implementieren. Diese benötigt noch einige Helfer-Funktionen die wir zuvor noch im Job integrieren. Die folgenden Funktionen überprüfen, ob die benötigten Spalten-Header in der CSV-Datei existieren und ob die benötigten Spalten mit Inhalten gefüllt sind. Zuletzt folgt noch eine Funktion zur einfachen Ermittlung eines Array Wertes.
/**
* Überprüft, ob alle benötigten Spalten vorhanden sind.
*
* @param string[] $csvHeaders
*
* @return bool
*/
private function requiredHeadersExist( $csvHeaders )
{
if(empty($this->requiredHeaders))
return false;
/** @var array $reqKeysFlipped */
$reqKeysFlipped = array_flip($this->requiredHeaders);
/** @var array $headersFlipped */
$headersFlipped = array_flip( $csvHeaders);
/** @var array $intersection */
$intersection = array_intersect_key($reqKeysFlipped, $headersFlipped);
return count($intersection) === count($this->requiredHeaders);
}
/**
* Überprüft, ob alle benötigten Spalten gefüllt sind
*
* @param array $row
* @return bool
*/
private function requiredColumnsFilled( $row )
{
/** @var string $requiredColumnKey */
foreach ($this->requiredColumns as $requiredColumnKey) {
if(trim($row[$requiredColumnKey]) === "")
return false;
}
return true;
}
/**
* Holt einen korrekten Wert auf einem ResultSet Array
*
* @param mixed $val
* @param mixed $default
* @return mixed
*/
private function getValue($val, $default)
{
return $val === "" ? $default : $val;
}
Die Import Funktion ist sehr komplex und kann auch noch weiter refaktorisiert und aufgespalten werden. Die einzelnen Abschnitte der Funktion kann man im Nachhinein erklären. Wichtig ist, dass Du genau wie in der "ftpcsvimport.php" Dein Model integrierst, das importiert werden soll. Im Beispiel ist das Model "Data" symbolisch für Dein Model hinterlegt. Das Beispiel dient lediglich als Implementierungsgrundlage und muss von Dir angepasst werden.
/**
* Importiert eine CSV Datei.
*
* @param string $file
* @return bool
*/
private function importFile( $file )
{
try {
/** @var string $fileData */
$fileData = Storage::disk( 'ftp' )
->get( $file );
/** @var Reader $reader */
$reader = Reader::createFromString($fileData);
$reader->setDelimiter(';');
$reader->setHeaderOffset(0);
/** @var ResultSet $resultSetRecords */
$resultSetRecords = (new Statement())->process($reader);
if(!$this->requiredHeadersExist($resultSetRecords->getHeader())) {
Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt nicht über die benötigten Spalten-Header. Die Datei wurde nicht importiert.', $file));
return false;
}
/** @var Generator $csvRows */
$csvRows = $resultSetRecords->getRecords();
if(empty($csvRows) || !($csvRows instanceof Generator) || (is_array($csvRows) && sizeof($csvRows) === 0)) {
Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
return false;
}
/** @var int $countImportedRows */
$countImportedRows = 0;
/** @var array $dataRow */
foreach ($csvRows as $dataRow) {
if(!$this->requiredColumnsFilled($dataRow))
continue;
/** @var int $dataId */
$dataId = $this->getValue($dataRow['dataId'], 0);
/** @var Data $data */
$data = Data::where('id', $dataId)
->first();
if(empty($data)) {
Data::create([
'id' => $dataId,
'name' => $this->getValue($dataRow['dataName'], ''),
'value' => $this->getValue($dataRow['dataValue'], '')
]);
$countImportedRows++;
}
}
if($countImportedRows === 0) {
Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
return false;
}
return true;
} catch (FileNotFoundException $e) {
Log::error(sprintf('Beim Importieren der Datei "%1$s" trat eine FileNotFoundException auf. Die Datei wurde nicht importiert.', $file));
return false;
} catch (CsvException $e) {
Log::error(sprintf('Beim Importieren der Datei "%1$s" trat eine LeagueCsvException auf. Die Datei wurde nicht importiert.', $file));
return false;
}
}
Im ersten Abschnitt der Funktion werden die Inhalte der zu importierenden Datei vom FTP Server als Zeichenkette geladen. Über den League Reader werden diese danach mit den CSV Einstellungen, wie bspw. dem Trennzeichen, als ResultSet geladen:
/** @var string $fileData */
$fileData = Storage::disk( 'ftp' )
->get( $file );
/** @var Reader $reader */
$reader = Reader::createFromString($fileData);
$reader->setDelimiter(';');
$reader->setHeaderOffset(0);
/** @var ResultSet $resultSetRecords */
$resultSetRecords = (new Statement())->process($reader);
Das ResultSet überprüft man im Anschluss. Es sollte die benötigten Header, sowie einen Inhalt besitzen:
if(!$this->requiredHeadersExist($resultSetRecords->getHeader())) {
Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt nicht über die benötigten Spalten-Header. Die Datei wurde nicht importiert.', $file));
return false;
}
/** @var Generator $csvRows */
$csvRows = $resultSetRecords->getRecords();
if(empty($csvRows) || !($csvRows instanceof Generator) || (is_array($csvRows) && sizeof($csvRows) === 0)) {
Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
return false;
}
Nachdem sichergestellt wurde, dass die Datei über die Header, sowie über Inhalt verfügt, beginnt der Import der einzelnen Datensätze der Datei. Mit einer Schleife werden alle Reihen des "Generator" durchlaufen, sichergestellt, dass die benötigten Spalten über Inhalte verfügen und zuletzt das Model in der Datenbank erstellt:
/** @var int $countImportedRows */
$countImportedRows = 0;
/** @var array $dataRow */
foreach ($csvRows as $dataRow) {
if(!$this->requiredColumnsFilled($dataRow))
continue;
/** @var int $dataId */
$dataId = $this->getValue($dataRow['dataId'], 0);
/** @var Data $data */
$data = Data::where('id', $dataId)
->first();
if(empty($data)) {
Data::create([
'id' => $dataId,
'name' => $this->getValue($dataRow['dataName'], ''),
'value' => $this->getValue($dataRow['dataValue'], '')
]);
$countImportedRows++;
}
}
if($countImportedRows === 0) {
Log::error(sprintf('Die zu importierende CSV-Datei "%1$s" verfügt über keinen Inhalt. Die Datei wurde nicht importiert.', $file));
return false;
}
Am Ende der Funktion befindet sich noch ein wenig Error-Handling das du beliebig verfeinern und erweitern kannst.
Task Scheduling implementieren
Die Planung von Aufgaben erfolgt in der Datei "Kernel.php". Diese befindet sich im Verzeichnis "app/Console/". In der Funktion "schedule" wird unser zuvor erstellter Job nun implementiert:
/**
* Define the application's command schedule.
*
* @param IlluminateConsoleSchedulingSchedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
/**
* Nächtlich laufender Task zum Importieren einer CSV-Datei
* vom FTP Server
* Laufzeit: 0:00 Uhr
*/
$schedule->job(new ImportCsvFromFtpServer)
->dailyAt('0:00');
}
Wird "schedule" nun Aufgerufen, so wird unser Job "geplant" und täglich um "0:00" in die Task-Queue "eingereiht". Aber woher kommt nun die Queue und wie ruft Laravel automatisch um Mitternacht das "einreihen" in die Task-Queue auf? Welche Queue Du verwendest ist zunächst Dir überlassen. Es gibt viele unterschiedliche Anbieter einer Task-Queue. Detailliertere Informationen zur Einrichtung einer Queue kannst Du der Dokumentation zu Queues entnehmen. Auf der Server-Management Plattform Laravel Forge lassen sich sehr einfach produktive Laravel-Umgebungen auf dem Cloud-Server Anbieter Deiner Wahl einrichten. Deine Queues können im Backend einfach angelegt und konfiguriert werden.
Das selbe gilt für die Ausführung Deiner geplanten Tasks. Dazu ist es nötig, dass Du einen Cron-Job auf Deinem Server einrichtest. Wenn Du Dich nicht mit der Einrichtung von Cron-Jobs auskennst, ist die Verwendung eines von Laravel Forge konfigurierten Webservers für Dich die beste Wahl. Dort kannst Du im Backend einfach konfigurieren, wann "schedule" ausgeführt werden soll. Alternativ kannst Du selbstverständlich selber Cron-Jobs anlegen. Hierzu rufst Du in der Konsole die Cron-Job Verwaltung "crontab" auf:
$ crontab -e
Hier kannst Du nun Deine Cron-Jobs des Servers hinterlegen. Über den folgend Eintrag wird der "scheduler" jede Minute einmal "schedule" aufrufen. Sollte der dort konfigurierte Zeitpunkt erreicht sein, wird bspw. der Import Job gequeued.
* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
Fazit zum FTP Server
In diesem Artikel habe ich Dir gezeigt, wie Du in Deiner Laravel-Anwendung einen automatischen CSV-Import von einem FTP Server implementierst. Ich hoffe, dass er Dir weiterhelfen konnte. Solltest Du Fragen haben, oder ich etwas wichtiges Vergessen habe, was Dir weiterhelfen könnte, lass mir doch einen Kommentar da und ich versuche Dir weiterzuhelfen.
Durch unsere langjährige Arbeit und über 100 erfolgreiche Projekte, konnten wir viele Erfahrungen sammeln. Dieses Know-How im Online-Marketing gaben wir u.a. bei Vorträgen von Google, der Industrie- und Handelskammer und der Handwerkskammer weiter.
Mit Know-How, Kreativität und Leidenschaft entwickeln wir auf unsere Kunden abgestimmte Marketing-Strategien, die Sie sicher und nachhaltig zum Erfolg führen. Gemeinsam setzen wir Ihr Online-Marketing so um, dass Sie langfristig Ihren Umsatz und Return-On-Investment steigern.
Jetzt kostenlosen Beratungstermin vereinbaren oder unter 0561 / 850 194 76 anrufen.
Task Queue & CSV Import mit Laravel 5.5 – so funktioniert's
Task oder Job Queues sind ein bewährtes Mittel um komplexe Aufgaben asynchron auszuführen und den Benutzer einer Anwendung nicht unnötig vor einer ladenden Website oder „eingefrorenen“ Software warten zu lassen. Ein weiterer, nicht zu unterschätzender Vorteil ist Auslagerung möglichererweise rechenintensiver Operationen in andere Prozesse, die nichts mit dem Webserver zutun haben.
Das PHP Framework Laravel liefert in seinem Funktionsumfang ebenfalls eine Queue-Funktionalität mit und gibt seinen Nutzeren auf diesem Weg ein Werkzeug an die Hand, das Nutzungsverhalten von komplexeren Laravel-Projekten stark zu verbessern.
In diesem Artikel befasse ich mich mit der Herausforderung einen Importer für CSV-Dateien in Laravel zu integrieren. Die Besonderheit ist, dass diese CSV Datein ungf. 50000 – 100000 Datensätze beinhalten können und der „Echtzeit-Import“ somit nicht möglich ist, schließlich kann der Benutzer der Anwendung nicht 30-60 Minuten warten, bis die Website wieder reagiert.
Für das „Abarbeiten“ der CSV-Datensätze werde ich kein Plugin verwenden. Ich entwickle gemeinsam mit Dir eigene kleine Code–Schnippsel, um CSV-Dateien Datensatz für Datensatz auszulesen und die beinhalteten Daten in eine Datenbank zu speichern.
Was ist eine Task Queue?
Eine Task Queue ist im Grunde eine Liste an Aufgaben, die sequenziell abgearbeitet wird. Welche Aufgabe als nächstes bearbeitet wird hängt von unterschiedlichen Kriterien ab. Der einfachste Fall ist „first in, first out“, also „wer zuerst kommt, mahlt zuerst“. Die Aufgabe die sich zuerst an unserer Queue angemeldet hat, wird auch als erstes bearbeitet. Weiterhin ist es möglich die Aufgaben mit weiteren Parametern zu versehen, wie bspw. einer Priorität, oder einem Wert für die Zeit, die die Aufgabe schon in der Queue verbringt, damit nicht nur Jobs einer höheren Priorität bearbeitet werden.
Queues die auf Prioritäten basieren haben aber eine andere Struktur als einfache Queues. Bei diesem Vorgehen gibt es für jede Prioriät eine eigene Queue. Verändert sich also die Priorität eines Jobs, wird er in eine andere Queue verschoben. Damit befassen ich mich aber in diesem Artikel noch nicht.
Betrachten wir nun erst mal die typischen Rollen, die in einer Queue agieren. Typische Rollen in einem Queue System sind der „Dispatcher“, der die Aufgaben entgegennimmt und sie gegebenenfalls parametrisiert in die Queue einfügt, die Queue als solche, der Queue Worker, der immer die Aufgabe aus der Queue entnimmt, die als nächstes an der Reihe ist und „last but not least“ der Code, der die Aufgabe verarbeitet. In unserem Beispiel ist das der Import einer großen CSV-Datei.
Nachfolgend findest Du eine Grafik, die den Aufbau eines Queue Systems grob skizziert:
Durch diesen Ablauf wird gewährleistet, dass der Webserver durch die komplexen Operationen nicht überlastet wird. Jeder ausstehende Import wird der Reihe nach abgearbeitet.
In der folgenden Grafik habe ich noch mal grob skizziert, wie die Rollenvertreilung in einer priorisierten Queue aufgebaut ist.
Wie wird die Task Queue in Laravel integriert?
Laravel bietet zu diesem Thema, wie auch zu vielen anderen Themen, eine sehr gute Dokumentation unter: Laravel 5.5 - Queues. Beginnen wir erst mal mit der Konfiguration. Im Verzeichnis „config“ Deiner Laravel-Installation findest Du die Queue Konfiguration „queue.php“. In dieser Datei Konfigurierst Du die Queue-Connection. Laravel bietet die Möglichkeit, Deine Queues in unterschiedlichen Systemen zu hinterlegen, wie unteranderem einer Datenbank, Amazons SQS, oder auch Redis. Wie Du Paypal in Laravel integrierst, erfährst Du hier.
In diesem Beitrag entscheide ich mich für den Connection-Typ „database“. Du kannst auch gerne eine andere Variante wählen. Ich gehe im Zuge dieses Beitrags aber lediglich auf die Variante Datenbank ein, da Performance in diesem Fall kein Kriterium ist.
Um nun die Datenbankmigration vorzubereiten, die Dir die benötigten Tabellenstrukturen für die Verwaltung der Jobs bereitstellt, verwendest Du Laravels CLI artisan.
$ php artisan queue:table
Die erstellte Migration integrierst Du über den Befehl „migrate“.
$ php artisan migrate
Die Integration der Job Klasse ist ebenfalls sehr simpel. Du kannst über den „make:job“ Befehl ein Template erzeugen lassen.
$ php artisan make:job ImportCsvFile
Das erzeugte Template sieht nun in etwas wie folgt aus:
<?php
namespace AppJobs;
use IlluminateBusQueueable;
use IlluminateQueueSerializesModels;
use IlluminateQueueInteractsWithQueue;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;
class ImportCsvFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $csvFilePath;
/**
* Create a new job instance.
*
* @param string $csvFilePath
* @return void
*/
public function __construct($csvFilePath)
{
$this->csvFilePath = $csvFilePath;
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
// Importieren des CSV Datei
}
}
Jobs verfügen über die Implementierung gegen das Interface ShouldQueue über die statische Methode dispatch. Diese ermöglicht es, den Job an die Queue anzuhängen. Als Parameter erwartet die Methode den CSV Pfad der Datei.
Integration des CSV-Import Controllers
Um das Beispiel etwas greifbarer zu machen zeige ich Dir nun noch, wie Du einen passenden Controller bereitstellst, der den Dispatcher später ausführt. Dafür erstellst Du den Controller „CsvImportController“ über artisan.
$ php artisan make:controller CsvImportController
Im folgen Beispiel führe ich drei Wege an, über die Du den dispatcher ansteuern kannst.
- Direkt
- Verzögert
- Mit anderen Jobs verkettet
<?php
namespace AppHttpControllers;
use AppJobsImportCsvFile;
use IlluminateHttpRequest;
use IlluminateSupportFacadesStorage;
class CsvImportController extends Controller
{
public function index () {
return view('import.csvfile');
}
public function store (Request $request) {
// CSV Datei hochladen und temporär speichern
$csvFilePath = Storage::putFile('uploads', $request->file('csvfile'));
// Import dispatchen
ImportCsvFile::dispatch($csvFilePath);
/* ### Weitere dispatch Optionen ## */
// Mit Verzögerung
// -------------------------------------
// ImportCsvFile::dispatch($csvFilePath)
// ->delay(now()->addMinutes(10));
// Job Verkettung
// -------------------------------------
// ImportCsvFile::withChain([
// new ValidateCsvFile,
// new ImportCsvFileToDatabase
// ])->dispatch();
}
}
Integration der CSV-Import View und Route
Die View (csvfile.blade.php) ist in diesem Beispiel minimalistisch dargestellt. Sie enthält lediglich ein Formular um die Datei hochzuladen. Als action ist die „named route“ → „csvimport.store“ hinterlegt, die Du im nachfolgenden Schritt integrierst.
<h2>CSV Importieren</h2>
<form action="{{ route('csvimport.store') }}" method="post" enctype="multipart/form-data">
{{ csrf_field() }}
<label for="csvfile">Bitte wählen Sie eine CSV Datei aus</label>
<input type="file" name="csvfile" id="csvfile"> <br />
<button type="submit">Datei importieren</button>
</form>
Die Routen trägst Du in der „web.php“ unter dem Verzeichnis „routes“ ein.
Route::get('/csvimport', 'CsvImportController@index')->name('csvimport.index');
Route::post('/csvimport/store', 'CsvImportController@store')->name('csvimport.store');
Ein Blick auf die CSV Datei und ihr Model
Die CSV Datei in diesem Beispiel ist ebenfalls minimalistisch aufgebaut. Ich habe mich für eine simple „Sales“ CSV Datei (Demo: "task_queue_sales.csv") entschieden, die über die Spalten „day“ und „revenue“ verfügt. Zunächst erstellst Du für das Beispiel das Model „Sales“ und die zugehörige Migrationsdatei über folgenden Befehl.
$ php artisan make:model Sales -m
Im erzeugten Model hinterlegst Du nun noch die beiden Properties „table“ und „fillable“.
<?php
namespace App;
use IlluminateDatabaseEloquentModel;
class Sales extends Model
{
protected $table = 'sales';
protected $fillable = ['day', 'revenue'];
}
Nun erzeugst Du noch eine Migration um die Spalten „day“ und „revenue“ zum Sales Model hinzuzufügen.
$ php artisan make:migration add_properties_to_sales
Die Migrationsdatei passt Du nun folgendermaßen an.
<?php
use IlluminateSupportFacadesSchema;
use IlluminateDatabaseMigrationsMigration;
class AddPropertiesToSales extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('sales', function($table) {
$table->date('day')->unique();
$table->float('revenue');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('sales', function($table) {
$table->dropColumn('day');
$table->dropColumn('revenue');
});
}
}
Nun gilt es die Migrationen anzulegen. Dazu verwendest Du den Befehl Migrate. Dazu ist es aber wichtig, dass Du die Datenbankverbindung in Laravel konfiguriert hast.
$ php artisan migrate
Nach einer erfolgreichen Migration sind die vorbereiteten Spalten in der Tabelle „sales“ hinterlegt. Wir können nun damit beginnen das Model in den CSV Importer zu integrieren. Dazu öffnest Du nun wieder die angelegte Job Klasse „ImportCsvFile“. In der „handle“ Methode hinterlegst Du nun den Code um die gespeicherte CSV Datei zeilenweise auszulesen und in die passenden Models anzulegen. Wichtig ist ebenfalls, dass Du den Namespace des Sales Models einträgst.
<?php
namespace AppJobs;
use AppSales;
use Exception;
use IlluminateBusQueueable;
use IlluminateQueueSerializesModels;
use IlluminateQueueInteractsWithQueue;
use IlluminateContractsQueueShouldQueue;
use IlluminateFoundationBusDispatchable;
use IlluminateSupportFacadesStorage;
class ImportCsvFile implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $csvFilePath;
/**
* Create a new job instance.
*
* @param string $csvFilePath
* @return void
*/
public function __construct($csvFilePath)
{
$this->csvFilePath = $csvFilePath;
}
/**
* Execute the job.
*
* @throws Exception
* @return void
*/
public function handle()
{
if (!Storage::disk('local')->exists($this->csvFilePath))
throw new Exception(sprintf(
'Die CSV Datei konnte nicht unter dem angegebenen Dateipfad gefunden werden. Dateipfad: "%1$s"'
, $this->csvFilePath
));
// Importieren der CSV Datei
$fileData = Storage::get($this->csvFilePath);
$csvData = str_getcsv($fileData, "n");
foreach ($csvData as &$row) {
$row = str_getcsv($row, ",");
if(is_array($row) && sizeof($row) == 2) {
$day = date('Y-m-d', strtotime($row[0]));
Sales::create([
'day' => $day,
'revenue' => $row[1]
]);
}
}
}
}
Der in der „handle“ Methode integrierte Code ist sehr simpel und um alles kurz und knapp zu halten, nicht refaktorisiert. Diverse Blöcke lassen sich wunderbar auslagern. Desweiteren sollten an mehreren Stellen Validierungen integriert werden.
Im ersten Abschnitt der Methode validiere Ich lediglich kurz, ob die Datei im „storage“ Ordner hinterlegt wurde. Ist dass der Fall, dann wird deren Inhalt geladen und mit der PHP Methode „str_getcsv()“ als Array geparsed.
Im letzten Abschnitt werden alle Zeilen der CSV Datei durchlaufen und die Daten jeder Zeile als Sale in der Datenbank gespeichert.
Queue Listener starten
Damit die Queue nun auch abgearbeitet wird, muss der Queue Listener gestartet werden.
$ php artisan queue:listen
Im Grunde stützt sich die Abarbeitung der Queue auf diesen CLI Prozess. Sollte die Queue auf einem Server eingerichtet werden und auch nach einem Fehler oder Serverneustart wieder erreichbar sein, so lohnt es sich für Dich einen Blick auf das Programm Supervisor zu werfen. Dieses läuft unter Linux Betriebssystemen und kann schnell installiert und konfiguriert werden. Laravel bietet in der Dokumentation ein Kapiel zu Supervisor an Laravel 5.5 - Supervisor configuration.
Fazit
In diesem Blogeintrag hast Du gelernt, wie Du Deine eigene Queue gestalten und entwickeln kannst. Du hast zu Beginn des Artikels die Queue in Deinem Laravel Projekt eingerichtet und hast Deinen eigenen Queue Job erstellt. Gegen ende des Artikels hast Du noch erfahren, wie Du aus dem Queue Job heraus eine CSV Datei in Deine Datenbank importieren kannst.
Solltest Du Fragen oder Anmerkungen haben, zögere nicht einen Kommentar zu hinterlassen.