Aus Linux-Magazin 11/2020

Go-Programm sucht nach Google-Drive-Dateien und holt sie aus der Cloud

© maglara, 123RF

Bücher stellt Mike Schilli nicht ins Regal, sondern scannt sie und speichert das PDF in Google Drive. Ein flinkes Go-Programm stöbert die Schmöker dort bei Bedarf wieder auf und lädt sie herunter.

Aus dem digitalen Bücherregal holt der Autor auf die Festplatte herunter, was er gerade braucht [1]. Die Browser-Schnittstelle auf Drive.google.com leistet dabei gute Dienste (Abbildung 1). Sie könnte aber einfacher und schneller zu handhaben sein, wenn es darum geht, Bücher zu suchen, die Ergebnisse aufzulisten und Treffer gleich herunterzuladen. Das in dieser Ausgabe vorgestellte Go-Programm macht das von der Kommandozeile aus und punktet deswegen bei Programmierern.

Das Übersetzen des Source-Codes aus den Listings 1 bis**4 sowie den Aufruf des erzeugten Binaries, das den Suchstring »algorithms-in-cpp« entgegennimmt, zeigt Abbildung 2. Auf Google Drive findet das Programm das passende PDF des Buchs “Algorithms in C++”. Es bietet die Datei zur Auswahl an und lädt sie herunter, sofern der User einwilligt. Während das rund 150 MByte große PDF übers Netzwerk eintrudelt, zeigt das Go-Programm einen je nach Internet-Verbindung langsam oder schnell voranschreitenden Fortschrittsbalken an, der die eingetroffenen Bytes im Verhältnis zur zu erwartenden Gesamtzahl illustriert.

Abbildung 1: Der Browser zeigt eingescannte Programmierbücher als PDF-Dateien in Google Drive an.

Abbildung 1: Der Browser zeigt eingescannte Programmierbücher als PDF-Dateien in Google Drive an.

Abbildung 2: Das Go-Programm in den <a href="#artRef-l1">Listings&nbsp;1</a> bis**<a href="#artRef-l4">4</a> sucht in Google Drive nach Dateien und l&auml;dt sie auf Wunsch herunter.

Abbildung 2: Das Go-Programm in den Listings 1 bis**4 sucht in Google Drive nach Dateien und lädt sie auf Wunsch herunter.

Nur für Eigentümer!

Freilich darf nicht Hinz und Kunz auf meine geschätzten und teuer bezahlten Bücher zugreifen. Deshalb muss sich ein neu geschriebener Client wie das vorgestellte Programm »gd« gegenüber Google Drive als von mir persönlich autorisiert ausweisen. Das funktioniert nicht schnöde per Username und Passwort, sondern über einen vorschriftsmäßig durchlaufenen OAuth2-Flow und anschließend mittels Access-Tokens, die der Client nach Ablauf ihrer Gültigkeit aus lokal zwischengespeicherten Refresh-Tokens erneuern kann.

Das Hauptprogramm in Listing 1 nimmt über das Paket flag auf der Kommandozeile den Suchbegriff entgegen, der auf eine oder mehrere Dateien in Google Drive passen sollte. In der vorgestellten Fassung sucht das Programm nach Treffern mit Dateinamen. Eine Volltextsuche wäre mit einer kleinen Änderung am Source-Code aber ebenfalls möglich.

Zum Einstieg in den OAuth2-Flow liest Zeile 20 die Datei »credentials.json« ein, die die sogenannten Client-Secrets definiert, also die Daten, mit denen Google die Applikation (das Go-Programm, nicht den User) identifiziert. Die Zustimmung des Benutzers holt später ein Browser-Dialog beim ersten Programmstart ein. Anschließend bietet »pickNGet()« in Zeile 35 dem Anwender Dokumente an, die auf den Suchbegriff passen, und lädt sie auf Wunsch herunter.

Was der Client nach erfolgreichem Durchwinken seitens des Users im Endeffekt darf, bestimmt der vorab für die Applikation festgelegte und bei Google registrierte Scope. Die Werte reichen vom Zugriff auf Meta-Informationen der auf dem Drive liegenden Dateien über tatsächliche Lese- und Laderechte bis hin zu uneingeschränkten Schreibrechten. Listing 1 definiert »DriveReadonlyScope« für das Go-Programm, erlaubt dem Client also das Abfragen der Dateinamen sowie das Herunterladen der fraglichen Inhalte.

Listing 1

gd.go

package main
import (
  "flag"
  "fmt"
  "golang.org/x/oauth2/google"
  "google.golang.org/api/drive/v3"
  "io/ioutil"
  "log"
  "os"
)
func main() {
  flag.Parse()
  if flag.NArg() != 1 {
    log.Fatalf(fmt.Sprintf("usage: %s partial-name", os.Args[0]))
  }
  query := flag.Arg(0)
  b, err := ioutil.ReadFile("credentials.json")
  if err != nil {
    log.Fatalf("Error reading client secret file: %v", err)
  }
  config, err := google.ConfigFromJSON(b, drive.DriveReadonlyScope)
  if err != nil {
    log.Fatalf("Error parsing config: %v", err)
  }
  client := oauth2Client(config)
  srv, err := drive.New(client)
  if err != nil {
    log.Fatalf("Error retrieving gdrive client: %v", err)
  }
  err = pickNGet(srv, query)
  if err != nil {
    log.Fatalf("Error retrieving document: %v", err)
  }
}

Stiefkind Go

Während die Developer Pages für die Google-Drive-API Beispiele in Java, Python und Node.js zeigen (Abbildung 3), scheint man der Haussprache Go bei Google nur stiefmütterliche Behandlung zukommen zu lassen.

Entwickler reiben sich verwundert die Augen, während sie durch autogenerierten Spaghetti-Code scrollen, um anhand der Funktionssignaturen herauszufinden, wie diese denn nun aufzurufen seien. Auf Stackoverflow finden sich fünf Jahre alte Anfragen ratloser Programmierer, und nur wenige Tapfere fanden je eine Antwort, um auch nur triviale Aufgaben zu lösen.

Man könnte freilich auch direkt aus Go die Web-API ansprechen, aber wenn Google schon ein SDK bereitstellt, sollten die Eierköpfe es auch dokumentieren und regelmäßig warten.

Abbildung 3: Au&szlig;er Java, Python und Node.js bietet Google wenig Hilfe bei der Nutzung der Google-Drive-API.

Abbildung 3: Außer Java, Python und Node.js bietet Google wenig Hilfe bei der Nutzung der Google-Drive-API.

Oauth, die Zweite

Woher kommt nun die Datei »credentials.json«, die das Client-Programm bei Google als API-Applikation registriert? Auf der Developer Console für die Google-API [2] gilt es, hierzu zunächst unter einem gültigen Google-Account die Google-Drive-API zu aktivieren (Abbildung 4). Auf derselben Seite muss man dann die Applikation als Desktop-App registrieren und festlegen, wie diese sich repräsentieren soll, wenn Google den Anwender später fragt, ob er der App Zugriff auf seine Google-Drive-Daten gewähren möchte.

Abbildung 4: Mit einem Klick aktivieren Entwickler die API auf Google Drive.

Abbildung 4: Mit einem Klick aktivieren Entwickler die API auf Google Drive.

Abbildung 5: JSON-Download nach dem Anlegen eines Desktop-Clients auf der API-Console im Reiter <span class="ui-element">Credentials</span>.

Abbildung 5: JSON-Download nach dem Anlegen eines Desktop-Clients auf der API-Console im Reiter Credentials.

Google bietet die Registrierungsdaten unter dem Namen »client-secret*« zum Download im JSON-Format an, der Client in Listing 1 bekommt sie unter dem Namen »credentials.json« eingefüttert. Mit diesen Daten erhält die App aber noch keinen Zugriff auf User-Daten, sondern kann sich lediglich bei Google als Desktop-App anmelden.

Fährt Listing 1 mit der JSON-Datei im selben Verzeichnis hoch, druckt es auf der Standardausgabe einen Link aus (Abbildung 6), den der Endanwender in die URL-Zeile eines Webbrowsers kopiert und an dessen Ziel er durch den Oauth2-Flow geleitet wird.

Abbildung 6: Beim ersten Start erwartet das Programms aus <a href="#artRef-l1">Listing&nbsp;1</a> die Datei &raquo;credentials.json&laquo; und gibt eine URL aus, die den User im Browser durch den Google-Oauth2-Flow leitet.

Abbildung 6: Beim ersten Start erwartet das Programms aus Listing 1 die Datei »credentials.json« und gibt eine URL aus, die den User im Browser durch den Google-Oauth2-Flow leitet.

Gütesiegel fehlt noch

Google fragt dann den unter seinem Account eingeloggten Anwender, ob er der App lesenden Zugriff auf die Google-Drive-Daten gewähren möchte. Da auf dem Go-Programm als hausgemachter App noch kein Google-Gütesiegel prangt, warnt der Dialog in Abbildung 7 davor, Zugriffsrechte zu vergeben. Mutige Snapshot-Leser klicken auf Advanced und erteilen Listing 1 auf der folgenden Seite trotzdem lesenden Zugriff auf Google Drive.

Abbildung 7: Google warnt vor der unverifizierten App, aber unter <span class="ui-element">Advanced</span> geht es auf eigene Gefahr weiter.

Abbildung 7: Google warnt vor der unverifizierten App, aber unter Advanced geht es auf eigene Gefahr weiter.

Der Google-Oauth2-Flow gibt nach nochmaliger Bestätigung (Abbildung 8) schließlich einen Hex-Code aus, den der Anwender in die Standardeingabe des auf der Kommandozeile wartenden Go-Programms kopiert. Das schluckt den Code und setzt seinen Lauf fort. Es kontaktiert den Google-Server damit und erhält von ihm je einen Access- und Refresh-Token. Beide verpackt Listing 1 in einer JSON-Datei und speichert sie unter »token.json« im selben Verzeichnis.

Abbildung 8: Noch eine Best&auml;tigung, und Google erstellt einen Access-Token, mit dem die App auf das Drive zugreifen kann.

Abbildung 8: Noch eine Bestätigung, und Google erstellt einen Access-Token, mit dem die App auf das Drive zugreifen kann.

Mit den nun persistent vorliegenden Credentials muss Listing 1 beim nächsten Aufruf nicht mehr durch den Oauth2-Flow, sondern kann gleich mit dem Token lesend auf die Daten in Google Drive zugreifen. Es ist wichtig, diese JSON-Datei vor fremdem Zugriff zu schützen: Jeder im Besitz des Tokens kann Einblick in die Gdrive-Daten nehmen. Schreibender Zugriff bleibt allerdings außen vor, da der Scope vorher beim Oauth2-Flow auf Lesen festgenagelt wurde.

Google-Hupf

Zum erstmaligen Abholen und Verwalten der Tokens bietet Listing 2 die Funktion »oauth2Client« an. Sie gibt dem Hauptprogramm einen HTTP-Client zurück, der beim Kommunizieren mit dem Google-Drive-Webserver die Authentifizierung des Users unter der Haube gleich mit erledigt.

Beim ersten Aufruf des Programms liegt noch kein Token in der Datei »token.json« vor, also liefert der Aufruf von »readCachedToken()« in Zeile 15 einen Fehler zurück. Abhilfe schafft der Aufruf der Funktion »fetchAccessToken()« in Zeile 17. Ab Zeile 23 druckt sie eine Google-URL aus, die der User in seinen Webbrowser kopiert und von dort durch den Oauth2-Flow geht.

Am Ende zeigt der Flow einen Hexcode, den der Anwender in das auf der Kommandozeile wartende Hauptprogramm kopiert. Zeile 29 schnappt den Code aus der Standardausgabe; Zeile 33 tauscht ihn auf dem Google-Server gegen einen Access-Token aus, den Zeile 18 im JSON-Format lokal im Dateisystem speichert. Bei folgenden Aufrufen holt »oauth2Client()« den Token gleich aus der lokalen Datei, der beim ersten Mal notwendige Google-Hupf entfällt.

Listing 2

oauth2.go

package main
import (
  "encoding/json"
  "fmt"
  "golang.org/x/net/context"
  "golang.org/x/oauth2"
  "log"
  "net/http"
  "os"
)
func oauth2Client(config *oauth2.Config) *http.Client {
  tokFile := "token.json"
  tok, err := readCachedToken(tokFile)
  if err != nil {
    tok = fetchAccessToken(config)
    cacheToken(tokFile, tok)
  }
  return config.Client(context.Background(), tok)
}
func fetchAccessToken(config *oauth2.Config) *oauth2.Token {
  url := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
  fmt.Printf("Point browser to %v, follow the flow and then paste "+
    "the code here.\n", url)
  var authCode string
  if _, err := fmt.Scan(&authCode); err != nil {
    log.Fatalf("Error reading auth code %v", err)
  }
  tok, err := config.Exchange(context.TODO(), authCode)
  if err != nil {
    log.Fatalf("Error getting access token: %v", err)
  }
  return tok
}
func readCachedToken(file string) (*oauth2.Token, error) {
  f, err := os.Open(file)
  if err != nil {
    return nil, err
  }
  defer f.Close()
  tok := &oauth2.Token{}
  err = json.NewDecoder(f).Decode(tok)
  return tok, err
}
func cacheToken(path string, token *oauth2.Token) {
  fmt.Printf("Saving credential file to: %s\n", path)
  f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
  if err != nil {
    log.Fatalf("Can't write token to %s: %v", path, err)
  }
  defer f.Close()
  json.NewEncoder(f).Encode(token)
}

Das Paket json aus dem Core-Fundus von Go macht das Lesen und Schreiben der Token-Daten zum Kinderspiel. Allerdings sollte sich einmal jemand hinsetzen und das Token-Handling auf Github als Paket bereitstellen.

Die Funktion »pickNGet()« in Listing 3 setzt die Suchabfrage an Google Drive ab. Der Dienst speichert Dateien in einer frei definierbaren Hierarchie von Ordnern. Oft sucht der Anwender jedoch einfach nach Dateiinhalt oder -name, und der Suchmaschinenriese gibt eine eindeutige ID der gefundenen Datei zurück.

Meine Dateinamen fallen meist eindeutig aus, weshalb Zeile 14 in Listing 3 die Suchabfrage »name contains x« nutzt. Wer Google lieber im Inhalt nach Textbrocken suchen lässt, tauscht den String in Zeile 14 einfach gegen »fullText contains x« aus. Weitere Suchabfragen erläutert das API-Dokument [3].

Maschinell erstelltes SDK

Beim Formatieren der Suchabfrage zeigt sich, dass das von Google bereitgestellte Go-SDK auf die Google-API einfach nur eine maschinell generierte Hülle um die Web-API ist.

Den API-Endpunkt zum Auflisten von Dateien spricht »srv.Files.List()« an (Zeile 15). Die verketteten Aufrufe von »Q(q).PageSize(100)« hängen die Suchabfrage an und setzen die Anzahl der pro Request maximal gelieferten Treffer auf 100. Auf Wunsch kann der Anwender jeweils den nächsten Schwung abholen, aber das Programm verzichtet darauf, da sich mehr als 100 Treffer nur schwer auf der Kommandozeile abarbeiten ließen.

Die verkettete Funktion »Fields()« begrenzt die zurückgelieferten Felder pro Treffer auf die eindeutige Dokumenten-ID, den Dateinamen und die Größe der Datei in Bytes. So kann »pickNGet()« dem Anwender eine Liste zur Auswahl präsentieren. Ein Druck auf [Y] startet den Download.

Listing 3

pick.go

package main
import (
  "bufio"
  "fmt"
  pb "github.com/schollz/progressbar/v3"
  "google.golang.org/api/drive/v3"
  "log"
  "os"
  "strings"
)
func pickNGet(srv *drive.Service, query string) error {
  q := fmt.Sprintf("name contains '%s'", query)
  r, err := srv.Files.List().Q(q).PageSize(100).
    Fields("nextPageToken, files(id, name, size)").Do()
  if err != nil {
    log.Fatalf("Error retrieving files: %v", err)
  }
  if len(r.Files) == 0 {
    fmt.Println("No files found.")
    return nil
  }
  reader := bufio.NewReader(os.Stdin)
  for _, file := range r.Files {
    fmt.Printf("Download %s (y/[n])? ", file.Name)
    text, _ := reader.ReadString('\n')
    if !strings.Contains(text, "y") {
      continue
    }
    bar := pb.DefaultBytes(file.Size, "downloading")
    fmt.Printf("Downloading %s (%d) ...\n", file.Name, file.Size)
    dwn, err := srv.Files.Get(file.Id).Download()
    if err != nil {
      log.Fatal("Unable to get download link %v", err)
    }
    defer dwn.Body.Close()
    reader := bufio.NewReader(dwn.Body)
    err = download(reader, file.Name, bar)
    if err != nil {
      log.Fatal("Download of %s failed: %v", file.Name, err)
    }
  }
  return nil
}

Die For-Schleife ab Zeile 28 iteriert über alle Treffer im Array »r.Files« und fordert den User bei jedem Durchgang auf, auf »os.Stdin« die Taste [Y] zu drücken, falls das Programm den aktuellen Treffer auf die Festplatte herunterladen soll. Dazu wartet die Funktion »ReadString()« in Zeile 30 auf eine Tasteneingabe des Users, die dieser mit der Eingabetaste abschickt. Falls dort nicht »y« stand, geht Zeile 32 mit »continue« in die nächste Runde und fragt den User, wie es denn nun mit diesem Treffer steht

Möchte der Anwender die Datei herunterladen, stellt Zeile 34 den Fortschrittsbalken auf aktuelle 0 Prozent und die maximale Länge auf die Größe der Datei. Der Google-Drive-Aufruf »srv.Files.Get()« in Zeile 37 wählt die gewünschte Datei anhand ihrer ID aus und initiiert mit »Download()« das Herunterladen. Der Google-Drive-Server schickt in diesem Fall eine Download-URL zurück, auf die der Client andockt, und der Ladevorgang beginnt.

Zeile 43 definiert auf den zurückkommenden Datenstrom einen gepufferten Reader aus dem Standard-Paket bufio. Den übergibt das Programm in Zeile 44 der Funktion »download()« aus Listing 4, zusammen mit einer Referenz auf den Fortschrittsbalken »bar« und dem Namen der Datei, unter der sie später lokal auf der Festplatte erscheinen wird.

Listing 4

download.go

package main
import (
  "bufio"
  pb "github.com/schollz/progressbar/v3"
  "io"
  "os"
)
func download(r io.Reader, lpath string, bar *pb.ProgressBar) error {
  outf, err := os.OpenFile(lpath, os.O_WRONLY|os.O_CREATE, 0644)
  if err != nil {
    return err
  }
  writer := bufio.NewWriter(outf)
  defer outf.Close()
  total := 0
  data := make([]byte, 1024*1024)
  for {
    count, rerr := r.Read(data)
    if rerr != io.EOF && rerr != nil {
      return err
    }
    total += count
    bar.Add(count)
    data = data[:count]
    _, werr := writer.Write(data)
    if werr != nil {
      return werr
    }
    if rerr == io.EOF {
      break
    }
  }
  writer.Flush()
  return nil
}

Reader ohne Typ

Dass eine Funktion ein Reader-Interface akzeptiert, ist typisch für Go: Die Funktion erhält so ein Objekt, das die »Read()«-Methode beherrscht, mit der sie als Konsument die Daten ansaugen kann. Dabei spielt es keine Rolle, woher diese stammen – aus einer lokalen Datei, dem Web oder einer Datenbank. Das sonst so typstrenge Go öffnet sich dann ohne langwierige Deklarationen, Funktionssignaturen, Klassendefinitionen oder Vererbung der Polymorphie. Die konsumierende Funktion ruft einfach »Read()« auf und kümmert sich einen feuchten Kehricht um feste Typen.

Häppchenweise

Erfolgen das Lesen und Schreiben der Daten häppchenweise, können gleich mehrere Dinge schiefgehen, und der Programmierer muss aufpassen wie ein Haftelmacher, damit die in die Zieldatei geschriebenen Daten auch intakt ankommen, also zu 100 Prozent identisch dem denen auf Google Drive sind.

Übers Internet eingelesen, trudeln Daten üblicherweise in kleinen Portionen ein. Selbst eine Datei mit mehreren Hundert Megabytes kommt bei der herunterladenden Applikation üblicherweise in Häppchen von nicht mehr als 32 KByte an. Da der Empfänger aus vorab bereitstehenden Meta-Informationen weiß, wie groß die gesamte Datei ist, braucht er nur die Happen aneinanderzureihen, um die Datei auf der Client-Seite wiederherzustellen. Gleichzeitig weiß er zu jedem Zeitpunkt, wie viel Prozent der Daten bereits angekommen sind und wie viel noch ausstehen.

Aufpassen beim Kopieren

Listing 4 legt optimistisch in »data« in Zeile 19 einen Puffer von 1 MByte an, in den die Funktion »Read()« den nächsten eingetrudelten Datenhappen legt. Eingehende Pakete sind aber typischerweise nur 32 KByte groß; der Rest des Puffers bleibt unbelegt, wenn »Read()« zurückkehrt. Die Anzahl der tatsächlich vorliegenden Bytes liegt im Rückgabewert »count« vor, und Zeile 28 reduziert mit »data[:count]« die Länge des Byte-Slices auf die tatsächliche Länge der Daten, um die hintendran klebenden Mülldaten abzuschneiden.

Versiegt der Datenstrom von Google Drive, weil das Ende der Datei erreicht ist, gibt »Read()« in Zeile 22 als Error-Code »io.EOF« zurück. Doch Vorsicht: Das heißt nicht, dass in »data« nicht noch Daten vorlägen. Vielmehr gibt »count« auch in diesem Fall an, wie viele Bytes vor dem EOF noch eingetroffen waren. Ein Client, der diesen letzten Happen missachtet, weil er denkt, dass mit einem EOF nichts mehr kommt, produziert korrupte Download-Dateien.

Am hinteren Ende der Kopierschleife steht der Writer, der stückweise Daten vom Reader erhält und sie in einer zum Schreiben geöffneten Datei ablegt. Er muss nicht nur den allerletzten Happen des Readers in die Zieldatei schreiben, der gleichzeitig mit dem EOF ankommt, sondern auch ganz am Ende seine internen Puffer leeren und deren Inhalt in die Zieldatei spülen. Unterbliebe dies in Zeile 39, fehlten im erzeugten PDF-Dokument die letzten paar Kilobytes, was manche PDF-Reader mit erstaunlich erratischem Verhalten quittieren.

Fazit

Und schon steht der voll funktionsfähige Google-Drive-Client als Kommandozeilentool bereit – stets einsatzbereit, “Information at your fingertips”, wie einst schon der alte Schwerenöter Bill Gates zum Besten gab. Wer zum Bücherladen nun nicht mehr die Kommandozeile verlassen muss, der spart Zeit und lässt die Konkurrenz hinter sich!

Online PLUS

Im Screencast unter http://www.linux-magazin.de/videos/ demonstriert Michael Schilli das vorgestellte Programmierbeispiel.

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 Fragen.

Infos

  1. Perl-Snapshot: Mike Schilli, “Papierbuch am Ende”, LM 12/2012, S. 102, https://www.lm-online.de/26999
  2. Google Developers Console: https://console.developers.google.com
  3. Google-Drive-API-Suchabfragen: https://developers.google.com/drive/api/v3/search-files
  4. Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/10/snapshot/
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 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:
1 Kommentar
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
michael
5 Jahre her

ist das erlaubt: eingescannte Bücher als PDF in der cloud zu speichern?

Nach oben