Eine Applikation, die veränderte Dateien unverzüglich anzeigt, muss über einen Mechanismus des Dateisystems davon erfahren. Mike Schilli setzt dazu eine Wanze in den Dateibaum des Go-Compilers.
Wer in einem Dateimanager schon einmal beobachtet hat, wie neu entstehende Dateien im dargestellten Verzeichnis sofort auftauchen, der hat sich vielleicht gefragt, wie das denn funktioniert. Ein wiederholtes Abfragen des Dateisystems verbietet sich aus Leistungsgründen. Stattdessen nutzen solche Applikationen die Inotify-Schnittstelle des verwendeten Linux-Dateisystems.
Betriebssysteme implementieren den Mechanismus auf unterschiedliche Art und Weise, Linux nutzt Inotify, der Mac Kqueue und Windows eine unaussprechbare Extrawurst. Zum Glück abstrahiert die Go-Library Fsnotify auf Github diesen Wildwuchs in eine einfache Schnittstelle. Autoren müssen ihre Applikationen also nur einmal schreiben, um damit alle Plattformen abzudecken.
Ohne Klimmzüge
Vor etwa 15 Jahren gab es zu diesem Thema schon einmal etwas im Programmier-Snapshot, damals in Perl [1], und es musste noch mit Fuse ein Spezialdateisystem ran. Heute ist die Funktion Standard.
In Go geht das Ganze ohne große Klimmzüge von der Hand, Listing 1 zeigt ein einfaches Beispiel zum Aufwärmen. Abbildung 1 visualisiert, wie aus dem Go-Code in »watch.go« ein ausführbares Binary »watch« entsteht, das anschließend ein neu erzeugtes Verzeichnis »/tmp/test/« überwacht. In einem weiteren Terminal setzt nun der User die Kommandos aus Abbildung 2 ab, die im Testverzeichnis erst eine neue Datei anlegen, Daten hineinschreiben, ihre Ausführungsrechte ändern und sie schließlich mit Rm löschen. Abbildung 1 bestätigt, dass das Go-Programm tatsächlich alle Änderungen mitbekommt und die Aktionen protokolliert.

Abbildung 1: Listing 1 lauscht auf Meldungen des Dateisystems in /tmp/test/ …
Dafür holt Listing 1 in Zeile 5 den Library-Code von Github, legt als ersten Schritt im »main«-Programm einen neuen Watcher an und teilt mit »defer« mit, dass der bei Programmende geschlossen werden soll.
Da die Überwachung des Dateisystems asynchron über Go-Channels abläuft, feuert Zeile 15 mit »go func« eine Goroutine ab, die sofort in eine Endlosschleife mit einer »select«-Anweisung einbiegt. Letztere blockiert den Programmfluss der Goroutine, bis Nachrichten aus dem Channel »watcher.Events« ankommen, gesendet vom Library-Code aus »fsnotify«, der dafür das Betriebssystem anzapft.
Listing 1
watch.go
package main
import (
"fmt"
"github.com/fsnotify/fsnotify"
)
func main() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
panic(err)
}
defer watcher.Close()
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
fmt.Printf("%+v\n", event)
}
}
}()
err = watcher.Add("/tmp/test")
if err != nil {
panic(err)
}
done := make(chan struct{})
<-done
}
Routinen und Blockade
Das Hauptprogramm fließt derweil ungehindert weiter, und Zeile 27 teilt »fsnotify« mittels »watcher.Add()« mit, dass sie das Verzeichnis »/tmp/test/« zu überwachen wünscht. Damit wäre das Hauptprogramm am Ende angekommen.
Da es aber weiterlaufen und in der vorher abgefeuerten Goroutine auf Ereignisse lauschen soll, erzeugt Zeile 32 kurz vor Schluss noch einen ungenutzten Channel, aus dem in Zeile 33 nie eine Nachricht ankommt: Seine einzige Aufgabe besteht darin, das Hauptprogramm so lange zu blockieren, bis der User es mit [Strg]+[C] abbricht.
Nicht rekursiv
Dabei setzt die Go-Library »fsnotify« mit jedem Aufruf von »Add()« nur jeweils ein weiteres Verzeichnis auf die Überwachungsliste. Das rekursive Einklinken eines ganzen Dateibaums steht angeblich auf der Roadmap des Fsnotify-Projekts, klappt aber zur Zeit noch nicht. Daher muss die Applikation dazu selbst Hand anlegen.
Um zum Beispiel zu verfolgen, welche Dateien der Go-Compiler in der Verzeichnishierarchie unterhalb von »~/go/« im Home-Verzeichnis des Users während der Arbeitsphase herunterlädt oder generiert, muss Listing 2 mit der Funktion »Walk()« aus dem Standardpaket »filepath« ab Zeile 24 erst einmal hinab in die Tiefen der Verzeichnisstruktur steigen.
Die Funktion nimmt als Parameter eine Callback-Funktion entgegen, die sie bei jedem gefundenen Dateisystemeintrag mit dessen Namen und seiner »FileInfo«-Struktur mit den Metadaten aufruft, wie Datei oder Verzeichnis, Größe in Bytes oder Zugangsberechtigungen. Trat beim Einsteigen in einen Teilbereich ein Fehler auf, ist stattdessen die Variable »err« auf den entsprechenden Fehler gesetzt.
Listing 2
fswatch.go
package main
import (
"fmt"
"github.com/fsnotify/fsnotify"
"log"
"os"
"os/user"
"path/filepath"
"strings"
)
func main() {
cur, err := user.Current()
dieOnErr(err)
home := cur.HomeDir
watcher, err := fsnotify.NewWatcher()
dieOnErr(err)
defer watcher.Close()
watchInit(watcher)
err = filepath.Walk(filepath.Join(home, "go"),
func(path string, info os.FileInfo, err error) error {
dieOnErr(err)
if info.IsDir() {
err := watcher.Add(path)
dieOnErr(err)
}
return nil
})
dieOnErr(err)
done := make(chan bool)
<-done
}
func eventAsString(event fsnotify.Event) string {
info, err := os.Stat(event.Name)
dieOnErr(err)
evShort := (strings.ToLower(event.Op.String()))[0:2]
dirParts := strings.Split(event.Name, "/")
pathShort := event.Name
if len(dirParts) > 3 {
pathShort = filepath.Join(dirParts[len(dirParts)-3 : len(dirParts)]...)
}
return fmt.Sprintf("%s %s %d", evShort, pathShort, info.Size())
}
func watchInit(watcher *fsnotify.Watcher) {
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Rename == fsnotify.Rename ||
event.Op&fsnotify.Remove == fsnotify.Remove {
continue
}
log.Printf("%s\n", eventAsString(event))
info, err := os.Stat(event.Name)
dieOnErr(err)
if info.IsDir() {
err := watcher.Add(event.Name)
dieOnErr(err)
}
case err, _ := <-watcher.Errors:
panic(err)
}
}
}()
}
func dieOnErr(err error) {
if err != nil {
panic(err)
}
}
Kurzer Prozess wegen Platzmangels
Um die ewigen Fehlerprüfungen nach Funktionsaufrufen mit »err != nil« und einer If-Bedingung auf eine magazinfreundliche Codelänge zu kürzen, definiert Listing 2 in Zeile 77 die Funktion »dieOnErr()«, die bei jedwelchem Fehler schlicht abbricht. Unter Produktionsbedingungen würde man hier stattdessen den Fehler protokollieren und eventuell behandeln, denn dort herrscht nicht der Druckerzeugnissen inhärente Platzmangel.
Die Callback-Funktion prüft nun bei jedem gefundenen Eintrag unterhalb des Verzeichnisses »~/go/« mittels »IsDir()«, ob es sich um ein Directory handelt. Falls ja, setzt es mit »Add()« einen Watcher darauf. Jeder davon verbrät unter Linux einen File-Deskriptor, von denen das Betriebssystem keinen unbegrenzten Vorrat vorhält. Das Kommando »ulimit -n« zeigt die verfügbare Anzahl an und erlaubt dem Administrator, sie hochzuschrauben. Normalerweise genügen aber die voreingestellten 1024 Deskriptoren völlig.
Was im Falle eines eintreffenden Inotify-Events passiert, definiert das Hauptprogramm in Zeile 22 mit dem Aufruf von »watchInit()«. Die Funktion steht ab Zeile 51 im Listing. Darin wartet eine asynchron laufende Goroutine auf Events aller definierten Watcher. Handelt es sich um das Ereignis eines neu generierten Verzeichnisses innerhalb der überwachten Dateistruktur, setzt Zeile 67 darauf ebenfalls einen Watcher an. Um eventuell doppelt definierte Watcher braucht sich das Programm jedoch keine Gedanken zu machen: Fsnotify ist schlau genug, Duplikate zu ignorieren.
Kopf-an-Kopf-Rennen
Allerdings ist dieses Verfahren nicht völlig zuverlässig: Bekommt der Tracker die Geburt eines neuen Verzeichnisses mit, muss er schnell einen Watcher darauf aufsetzen, um künftige Änderungen darin mitzubekommen. Erzeugt aber eine Applikation direkt nach dem Anlegen des Verzeichnisses sofort darin Dateien, kann sie dem Tracker eventuell zuvorkommen, und der bekommt die Änderung nicht mit.
Außerdem verfolgt das ursprünglich aufgerufene »Walk()« zum Einsammeln aller Unterverzeichnisse keine symbolischen Links weiter. Wer das möchte, muss diese mit der Funktion »EvalSymlinks()« auflösen, dabei aber aufpassen wie ein Haftelmacher, damit der Walker sich nicht in einer Endlosschleife festfrisst.
Events, die das Umbenennen oder Löschen eines Eintrags melden, filtert die If-Bedingung in Zeile 59 heraus, denn ein »os.Stat()« auf einen derartigen Eintrag würde fehlschlagen. Alle anderen Events druckt Zeile 63 schön formatiert aus.
Was der Watcher während des Laufs des Compilers mit »go build fswatch.go« so alles findet, zeigt Abbildung 3. Der Watcher meldet das Anlegen etlicher Cache-Verzeichnisse, die dabei helfen, den von Github heruntergeladenen Fsnotify-Code zu übersetzen und einzubinden. Das Kürzel »cr« steht hierbei für die Action “create”. Andere Meldungen tragen die Kennung »ch« für »chmod«, wenn der Compiler die Zugangsbits manipuliert. Zieht sich also das Kompilieren eines länglichen Go-Programms mit vielen Abhängigkeiten hin, weiß der User mit diesem Helferlein genau, was der Compiler gerade treibt, und kann so ungefähr abschätzen, wie lange es voraussichtlich noch dauern wird.

Abbildung 3: Während der Go-Compiler Sourcen von Github lädt, um ein Binary zu kompilieren, verfolgt Fsnotify die dabei neu erzeugte Dateien.
Um die aufgetretenen Ereignisse sauber zu protokollieren, formatiert die Funktion »eventAsString()« ab Zeile 39 sie noch rudimentär um. Die Namen von Events kürzt Zeile 42 auf deren erste zwei Zeichen und macht mit »ToLower()« Kleinbuchstaben daraus. Ellenlange Verzeichnispfade teilt Zeile 43 in ihre Bestandteile auf, Zeile 46 kürzt sie auf die letzten drei Teilpfade (falls sie deren mehr findet). Mit der Array-Slice-Syntax »[m:n]« extrahiert sie die letzten drei mit »len(dirPaths)-3« für »m« und »len(dirParts)« für »n«.
Dabei ist das Element am Index »m« definitionsgemäß im Ergebnis enthalten, das für »n« nicht. Da »Join()« aus dem Paket filepath nicht etwa ein Array von Teilpfaden zusammenfügt, sondern eine variable Anzahl von Einzelelementen, machen die abschließenden drei Punkte aus dem vom Slice-Operator kommenden Array-Slice eine ausgeflachte Liste von Einzelelementen.
Das ganze ließe sich nun schön in eine UI einbetten, die zu jedem Compilerlauf den Anwender stetig mit Updates unterhält, sodass er sofort merkt, ob nur das Netzwerk hängt oder der Vorgang wegen Code-Bloat tatsächlich so lange dauert. (uba)
Infos
- Perl-Snapshot: Mike Schilli, “Auferstanden aus Archiven”, LM 01/2006, S. 100, https://www.lm-online.de/9724
- Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/04/snapshot/







