Verzeichnis mit vielen Dateien in mehreren Schritten mit PHP kopieren

Ich arbeite momentan an einem kleinen eigenen Projekt, bei dem unter anderem ein WordPress-Plugin eine Kopie der Installation erstellen muss. Da ich die Server-Umgebung der potenziellen User nicht kenne, kann ich weder davon ausgehen dass ich dafür Linux-Befehle nutzen kann, noch das ein hoher Wert für die PHP-max_execution_time gesetzt ist.

Das Kopieren muss also über PHP ablaufen und auch funktionieren, wenn zum Beispiel nur 30 Sekunden Skript-Laufzeit zur Verfügung stehen.

30 Sekunden sind wenig für ein Skript, das viele Dateien kopieren soll. Wenn man eine Liste der Dateien ermittelt und diese nur durchläuft, ohne etwas damit anzustellen, kann das schon länger als 30 Sekunden dauern.

Wir müssen das Kopieren also so aufbauen, dass es vor Erreichen des Zeitlimits (ich hatte es für einen Test auf 30 Sekunden gesetzt) den aktuellen Stand speichert, einen weiteren Durchlauf plant und dann beendet wird. Das können wir im WordPress-Kontext mit den WordPress-Cron-Events umsetzen.

Probleme hatte ich bei dem Versuch, wie ich die bereits kopierten Dateien für den nächsten Durchlauf ausschließen kann, sodass sie auch nicht als bereits bearbeitet in einer foreach-Schleife übersprungen werden müssen.

Symfony-Finder-Komponente to the rescue

Relativ schnell bin ich über die Finder-Komponente von Symfony gestolpert. Die Komponente ermöglicht es, Verzeichnisse und Dateien anhand unterschiedlicher Kriterien zu finden und unter anderem auch Verzeichnisse auszuschließen.

Dieser Punkt ist der entscheidende bei meiner Lösung, die in ein paar Schritten beschrieben so aussieht:

  1. Alle Dateien aus dem WordPress-Verzeichnis werden ermittelt, abgesehen von dem Uploads-Verzeichnis.
  2. Start des Kopier-Vorgangs.
  3. Nachdem ein Verzeichnis fertig kopiert ist, wird es in ein Array eingefügt, damit es beim nächsten Durchlauf mit vom Finder ignoriert werden kann.
  4. Wenn die Timeout-Zeit fast erreicht ist, wird der aktuelle Stand des Objekts in eine Datei gespeichert, ein WP-Cron-Event erstellt und das Programm beendet.
  5. Wenn das Cron-Event ausgeführt wird, werden die Dateien aus den noch nicht abgearbeiteten Verzeichnissen ermittelt und wieder ab Schritt 2 weitergemacht.

Mein aktueller Stand sieht dazu so aus (noch nicht fertig, aber funktioniert und sollte als Inspiration ausreichen. Neben der Finder-Komponente nutze ich noch die Filesystem-Komponente – es gibt den Code auch als Gist):

<?php /** * Main plugin code. * * @package FlorianBrinkmann\Copier */ namespace FlorianBrinkmann\Copier; use Symfony\Component\Filesystem\Exception\IOExceptionInterface; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\Finder\Finder; /** * Class Plugin * * @package FlorianBrinkmann\Copier */ class Plugin { /** * Absolute path to the WordPress install. * * @var string */ private $abspath = ''; /** * Absolute path where we want to copy the files to. * * @var string */ private $dest = ''; /** * Name of destination directory. * * @var string */ private $dest_dir_name = ''; /** * Array of default directories to exclude. * * @var array */ private $default_exclude = []; /** * Array of additional to exclude. * * @var array */ private $additional_exclude = []; /** * Path to status file. * * @var string */ private $status_file; /** * Current file index. * * @var string */ private $current_file_index = ''; /** * Current path. * * @var string */ private $current_path = ''; /** * Tables of WordPress install. * * @var array */ private $tables = []; /** * The current step of the process. * * @var string */ private $step = ''; /** * Time limit in seconds. Default 30. * * @var int */ private $time_limit = 30; /** * Unix timestamp of init event. * * @var int */ private $timer; /** * Symfony filesystem object. * * @var Filesystem */ private $filesystem; /** * List of directories and files. * * @var array */ private $files_list; public function init() { // Set timer. $this->timer = time(); // Set filesystem property. $this->filesystem = new Filesystem(); $this->step = 'file-list-creation'; // Set dest folder for copying. $dest_dir_name = uniqid( 'uaas-copy-' ); // Check if folder exists. If so, change name and check again. // @todo: Only check for a limited time. while ( $this->filesystem->exists( "$this->abspath/$dest_dir_name" ) ) { $dest_dir_name = uniqid( 'uaas-copy-' ); } $this->dest_dir_name = $dest_dir_name; $this->dest = trailingslashit( "$this->abspath{$dest_dir_name}" ); // Create file list for cloning. $this->default_exclude = [ 'wp-content/uploads', $this->dest_dir_name ]; $this->create_file_list(); // Create dest folder. $this->filesystem->mkdir( $this->dest, 0755 ); // Create status file. $this->status_file = "{$this->dest}uaas-status.txt"; $this->filesystem->dumpFile( $this->status_file, serialize( $this ) ); // We have the files in the files_list property now, so we can clone! $this->step = 'copying-files'; $this->copy_files(); } /** * Continue copying. */ public function continue_copying() { // Check for step. if ( $this->step !== 'copying-files' ) { return false; } // Set timer. $this->timer = time(); // Update file list. $this->create_file_list(); $this->copy_files(); } /** * Create a list of the files that we want to copy. */ private function create_file_list() { $finder = new Finder(); // @todo: check for modified uploads destination. // @todo: ignore other directories like cache, backups, … $exclude = array_merge( $this->default_exclude, $this->additional_exclude ); $finder->files()->in( $this->abspath )->exclude( $exclude )->notPath( '/.*\/node_modules\/.*/' ); if ( ! $finder->hasResults() ) { // @todo: Add error, nothing found. return; } $this->files_list = $finder; } /** * Copy the files. */ private function copy_files() { // Loop the files list. $already_processed = true; foreach ( $this->files_list as $index => $file ) { // Check if we already had that file. if ( $this->current_file_index === '' ) { $already_processed = false; } else if ( $this->current_file_index === $index ) { $already_processed = false; } if ( $already_processed ) { continue; } $tmp = str_replace( '\\', '/', $file->getRelativePath() ); // Check if current path is not empty. if ( $this->current_path !== '' ) { // Now check if the path of prev file // does not exist in path of current file. if ( strpos( $tmp, $this->current_path ) !== 0 ) { $pushed_to_array = false; // Add prev path to exclude in conditional cases. // Add it if is wp-admin folder or a direct subfolder. if ( strpos( $this->current_path, 'wp-admin' ) === 0 && substr_count( $this->current_path, '/' ) <= 1 ) { array_push( $this->additional_exclude, untrailingslashit( $this->current_path ) ); $pushed_to_array = true; } // Add it if is wp-content folder or up to two levels deeper. if ( strpos( $this->current_path, 'wp-content' ) === 0 ) { // Get first three directories of paths. // https://stackoverflow.com/a/1935929/7774451 $path_parts = explode( '/', $this->current_path, 4 ); if ( isset ( $path_parts[3] ) ) { unset( $path_parts[3] ); } $tmp_path_parts = explode( '/', $tmp, 4 ); if ( isset ( $tmp_path_parts[3] ) ) { unset( $tmp_path_parts[3] ); } // If both arrays would be the same, that means we are deeper than three subdirs. // CHECK WHY THAT DOES NOT WORK FOR ANTISPAM-BEE AND ANTISPAM-BEE-3-0 if ( $path_parts !== $tmp_path_parts ) { // Push the path from $path_parts. array_push( $this->additional_exclude, implode( '/', $path_parts ) ); $pushed_to_array = true; } } // Remove entries from exclude that are covered by more general rules. if ( $pushed_to_array ) { $filtered = array_filter( $this->additional_exclude, function( $var ) { // Check if $var is equal with current_path. if ( $var === untrailingslashit( $this->current_path ) ) { return true; } // If $var contains $this->current_path, remove it. if ( strpos( $var, untrailingslashit( $this->current_path ) ) === 0 ) { return false; } return true; } ); $this->additional_exclude = $filtered; } } } $this->current_path = str_replace( '\\', '/', $file->getRelativePath() ); $absolute_file_path = str_replace( '\\', '/', $file->getRealPath() ); // Create dest path for file. $dest_file_path = str_replace( $this->abspath, $this->dest, $absolute_file_path ); // Copy the file. try { $this->filesystem->copy( $absolute_file_path, $dest_file_path ); } catch ( IOExceptionInterface $exception ) { // @todo: make something with the error. } // Check if we are near the self-defined script timeout limit. if ( time() - $this->timer >= $this->time_limit - 1 ) { $this->current_file_index = $index; // Store the current object in a file. $this->filesystem->dumpFile( $this->status_file, serialize( $this ) ); // Add a new cron event in the near future to continue the copying. wp_schedule_single_event( time(), 'flobn_uaas_continue_copying', [ $this->status_file ] ); // Exit. exit(); } } error_log( 'Finished file copying' ); } public function set_abspath( string $abspath ) { // Replace backslash with slash. $abspath = str_replace( '\\', '/', $abspath ); $this->abspath = trailingslashit( $abspath ); } }

Um den Prozess zu starten, müssen die folgenden drei Code-Zeilen ausgeführt werden (die Datei mit der Klasse muss per require_once ebenfalls geladen werden, oder über den Autoloader von Composer):

namespace FlorianBrinkmann\Copier; // Load Composer autoloader. From https://github.com/brightnucleus/jasper-client/blob/master/tests/bootstrap.php#L55-L59 $autoloader = dirname( __FILE__ ) . '/vendor/autoload.php'; if ( is_readable( $autoloader ) ) { require_once $autoloader; } // Create Plugin object. $uaas = new Plugin(); // Set abspath property. $uaas->set_abspath( ABSPATH ); $uaas->init();

Jetzt zur Erklärung der wichtigsten Teile der Plugin-Klasse:

  • Zeile 120: Die timer-Eigenschaft wird auf den aktuellen Timestamp gesetzt, damit wir später prüfen können, wie lange das Skript schon läuft.
  • Zeile 128: Es wird ein Name für den Ordner generiert, in den die Dateien kopiert werden sollen. Danach wird zur Sicherheit geprüft, ob das Verzeichnis bereits existiert.
  • Zeile 141 und 142: Die standardmäßig zu ignorierenden Verzeichnisse sind das Uploads-Verzeichnis und das Verzeichnis, in das kopiert werden soll. Dann wird die Dateiliste erstellt.
  • Zeile 177–192: Ausführen des Symfony-Finders in Zeile 183. Wir ignorieren zusätzlich zu unseren definierten Ausnahen und den Ausnahmen, die direkt von Finder kommen (Version-Control-Verzeichnisse zum Beispiel) noch node_modules-Verzeichnisse. In Zeile 191 wird das Finder-Objekt in der files_list-Eigenschaft gespeichert.
  • Zeile 148 und 149: Wir erstellen eine Status-Datei in dem Verzeichnis, in das die Dateien eingefügt werden, und speichern den aktuellen Stand des Objekts ($this) darin ab.
  • Zeile 153: Wir starten das Kopieren.
  • Zeile 197 und folgende: Die Methode zum Kopieren der Dateien. Wir durchlaufen in Zeile 200 die Dateiliste und prüfen zunächst, ob Dateien bereits kopiert wurden, indem wir warten, bis der Index der letzten verarbeiteten Datei kommt (die Dateien liegen immer in derselben Reihenfolge vor, den Index der aktuellen Datei setzen wir in Zeile 288). Alle Dateien vor dem Index können wir als bereits abgearbeitet betrachten.
  • Ab Zeile 212 beginnen wir mit der Prüfung auf vollständig abgearbeitete Verzeichnisse. Wir können dabei nicht einfach jedes Verzeichnis nehmen, da das ab einer gewissen Menge den Finder überlasten würde. Wir beschränken uns im wp-admin-Verzeichnis auf direkte Unterverzeichnisse und in wp-content auf zwei Ebenen Unterverzeichnisse, also beispielsweise bis wp-content/plugins/antispam-bee.
  • In Zeile 252 bis 268 schauen wir, ob in $this->additional_exclude Verzeichnisse liegen, die von anderen Einträgen abgedeckt werden, und entfernen diese. Das wäre beispielsweise für die Plugin-Verzeichnisse der Fall, wenn das komplette wp-content/plugins abgearbeitet ist.
  • In Zeile 281 wird die Datei kopiert.
  • In Zeile 287 bis 289 wird geprüft, ob wir bis auf eine Sekunde an das Zeitlimit sind (hier manuell auf 30 Sekunden gesetzt, das kann natürlich dynamisch an die vorhandenen Limits angepasst werden). Wenn das der Fall ist, wird die aktuell verarbeitete Datei in current_file_index gespeichert und der aktuelle Stand des Objekts in der Status-Datei. Anschließend wird ein Cron-Event geplant, das den Action-Hook flobn_uaas_continue_copying ausführt und den Pfad zur Status-Datei als Parameter übergibt.
  • Zeile 159–172: Von der Funktion, die an den Hook angehängt wird, wird die continue_copying()-Methode ausgeführt. Wir prüfen darin zunächst, ob step auf copying-files steht und falls ja, setzen wir timer, erstellen die Dateiliste (in create_file_list() werden dann auch die abgearbeiteten Verzeichnisse ignoriert) und starten wieder mit dem Kopieren.

Die Action, die von dem Cron-Event ausgeführt wird, sieht so aus:

add_action( 'flobn_uaas_continue_copying', function( $status_file ) { // Get status file. $status_file_contents = file_get_contents( $status_file ); // unserialize object. $uaas = unserialize( $status_file_contents ); // Continue with copying. $uaas->continue_copying(); } );

Wir holen uns den Inhalt der Status-Datei, machen die Serialisierung des Objekts rückgängig und führen die continue_copying()-Methode aus.

Und damit haben wir einen Prozess, der es auch mit recht kleinen Limits schafft, große Verzeichnisse zu kopieren 🎉

Wenn das Kopieren einer Datei sehr lange dauert, kann es natürlich trotzdem sein, dass das Skript in ein Timeout läuft, da die Prüfung ja erst nach dem Kopieren stattfindet, und nicht währenddessen. Aber ich denke als Startpunkt ist das gut geeignet so.

7 Kommentare zu »Verzeichnis mit vielen Dateien in mehreren Schritten mit PHP kopieren«

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.