Aus Linux-Magazin 12/2023

Dateien prüfen per Go-Kommandozeilenwerkzeug

© Marian Vejcik / 123RF.com

Damit Mike Schilli seine Google-Drive-Dateien mit drei verschiedenen Pattern Matchern prüfen kann, baut er sich in Go ein Kommandozeilenwerkzeug mit lokalem Cache.

Meine digitale Bibliothek gescannter Papierbücher liegt in Form von PDF-Dateien in einem Account bei Google Drive. Zwar hat Google bislang meine Daten vorbildlich vorrätig gehalten, aber mit dem Such-Interface werde ich einfach nicht recht warm. Google-typisch präsentiert der Browser ein Suchfeld, über das sich der indizierte Volltext aller Dateien in allen Ordnern fix durchforsten lässt. Eine simple Antwort auf die Frage, ob ich ein bestimmtes Buch bereits abgelegt habe, fällt schon schwerer, denn dazu müsste man die Dateinamen untersuchen und die Suche auf bestimmte Ordner beschränken.

Zum Glück bietet Google aber eine intuitiv zu bedienende API [1] auf die Nutzerdaten in Google Drive. Da bietet sich ein Kommandozeilenwerkzeug zur Auswertung an. Wenn wir schon dabei sind, lohnt sich ein Ausflug in die Welt der Pattern Matcher, von denen es bekanntlich unterschiedlichste Varianten gibt. So matcht die Shell mit einem Glob-Mechanismus, Programmiersprachen dagegen üblicherweise mit regulären Ausdrücken. Ein simpler String Matcher wie das Grep-Kommando ist oft die praktischste Lösung.

Parallele Regex-Welten

Wer auf der Kommandozeile »ls *.jpg« eintippt, erwartet, dass der Match-Mechanismus der Shell alle Dateien mit der Endung ».jpg« findet. Dieser Musterabgleich unterscheidet sich grundlegend von den in Programmiersprachen verwendeten regulären Ausdrücken nach PCRE (Perl Compatible Regular Expressions [2]). Die entsprangen witzigerweise vor vielen Jahren der Skriptsprache Perl, aber alle modernen Sprachen von Python über Java und C++ bis hin zu Go unterstützen sie ebenfalls.

Die Joker-Karte der Regex-Welt ist “.*”: Der Ausdruck passt auf beliebige Strings. Dabei lässt der Punkt im Pattern beliebige Zeichen zu, und der nachfolgende Stern steht für beliebig viele Wiederholungen (inklusive keiner). Das Äquivalent beim Shell-Globbing wäre “*” wie bei »*.jpg« erläutert, allerdings mit einer Einschränkung: Die Shell matcht niemals über den Pfadseparator hinaus. Folglich passt »/tmp/f*« nicht auf »/tmp/foo/bar«.

Grep hingegen akzeptiert mit dem Suchstring »foo« die Zeile »/tmp/foo/bar«. Es interpretiert den Schrägstrich also keineswegs gesondert und ist schon zufrieden, wenn das Pattern auch nur auf einen Bruchteil der Eingabe passt. Anders ausgedrückt, verzichtet Grep auf das sogenannte Ankern (Anchoring), ein Ausstopfen des Patterns mittels »*foo*« ist nicht notwendig.

Treffer nach Gusto

Damit Sie mit dem vorgestellten Binary »gdls« später Ihre Match-Strategie in den Google-Drive-Daten nach Gusto wählen können, spendiert Listing 1 das Kommandozeilen-Flag »–match«. Es darf die Werte »contains« (Standard), »glob« oder »regex« annehmen. Entsprechend verzweigt der Code ab Zeile 29 in einen reinen Substring-Match (Bibliotheksfunktion »strings.Contains()«), einen Glob-Match (»Match()« aus dem Standardpaket filepath) oder einen vollen Regex-Match aus der Go-Regexp-Library mit »regexp.MatchString()«.

Abbildung 1 zeigt das neu erstellte Programm Gdls in Aktion. Ein Substring-Match auf “bukowski” findet alle Bücher des amerikanischen Schriftstellers Charles Bukowski, von denen sich sage und schreibe 15 in meiner Bibliothek befinden. Um die Anzahl der Treffer zu begrenzen, schiebt das dritte Kommando die Treffer nach Unix-Manier in ein nachgestelltes Grep-Kommando. Das lässt aus den 15 Treffern ein Buch übrig, dessen Titel die Zeichenkette “mad” enthält.

Abbildung 1: Drei verschiedene Pattern Matcher in Aktion.

Abbildung 1: Drei verschiedene Pattern Matcher in Aktion.

Alternativ filtert der Regex-Match mit “bukowski.*mad” im vierten Kommando schon vorab den einzigen Treffer heraus. Der Ausdruck passt auf Dateien mit dem entsprechenden Namen, unabhängig davon, in welchem Ordner sie sich befinden. Im Gegensatz dazu passt der Glob-Match mit der Shell-typischen Stern-Syntax und Pfadrestriktionen in der letzten Zeile nur auf Dateien, die im Ordner »books/« liegen. Es ist also für jeden Geschmack etwas dabei.

Listing 1

Gdls

package main
import (
  "flag"
  "log"
  "os"
  "path"
  "path/filepath"
  "regexp"
  "strings"
)
func main() {
  matchMethod := flag.String("match", "contains", "match method (contains, glob, regex)")
  update := flag.Bool("update", false, "Update from Google Drive")
  flag.Parse()
  gddb := NewGdDb()
  defer gddb.Close()
  if *update {
    gddb.Init()
    updater(gddb)
    return
  }
  pattern := ""
  if flag.NArg() == 1 {
    pattern = flag.Arg(0)
  }
  gddb.RegexFu = func(re, s string) (bool, error) {
    var matches bool
    var err error
    switch *matchMethod {
    case "contains":
      matches = strings.Contains(s, re)
    case "glob":
      matches, err = filepath.Match(re, s)
    case "regex":
      matches, err = regexp.MatchString(re, s)
    default:
      log.Fatalf("Unknown: %s", *matchMethod)
    }
    if err != nil {
      return false, err
    }
    return matches, nil
  }
  gddb.Search(pattern)
}
func dbPath() string {
  dir, err := os.UserHomeDir()
  if err != nil {
    panic(err)
  }
  return path.Join(dir, ".gdrive.db")
}

Damit die Suchkommandos in Abbildung 1 flüssig Ergebnisse liefern, fragt das Go-Programm Google Drive nicht direkt ab, sondern nutzt eine lokale Kopie der Dateinamen in einer SQLite-Datenbank auf dem ausführenden Rechner. Diesen lokalen Cache frischen Sie bei Bedarf über den Aufruf »gdls –update« auf. Daraufhin kontaktiert Gdls Ihren Google-Drive-Account, holt die Namen aller aktuell gespeicherten Dateien und speist sie in die Tabelle »files« einer SQLite-Datenbank in der lokalen Datei »~/.gdrive.db« ein (Abbildung 2).

Abbildung 2: Eine SQLite-Datenbank speichert alle Dateipfade aus Google Drive.

Abbildung 2: Eine SQLite-Datenbank speichert alle Dateipfade aus Google Drive.

Listing 1 verpackt die drei verschiedenen Queries als Library-Aufrufe in der Funktion »RegexFu()« (ab Zeile 26). Listing 4 registriert diese später mit der Engine der SQLite-Datenbank. Die SQLite-Session in Abbildung 2 sucht noch händisch und SQL-typisch nach »like %bukowski%« – ein viertes Verfahren zum Pattern-Matching. Die Suchanfragen des Go-Programms nutzen später die in SQLite eingebaute Funktion »regexp()«, die wir mit der benutzerdefinierten Funktion »RegexFu« überladen.

Ausweis, bitte!

Wie landen nun die Metadaten aus Google Drive in der SQLite-Datenbank? Google erlaubt den Zugriff auf die Drive-Daten nur für ausgewiesene User. Deswegen muss ein Programm, das die Namen der darin enthaltenen Dateien abholen möchte, sich entsprechend autorisieren.

Hierzu müssen Sie auf der Google Cloud Console [1] zunächst ein Projekt anlegen, die Google-Drive-API freischalten (Abbildung 3) und eine neue Client-Applikation hinzufügen (Abbildung 4). Der Server antwortet mit einer neu generierten Client ID und einem Client secret (Abbildung 5).

Abbildung 3: Freischalten der Google Drive API vor der Nutzung.

Abbildung 3: Freischalten der Google Drive API vor der Nutzung.

Abbildung 4: Anmelden der App auf der Google Cloud Console.

Abbildung 4: Anmelden der App auf der Google Cloud Console.

Abbildung 5: <span class="ui-element">Client ID</span> und <span class="ui-element">Client secret</span> dienen als Ausweis f&uuml;r die App.

Abbildung 5: Client ID und Client secret dienen als Ausweis für die App.

Das Client-Geheimnis berechtigt allerdings noch nicht zum Zugriff auf die Daten, sondern lediglich zum Einholen eines Access-Tokens auf dem Google-API-Server. Liegt später solch ein Token dem Request an die Google-Drive-API bei, rückt der Server die Daten heraus. Das Access-Token gilt nur zeitlich beschränkt, lässt sich aber mit dem gleichzeitig zugeteilten Refresh-Token wieder und wieder auffrischen. Das Client secret steht im Dialog in Abbildung 5 im JSON-Format zum Download bereit, und Sie legen es in der Datei »creds.json« im lokalen Verzeichnis ab.

Abbildung 6: Abholen des Access-Tokens beim ersten Aufruf.

Abbildung 6: Abholen des Access-Tokens beim ersten Aufruf.

Beim ersten Aufruf mit »gdls –update« liest das Programm die Credentials-Datei »creds.json« mit Client-ID und Client-Secret ein. Zu diesem Zeitpunkt liegt noch kein Access-Token vor, also ruft Gdls die Funktion »getTokenFromWeb()« aus Listing 2 auf (ab Zeile 20). Sie schreibt die URL zur Credential-Aktivierung auf die Standardausgabe und fordert Sie auf, die Adresse in das Eingabefenster eines Webbrowsers einzutippen. Der damit kontaktierte Google-Server stellt dann zunächst sicher, dass der Google-Drive-Besitzer in seinen Account eingeloggt ist. Dann fragt er in einem Dialog ab, ob es in Ordnung geht, der neuen Applikation entsprechende Rechte einzuräumen.

Dazu bringt Google zunächst eine Warnung (Abbildung 7) und dann einen sogenannten OAuth-Consent-Dialog (Abbildung 8), den Sie abnicken müssen. Damit weiß der API-Server, dass Sie damit einverstanden sind, dass eine unregistrierte und damit hoch suspekte Applikation die privaten Drive-Daten lesen darf.

Abbildung 7: Google warnt vor der hoch suspekten, selbst geschriebenen App.

Abbildung 7: Google warnt vor der hoch suspekten, selbst geschriebenen App.

Abbildung 8: Der User gibt im Browser sein Einverst&auml;ndnis.

Abbildung 8: Der User gibt im Browser sein Einverständnis.

Listing 2

Token-Abfrage

package main
import (
  "context"
  "encoding/json"
  "fmt"
  "golang.org/x/oauth2"
  "os"
)
const tokenFile = "token.json"
func readToken() (*oauth2.Token, error) {
  f, err := os.Open(tokenFile)
  if err != nil {
    return nil, err
  }
  defer f.Close()
  tok := &oauth2.Token{}
  err = json.NewDecoder(f).Decode(tok)
  return tok, err
}
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token
 {
  authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
  fmt.Printf("Launch in browser and copy auth code: \n%v\n", authURL)
  var authCode string
  fmt.Scan(&authCode)
  tok, err := config.Exchange(context.TODO(), authCode)
  panicOnErr(err)
  return tok
}
func saveToken(token *oauth2.Token) {
  f, err := os.Create(tokenFile)
  panicOnErr(err)
  defer f.Close()
  err = json.NewEncoder(f).Encode(token)
  panicOnErr(err)
}

Mit Haken und Ösen

Stimmen Sie dem Prozess trotz vehementer Warnungen zu (schließlich ist die App nicht abgesegnet und der Entwickler nicht öffentlich bekannt), dirigiert der Server den Browser zu einer voreingestellten URL auf »localhost«, auf der mangels lokaler Konfiguration niemand lauscht. Entsprechend meldet der Browser einen Fehler (Abbildung 9).

Abbildung 9: Der Autorisierungscode steht im Parameter &raquo;code&laquo;.

Abbildung 9: Der Autorisierungscode steht im Parameter »code«.

Aber nicht verzagt: Die angezeigte URL im Eingabefenster enthält im Parameter »code« den Autorisierungscode, den Sie kurzerhand in die Eingabe des wartenden Programms kopieren (Abbildung 6). Das kontaktiert daraufhin den Google-Server und erhält gegen Ablieferung des Codes das sehnlichst erwartete Access-Token. Damit klappt der Zugriff auf die Daten, und der Download-Prozess beginnt loszurattern.

Damit diese Zirkusnummer nicht bei jedem Aufruf des Programms von Neuem beginnt, speichert Gdls in »saveToken()« (Listing 2, ab Zeile 29) das Access-Token samt dem ebenfalls beiliegenden Refresh-Token in der Datei »token.json«. Von dort liest »readToken()« ab Zeile 10 es später wieder ein. Beim nächsten Aufruf nutzt das Programm hinter den Kulissen auf diese Weise das noch gültige Access-Token oder besorgt sich mit dem Refresh-Token ein neues. Sie bekommen davon nichts mit, ausgenommen vielleicht, dass es hin und wieder ein paar Sekunden länger dauert, bis der Zugriff auf Google Drive wieder klappt.

Alle Listings dieser Ausgabe behandeln Fehler übrigens aus Platzgründen mit der Utility-Funktion »panicOnErr()«. Voll ausgereifte Applikationen nutzen stattdessen das Modul log für hilfreiche Meldungen und geben Fehler an aufrufende Programmteile zurück, statt mit »panic()« gleich alles hinzuwerfen.

Fangfrisch eingeweckt

Mit den griffbereiten Zugriffsdaten darf nun die Funktion »updater()« aus Listing 3 mittels »listAllFiles()« (ab Zeile 32) die Namen aller in Google Drive verstreuten Dateien abholen.

Dazu liest die Bibliotheksfunktion »google.ConfigFromJSON()« in Zeile 17 erst die Client-ID und das Client-Secret aus der Konfigurationsdatei. Dann versucht »getClient()« ab Zeile 24 zunächst, ein gültiges Access-Token zu finden. Schlägt das fehl, beginnt »getTokenFromWeb()« in Zeile 27 den vorher erwähnten Token-Tanz mit dem Browser.

Der neu erstellte Client hilft dann der in Zeile 11 importierten Library drive/v3, die OAuth-spezifische Kommunikation zu erledigen. Diese von Google offiziell herausgegebene Library macht es gar nicht so einfach, das Drive komplett abzusuchen und dabei die Ordnerstruktur im Auge zu behalten.

Die Funktion »listAllFiles()« fängt beim obersten Ordner (mit der ID »root«) an, alle enthaltenen Einträge mit dem Query in Zeile 33 abzufragen. Dort eventuell gefundene Verzeichnisse erkennt sie mit der Typprüfung in Zeile 40 und ruft sich jeweils rekursiv auf, um sich in der Hierarchie weiter nach unten zu bohren. Die Namen normaler Dateien kopiert es mit dem beim Aufruf hereingereichten Objekt »gddb« und dessen Funktion »Add()« in Zeile 44 in den lokalen SQLite-Cache. Dank der mittels Rekursion durchgeschleiften Ordnerpfade stehen so die absoluten Dateipfade aus Google Drive bereit.

Listing 3

Google-Drive-Zugriff

package main
import (
  "context"
  "fmt"
  "io/ioutil"
  "net/http"
  "path/filepath"
  _ "github.com/mattn/go-sqlite3"
  "golang.org/x/oauth2"
  "golang.org/x/oauth2/google"
  "google.golang.org/api/drive/v3"
)
func updater(gddb GdDb) {
  credentialsFile := "creds.json"
  data, err := ioutil.ReadFile(credentialsFile)
  panicOnErr(err)
  config, err := google.ConfigFromJSON(data, drive.DriveReadonlyScope)
  panicOnErr(err)
  client := getClient(config)
  service, err := drive.New(client)
  panicOnErr(err)
  listAllFiles(service, gddb, "root", "")
}
func getClient(config *oauth2.Config) *http.Client {
  tok, err := readToken()
  if err != nil {
    tok = getTokenFromWeb(config)
    saveToken(tok)
  }
  return config.Client(context.Background(), tok)
}
func listAllFiles(service *drive.Service, gddb GdDb, folderID, parentPath string) {
  query := fmt.Sprintf("trashed=false and '%s' in parents", folderID)
  pageToken := ""
  for {
    r, err := service.Files.List().Q(query).Fields("nextPageToken, files(id, name, mimeType)").PageToken(pageToken).Do()
    panicOnErr(err)
    for _, file := range r.Files {
      fullPath := filepath.Join(parentPath, file.Name)
      if file.MimeType == "application/vnd.google-apps.folder" {
        listAllFiles(service, gddb, file.Id, fullPath)
      } else {
        fmt.Printf("Adding %s\n", fullPath)
        gddb.Add(fullPath)
      }
    }
    pageToken = r.NextPageToken
    if pageToken == "" {
      break
    }
  }
}
func panicOnErr(err error) {
  if err != nil {
    panic(err)
  }
}

Häppchenweise

Die Google-API liefert aber auf eine Search-Query hin keineswegs alle Dateien in einem Ordner. Sie paginiert das Ergebnis ungefragt, falls mehr als 100 Treffer vorliegen. Liegt im JSON einer Server-Antwort ein »nextPageToken« vor, geht es noch weiter.

Der Server ist übrigens keineswegs verpflichtet, genau 100 Ergebnisse in eine Antwort zu packen. Diese voreingestellte Größe fungiert als Maximalwert, den der Dienst aus Effizienzgründen auch unterschreiten darf, was besonders bei API-Servern mit verteilter Datenhaltung nicht selten vorkommt. Clients, die nur dann eine Folgeseite anfordern, wenn sie 100 Ergebnisse finden (oder erst gar nicht auf Folgeseiten prüfen) liefern ohne jegliche Fehlermeldung unvollständige Daten. Die Benutzer tappen dann für immer im Dunkeln.

Abgeheftet und archiviert

Das Tool speichert die bei Google Drive gefundenen Dateinamen in der SQLite-Datei ».gdls.db« im Home-Verzeichnis. Listing 4 packt die Funktionen, die auf die Datenbank zugreifen, in ein objektorientiertes Format. Der Konstruktor »NewGdDb()« ab Zeile 12 öffnet die Verbindung zur Datenbank und verpackt das Handle in eine Struktur vom Typ »GdDb« (definiert ab Zeile 7). Nach dem Ausfüllen gibt er sie dem Aufrufer für zukünftige Funktionsaufrufe aus dem Paket zurück.

Ruft der Client später »db.Init()« auf, löscht der Code ab Zeile 21 mithilfe eines SQL-Kommandos die Tabelle »files« (falls sie in einer alten Version aus vorherigen Läufen existiert) und legt eine neue an, die den laufenden IDs die vollständigen Pfade gefundener Dateien zuweist. In Abbildung 2 war bereits das Datenbankschema zu sehen.

Die Funktion »Add()« ab Zeile 31 fügt mit dem SQL-Kommando »insert()« neue Pfadeinträge als Tabellenzeilen an, während »Search()« ab Zeile 35 Einträge nach den vordefinierten Match-Algorithmen absucht und Treffer ausgibt. Dazu registriert die Funktion in SQLite die benutzerdefinierte Funktion »regex()« und setzt sie auf die in den Konstruktor hereingereichte Go-Funktion mit den drei Match-Algorithmen. Davon ist zu diesem Zeitpunkt bereits einer vom Hauptprogramm her vorausgewählt.

Bei der Suche mit »SELECT« in der SQL-Datenbank springt dann die benutzerdefinierte Funktion in einer Where-Klausel ein und filtert die Treffer entsprechend. Die For-Schleife ab Zeile 56 iteriert über die genehmigten Treffer und schreibt sie auf die Standardausgabe.

Listing 4

Datenbankzugriff

package main
import (
  "database/sql"
  "fmt"
  "github.com/mattn/go-sqlite3"
)
type GdDb struct {
  Db        *sql.DB
  RegexFu   func(re, s string) (bool, error)
  TableName string
}
func NewGdDb() GdDb {
  db, err := sql.Open("sqlite3", dbPath())
  panicOnErr(err)
  return GdDb{Db: db, TableName: "files"}
}
func (gddb GdDb) Close() {
  gddb.Db.Close()
}
func (gddb GdDb) Init() {
  sql := `DROP TABLE If EXISTS ` + gddb.TableName
  _, err := gddb.Db.Exec(sql)
  panicOnErr(err)
  sql = `CREATE TABLE ` + gddb.TableName + ` (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    path TEXT
  );`
  _, err = gddb.Db.Exec(sql)
  panicOnErr(err)
}
func (gddb GdDb) Add(path string) error {
  _, err := gddb.Db.Exec("INSERT INTO "+gddb.TableName+" (path) VALUES (?)", path)
  return err
}
func (gddb GdDb) Search(pattern string) {
  sql.Register("sqlite3_FunctionRegistration", &sqlite3.SQLiteDriver{
    ConnectHook: func(conn *sqlite3.SQLiteConn) error {
      if err := conn.RegisterFunc("regex", gddb.RegexFu, true); err != nil {
        return err
      }
      return nil
    }})
  db, err := sql.Open("sqlite3_FunctionRegistration", dbPath())
  panicOnErr(err)
  defer db.Close()
  query := fmt.Sprintf("SELECT path FROM %s", gddb.TableName)
  var rows *sql.Rows
  if pattern == "" {
    rows, err = db.Query(query)
  } else {
    query += fmt.Sprintf(" WHERE regex(?, path)")
    rows, err = db.Query(query, pattern)
  }
  panicOnErr(err)
  defer rows.Close()
  for rows.Next() {
    var name string
    panicOnErr(rows.Scan(&name))
    fmt.Printf("%s\n", name)
  }
  panicOnErr(rows.Err())
}

Abteilung, marsch!

Wie immer führt der Dreisprung aus Abbildung 10 zu einem ausführbaren Programm, nachdem der Go-Compiler die im Code referenzierten Libraries aus dem Netz geladen und vorkompiliert hat. Bei Bedarf können Sie die Suchfunktionen weiter anpassen. Es böte sich beispielsweise an, Groß- und Kleinschreibung zu ignorieren. Wie immer bei Selbstgeschriebenem sind der Fantasie keine Grenzen gesetzt. (uba/jlu)

Abbildung 10: Diese drei Build-Kommandos erzeugen das Go-Binary.

Abbildung 10: Diese drei Build-Kommandos erzeugen das Go-Binary.

Der Autor

Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Ihre Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 7 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
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