Um Dateien schnell in verästelten Hierarchien nach Metadaten zu sortieren, legt ein Go-Programm einen Index in einer SQlite-Datenbank an. Mike Schilli modelt das Projekt Codesearch für eigene Zwecke um.
Das waren noch Zeiten, als man einfach eine Codezeile oder einen Variablennamen in Googles Codesearch-Seite (Abbildung 1) eingeben konnte und die Datenkrake ruckzuck ausspie, in welchen Open-Source-Repositories diese auftauchten. Leider hat Google den Service vor etlichen Jahren wohl mangels Profit eingestellt.
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 dann blitzschnell nach Codestücken zu durchsuchen. An anderer Stelle erklärt Autor Russ Cox, damals Praktikant bei Google, wie die Suche funktioniert [3].
Wie wäre es, nach einem ähnlichen Verfahren einen Index der Dateien unterhalb eines Einstiegsverzeichnisses anzulegen, um blitzschnell Abfragen wie zum Beispiel “Welche Dateien wurden kürzlich modifiziert?”, “Welche sind die größten Platzverschwender?” oder “Welche Dateinamen passen auf das folgende Muster?” auszuführen?
Die Unix-Dateisysteme speichern 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 Zeitstempel wie den Zeitpunkt der letzten Modifizierung (Abbildung 2).
Neuere Dateisysteme wie ZFS oder Btr-FS gehen zwar in der Organisation der darin enthaltenen Dateien in Richtung Datenbank, aber nicht weit genug, damit man aus dem Userland Queries mit Schmackes darauf absetzen könnte.
Schmackes statt Pause
Wer zum Beispiel alle Dateien über 100 MByte auf der Platte aufstöbern möchte, kann dies mit einem »find«-Aufruf wie
find / -type f -size +100M
tun und, falls die Suche auf einer traditionellen Festplatte läuft, gleich in die Kaffeepause gehen und sich selbst auf einer schnellen SSD auf lange Suchzeiten gefasst machen. Grund dafür: Die Daten liegen Abfrage-unfreundlich auf den Sektoren der Platte verstreut.
Online PLUS
Im Screencast demonstriert Michael Schilli das Beispiel: https://www.linux-magazin.de/videos/
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 darauf herumorgeln kann. Das Utility »updatedb« auf Linux tut Ähnliches, damit der User mit »locate« blitzschnell nach Dateinamen suchen kann.
Weil ein Programmieren-Snapshot niemals vor neuen Herausforderungen zurückschreckt und dieser während eines Hawaii-Urlaubs mit viel Zeit und guter Laune entstanden ist, erfolgt die Implementierung in der Programmiersprache Go – genau wie beim 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.
Listing 1
create.go
01 package main
02
03 import (
04 "database/sql"
05 _ "github.com/mattn/go-sqlite3"
06 )
07
08 func main() {
09 db, err := sql.Open("sqlite3", "./files.db")
10 if err != nil {
11 panic(err)
12 }
13
14 _, err = db.Exec("CREATE TABLE files " +
15 "(path text, modified int, size int)")
16 if err != nil {
17 panic(err)
18 }
19 }
Um den notwendigen SQlite-Treiber zu installieren, holt das Bordmittel »go get« den Sourcecode 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 Skripte wie Listing 1 den Treiber in ihrer »import«-Sektion unter dem vollen Namen laden. Der Unterstrich in Zeile 5 dient dazu, den sehr strengen Go-Compiler vor dem Explodieren zu bewahren, weil ein Paket importiert wurde, das offenbar nirgendwo im Code referenziert ist. 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.
Old-School-Fehlerprüfung
Der in Zeile 14 abgesetzte SQL-Befehl »CREATE« legt eine neue Datenbanktabelle an. Auch hier prüft Zeile 16, ob ein Fehler zurückkam, und bricht notfalls mit einer Meldung ab. Go glaubt noch an die alte Schule der individuellen Prüfung von Rückgabewerten, statt dem Programmierer zu erlauben Exceptions zu werfen, hochblubbern zu lassen und irgendwo weiter oben zu verarbeiten.
Noch ein Wort zu den unterschiedlichen Zuweisungen in den Zeilen 9 und 14, die erst mit »:=« und dann mit »=« erfolgen. Die erste ist Gos Zuweisung mit Deklaration. Ist eine Variable noch nicht deklariert, erledigt dies die Kurzform »=«.
Wehe!
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. 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.
Kompiliert wird der Code ganz einfach mittels
go build create.go
und einen Sekundenbruchteil später steht ein Executable »create« bereit, das alle Abhängigkeiten und Libraries bereits enthält, sich also ohne Weiteres auf einen weiteren Rechner mit dem gleichen Betriebssystem kopieren lässt, ohne dass ein Rattenschwanz von Abhängigkeiten folgen müsste. Das entstehende Binary ist mit etwa 4,5 MByte allerdings auch nicht gerade ein Leichtgewicht.
Closure als Brücke
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 Programm ü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 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 Scope dieses Callback ab Zeile 34 existiert, auf das dieses zugreifen und die nötigen Änderungen an der Datenbank vornehmen könnte. Die Lösung des Dilemmas liegt in einer Closure zur Funktion »Visit()«.
Listing 2
index.go
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", "./files.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 }
Dazu definiert »Visit()« in Zeile 34 zwischen dem Schlüsselwort »func« und dem Funktionsnamen einen so genannten »Receiver« und weist Go somit an, die Datenstruktur »Walker« (Zeile 10), die ein Datenbank-Handle enthält, mit der Funktion »Visit()« zu verbandeln. So kann »Visit()« über die Variable »w« auf das Handle zugreifen und 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« den Wert »nil« hat und alles okay ist, definiert Zeile 47 eine Funktion »checkErr()«, die dies erledigt und das Programm mit »panic« abbricht, falls etwas Unvorhergesehenes passiert ist.
Fundstücke
Nachdem der Indexer seine Arbeit beendet hatte, lagen auf meinem Rechner, wie Abbildung 3 zeigt, über eine Million Einträge in der Datenbanktabelle »files«. Grund für die hohe Anzahl von Dateien waren wohl zahlreiche geklonte Git-Repositories sowie Snapshot-Artikel aus mehr als 20 Jahren. Mit diesen Daten in der SQlite-Datenbank »files.db« kann ein Client schnell Queries abfeuern und etwa feststellen, welche Dateien unter meinem Homeverzeichnis sich kürzlich verändert haben.

Abbildung 3: Nach dem Lauf des Indexers liegen mehr als eine Million Datei-Einträge in der SQlite-Datenbank.
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.
Listing 3
latest.go
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", "./files.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 }
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 zuvor 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 falsch ist, sondern bricht mit hilfreichen Fehlermeldungen ab.
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.
Listing 4
max-size.go
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", "./files.db")
15 checkErr(err)
16
17 max_files := flag.Int("max-files", 10,
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 " +
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 }
Zunächst verlangt es die Deklaration der Variablen, die den Parameter entgegennimmt (»max_files« in Zeile 17). 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. Der Tanz mit den Typumwandlungen hat 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 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 ein SQL-Query ein und gibt die Treffer aus.
Listing 5
like.py
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)
Mehr Luxus, mehr Zeilen
Gos Typsicherheit und dass es nicht innerhalb eines Bytecode-Interpreters läuft, sondern als kompiliertes Binary mit eleganterem Memory-Management als ein C- oder C++-Programm, hat seinen Preis: Es erfordert detailliertere Instruktionen und zieht den Programmcode in die Länge. Go-Programme laufen schneller als Python-Skripte, aber wie so oft liegt der Engpass nicht im Abarbeiten der Instruktionen, sondern in der Kommunikation mit externen Systemen: In diesem Fall verbraten Aufrufe der Datenbank den Hauptteil der Rechenzeit, ob das Programm 10 oder 100 Prozent schneller läuft, ist irrelevant.
Dennoch ist das kompakte Binary-Format mit eingebetteten Libraries und ohne Dependency Hell ein Vorteil und ein Grund für Gos Popularität in der Systemprogrammierung. (uba)
Infos
-
Listings zu diesem Artikel: https://www.linux-magazin.de/static/listings/magazin/2018/07/snapshot/
-
Google Code Search: https://github.com/google/codesearch
-
Russ Cox, “Regular Expression Matching with a Trigram Index”, 2012: https://swtch.com/~rsc/regexp/regexp4.html








