Stöbern mit System (Linux-Magazin, September 2005)

Die Commandline-Utilty find oder das Perl-Modul File::Find hangeln sich nur sehr langsam durch das Dateisystem. Das Skript rummage hingegen hält Meta-Informationen aller Einträge zusätzlich in einer MySQL-Datenbank, und kann sogar per Volltextsuche blitzschnell auf Dateien im hintersten Winkel zugreifen.

Wo ist das Skript, das ich gestern zusammenklopft habe? Welche Dateien sind seit letzter Woche neu? Welche verbrauchen den meisten Platz? Welche wurden seit drei Jahren nicht mehr angefasst? Wo war nochmal der Text von letzter Woche, der die Begriffe ``Michael'' und ``Gehaltserhöhung'' enthielt? Natürlich lässt sich das Dateisystem schrittweise durchforsten und die gewünschte Information hervorholen. Spottbillige Riesenfestplatten haben allerdings in den letzten Jahren dafür gesorgt, dass Anwender ihr Home- Verzeichnis nicht regelmäßig ausmisten und find und Co. oft zehn- oder gar hunderttausende von unnützen Einträgen abwandern müssen. Wer hat schon Zeit, eine halbe Stunde zu warten?

Utilities wie ``slocate'' durchforsten den Dateibaum bei Nacht und erlauben dem Entwickler während der Arbeitsstunden, Dateien schnell per Namen zu lokalisieren. Der Google-Desktop [2] und ``Spotlight'' unter OSX setzen noch eins drauf: Sie bilden einen Meta-Index und erlauben dem Benutzer, Dateien anhand von allerlei Merkmalen zu lokalisieren.

Das heute vorgestellte Skript rummage implementiert eine Desktopsuche in Perl. Es achtet nicht nur auf Dateinamen, sondern merkt sich auch, wann Dateien zum ersten Mal auftauchten, wann sie zum letzten Mal verändert wurden, und legt eine Reihe von Meta- Informationen über jede gefundene Datei in einer MySQL- Datenbank (Abbildung 1) ab. Von Textdateien bildet es einen Volltext-Index, damit der Benutzer später Dateien per Keywordsuche auf ihren Inhalt aufstöbern kann.

Abbildung 1: Das Schema der Tabelle 'file', in der 'rummage' Meta-Daten von Dateien im Dateisystem ablegt.

Volltext auf Abwegen

Ab Version 3.23.23 bietet MySQL eine FULLTEXT-Option, mit der man Tabellenspalten markieren kann, deren Inhalt dann später per Volltextsuche zugänglich ist. Mit 4.0.1 kamen boolsche Verknüpfungen der Suchausdrücke hinzu. Sogar Stopplisten (Generische Wörter, die nicht indiziert werden) und ``Query Expansion'' (Dokumente, die Wörter aus Dokumenten enthalten, die ein Query lieferte) werden unterstützt. Allerdings liess die Query-Geschwindigkeit bei vielen Dateien im Test stark zu wünschen übrig. Und da jedes Volltext- Dokument in die Datenbank wandert, nimmt diese bald gigantische Ausmaße an.

Auch das Perl-Modul DBIx::FullTextSearch, das einen eigenen Index definiert und MySQL als Backend verwendet, hat seine Macken. Die Indizierung ist recht langsam und sobald mehr als 30.000 Dateien im Index sind, nimmt die Geschwindigkeit überproportional ab.

rummage setzt deswegen auf den schon in [3] bewährten Indizierer SWISH-E, der rasend schnell indiziert, nach Schlüsselworten und Phrasen sucht und problemlos nach oben skaliert. Das Modul SWISH::API::Common vom CPAN erleichtert die Kommunikation mit SWISH-E, indem es sich auf die am häufigsten genutzten Aspekte konzentriert. Allerdings kann SWISH-E keine Dateien aus einem einmal erstellten Index löschen, so dass man am besten jeden Tag alles neu indiziert. Während eines nächtlichen Cronjobs schafft es problemlos ein paar Hunderttausend Dateien, das dürfte für den Hausgebrauch reichen.

Pfade zum Ziel

Nach dem ersten erfolgreichen Indizierungslauf mit rummage -u (update) darf der Benutzer endlich auf die Metadaten und den Volltextindex zugreifen. Textdateien, die ein Schlüsselwort enthalten, fördert der Befehl rummage -k query zutage (-k steht für Keyword). Kasten 1 zeigt einige Beispiele.

Wie das Schema in Abbildung 1 zeigt, speichert die MySQL-Datenbank zu jeder Datei den vollständigen Pfad, die Größe in Bytes und die Zeitpunkte ihres ersten Auftauchens, des letzten Lesezugriffs und des Zeitpunkts der letzten Modifikation.

Eine Datei call.sgml, die sich irgendwo versteckt in den Tiefen der indizierten Hierarchie befindet, sucht ein Pfad-Match mit rummage -p call.sgml heraus. Aus call.sgml macht rummage intern das SQL-Pattern %call.sgml% und fragt die Tabelle file mit WHERE path LIKE "%call.sgml%" ab. Auch ein Teil-Pfad wie beispiele/call.sgml funktioniert, dann findet rummage die Datei nur, falls sie sich in einem Unterverzeichnis namens beispiele befindet.

Kasten: rummage-Kommandos

    rummage -u -v       # Datenbank auffrischen oder generieren;
                        # -v für detaillierte Statusausgaben
                        # in die Logdatei
    rummage -k 'linux'  # Schlüsselwortsuche nach "linux"
    rummage -k '"mike schilli"'   # Phrasensuche
    rummage -k 'foo AND (bar OR baz)' # Dokumente mit "foo" und "bar"
                                      # oder mit "foo" und "baz"
    rummage -k 'torvald*'             # Wildcard-Suche
    rummage -p teilpfad # Datei per Name oder Pfad aufstöbern
    rummage -n 20       # Die letzten 20 modifizierten Dateien zeigen
    rummage -m '7 day'  # Alle während der letzten Woche modifizierten Dateien

Die letzten 20 Dateien, die kürzlich modifiziert wurden, findet der Aufruf rummage -n 20. Ohne Angabe eines Integers kommen die Pfade der letzten 10 modifizierten Dateien zurück. Alle Dateien, die innerhalb der letzten Woche modifiziert wurden, zeigt der Aufruf rummage -m "7 day". Er generiert eine MySQL-Abfrage der Form

    SELECT * FROM file
    WHERE DATE_SUB(NOW(), 
      INTERVAL 7 DAY) <= mtime

und lässt MySQL für jeden Eintrag ausrechnen, ob das Ergebnis des MySQL-internen Datumsberechnung einen Zeitpunkt bestimmt, der vor dem Modifikationsstempel der untersuchten Datei liegt. ``3 month'' oder ``18 hour'' wären weitere Zeiträume, die MySQLs INTERVAL-Funktion unterstützt. Das alles geht natürlich nicht in Echt-Zeit, sondern bezieht sich auf den letzten Update der Datenbank, der normalerweise letzte Nacht stattfand. Alles was danach geschah, ist für rummage unsichtbar.

Die erste Sektion in Listing rummage sollte an die lokalen Bedürfnisse angepasst werden. Die Konstante $MAX_SIZE bestimmt die maximale Länge des indizierten Inhalts einer Textdatei. Auch wenn eine 100MB lange Logdatei mit Perls Operator -T in SWISH::API::Common als Text erkannt wird, soll sie wahrscheinlich nicht ungekürzt in den Index aufgenommen werden. Der Wert 100_000 bestimmt, dass nur die ersten 100KB indiziert werden. Eine Zeile weiter gibt der Data Source Name $DSN dem DBI-Class-Modul den Datenbanktreiber (mysql, also DBD::mysql) und den Namen der Datenbank (dts) bekannt. @DIRS schließlich ist ein Array von Verzeichnisnamen, die rummage rekursiv durchstöbert. Werden statt Verzeichnissen symbolische Links angegeben, löst Zeile 17 diese auf. Wem das Indizieren des gesamten Home-Verzeichnisses zu lange dauert, kann sich auf ein oder mehrere Unterverzeichnisse, zum Beispiel einen lokalen CVS-Workspace, beschränken.

Zeile 19 deklariert die Funktion psearch, die später Suchergebnisse der verschiedenen Abfragen ausgibt. Dies geschieht mit einem Prototyp, der festlegt, dass psearch den ersten und einzigen Parameter als Skalar erwartet. Das ist später wichtig, denn wenn die Ausgabe der DBI::Class-Methoden search() oder search_like() an psearch übergeben werden, sollen diese in skalarem Kontext stehen, denn nur so geben sie einen Iterator zurück, den psearch dann auswertet. Fiele der Prototyp weg, stünde die Methode search() in dem Ausdruck psearch($db->search(...)) im Array-Kontext -- und damit gäbe die search()-Methode des Moduls DBI::Class per ihrer Definition eine Liste von Treffern zurück und keinen Iterator.

getopts() analysiert die übergebenen Kommandozeilenparameter. Falls sich herausstellt, dass mit -u ein Update der Datenbank gefordert wurde, schaltet Zeile 23 das Log4perl-Framework ein. Wurden mit -v (verbose) ausführliche Meldungen verlangt, wird der Level auf $DEBUG gesetzt, sonst kommen mit $INFO nur die wichtigsten Meldungen zur Logdatei durch. Die Logdatei wird mit jedem Lauf neu überschrieben, damit sie nicht auf Dauer die Platte auffüllt. Eine Alternative wäre eine Log4perl-Konfiguration mit Log::Dispatch::FileRotate.

db_init() in Zeile 29 ruft die gleichnamige Funktion in Zeile 144 auf, die die Datenbank mit der Tabelle file initialisiert, falls dies noch nicht geschehen ist. Außerdem definiert sie einen Index auf die Spalte path, damit rummage später blitzschnell nachsehen kann, ob ein Eintrag für eine Datei schon existiert oder nicht, und ob sich der Zeitstempel geändert hat. So kann der allererste Suchlauf von rummage nach der Installation einige Zeit dauern, aber darauffolgende Auffrischläufe gehen deutlich schneller.

Aus dem Datenbankschema wiederum generiert Class::DBI::Loader in Zeile 31 die objektorientierte Repräsentation der Datenbank für Class::DBI. Unter dem Deckmantel der Klasse Rummage::File wird ab dann objektorientiert auf die Tabelle file zugegriffen.

Ab Zeile 44 arbeitet rummage Kommandozeilenparameter für Suchabfragen ab. Liegt ein Ergebnis als Iterator vor, wird es mit psearch() ausgegeben, das einfach solange ->next() aufruft, bis der Iterator keine Ergebnisobjekte mehr liefert. Die Methode path() der Ergebnisobjekte kitzelt dort den Dateipfad jedes Treffers hervor, die Methode mtime() den letzten Modifikationszeitpunkt des Eintrags.

Nicht alle Abfragen lassen sich bequem mit der Abstraktion von Class::DBI erledigen. Wird's zu kompliziert, kann man aber mit Class::DBI wieder auf die SQL-Ebene hinabsteigen. Die Methode set_sql definiert dann eine z.B. newest benannte Abfrage, die dann nachfolgend als search_newest() in der Class::DBI- Abstraktion vorliegt.

Frisch in Schwung

Bekommt rummage den Parameter -u hereingereicht, verlangt der Benutzer danach, das Dateisystem mit File::Find zu durchstöbern und die Datenbank mit den neuesten Metainformationen zu aufzufrischen. Hierzu setzt der in Zeile 79 definierte und in Zeile 83 abgefeuerte UPDATE-Befehl zunächst die checked- Spaltenwerte aller Tabelleneinträge auf 0. Findet die Stöberfunktion einen Eintrag tatsächlich im Dateisystem, gilt dieser als verifiziert und die checked-Spalte des Eintrags wird auf 1 gesetzt. Verbleiben nach dem Durchlauf einige Einträge mit checked=0, sind diese offensichtlich seit dem letzten Lauf aus dem Dateisystem verschwunden und müssen aus der Datenbank gelöscht und aus dem Index entfernt werden.

Zeile 84 startet die Funktion find, die in den angegebenen Verzeichnissen zu suchen beginnt und sich stetig durch die Dateihierarchie nach unten arbeitet. Für jeden gefundenen Eintrag wird die ab Zeile 104 definierte Funktion wanted aufgerufen. Zeile 106 dort weist alles, was nicht wie eine Datei aussieht, sofort zurück. Das Kommando stat in Zeile 112 ermittelt die Größe der Datei in Bytes, und den Zeitpunkt ihres letzten Auslesens und Schreibens.

Falls ein dem Pfad entsprechender Eintrag in der Datenbank existiert, prüft Zeile 120, ob der Zeitpunkt der letzten Modifikation mit dem in der Datenbank gespeicherten Wert übereinstimmt. Ist das nicht der Fall, frischen die Zeilen 124 bis 126 die Metainformationen (mtime, atime, size) des Eintrags auf. Falls die Datei noch nicht in der Datenbank ist, kreiert die create-Methode in Zeile 129 einen neuen Eintrag.

Zwischen Zeitformaten

MySQL erwartet für seine DATETIME-Felder das Datum im Format ``YYYY-MM-DD HH:MM:SS'', doch Perls stat-Befehl liefert die Unix-Zeit in Sekunden. Das Modul Time::Piece::MySQL vom CPAN stellt die Methode mysql_datetime bereit. Die ab Zeile 188 in rummage definierte Funktion mysqltime kürzt den Aufruf ab. Eine weitere Möglichkeit wäre die in MySQL eingebaute Funktion FROM_UNIXTIME(), aber dafür müsste rummage die DBI::Class-Abstraktion erneut durchbrechen.

Leichen und Speicherfresser

rummage lässt sich noch beliebig erweitern. Mit den bereits eingespeisten Metadaten untersuchter Dateien kann der Benutzer mit dem Clientprogramm mysql schon herumspielen, bevor rummage mit weiteren DBI::Class-gestützten Abfragen intelligenter gemacht wird.

Auch die DBI-Shell dbish vom CPAN leistet gute Dienste: Sie dockt an beliebige von DBI unterstützte Datenbanken an und erlaubt SQL-Abfragen. Sie wird mit dem Module DBI::Shell vom CPAN installiert und für eine MySQL-Datenbank wie folgt aufgerufen:

    dbish dbi:mysql:<TABLE> user password

Abbildung 2 zeigt sie in Aktion: Eine SQL-Abfrage nach den zehn größten Speicherfressern a la

    SELECT path, size FROM file 
    ORDER BY size DESC LIMIT 10;

zieht die Festplattenschmarotzer an den Ohren hervor.

Abbildung 2: Die größten Speicherfresser per MySQL-Abfrage

Die 10 ältesten Dateileichen, die schon seit Ewigkeiten nicht mehr ausgelesen wurden, fördert dieser SQL-Ausdruck zutage:

    SELECT path, atime FROM file 
    ORDER BY atime ASC LIMIT 10;

Textdateien laufen allerdings täglich durch den Indizierer. Wenn das Dateisystem nicht gerade mit der Option noatime gemountet wurde, wäre die letzte Access-Zeit einer Textdatei deswegen nie älter als ein Tag. Deswegen setzt der Konstruktor SWISH::API::Common->new() den Parameter atime_preserve, der das Modul zum Mogeln veranlasst, indem es den Zugriffsstempel jeder indizierten Datei nach dem Auslesen wieder auf den ursprünglichen Wert zurücksetzt.

Listing 1: rummage

    001 #!/usr/bin/perl -w
    002 use strict;
    003 
    004 use Getopt::Std;
    005 use File::Find;
    006 use DBI;
    007 use Class::DBI::Loader;
    008 use Log::Log4perl qw(:easy);
    009 use SWISH::API::Common;
    010 use Time::Piece::MySQL;
    011 
    012 my $MAX_SIZE   = 100_000;
    013 my $DSN        = "dbi:mysql:dts";
    014 my @DIRS       = ("$ENV{HOME}");
    015 my $COUNTER    = 0;
    016 
    017 @DIRS = map { -l $_ ? readlink $_ : $_ } @DIRS;
    018 
    019 sub psearch($);
    020 getopts("un:m:k:p:v", \my %opts);
    021 
    022 if($opts{u}) {
    023   Log::Log4perl->easy_init({
    024     level => $opts{v} ? $DEBUG : $INFO, 
    025     file  => ">/tmp/rummage.log",
    026   });
    027 }
    028 
    029 db_init($DSN);
    030 
    031 my $loader = Class::DBI::Loader->new(
    032  dsn        => $DSN,
    033  user       => "root",
    034  namespace  => "Rummage",
    035 );
    036 
    037 my $filedb = $loader->find_class("file");
    038 
    039 my $swish = SWISH::API::Common->new(
    040     file_len_max   => $MAX_SIZE,
    041     atime_preserve => 1,
    042 );
    043   # Keyword search
    044 if($opts{k}) {
    045   my @docs = $swish->search($opts{k});
    046   print $_->path(), "\n" for @docs;
    047 
    048   # Search by mtime
    049 } elsif($opts{m}) {
    050   $filedb->set_sql(modified => qq{
    051     SELECT __ESSENTIAL__
    052     FROM __TABLE__
    053     WHERE DATE_SUB(NOW(), 
    054             INTERVAL $opts{m}) <= mtime
    055   });
    056   psearch($filedb->search_modified());
    057 
    058   # Search by path
    059 } elsif($opts{p}) {
    060   psearch($filedb->search_like(
    061                 path => "%$opts{p}%"));
    062 
    063   # Search newest
    064 } elsif(exists $opts{n}) {
    065   $opts{n} = 10 unless $opts{n};
    066 
    067   $filedb->set_sql(newest => qq{
    068     SELECT __ESSENTIAL__
    069     FROM __TABLE__
    070     ORDER BY mtime DESC
    071     LIMIT $opts{n}
    072   });
    073 
    074   psearch($filedb->search_newest());
    075 
    076     # Index Home Directory
    077 } elsif($opts{u}) {
    078     # Set all documents unchecked
    079   $filedb->set_sql("uncheck_all", qq{
    080     UPDATE __TABLE__ 
    081     SET checked=0
    082   });
    083   $filedb->sql_uncheck_all()->execute();
    084   find(\&wanted, @DIRS);
    085 
    086       # Update keyword index
    087   $swish->index_remove();
    088   $swish->index(@DIRS);
    089 
    090     # Delete all dead documents in the DB
    091   $filedb->set_sql("delete_dead", qq{
    092     DELETE FROM __TABLE__ 
    093     WHERE checked=0
    094   });
    095   $filedb->sql_delete_dead()->execute();
    096 
    097 } else {
    098   LOGDIE "usage: $0 [-u] [-v] [-n [N]] ",
    099          "[-p pathlike] [-k keyword] ",
    100          "[-m interval]";
    101 }
    102 
    103 ###########################################
    104 sub wanted {
    105 ###########################################
    106   return unless -f;
    107 
    108   DEBUG ++$COUNTER, 
    109         " $File::Find::name";
    110 
    111   my($size,$atime,$mtime) = 
    112                          (stat($_))[7,8,9];
    113   $atime = mysqltime($atime);
    114   $mtime = mysqltime($mtime);
    115 
    116   my $entry;
    117 
    118   if(($entry) = $filedb->search(
    119               path => $File::Find::name)) {
    120     if($entry->mtime() eq $mtime) {
    121       DEBUG "$File::Find::name unchanged";
    122     } else {
    123       INFO "$File::Find::name changed";
    124       $entry->mtime($mtime);
    125       $entry->size($size);
    126       $entry->atime($atime);
    127     }
    128   } else {
    129     $entry = $filedb->create({
    130       path       => $File::Find::name,
    131       mtime      => $mtime,
    132       atime      => $atime,
    133       size       => $size,
    134       first_seen => mysqltime(time()),
    135     });
    136   }
    137 
    138   $entry->checked(1);
    139   $entry->update();
    140   return;
    141 }
    142 
    143 ###########################################
    144 sub db_init {
    145 ###########################################
    146   my($dsn) = @_;
    147 
    148   my $dbh = DBI->connect($dsn, "root", 
    149             "", { PrintError => 0 });
    150 
    151   LOGDIE "Connecting to DB failed: ", 
    152          DBI::errstr unless $dbh;
    153 
    154   if(! $dbh->do(q{select * from 
    155                   file limit 1})) {
    156     $dbh->do(q{
    157       CREATE TABLE file (
    158         fileid     INTEGER
    159                    PRIMARY KEY 
    160                    AUTO_INCREMENT,
    161         path       VARCHAR(255),
    162         size       INTEGER,
    163         mtime      DATETIME,
    164         atime      DATETIME,
    165         first_seen DATETIME,
    166         type       VARCHAR(255),
    167         checked    INTEGER
    168     )}) or LOGDIE "Cannot create table";
    169 
    170     $dbh->do(q{
    171       CREATE INDEX file_idx ON file (path)
    172     });
    173   }
    174 }
    175 
    176 ###########################################
    177 sub psearch($) {
    178 ###########################################
    179   my($it) = @_;
    180 
    181   while(my $doc = $it->next()) {
    182     print $doc->path(), 
    183     " (", $doc->mtime(), ")", "\n";
    184   }
    185 }
    186 
    187 ###########################################
    188 sub mysqltime {
    189 ###########################################
    190   my($time) = @_;
    191   return Time::Piece
    192          ->new($time)->mysql_datetime();
    193 }

Installation

Die Installation der notwendigen Perl-Module sollte ohne großes Tohuwabohu mit einer CPAN-Shell zu schaffen sein. Das Anlegen der Datenbank dts in MySQL erledigt die Utilty mysqladin:

    mysqladmin --user=root create dts

Das Anlegen der Tabellen erledigt rummage selbständig. Ein Cronjob ruft rummage einmal täglich um 3:05 auf:

    05 03 * * * LD_LIBRARY_PATH=/usr/local/lib /home/mschilli/bin/rummage -u -v >/dev/null 2>&1

Die MySQL-Datenbank ist normalerweise in allen Linux-Distributionen enthalten. Falls nicht, steht die stabile Version 4.1 auf mysql.com kostenlos zum Download bereit.

Der Indizierer swish-e und das Modul SWISH::API sind unter swish-e.org erhältlich. SWISH::API::Common vom CPAN versucht, beide automatisch zu installieren. Falls das nicht klappt, sollte swish-e 2.4.3 oder besser runtergeladen und mit ./configure; make install installiert werden. Das Modul SWISH::API liegt der Distribution bei, die Befehlsfolge

    cd perl
    LD_RUN_PATH=/usr/local/lib perl Makefile.PL
    make install

wird es installieren.

Infos

[1]
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2005/09/Perl

[2]
Google Desktop Search, http://desktop.google.com

[3]
``Perlensuche'', Michael Schilli, Indizieren und Volltextsuche in Dokumentationen mit Swish-E, http://www.linux-magazin.de/Artikel/ausgabe/2003/10/perl/perl.html

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.