weidner/archives/2022/02/

Perl: File::Find effizienter verwenden

Seit einigen Jahren verwende ich App::VOJournal um meine tägliche Arbeit in VimOutliner-Dateien zu organisieren. Dazu sucht das Modul nach der letzten Journaldatei, übernimmt die offenen Punkte, falls die Datei nicht vom heutigen Tag ist, und startet anschließend Vim mit der aktuellen Journaldatei. Die Journaldateien befinden sich unterhalb eines Startverzeichnisses nach dem Muster $basedir/YYYY/MM/YYYYMMDD.otl.

Mit der Zeit haben sich in den Jahresverzeichnissen unter $basedir jedoch etliche andere Dateien angesammelt. Das sind zum einen Projekte, die mit Directory::Organize organisiert sind, ein Verzeichnis mit Briefen pro Jahr und alte Dateien bis zurück nach 1988.

Dadurch musste das Modul nun statt Dutzenden bis einige Hundert viele Tausend Dateien evaluieren, mit der Folge, dass es mit der Zeit sehr langsam startete.

Was passiert bei der Suche nach dem letzen Journal?

App::VOJournal verwendet das Modul File::Find, um die letzte Journal-Datei zu finden und diese nötigenfalls als Template für die aktuelle zu verwenden.

Es startet File::Find::find(), das wiederum jede gefundene Datei einer Callback-Funktion vorlegt, die entscheidet, wie mit der Datei zu verfahren ist. Durch Auswahl des Dateinamens nach dem Suchmuster und lexikalischen Vergleich mit dem Namen der aktuellen Datei findet die Callback-Funktion die letzte Datei, wenn es diese gibt, und legt sie in der Variablen $last_file ab.

my $wanted = sub {
    my $this_file = $File::Find::name;

    if ($this_file =~ qr|^$basedir/\d{4}/\d{2}/\d{8}[.]otl$|
        && 0 < ($this_file cmp $last_file)
        && 0 >= ($this_file cmp $next_file)) {
        $last_file = $this_file;
    }
};
find({wanted => $wanted},$basedir);

Die Callback-Funktion kann jedoch nicht die Suche von File::Find::find() abbrechen, so dass immer alle Dateien unterhalb von $basedir untersucht werden. Das war am Anfang, als es nur um wenige Dutzend Dateien ging, kein Problem.

Die Abhilfe

Bei genauerer Betrachtung der Dokumentation zu File::Find fiel mir die Möglichkeit auf, mit einer weiteren Callback-Funktion die Liste der Dateien, welche dem ersten Callback vorgelegt werden, zu manipulieren.

Das geht sehr einfach mit der Perl-Funktion grep. Da die gewünschten Dateien zwei Verzeichnisebenen unter $basedir liegen, rufe ich grep auf drei verschiedene Arten auf, je nachdem auf welcher Ebene sich File::Find::find() gerade befindet:

my $preprocess = sub {
    my @files = ();

    if ($File::Find::dir =~ /^$basedir$/) {
        @files = grep { /^\d{4}$/ } @_;
    }
    elsif ($File::Find::dir =~ m|^$basedir/\d{4}$|) {
        @files = grep { /^\d{2}$/ } @_;
    }
    elsif ($File::Find::dir =~ m|^$basedir/\d{4}/\d{2}$|) {
        @files = grep { /^\d{8}\.otl$/ } @_;
    }
    return @files;
};

Die Funktion, auf die $preprocess verweist, bekommt die Liste aller Dateien und Verzeichnisse in $File::Find::dir als Liste in der Variablen @_ übergeben und gibt nur die passenden Dateinamen zurück.

Diese Optimierung findet sich in commit dd3dc3e bei den Quellen und in Release v0.4.6.

Weitere Optimierungen

Die Beschränkung der Dateien hat das Skript stark beschleunigt. Beim weiteren Nachdenken über das Problem fielen mir anschließend noch weitere Optimierungen ein.

Wenn $wanted der Funktion $preprocess signalisiert, dass es die Datei gefunden hat, kann diese anschließend leere Listen zurückgeben und auf diese Weise die Suche verkürzen.

my $wanted = sub {
    my $this_file = $File::Find::name;

    if ($this_file =~ qr|^$basedir/\d{4}/\d{2}/\d{8}[.]otl$|
        && 0 < ($this_file cmp $last_file)
        && 0 >= ($this_file cmp $next_file)) {
        $last_file = $this_file;
        $got_it = 1;
    }
};

Da beide Funktionen als Closure innerhalb von _find_last_file() realisiert sind, kann ich für die Signalisierung die lokale Variable $got_it innerhalb dieser Funktion verwenden.

Die Funktion $preprozess kann die Listen außerdem rückwärts sortieren, so dass die interessanten Dateien zuerst auftauchen. Damit wird das gesuchte Journal noch schneller gefunden.

my $preprocess = sub {
    my @files = ();

    if ($got_it) {
        # leave it empty
    }
    elsif ($File::Find::dir =~ /^$basedir$/) {
        @files = grep { /^\d{4}$/ } @_;
    }
    elsif ($File::Find::dir =~ m|^$basedir/\d{4}$|) {
        @files = grep { /^\d{2}$/ } @_;
    }
    elsif ($File::Find::dir =~ m|^$basedir/\d{4}/\d{2}$|) {
        @files = grep { /^\d{8}\.otl$/ } @_;
    }
    return sort {$b cmp $a} @files;
};

Anschließend kann ich mir die aufwendigen Tests innerhalb von $wanted sparen, weil die gesuchte Datei immer die erste ist, die die Kriterien erfüllt. Ich breche alse $wanted sofort ab, wenn $got_it bereits gesetzt ist.

Wie kann ich das testen?

Mit diesen Optimierungen wird der Code komplexer und es gibt mehr Möglichkeiten Fehler einzubauen. Darum war meine nächste Sorge, wie ich die Funktionalität testen kann.

Eine erste Idee war, die Callback-Funktionen nicht als Closures zu realisieren, sondern als normale benannte Funktionen, so dass ich sie einzeln mit File::Find und einem Mockup für die andere Funktion testen kann.

Das würde wiederum die Signalisierung zwischen beiden Funktionen erschweren, weil diese keine lokale Variable dafür verwenden können.

Ich habe mich schließlich für einen Hook entschieden, mit dem ich die aktuell vorgelegte Datei und alle bisher gefunden Daten an eine Funktion weitergebe, die ich optional in einer Hash an _find_last_file() übergebe.

sub _find_last_file {
    my ($basedir,$next_file,$f) = @_;
    my $last_file = '';
    my $got_it = 0;
    my $wanted = sub {
        my $this_file = $File::Find::name;

        return if ($got_it);
        if ($this_file =~ qr|^$basedir/\d{4}/\d{2}/\d{8}[.]otl$|
            && 0 < ($this_file cmp $last_file)
            && 0 >= ($this_file cmp $next_file)) {
            $last_file = $this_file;
            $got_it = 1;
        }
        if ($f->{wanted}) {
            $f->{wanted}->($this_file,$last_file,$next_file,$got_it);
        }
    };
...
    find({wanted => $wanted,
          preprocess => $preprocess,
         },$basedir);
}

Damit kann ich im Test diese Informationen ausleiten und auswerten, so wie hier in t/find_last_file.t:

@visited = ();
$last_file = App::VOJournal::_find_last_file($basedir,$oldjournalfile,
    { wanted => sub { push @visited,[@_] } }
);
is($last_file, $oldjournalfile, "find the last file");
is(scalar @visited, 5, "looked at five files/dirs");

Beide Tests sind abhängig von der Testumgebung. Diese legt mehrere Verzeichnisse und Dateien an, innerhalb derer dann _find_last_file() sucht. Dabei übergebe ich eine anonyme Funktion, die die Informationen von $wanted in das Array @visited kopiert.

Der erste Test prüft ob die korrekte Datei überhaupt gefunden wird.

Der zweite Test kontrolliert, dass $wanted genau 5 Dateien beziehungsweise Verzeichnisse untersucht. Ohne Auswahl und Sortierung wären das einige mehr in der aufgebauten Testumgebung.

Was sagen die CPAN-Tester?

Mehr Tests sind natürlich auch mehr Möglichkeiten, problematische Stellen zu finden.

Bevor ich ein neues Release eines Perl-Moduls auf CPAN veröffentliche, lasse ich immer die Tests auf meinem Rechner durchlaufen, um zu sehen, ob es Probleme gibt.

perl Build.PL
./Build disttest

Dabei war mir bereits aufgefallen, dass File::Find problematisch im Taint-Mode ist.

Deshalb hatte ich hier schon die Option untaint => 1 an File::Find::find() übergeben, denn die Tests werden im Taint-Mode aufgerufen. Damit lief bei mir alles durch und ich lud Version v0.4.7 von App::VOJournal auf CPAN.

Bereits nach einem Tag kamen die Fehlerberichte herein. Die neuen Tests scheiterten auf der Plattform mswin32, wie zum Beispiel dieser hier.

Die Fehlermeldung lautet:

insecure cwd in find(depth) at C:/Strawberry/perl/lib/File/Find.pm line 278.

Ich schaute auf meiner Installation unter Ubuntu nach und fand den Aufruf von die an genau dieser Stelle.

274         unless ( $no_chdir ) {
275             if ( ($check_t_cwd) && (($untaint) && (is_tainted($cwd) )) ) {
276                 ( $cwd_untainted ) = $cwd =~ m|$untaint_pat|;
277                 unless (defined $cwd_untainted) {
278                     die "insecure cwd in find(depth)";
279                 }
280                 $check_t_cwd = 0;
281             }
282             unless (chdir $cwd_untainted) {
283                 die "Can't cd to $cwd: $!\n";
284             }
285         }

Der Code-Ausschnitt zeigt, dass der gesamte Block von der Variablen $no_chdir abhängt und mit dieser deaktiviert werden kann.

Die Handbuchseite zu File::Find sagt dazu

"no_chdir"

Does not "chdir()" to each directory as it recurses. The "wanted()"
function will need to be aware of this, of course. In this case, $_
will be the same as $File::Find::name.

Das stellt kein Problem für App::VOJournal::_find_last_file() dar, also habe ich die Option no_chdir => 1 zum Aufruf von File::Find::find() hinzugefügt und mit dieser Änderung v0.4.8 auf CPAN geladen.

Nach etwa einem Tag konnte ich bereits sehen, dass die Tests mit mswin32 nun ebenfalls funktionieren. Somit haben die CPAN-Tester effektiv dazu beigetragen, das Modul besser zu machen.

Posted 2022-02-01
Tags: