Aus Linux-Magazin 09/2005

Desktop-Suche mit Perl-Skript

Das Kommandozeilentool »find« hangelt sich nur sehr gemächlich durchs Dateisystem. Dagegen lokalisiert das hier vorgestellte Skript »rummage« dank einer Datenbank mit Meta-Informationen blitzschnell selbst entlegene Dateien.

Wo ist das Skript, das ich gestern zusammenklopft habe? Welche Dateien sind neu, brauchen den meisten Platz oder wurden seit drei Jahren nicht mehr angefasst? Dateisysteme lassen sich natürlich schrittweise durchforsten und so die Antworten auf solche Fragen finden. Spottbillige Riesenfestplatten haben allerdings in den letzten Jahren dafür gesorgt, dass Anwender ihr Homeverzeichnis nicht regelmäßig ausmisten und Find & Co. oft zehn- oder gar hunderttausend unnütze Einträge abwandern müssen. Das dauert.

Utilities wie »slocate« durchforsten den Dateibaum bei Nacht, legen dabei einen Index an und erlauben es dem Anwender, die indizierten Dateien anschließend schnell über ihren Namen zu finden. Der Google-Desktop [2] und Spotlight unter Mac OS X setzen sogar noch eins drauf: Sie indizieren zusätzlich verschiedene Metadaten und gestatten es dem Benutzer damit, Dateien anhand von Merkmalen wie Typ, Größe, Name oder Alter wiederzufinden.

Das hier vorgestellte Skript »rummage« implementiert eine Desk-topsuche in Perl. Es achtet nicht nur auf Dateinamen, es merkt sich au-ßerdem, wann Dateien zum ersten Mal auftauchten und wann sie zum letzten Mal geändert wurden. Es legt eine Reihe von Meta-Informationen über jede gefundene Datei in einer MySQL-Datenbank ab (Abbildung 1). Von Textdateien bildet es einen Volltextindex, damit der Benutzer später per Keyword-Suche ihren Inhalt durchstöbern kann.

Volltext auf Abwegen

Seit der Version 3.23.23 bietet MySQL eine Option, die Tabellenspalten markiert, deren Inhalt später per Volltextsuche zugänglich sein soll. Mit 4.0.1 kamen boolesche Verknüpfungen der Suchausdrücke hinzu. Der Anwender kann sogar Stopplisten anlegen, mit denen er häufige, aber nicht sinntragende Wörter von der Indizierung ausschließt. Außerdem beherrscht die Datenbank Query-Expansion, kann also in Dokumenten suchen, die sie ihrerseits erst im Ergebnis einer Abfrage fand. Allerdings lässt die Query-Geschwindigkeit bei vielen Dateien stark zu wünschen übrig. Und da jedes Volltextdokument 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 Macken. Die Indizierung ist recht langsam und sobald mehr als 30000 Dateien im Index sind, nimmt die Geschwindigkeit überproportional ab. Rummage setzt daher auf den bewährten Indizierer Swish-E [3], der rasend schnell arbeitet, nach Schlüsselworten und Phrasen sucht und problemlos 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, sodass man am besten jeden Tag alles neu indiziert. Während eines nächtlichen Cronjobs schafft der Indizierer problemlos ein paar hunderttausend Dateien, das dürfte für den Hausgebrauch reichen. Zum Beispiel: »05 03 * * * LD_LIBRARY_PATH=/usr/local/lib /home/mschilli/bin/rummage -u -.v /dev/null 2>&1«

Nach dem ersten Indizierungslauf mit »rummage -u« (für Update) darf der Benutzer endlich auf die Metadaten und den Volltextindex zugreifen. Textdateien, die ein Schlüsselwort enthalten, zeigt der Befehl »rummage -k query« an (»-k« steht für Keyword). Der Kasten “Rummage-Kommandos” enthält 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 der letzten Modifikation.

Eine Datei »call.sgml«, die sich irgendwo versteckt in den Tiefen der indizierten Hierarchie befindet, ist per Pfad-Match mit »rummage -p call.sgml« zu finden. 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 Teilpfad wie »Beispiele/call.sgml« funktioniert, dann entdeckt »rummage« die Datei nur, falls sie in einem Unterverzeichnis namens »Beispiele« liegt.

Die letzten 20 Dateien, die kürzlich modifiziert wurden, findet der Aufruf »rummage -n 20«. Ohne Angabe eines Integers kommen die Pfade der letzten zehn modifizierten Dateien zurück. Alle innerhalb der letzten Woche geänderten Dateien, 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

MySQL rechnet hier für jeden Eintrag aus, ob das Änderungsdatum länger als sieben Tage zurückliegt. Bei Bedarf lässt sich die Anzahl Tage in dem Ausdruck auch gegen »3 month« oder »18 hour« austauschen. Das alles geht natürlich nicht in Echtzeit, sondern bezieht sich auf das letzte Update der Datenbank in der Nacht zuvor. Alles was danach geschah, ist für »rummage« unsichtbar.

Der erste Abschnitt in Listing 1 sollte an die individuellen Bedürfnisse angepasst werden. Die Konstante »$MAX_SIZE« bestimmt die maximale Länge des indizierten Inhalts einer Textdatei. So lässt sich beispielsweise von Logfiles nur der Anfang im Index berücksichtigen. Der Wert »100000« bestimmt in diesem Beispiel, dass das Tool nur die ersten 100 KByte indiziert.

Eine Zeile weiter nennt der Data Source Name »$DSN« dem DBI-Class-Modul den Datenbanktreiber und den Namen der Datenbank (»dts«). Schließlich ist »@DIRS« ein Array von Verzeichnisnamen, die »rummage« rekursiv durchstöbert. Gibt der Benutzer statt Verzeichnissen symbolische Links an, löst Zeile 17 diese auf. Wenn das Indizieren des Homeverzeichnisses zu lange dauert, ist die Beschränkung auf ein oder mehrere Unterverzeichnisse möglich, etwa auf einen lokalen CVS-Workspace.

Rummage-Kommandos

Datenbank auffrischen oder generieren:rummage -u -v Schlüsselwortsuche nach »linux«:rummage -k \’linux\’ Phrasensuche: rummage -k \'”mike schilli”\’ »foo« und »bar« oder »baz« finden:rummage -k \’foo AND (bar OR baz)\’ Wildcard-Suche: rummage -k \’torvald*\’ Datei per Name oder Pfad aufstöbern:rummage -p Teilpfad Die letzten 20 modifizierten Dateien:rummage -n 20 Vergangene Woche modifizierte Dateien:rummage -m \’7 day\’

Unverzichtbarer Prototyp

Die Zeile 19 deklariert die Funktion »psearch()«, die später Suchergebnisse der verschiedenen Abfragen ausgibt. Dies geschieht mit Hilfe eines Prototyps, der festlegt, dass »psearch()« den ersten und einzigen Parameter als Skalar erwartet. Das ist wichtig, denn die Ausgabe der DBI::Class-Methoden »search()« oder »search_like()« an »psearch()« sollen in skalarem Kontext stehen, weil sie nur so einen Iterator zurückgeben, den »psearch()« 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 nach 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 ist, 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 wichtige Meldungen durch. Die Logdatei wird mit jedem Lauf neu überschrieben, damit sie nicht auf Dauer die Platte füllt. Eine Alternative wäre eine Log4perl-Konfiguration mit »Log::Dispatch::FileRotate«.

In Zeile 29 ruft »db_init()« die gleichnamige Funktion in Zeile 144 auf, die die Datenbank mit der Tabelle »file« initialisiert, sofern noch nicht geschehen. 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 erste Suchlauf von »rummage« nach der Installation einige Zeit dauern. Darauf folgende Auffrischläufe gehen deutlich schneller.

Objektorientierte Abfragen

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. Liegt ein Ergebnis als Iterator vor, gibt es »psearch()« aus, das einfach so lange »->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.

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 Meta-Informationen aufzufrischen. Hierzu setzt der in Zeile 79 definierte »UPDATE«-Befehl zunächst die »checked«-Spaltenwerte aller Tabelleneinträge auf »0«. Findet die Stöberfunktion einen Eintrag im Dateisystem, gilt dieser als verifiziert und die »checked«-Spalte wird auf »1« gesetzt. Verbleiben nach dem Durchlauf einige Einträge mit »checked=0«, sind diese offensichtlich in der Zwischenzeit verschwunden und müssen aus der Datenbank und dem Index gelöscht werden.

Zeile 84 startet die Funktion »find()«, die in den angegebenen Verzeichnissen zu suchen beginnt und sich stetig durch die Dateihierarchie arbeitet. Für jeden gefundenen Eintrag ruft »find()« die ab Zeile 104 definierte Funktion »wanted()« auf, Zeile 106 weist alles, was nicht wie eine Datei aussieht, sofort zurück. Das Kommando »stat« in Zeile 112 ermittelt zusätzlich noch Größe und Zeitstempel der Datei.

Ist ein dem Pfad entsprechender Eintrag in der Datenbank vorhanden, 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 Meta-Informationen (»mtime«, »atime«, »size«) des Eintrags auf. Falls die Datei noch nicht in der Datenbank steht, sorgt die »create«-Methode in Zeile 129 für einen neuen Eintrag.

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.

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 }

Speicherfresser

Rummage lässt sich noch 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 Modul DBI::Shell installiert und für eine MySQL-Datenbank folgendermaßen aufgerufen: »dbish dbi:mysql:dts User Password«. Die SQL-Abfrage

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

nach den zehn größten Speicherfressern (Abbildung 2) holt diese Verschwender ans Tageslicht.

Die zehn ältesten Dateileichen, die schon seit Ewigkeiten nicht mehr ausgelesen wurden, identifiziert dieser SQL-Ausdruck:

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

Textdateien laufen allerdings täglich durch den Indizierer. Die letzte Access-Zeit einer Textdatei wäre folglich nie älter als einen Tag. Aus diesem Grund setzt der Konstruktor »SWISH::API::Common->new()« den Parameter »atime_preserve«, der das Modul zum Mogeln veranlasst. Nun setzt es den Zugriffsstempel jeder indizierten Datei nach dem Auslesen wieder auf den zunächst vorgefundenen Wert zurück.

Installation

Die Installation der notwendigen Perl-Module sollte ohne Schwierigkeiten zu schaffen sein. Das Anlegen der Datenbank »dts« in MySQL erledigt das Utility »mysqladmin« mit dem Befehl »mysqladmin –user=root create dts«. Die Tabellen erstellt »rummage« ohne Hilfe des Benutzers selbstständig.

Der Indizierer Swish-E und das Modul SWISH::API sind bei Swish-E.org erhältlich. SWISH::API::Common vom CPAN versucht alle beide automatisch zu installieren. Falls das nicht klappt, sollte sich der Benutzer Swish-E 2.4.3 oder neuer runterladen und mit »./configure; make install« installieren. Das Modul SWISH::API liegt der Distribution bei, die Befehlsfolge

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

installiert die Swish-Schnittstelle auf dem Rechner. (jcb)

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] Michael Schilli, “Perlensuche”: https://www.linux-magazin.de/Artikel/ausgabe/2003/10/perl/perl.html]

Der Autor


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]

Copyright © 2005 Linux New Media AG

LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben