Was waren das noch für Zeiten, als man einfach eine Codezeile oder einen ungewöhnlichen Variablennamen in Googles "Codesearch"-Seite (Abbildung 1) eingeben konnte, und die Datenkrake ruckzuck ausspie, in welchen Open-Source-Repositories letztere auftauchten? Leider hat Google den Service vor etlichen Jahren wohl mangels Profit eingestellt (offizielle Begründung: "Focus on higher impact products"), aber noch ist nicht alles verloren, denn das Github-Projekt "Codesearch" [2] mit seinem in Go gebauten Indexer erlaubt es zumindest, lokal vorliegende Repositories zu durchstöbern, zu indizieren und anschließend blitzschnell nach Codestücken zu durchsuchen. An anderer Stelle erklärt Autor Russ Cox, damals Praktikant bei Google, wie die Suche funktionert ([3]).
Wie wäre es, nach einem ähnlichen Verfahren einen Index der Dateien unterhalb eines Einstiegsverzeichnisses anzulegen, um daraufhin blitzschnell ausgeführte Abfragen wie "was sind die zehn größten Platzverschwender", "welche Dateien wurden kürzlich modifiziert" oder "welche Dateinamen passen auf folgendes Muster" zuzulassen?
Unix-Dateisysteme speichern diese Metadaten in den Inodes, die aber in ausgeflachten Strukturen auf der Platte liegen, die Datenbank-ähnliche Abfragen nur im Schneckentempo zulassen. Der Befehl stat
auf eine Datei enthüllt, dass das Dateisystem die Dateigröße speichert, sowie diverse Zeitstempel wie den Zeitpunkt der letzten Modifizierung (Abbildung 2). Neuere Dateisystem wie ZFS
oder btrfs
gehen zwar in der Organisation der darin enthaltenen Dateien in Richtung Datenbank, aber eben nicht weit genug, damit es aus dem Userland möglich wäre, Queries mit Schmackes darauf abzusetzen.
Abbildung 1: So sah anno 2007 die Seite von "Google Code Search" aus (Quelle: archive.org) |
Wer zum Beispiel alle Dateien über 100M auf der Platte aufstöbern möchte, kann dies mit einem find
-Aufruf wie
$ find / -type f -size +100M
tun, kann aber auf einer traditionellen Festplatte gleich in die Kaffeepause gehen und sich selbst auf einer schnellen SSD auf lange Suchzeiten gefasst machen, einfach aus dem Grund, weil die Daten sehr abfrageunfreundlich auf den Sektoren der Platte verstreut vorliegen.
Abbildung 2: Mit stat ermittelte Inode-Metadaten einer Datei zum Aufbau eines Indexes zur späteren Abfrage. |
Um dies abzukürzen, soll ein Indexer in regelmäßigen Abständen, zum Beispiel während ruhiger Phasen in den frühen Morgenstunden die Metadaten einholen und in einer SQLite-Datenbank ablegen, damit ein Abfragetool daruf blitzschnell herumorgeln kann. Die Utiltiy updatedb
auf Linux tut ähnliches, damit der User mit locate
blitzschnell nach Dateinamen suchen kann.
Und weil der Programmiersnapshot niemals vor neuen Herausforderungen zurückschreckt (und er während eines Hawaiiurlaubes mit viel Zeit und guter Laune entstanden ist), erfolgt die Implementierung in der Programmiersprache Go, ganz genau wie das Codesearch-Projekt. Als Index kommt eine SQLite-Datenbank zum Einsatz, die zwar nur eine Datei belegt, aber optimierte Queries auf selbst mittelgroße Datenmengen zulässt.
Dazu legt Listing 1 eine SQLite-Datenbank namens files.db
an, die zu jeder gefundenen Datei im Verzeichnisbaum einen Tabelleneintrag mit den Feldern path
(Vollständiger Dateipfad), size
(Dateigröße) und modified
(Zeitstempel der letzten Modifizierung) einfügt.
01 package main 02 03 import ( 04 "database/sql" 05 _ "github.com/mattn/go-sqlite3" 06 "os" 07 ) 08 09 func main() { 10 11 file := ".findex.db" 12 os.Remove(file) 13 14 db, err := sql.Open("sqlite3", file) 15 if err != nil { 16 panic(err) 17 } 18 19 _, err = db.Exec("CREATE TABLE files " + 20 "(path text, modified int, size int)") 21 if err != nil { 22 panic(err) 23 } 24 }
Um den notwendigen SQLite-Treiber zu installieren, holt das Bordmittel go get
den Source-Code von Github ab, löst etwaige Abhängigkeiten auf und kompiliert und installiert das Ergebnis lokal:
$ go get github.com/mattn/go-sqlite3
Anschließend dürfen Skripts wie Listing 1 den Treiber in ihrer import
-Sektion unter dem vollen Namen laden, der Unterstrich in Zeile 5 dient dazu, den ultrastrengen Go-Compiler vor der Explosion zu bewahren, weil ein Paket importiert wurde, das offenbar nirgendwo im Code referenziert wird. Go duldet keine überflüssigen Abhängigkeiten, im vorliegenden Fall ist der Treiber jedoch notwendig.
Der Anfang jedes Go-Programms steht in einem package main
in einer Funktion main
, so auch in Listing 1 ab Zeile 8. Dort öffnet die Methode Open()
über Gos Standard-Datenbank-Interface database/sql
(und unter der Haube mit dem SQLite-Treiber) eine Verbindung zur Dateidatenbank files.db
, legt letztere also erstmals an. Falls sie schon existiert oder ein anderer Fehler auftritt, enthält der Rückgabewert err
einen Wert ungleich nil
und Zeile 11 schlägt mit panic()
Alarm und bricht den Programmfluss ruckartig ab.
Der nachfolgend in Zeile 14 abgesetzte SQL-Befehl create
legt eine neue Datenbanktabelle an, und auch hier prüft Zeile 16, ob ein Fehler zurück kam und bricht notfalls mit einer Meldung ab. Go glaubt noch an die alte Schule der individuellen Prüfung von Rückgabewerten, statt dass es dem Programmierer erlaubt, Exceptions zu werfen, hochblubbern zu lassen und irgendwo weiter oben zu verarbeiten. Noch ein Wort zu den verschiedenen Zuweisungen in Zeile 9 und Zeile 14, die erst mit ":=" und dann mit "=" erfolgt. Das erste ist Gos Zuweisung mit Deklaration, ist eine Variable bislang noch nicht deklariert, erledigt die Kurzform mit ":=" dies.
Aber wehe, wenn in der Deklarations-Liste der Variablen nur bereits deklarierte Variablen stehen, dann weigert sich Go stur, den Code überhaupt zu kompilieren. In diesem Fall muss die Zuweisung wie in Zeile 14 mit "=" erfolgen. Ebenfalls ein Go-Unikum sind Funktionen mit mehreren Rückgabewerten. Oft ist in der Liste der Werte eine Error-Variable enthalten, und ist sie auf nil
gesetzt, ging alles gut. Interessiert ein zurückkommender Wert nicht, wie zum Beispiel der erste Rückgabewert von db.Exec() in Zeile 16, schreibt der Go-Apostel einen Unterstrich an die Stelle einer überflüssigen Variablen. Von einer Funktion, die zwei Rückgabewerte liefert nur einen abzuholen hätte einen Compile-Fehler zur Folge.
Abbildung 3: Nach dem Lauf des Indexers liegen mehr als eine Million Dateieinträge in der SQLite-Datenbank. |
Compiliert wird der Code mittels
$ go build create.go
und einen Sekundenbruchteil später steht ein Executable create
zur Verfügung, das alle Abhängigkeiten und Libraries bereits enthält, also ohne weiteres auf einen weiteren Rechner mit dem gleichen Betriebssystem kopiert werden kann, ohne dass ein Rattenschwanz von Abhängigkeiten folgen müsste. Das entstehende Binary ist mit etwa 4.5MB allerdings auch nicht gerade ein Leichtgewicht.
01 package main 02 03 import ( 04 "database/sql" 05 _ "github.com/mattn/go-sqlite3" 06 "os" 07 "path/filepath" 08 ) 09 10 type Walker struct { 11 Db *sql.DB 12 } 13 14 func main() { 15 if len(os.Args) != 2 { 16 panic("usage: " + os.Args[0] + 17 " start_dir") 18 } 19 root := os.Args[1] 20 21 db, err := 22 sql.Open("sqlite3", ".findex.db") 23 24 w := &Walker{ 25 Db: db, 26 } 27 28 err = filepath.Walk(root, w.Visit) 29 checkErr(err) 30 31 db.Close() 32 } 33 34 func (w *Walker) Visit(path string, 35 f os.FileInfo, err error) error { 36 stmt, err := w.Db.Prepare( 37 "INSERT INTO files VALUES(?,?,?)") 38 checkErr(err) 39 40 _, err = stmt.Exec( 41 path, f.ModTime().Unix(), f.Size()) 42 checkErr(err) 43 44 return nil 45 } 46 47 func checkErr(err error) { 48 if err != nil { 49 panic(err) 50 } 51 }
Listing 2 zeigt den Indexer, der sich mittels der Walk()
-Methode des Standardpakets path/filepath
durch eine Dateihierarchie wühlt, beginnend mit dem Startverzeichnis, das der User auf der Kommandozeile angegeben hat. Dem Progarmm übergebene Argumente liegen im Array os.Args
, und zwar wie in C mit dem Programmnamen als erstem Element und allen Aufrufparametern in den folgenden. Den Dateibaum zu durchstöbern wäre nicht weiter schwierig, doch Go kommuniziert in Zeile 28 mit diesem Wanderer über eine Callback-Funktion Visit()
. Das Problem ist hier, dass kein Datenbank-Handle innerhalb des Scopes dieses Callbacks ab Zeile 34 existiert, auf das diese zugreifen und die notwendigen Änderungen an der Datenbank vornehmen könnte. Die Lösung des Dilemmas liegt in einer Closure zur Funktion Visit()
. Dazu definiert Visit()
in Zeile 34 zwischen dem Schlüsselwort func
und dem Funktionsnamen einen sogenannten Receiver
und weist Go somit an, die Datenstruktur Walker
(Zeile 10), die ein Datenbankhandle enthählt, mit der Funktion Visit()
zu verbandeln. Auf diese Weise kann Visit()
über die Variable w
auf das Db-Handle zugreifen und damit neue Records in die Datenbank einfügen.
Die eigentliche Arbeit beim Kommunizieren mit der Datenbank übernimmt dabei die Methode Prepare()
, die einen SQL-Befehl vorbereitet und ein Statement-Handle zurückgibt, worauf Zeile 40 auf letzteres die Methode Exec
abfeuert und dem SQL-Kommando die Parameter für den Pfad der Datei, den Zeitstempel ihrer letzten Modifizierung und ihre Größe in Bytes übergibt.
Damit das Programm nicht nach jedem Funktionsaufruf prüfen muss, ob die Fehlervariable err
ja den Wert nil
hat und demnach alles in trockenen Tüchern ist, definiert Zeile 47 eine Funktion checkErr
, die dies erledigt, und das Programm mit panic
abbricht, falls etwas unvorhergesehenes passiert ist.
Nachdem der Indexer seine Arbeit beendet hatte, lagen auf meinem Rechner, wie Abbildung 2 zeigt, mehr als eine Million Einträge in der Datebanktabelle files
. Grund für die recht hohe Anzahl von Dateien waren wohl zahlreiche geklonte Git-Repositories, sowie mehr als 20 Jahre Programmier-Artikel der Snapshot-Reihe. Mit diesen Daten in der SQLite-Datenbank files.db
kann nun ein Client schnell Queries abfeuern und zum Beispiel feststellen, welche Dateien unter meinem Home-Verzeichnis sich kürzlich verändert haben.
01 package main 02 03 import ( 04 "database/sql" 05 "fmt" 06 _ "github.com/mattn/go-sqlite3" 07 ) 08 09 func main() { 10 db, err := 11 sql.Open("sqlite3", ".findex.db") 12 checkErr(err) 13 14 rows, err := db.Query("SELECT path, " + 15 "modified FROM files " + 16 "ORDER BY modified DESC LIMIT 10") 17 checkErr(err) 18 19 var path string 20 var mtime string 21 22 for rows.Next() { 23 err = rows.Scan(&path, &mtime) 24 checkErr(err) 25 fmt.Printf("%s %s\n", path, mtime) 26 } 27 } 28 29 func checkErr(err error) { 30 if err != nil { 31 panic(err) 32 } 33 }
Hierzu verbindet sich Listing 3 mit der SQLite-Datenbank und setzt einen Select-Befehl ab, der alle Zeilen der Tabelle selektiert, absteigend nach dem Zeitstempel in der Spalte modified
sortiert und die ersten zehn Treffer ausgibt.
Die Methode rows.Next()
arbeitet sich dabei Schrittweise durch die Treffer, rows.Scan()
holt die ersten zwei Spaltenwerte ein und weist sie den als Pointer übergebenen Variablen path
und mtime
zu, beide wurden vorher als String deklariert. Go unterstützt Pointer, doch nimmt es dem User das Memory-Management ab und stürzt nicht wie C ins Bodenlose wenn eine Adresse wegen eines Bugs mal nicht stimmt, sondern bricht mit einer hilfreichen Fehlermeldung ab.
01 package main 02 03 import ( 04 "database/sql" 05 "fmt" 06 "flag" 07 "os" 08 "strconv" 09 _ "github.com/mattn/go-sqlite3" 10 ) 11 12 func main() { 13 db, err := 14 sql.Open("sqlite3", ".findex.db") 15 checkErr(err) 16 17 max_files := flag.Int("max-files", 30, 18 "max number of files") 19 20 flag.Parse() 21 if len(flag.Args()) != 0 { 22 panic("usage: " + os.Args[0]) 23 } 24 25 rows, err := db.Query("SELECT path," + 26 "size FROM files WHERE path NOT like '%.git%'" + 27 "ORDER BY size DESC LIMIT " + 28 strconv.Itoa(*max_files)) 29 checkErr(err) 30 31 var path string 32 var size string 33 34 for rows.Next() { 35 err = rows.Scan(&path, &size) 36 checkErr(err) 37 fmt.Printf("%s %s\n", path, size) 38 } 39 } 40 41 func checkErr(err error) { 42 if err != nil { 43 panic(err) 44 } 45 }
Welche Dateien verbrauchen den meisten Speicherplatz? Listing 4 findet das schnell heraus, indem es mit dem Select-Query ab Zeile 25 alle Einträge nach der Spalte size
absteigend sortiert ("ORDER BY size DESC") und die Ausgabe mittels LIMIT auf eine Maximalzahl von Treffern beschränkt. Diese Zahl legt der User mit dem Parameter -max-files
auf der Kommandozeile fest, und Go bietet mit dem Paket flag
eine komfortable Schnittstelle zum Parsen der Parameter eines Kommandos. Zunächst verlangt es die Deklaration der Variablen, die den Parameter entgegennimmt (max_files
in Zeile 17), und der Aufruf der Methode flag.Int()
legt dabei fest, dass als Wert nur ein Integer in Frage kommt. Anschließend analysiert flag.Parse()
(Zeile 20) die vorliegenden Kommandozeilenparameter, und weist, falls der User -max-files
gesetzt hat, diesen Wert einer Variablen zu, auf die der Pointer max_files
zeigt. Die Funktion Itoa()
aus dem Paket strconv
wandelt den Integer hinter dem dereferenzierten Pointer *max_files
wieder in einen String um, und Zeile 28 fügt ihn per LIMIT-Klausel in den SQL-Befehl ein. Dieses Ringelpietz mit verschiedenen Typumwandlungen hat aber wenigstens den Vorteil, dass so im Query tatsächlich ein Integer ankommt und nicht etwa eine Zeichenfolge zur SQL-Injektion.
Dass ein Datenbank-Client in einer Skriptsprache wie Python doch leichter von der Hand geht, zeigt Listing 5. Es kramt alle Datenbankeinträge hervor, deren Dateipfade einem vorgegebenen Muster entsprechen. Es nimmt auf der Kommandozeile einen regulären Ausdruck entgegen, pflanzt ihn in einen SQL-Query ein und gibt die Treffer aus.
01 #!/usr/bin/env python3 02 import sys 03 import sqlite3 04 05 try: 06 _, pattern = sys.argv 07 except: 08 raise SystemExit( 09 "usage: " + sys.argv[0] + " pattern") 10 11 conn = sqlite3.connect('files.db') 12 c = conn.cursor() 13 like = "%" + pattern + "%" 14 for row in c.execute('SELECT path,size FROM files WHERE path LIKE ?', [like]): 15 print(row)
Gos Typsicherheit und die Tatsache, dass es nicht innerhalb eines Bytecode-Interpreters läuft sondern als compiliertes Binary mit etwas eleganterem Memory-Management als ein C oder C++-Programm hat seinen Preis: Es erfordert etwas detailliertere Instruktionen und zieht den Programmcode in die Länge. Go-Programme laufen schneller als Python-Skripts, aber wie so oft liegt der Flaschenhals auch hier nicht beim Abarbeiten der Instruktionen, sondern beim Kommunizieren mit externen Systemen: Im vorliegenden Fall verbraten die Aufrufe der SQLite-Datenbank den Hauptteil der Rechenzeit und ob das Hauptprogramm nun 10% oder gar 100% schneller läuft, ist komplett irrelevant. Dennoch ist Gos kompaktes Binary-Format mit eingebetteten Libraries und ohne "Dependency Hell" ein dicker Vorteil und zweifellos ein Grund für seine Popularität gerade in der Systemprogrammierung.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2018/07/snapshot/
Google Code Search, https://github.com/google/codesearch
"Regular Expression Matching with a Trigram Index or How Google Code Search Worked", Russ Cox, 2012, https://swtch.com/~rsc/regexp/regexp4.html
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc