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. |
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.
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.
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.
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.
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.
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 }
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.
Michael Schilliarbeitet 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. |