Bücherbus (Linux-Magazin, November 2020)

Seit fast schon zehn Jahren stelle ich keine Bücher mehr ins Regal, sondern scanne sie ein, spiele das PDF auf mein Google-Drive und lade, was ich gerade brauche, auf meine Festplatte herunter ([2]). Die Browser-Schnittstelle auf drive.google.com leistet dabei gute Dienste (Abbildung 1), könnte aber nach meinem Geschmack einfacher und schneller zu handhaben sein, wenn es darum geht, Bücher zu suchen, die Ergebnisse aufzulisten und etwaige Treffer gleich herunterzuladen. Das in dieser Ausgabe vorgestellte Go-Programm tut dies von der Kommandozeile, und punktet deswegen extra bei Programmierern.

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

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

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

Halt, Zugriff nur für Eigentümer!

Natürlich darf nicht Hinz und Kunz auf meine geschätzten und teuer bezahlten Bücher zugreifen, und deshalb muss sich ein neu geschriebener Client wie das vorgestellte Programm gd gegenüber Google Drive als von mir persönlich autorisiert ausweisen. Dies funktioniert natürlich nicht, indem Username oder Passwort irgendwo herumliegen, sondern über einen vorschriftsmäßig durchlaufenen OAuth2-Flow und anschließend mittels Access-Tokens, die der Client nach Ablauf ihrer Gültigkeit aus ebenfalls lokal zwischengespeicherten Refresh-Tokens erneuern kann.

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

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 (also das Go-Programm und nicht den User) identifiziert. Die Zustimmung des Endusers holt später ein Browserdialog beim ersten Programmstart ein. Ist dies erfolgt, bietet pickNGet() in Zeile 35 dem User 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 Applikaton festgelegte und bei Google registrierte Scope. 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 den Download deren Inhalts.

Listing 1: gd.go

    01 package main
    02 
    03 import (
    04   "flag"
    05   "fmt"
    06   "golang.org/x/oauth2/google"
    07   "google.golang.org/api/drive/v3"
    08   "io/ioutil"
    09   "log"
    10   "os"
    11 )
    12 
    13 func main() {
    14   flag.Parse()
    15   if flag.NArg() != 1 {
    16     log.Fatalf(fmt.Sprintf("usage: %s partial-name", os.Args[0]))
    17   }
    18   query := flag.Arg(0)
    19 
    20   b, err := ioutil.ReadFile("credentials.json")
    21   if err != nil {
    22     log.Fatalf("Error reading client secret file: %v", err)
    23   }
    24   config, err := google.ConfigFromJSON(b, drive.DriveReadonlyScope)
    25   if err != nil {
    26     log.Fatalf("Error parsing config: %v", err)
    27   }
    28 
    29   client := oauth2Client(config)
    30   srv, err := drive.New(client)
    31   if err != nil {
    32     log.Fatalf("Error retrieving gdrive client: %v", err)
    33   }
    34 
    35   err = pickNGet(srv, query)
    36   if err != nil {
    37     log.Fatalf("Error retrieving document: %v", err)
    38   }
    39 }

Stiefkind Go

Während die "Developer Pages" für die Google-Drive-API Beispiele in Java, Python und NodeJS zeigen, scheint man der Haussprache Go bei Google hier nur stiefmütterliche Behandlung zukommen zu lassen. Entwickler reiben sich verwundert die Augen, während sie durch autogenerierten Spaghetticode 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 auf die Web-API ansprechen, aber wenn Google schon ein SDK bereitstellt, sollten die Eierköpfe es auch dokumentieren und regelmäßig warten.

Abbildung 3: Der Go-Client der Google-Drive-API liegt nur als maschinell generierter Code ohne Dokumentation vor.

Abbildung 4: 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 Clientprogramm bei Google als API-Applikation registriert? Auf der Developer-Console für die Google-API ([3]) ist hierzu zunächst unter einem gültigen Google-Account die Google-Drive-API zu aktivieren (Abbildung 5). Auf derselben Seite gilt es dann, die Applikation als "Desktop-App" zu registrieren und, festzulegen, wie sich diese 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 5: Mit einem Klick aktivieren Entwickler die API auf Google Drive.

Abbildung 6: JSON-Download nach dem Anlegen eines Desktop-Clients auf der API-Console unter dem 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 hat die App aber noch keinen Zugriff auf Userdaten, sondern wurde lediglich bei Google als Desktop-App angemeldet. Fährt Listing 1 mit der JSON-Datei im gleichen Verzeichnis hoch, druckt es auf der Standardausgabe einen Link aus (Abbildung 7), den der Endanwender in die URL-Zeile eines Browsers kopiert und dort durch den Oauth2-Flow geleitet wird.

Abbildung 7: Beim ersten Start von Listing 1 erwartet dieses die Datei credentials.json und gibt einen URL aus, der 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 natürlich noch kein Google-Gütesiegel prangt, warnt der Dialog in Abbildung 8 davor, Zugriffsrechte zu vergeben. Mutige Snapshot-Leser klicken auf "Advanced" und erteilen Listing 1 auf der folgenden Seite trotzdem den lesenden Zugriff auf das Google-Drive.

Abbildung 8: Google warnt vor der unverifizierten App, aber unter "Advanced" geht's auf eigene Gefahr weiter.

Der Google-Oauth2-Flow gibt nach nochmaliger Bestätigung (Abbildung 9) schließlich einen Hex-Code aus (Abbildung 10), den der Anwender in die Standardeingabe des auf der Kommandozeile wartenden Go-Programms in Abbildung 7 kopiert. Dieses schluckt den Code, setzt seinen Lauf fort, kontaktiert den Google-Server damit und erhält von ihm einen Access- und einen Refresh-Token. Beide verpackt Listing 1 in einer Json-Datei und speichert diese unter token.json im gleichen Verzeichnis ab.

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

Mit diesen 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 im Google-Drive zugreifen. Es ist wichtig, diese Json-Datei vor fremdem Zugriff zu schützen -- jeder im Besitz des Tokens kann ebenfalls Einblick in die Gdrive-Daten nehmen. Schreibender Zugriff ist allerdings nicht erlaubt, da der sogenannte 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, die dem Hauptprogramm einen HTTP-Client zurückgibt, der beim Kommunzieren mit dem Webserver des Google-Drives die Authenifizierung des Users unter der Haube gleich miterledigt. 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 hierbei der Aufruf der Funktion fetchAccessToken() in Zeile 17. Ab Zeile 23 druckt sie eine Google-URL aus, die der User in seinen Browser 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 (Abbildungen 7 und 10). Zeile 29 schnappt den Code aus der Standardausgabe auf und 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 enfällt.

Abbildung 10: Mit diesem Hex-String darf die Desktop-App den Access-Token abholen.

Listing 2: oauth2.go

    01 package main
    02 
    03 import (
    04   "encoding/json"
    05   "fmt"
    06   "golang.org/x/net/context"
    07   "golang.org/x/oauth2"
    08   "log"
    09   "net/http"
    10   "os"
    11 )
    12 
    13 func oauth2Client(config *oauth2.Config) *http.Client {
    14   tokFile := "token.json"
    15   tok, err := readCachedToken(tokFile)
    16   if err != nil {
    17     tok = fetchAccessToken(config)
    18     cacheToken(tokFile, tok)
    19   }
    20   return config.Client(context.Background(), tok)
    21 }
    22 
    23 func fetchAccessToken(config *oauth2.Config) *oauth2.Token {
    24   url := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
    25   fmt.Printf("Point browser to %v, follow the flow and then paste "+
    26     "the code here.\n", url)
    27 
    28   var authCode string
    29   if _, err := fmt.Scan(&authCode); err != nil {
    30     log.Fatalf("Error reading auth code %v", err)
    31   }
    32 
    33   tok, err := config.Exchange(context.TODO(), authCode)
    34   if err != nil {
    35     log.Fatalf("Error getting access token: %v", err)
    36   }
    37   return tok
    38 }
    39 
    40 func readCachedToken(file string) (*oauth2.Token, error) {
    41   f, err := os.Open(file)
    42   if err != nil {
    43     return nil, err
    44   }
    45   defer f.Close()
    46   tok := &oauth2.Token{}
    47   err = json.NewDecoder(f).Decode(tok)
    48   return tok, err
    49 }
    50 
    51 func cacheToken(path string, token *oauth2.Token) {
    52   fmt.Printf("Saving credential file to: %s\n", path)
    53   f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
    54   if err != nil {
    55     log.Fatalf("Can't write token to %s: %v", path, err)
    56   }
    57   defer f.Close()
    58   json.NewEncoder(f).Encode(token)
    59 }

Das json-Paket 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 Suchabfrage an das Google-Drive setzt die Funktion pickNGet() in Listing 3 ab. Das Google-Drive speichert Dateien in einer frei definierbaren Hierarchie von Ordnern, aber oft sucht der Anwender einfach nach Dateiinhalt oder -name und der Suchmaschinenriese gibt eine Eindeutige ID der gefundenen Datei zurück. Meine Dateinamen sind meist eindeutig, deshalb nutzt Zeile 14 in Listing 3 die Suchabfrage "name contains x". Wer Google lieber nach Textbrocken im Inhalt suchen lässt, tauscht den String in Zeile 14 einfach gegen "fullText contains x" aus. Weitere Suchabfragen definiert das API-Dokument unter [4].

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 Files spricht srv.Files.List() (Zeile 15) an, und 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 einholen, 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 (Abbildung 2). Tippt dieser "y", springt der Download an.

Listing 3: pick.go

    01 package main
    02 
    03 import (
    04   "bufio"
    05   "fmt"
    06   pb "github.com/schollz/progressbar/v3"
    07   "google.golang.org/api/drive/v3"
    08   "log"
    09   "os"
    10   "strings"
    11 )
    12 
    13 func pickNGet(srv *drive.Service, query string) error {
    14   q := fmt.Sprintf("name contains '%s'", query)
    15   r, err := srv.Files.List().Q(q).PageSize(100).
    16     Fields("nextPageToken, files(id, name, size)").Do()
    17   if err != nil {
    18     log.Fatalf("Error retrieving files: %v", err)
    19   }
    20 
    21   if len(r.Files) == 0 {
    22     fmt.Println("No files found.")
    23     return nil
    24   }
    25 
    26   reader := bufio.NewReader(os.Stdin)
    27 
    28   for _, file := range r.Files {
    29     fmt.Printf("Download %s (y/[n])? ", file.Name)
    30     text, _ := reader.ReadString('\n')
    31     if !strings.Contains(text, "y") {
    32       continue
    33     }
    34     bar := pb.DefaultBytes(file.Size, "downloading")
    35 
    36     fmt.Printf("Downloading %s (%d) ...\n", file.Name, file.Size)
    37     dwn, err := srv.Files.Get(file.Id).Download()
    38     if err != nil {
    39       log.Fatal("Unable to get download link %v", err)
    40     }
    41     defer dwn.Body.Close()
    42 
    43     reader := bufio.NewReader(dwn.Body)
    44     err = download(reader, file.Name, bar)
    45     if err != nil {
    46       log.Fatal("Download of %s failed: %v", file.Name, err)
    47     }
    48   }
    49 
    50   return nil
    51 }

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 vom Drive auf die Festplatte herunterladen soll. Hierzu wartet die Funktion ReadString() in Zeile 30 auf eine Tasteneingabe des Users, die dieser mit der Enter-Taste abschickt. Falls dort nicht y stand, geht Zeile 32 mit continue in die nächste Runde und fragt den User wie's denn nun mit diesem Treffer steht?

Will der User die Datei haben, stellt Zeile 34 den Progressbar auf aktuelle 0% und die maximale Länge auf die Größe der Datei. Der Google-Drive-Aufruf srv.Files.Get() in Zeile 37 selektiert die gewünschte Datei anhand ihrer ID und initiiert mit Download() den Vorgang des Herunterladens. Der Google-Drive-Server schickt in diesem Fall einen Download-URL zurückt, auf den der Client andockt, und der Ladevorgang beginnt. Zeile 43 definiert auf den zurückkommenden Datenstrom einen gepufferten Reader aus dem Stdandard-Paket bufio, den das Programm in Zeile 44 der Funktion download() aus Listing 4 übergibt, zusammen mit einer Referenz auf den Fortschrittsbalken bar und dem Namen der Datei, unter der sie später lokal auf der Festplatte erscheinen wird.

Dass eine Funktion ein Reader-Interface akzeptiert, ist typisch in Go: Die Funktion erhält so ein Objekt, das die Read()-Methode beherrscht, mit der sie als Konsument die Daten ansaugen kann. Dabei ist es egal, woher diese stammen, aus einer lokalen Datei, vom Web, oder einer Datenbank. Das sonst so typstrenge Go öffnet sich so 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

Beim häppchenweisen Lesen und Schreiben der Daten 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% identisch dem denen auf der Serverseite, also dem Google-Drive sind.

Übers Internet eingelesen, trudeln Daten üblicherweise in Happen ein. Selbst eine Datei mit mehreren hundert Megabytes kommt bei der herunterladenden Applikation üblicherweise in Häppchen von nicht mehr als 32k Bytes 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 Clientseite wiederherzustellen. Gleichzeitig weiß er zu jedem Zeitpunkt, wieviel Prozent der Daten bereits angekommen sind und wieviel noch ausstehen.

Listing 4: download.go

    01 package main
    02 
    03 import (
    04   "bufio"
    05   pb "github.com/schollz/progressbar/v3"
    06   "io"
    07   "os"
    08 )
    09 
    10 func download(r io.Reader, lpath string, bar *pb.ProgressBar) error {
    11   outf, err := os.OpenFile(lpath, os.O_WRONLY|os.O_CREATE, 0644)
    12   if err != nil {
    13     return err
    14   }
    15   writer := bufio.NewWriter(outf)
    16   defer outf.Close()
    17 
    18   total := 0
    19   data := make([]byte, 1024*1024)
    20 
    21   for {
    22     count, rerr := r.Read(data)
    23     if rerr != io.EOF && rerr != nil {
    24       return err
    25     }
    26     total += count
    27     bar.Add(count)
    28     data = data[:count]
    29 
    30     _, werr := writer.Write(data)
    31     if werr != nil {
    32       return werr
    33     }
    34 
    35     if rerr == io.EOF {
    36       break
    37     }
    38   }
    39   writer.Flush()
    40   return nil
    41 }

Aufpassen beim Kopieren

Listing 3 legt optimistisch in data in Zeile 19 einen Puffer von einem Megabyte an, in den die Funktion Read() den nächsten eingetrudelten Datenhappen legt. Eintrudelnde Pakete sind aber typischerweise nur 32kB groß, der Rest des Puffers bleibt unbelegt, wenn Read() zurückkehrt. Die Anzahl der tatsächlich vorliegenden Bytes liegt aber 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 hinten daranklebenden Mülldaten abzuschneiden.

Versiegt der Datenstrom vom Google-Drive, weil das Ende der Datei erreicht ist, gibt Read() in Zeile 22 als Error-Code io.EOF zurück, aber Vorsicht: das heißt nicht, dass in data nicht noch Daten vorlägen. Vielmehr gibt count auch in diesem Fall an, wieviele Bytes vor dem EOF noch eingetroffen waren. Ein Client, der diesen letzten Happen misachtet, 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 diese in einer zum Schreiben geöffneten Datei ablegt. Er muss nicht nur auch 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.

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, spart Zeit und lässt die Konkurrenz hinter sich!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2020/10/snapshot/

[2]

Michael Schilli, "Papierbuch am Ende": Linux-Magazin 12/12, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2012/12/perl-snapshot/<U>

[3]

Google Developers Console: https://console.developers.google.com

[4]

Google Drive API Suchabfragen: https://developers.google.com/drive/api/v3/search-files

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 mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc

Around line 676:

Unknown directive: =header2