Stöbern mit System (Linux-Magazin, Januar 2001)

Perl bietet eine Fülle wenig bekannter Funktionen an, um elegant und portabel mit Dateien in Dateisystemen herumzuspielen. Hier kommt die längst fällige Einführung.

Fangen wir an mit einem scheinbar trivialen Thema: Wenn das Verzeichnis, in dem eine Datei liegt, mit $dir gegeben ist und der Dateiname in $file liegt -- wie lautet dann der vollständige Pfad zur Datei? "$dir/$file" ist nicht immer richtig! Was ist, wenn $dir das Root-Verzeichnis / ist und in $file der String "datei" steht? "//datei" wäre die Folge. Oder falls $dir schon einen abschließenden Slash enthält, wie etwa in "/tmp/"? Oder falls wir (schauder!) unter Windows arbeiteten, wo Backslashes als Pfadtrenner fungieren: \tmp\datei hieße es da zum Beispiel. Diese ganzen Sonderfälle deckt das Modul File::Spec ab, welches eine Methode File::Spec->catfile() anbietet, welche Pfad- und Dateinamen plattformübergreifend und intelligent zusammenkleistert:

    use File::Spec;
    my $dir  = "/tmp";
    my $file = "test.dat";
    my $path = 
     File::Spec->catfile($dir, $file); 
      # => '/tmp/test.dat' (Linux)
      # => '\tmp\test.dat' (Windows)

Wie alle heute vorgestellten Module liegt auch File::Spec der Perl-Distribution ab Release 5.6.0 bereits bei, keine zusätzlichen Installierungsklimmzüge sind erforderlich. Es ist so bequem, sich nicht mehr um die Sonderfälle zu kümmern und auch automatisch die für die aktuelle Plattform (Unix, Windows, VMS, OS2 und Mac) gültigen Pfadangaben zu bekommen, dass File::Spec tatsächlich abhängig macht -- wer es einmal in seine Werkzeugkiste packte, gibt es nicht mehr her.

Wer nicht dauernd File::Spec->catfile() sagen will, sondern lieber catfile() importiert, holt statt File::Spec einfach File::Spec::Functions herein. Ausgewählte File::Spec-Funktionen wie catfile() importiert es ungefragt:

    use File::Spec::Functions;
    print 
      catfile("./tmp/", "./test.log"); 
        # => tmp/./test.log
    print "\n";

Hier werden die Grenzen von catfile() klar: Man sieht, dass catfile() zwar das erste ./ eliminiert, das zweite (vor test.log) aber beibehält: tmp/./test.log ist das Ergebnis. Um aus einer Pfadangabe doppelte Slashes und überflüssige ./-Angaben zu entfernen, daraus also einen kanonischen Pfad zu machen, dazu dient die Funktion canonpath():

    use File::Spec::Functions;
    print 
       canonpath("./tmp//./test.dat"); 
        # => "tmp/test.dat"
    print "\n";

Installierte Module aufstöbern

Ein Beispiel: Um einen Blick auf die Implementierung von File::Spec zu werfen, müssen wir herausfinden, wohin es installiert wurde. Irgendwo in die Perl-Bibliotheken, zweifellos. Da Perl das Modul laden kann, muss unterhalb einer der Pfade im Perl-Include-Pfad @INC die Datei File/Spec.pm stehen. Listing wpm.pl nimmt den Namen eines Moduls auf der Kommandozeile entgegen, baut daraus den Namen der gesuchten *.pm-Datei und spult anschließend durch alle Pfade in @INC, um mit dem Dateitester -e herauszufinden, wo das Modul sich tatsächlich befindet. Im Erfolgsfall bricht es mit last sofort ab, denn sinnigerweise sollte es nur eine Installation geben:

    $ wpm.pl File::Spec
    /usr/lib/perl5/5.6.0/File/Spec.pm

Zeile 7 spaltet den in $ARGV[0] übergebenen Modulnamen an den ::-Trennern in Einzelteile, die catfile() anschließend wieder mit Pfadtrennern zusammenfügt. Zeile 11 hängt noch den gerade untersuchten Pfad aus @INC davor, auch wieder mit catfile().

Listing 1: wpm.pl

    01 #!/usr/bin/perl -w
    02 
    03 use File::Spec::Functions;
    04     # Ohne Modulnamen aufgerufen?
    05 die "usage: $0 Module" unless defined $ARGV[0];
    06 
    07 $subpath  = catfile(split "::", $ARGV[0]) . ".pm";
    08 
    09 foreach $path (@INC) {
    10         # Pfad + Modulverzeichnis + Modul + pm
    11     $fullpath = catfile($path, $subpath);
    12 
    13     if(-e $fullpath) {
    14         print "$fullpath\n";   # Gefunden!
    15         last;
    16     }
    17 }

Absolut und Relativ

Ein notorisch schwieriges Problem ist es, absolute in relative Pfadangaben zu transformieren. Steht man gerade in /home/mschilli und sagt tmp/abc.tgz, schließt die rel2abs()-Funktion messerscharf auf das richtige absolute Verzeichnis:

    use File::Spec::Functions 
                        qw(rel2abs);
    print rel2abs("tmp/abc.tgz");
     => /home/mschilli/tmp/abc.tgz
    print "\n";

Hinter den Kulissen ermittelt rel2abs() mit der cwd()-Funktion (aus dem Modul Cwd) zunächst das aktuelle Verzeichnis, das sich im obigen Fall als /home/mschilli herausstellt und nimmt dann die Pfadtransformation vor. Zu beachten ist, dass File::Spec::Functions die Funktion rel2abs() nicht standardmäßig exportiert, sondern dass das aufrufende Programm mit einer an die use-Anweisung angehängten Liste explizit danach fragen muss.

Zwei weitere nützliche Funktionen aus File::Spec::Functions seien ebenfalls erwähnt: curdir() zeigt an, wie der Spezialeintrag für das aktuelle Verzeichnis heißt ("." unter Unix/Windows) und updir() gibt an, wie der Link zum darüberliegenden Verzeichnis lautet (".." unter Unix, Windows). Die Lesefunktion für Verzeichnisse, readdir(), liefert diese Sondereinträge ja immer mit. Um letztere auszublenden, hilft der Code in Listing readdir.pl, der in die nächste Runde springt, falls einer der Spezialeinträge auftaucht.

Listing 2: readdir.pl

    01 #!/usr/bin/perl -w
    02 
    03 use File::Spec::Functions;
    04 
    05 $dir = "/tmp";
    06 
    07 opendir(DIR, $dir) or die("Cannot open $dir");
    08 
    09 foreach $dir (readdir DIR) {
    10     next if $dir eq updir();
    11     next if $dir eq curdir();
    12 
    13     print "Verzeichnis gefunden: $dir\n";
    14 }
    15 
    16 closedir(DIR);

Eine schnellere Möglichkeit unter Unix, wo klar ist, dass die Kandidaten "." und ".." heißen, ist freilich, sie direkt mit einem regulären Ausdruck wie

    next if $dir =~ /^\.{1,2}\z/;

anzusprechen. Der reguläre Ausdruck sucht nach dem Zeilenanfang (^), einem oder zwei Punkten (als \. ausmaskiert, weil der Punkt als Metazeichen im Regex sonst eine Sonderbedeutung hat) und dem Stringende \z. Warum \z und nicht das sonst übliche $ als Zeilenendemarkierung? $ passt auf das Ende der ersten Zeile des Strings, \z ist das definitive Stringende. Filenamen unter Unix dürfen auch mehrere Zeilen lang sein und eine Datei mit einem Namen wie etwa "..\ndatei" soll bei der Auflistung nicht unter den Tisch fallen.

Pfade und Dateien

Um eine Datei vom zu ihr führenden Pfad zu trennen, exportiert das Modul File::Basename die Funktion basename(), die zu einer Pfadangabe wie zum Beispiel /tmp/01/test.dat den Dateinamen test.dat extrahiert, während die Funktion dirname() (ebenfalls aus File::Basename) sich auf den Pfad konzentriert:

    use File::Basename;
    print basename("/tmp/test.dat");
        # => test.dat
    print "\n";
    print dirname("/tmp/test.dat");
        # => /tmp
    print "\n";

Mit mkdir() bietet Perl ja die Möglichkeit, neue Verzeichnisse anzulegen, allerdings nur direkt und nicht in beliebiger Tiefe: mkdir("/tmp/01/test") kann nur funktionieren, falls /tmp/01 schon existiert. Das Modul File::Path exportiert mkpath() und rmpath(), die beliebig tiefe Verzeichnisse erzeugen und auch wieder abräumen:

    use File::Path;
    mkpath("/tmp/01/02/03/04/05") or 
                   die "mkpath failed";
    rmtree("/tmp/01") or 
                   die "rmtree failed";

mkpath() ist ein mkdir() mit Tiefenwirkung, rmtree() entspricht dem rm -rf der Shell und löscht im Beispiel alle von mkpath() vorher erzeugten Verzeichisse.

Kopieren und Verschieben

Eine Datei in eine andere zu kopieren geht nur durch Einlesen der ersten und anschließendes Ausschreiben in die Zweite. Das Modul File::Copy vereinfacht dies drastisch:

    use File::Copy;
        # Backup von test.dat 
    copy("test.dat","test.dat.old") 
              or die "copy failed";

Die exportierte Funktion copy() stellt auch sicher, dass die Dateiattribute wie Zugriffsrechte so kopiert werden, wie die systemeigene Kopierfunktion (unter Unix cp) es tun würde. Reines Umbenennen einer Datei erledigt die move()-Funktion, die ebenfalls von File::Copy exportiert wird, obwohl das nicht auf den ersten Blick einleuchtet. move() funktioniert im Gegensatz zum sonst in Perl verwendeten rename() selbst unter extremen Umständen, wie zum Beispiel wenn Quell- und Zieldatei auf unterschiedlichen Dateisystemen liegen -- genau wie das Unix-Kommando mv. In diesem Fall artet das Umbenennen in Kopieren-und-Löschen aus:

    use File::Copy;
    move("/tmp/test.dat", 
         "/mnt/disk2/test.dat") or
        die "move failed";

Stöbern mit File::Find

Wie die Unix-Funktion find durchstöbert die find()-Funktion des File::Find-Moduls Verzeichnisse und deren Abkömmlinge rekursiv und führt während des Abstiegs beliebig definierbare Kniffe aus. Die find()-Funktion nimmt dabei eine Referenz auf eine benutzerdefinierte Funktion entgegen, gefolgt von einem oder mehreren Verzeichnissen, in die sie anschließend depth-first vordringt. Für jeden gefundenen Eintrag (egal ob Verzeichnis, symbolischer Link oder Datei) wird die benutzerdefinierte Funktion angesprungen und dort $_ auf den Namen des Eintrags gesetzt. Außerdem befindet sich die Funktion zum Zeitpunkt ihres Aufrufs schon im entsprechenden Verzeichnis, so dass man zum Beispiel ganz einfach mit Filetestern wie -f $_ bzw. -d $_ prüfen kann, ob es sich bei dem Eintrag um eine Datei oder ein Verzeichnis handelt. Um zum Beispiel herauszufinden, welche Unterverzeichnisse und Unter-Unterverzeichnisse sich unterhalb des /tmp-Verzeichnisses befinden, hilft Listing finddir.pl.

Listing 3: finddir.pl

    01 #!/usr/bin/perl -w
    02 
    03 use File::Spec::Functions;
    04 use File::Find;
    05 
    06 find( \&wanted, '/tmp');
    07 
    08 sub wanted {
    09    return unless -d $_;
    10    return if $_ eq curdir();
    11 
    12    print "$File::Find::name\n";
    13 }

finddir.pl lässt perl sofort aus der Ansprungfunktion wanted aussteigen, falls der gesuchte Eintrag kein Verzeichnis (Operator -d) ist. File::Find unterdrückt zwar die Einträge "." und ".." in den gefundenen Unterverzeichnissen, liefert aber für das Startverzeichnis einmal ".", wohl um mit dem Unix-Kommando find gleichzuziehen, das ebenfalls das Startverzeichnis meldet. Die Zeile return if $_ eq curdir(); unterdrückt in finddir.pl diese Ausgabe.

Falls sich ein Verzeichnis findet, gibt der print-Befehl den vollständigen Pfad des Eintrags aus, den File::Find praktischerweise im Skalar $File::Find::name bereitstellt (also zum Beispiel /tmp/test.dat). Die andere unter Umständen nützliche Variable $File::Find::path liefert den dirname() des Eintrags.

File::Find folgt normalerweise aus Sicherheitsgründen keinen symbolischen Links. Das lässt sich aber umkonfigurieren. Die exportierte Funktion find() nimmt statt einer Referenz auf eine Ansprungsfunktion als erstem Parameter auch eine Referenz auf einen Hash entgegen, in dem verschiedene Konfigurationswerte stehen. Der Eintrag wanted des Hashs zeigt dann auf die Ansprungsfunktion, und falls unter dem Schlüssel follow ein wahrer Wert steht, folgt File::Find auch symbolischen Links. Es stellt auch bei zyklischen Verweisen sicher, dass jeder Eintrag im Dateisystem nur einmal durchlaufen wird:

    find({ wanted => \&wanted, 
           follow => 1 }, 
         $start_dir);

Hier heißt die Ansprungsfunktion wanted() und das Startverzeichnis für die Suche steht in $start_dir. Mit File::Find lassen sich die wildesten Dinge zaubern -- wie etwa die letzten 3 veränderten Dateien auf der Festplatte zu finden oder alle Dateien zu löschen, die größer sind als 10 Megabyte und einem bestimmten Benutzer gehören. wanted() darf auch jede vorbeizischende Datei kurz öffnen und nach bestimmten Textmustern durchkämmen -- und so die Festplatte nach Dateien mit bestimmtem Inhalt durchforsten. Lasst eurer Fantasie freien Lauf!

Dieser Text entstammt einem Fortgeschrittenen-Abschnitt des neuen Buches Perl Lernen, das voraussichtlich im Frühjahr 2000 bei Addison-Wesley erscheinen wird.

Michael Schilli

arbeitet als Software-Engineer bei Yahoo! in Sunnyvale, Kalifornien. Er hat "Goto Perl 5" (deutsch) und "Perl Power" (englisch) für Addison-Wesley geschrieben und ist unter mschilli@perlmeister.com zu erreichen. Seine Homepage: http://perlmeister.com.