Geben statt nur Nehmen (Linux-Magazin, September 2024)

Aufmerksamen Lesern dieser Kolumne ist sicher schon aufgefallen, dass die vorgestellten Go-Listings oft Pakete auf Github referenzieren, die der Go-Compiler klaglos von dort abholt und als Library in geschnürte Binaries einbindet. Aber immer nur Nehmen ist auch nicht schön, wie schwer wäre es wohl, selbst ein bisserl Code zu schreiben, und diesen mit der Welt auf Github zu teilen, auf dass Programmierfüchschen nah und fern ihn nutzen, Zeit sparen und Lobeshymnen auf den Autor singen könnten?

Nützlich wäre zum Beispiel ein simples Paket, das es einer Go-Applikation erlaubt, von ihr verwendete Passwörter und API-Tokens in eine externe Datei auszulagern. Denn diese Strings sollten nicht direkt im Code stehen, und nicht nur deshalb, weil die Listings hier abgedruckt im Heft stehen. Auch im Produktionsbetrieb sind "hart kodierte" Strings nicht gern gesehen, weil der Code meist offen in einem Github-Repo liegt und automatische Installationen gerne Binaries und Geheimnisse getrennt ausrollen, ganz so, als würde der User sie nach der Installation von Hand konfigurieren.

Nehmen wir die Beispiel-Applikation in Listing 1, die die fünf am häufig abgerufenen Videos eines Youtube-Channels einholt und dafür einen geheimen API-Key und eine Channel-ID braucht. Statt Strings mit sensiblen Daten im Code zu halten, ruft dieser zweimal die Lookup()-Funktion auf, die aus einer externen menschenlesbaren Datei nach einem String zu dem angegebenen Stichwort sucht (einmal "youtube-api-key" und einmal "youtube-channel-id"), und das Ergebnis an die Applikation zurückreicht.

Listing 1: yttop.go

    01 package main
    02 import (
    03   "context"
    04   "fmt"
    05   "github.com/mschilli/go-murmur"
    06   "google.golang.org/api/option"
    07   "google.golang.org/api/youtube/v3"
    08   "log"
    09 )
    10 func main() {
    11   m := murmur.NewMurmur()
    12   apiKey, err := m.Lookup("youtube-api-key")
    13   if err != nil {
    14     panic(err)
    15   }
    16   channelID, err := m.Lookup("youtube-channel-id")
    17   if err != nil {
    18     panic(err)
    19   }
    20   ctx := context.Background()
    21   service, err := youtube.NewService(ctx, option.WithAPIKey(apiKey))
    22   videoIDs := make([]string, 0)
    23   call := service.Search.List([]string{"id"}).ChannelId(channelID).MaxResults(5).Order("viewCount")
    24   resp, err := call.Do()
    25   if err != nil {
    26     log.Fatalf("Error making search API call: %v", err)
    27   }
    28   for _, item := range resp.Items {
    29     if item.Id.Kind == "youtube#video" {
    30       videoIDs = append(videoIDs, item.Id.VideoId)
    31     }
    32   }
    33   videoCall := service.Videos.List([]string{"snippet", "statistics"}).Id(videoIDs...)
    34   videoResponse, err := videoCall.Do()
    35   if err != nil {
    36     log.Fatalf("Error making videos API call: %v", err)
    37   }
    38   for _, item := range videoResponse.Items {
    39     fmt.Printf("%6d %.40s (%s)\n", item.Statistics.ViewCount, item.Snippet.Title, item.Id)
    40   }
    41 }

Leise murmeln

Diese praktische Utility-Funktion Lookup() ist Teil eines neu erzeugten Go-Pakets, das alle Programmierer dieser Welt aus meinem öffentlichen Github-Account direkt in ihren Go-Code einbinden können. Und da man geheime Dinge nicht lauthals ausposaunen sollte, sondern allenfalls murmeln oder raunen, soll das Paket Murmur (Englisch für murmeln) heißen.

Dabei kommt das Paket objektorientiert daher und die Applikation in Listing 1 ruft in Zeile 11 den Konstruktor murmur.NewMurmur() auf. Zeile 5 zieht das Paket vorher per URL unter dem Pfad mschilli/go-murmur von Github herein, gemäß der Konvention, dass Github-Repos, die Go-Pakete enthalten, immer mit go-* beginnen. Intern definiert das Paket seinen Namen allerdings mit murmur, nicht go-murmur. Wie das geht, wird seine Implementierung weiter unten in Listing 2 zeigen.

Abbildung 1: In der Murmeldatei versteckte Geheimnisse.

Damit die Methode Lookup() auf das erzeugte Objekt m das Geheimnis zu einem Schlüssel wie "youtube-api-key" findet, muss sie die YAML-Datei in Abbildung 1 aufspüren, in der die Schlüssel auf Geheimnisse zeigen. Normalerweise steht die Datei im Home-Verzeichnis des Users unter ~/.murmur, der User kann aber, wie wir später sehen werden, dem Konstruktur einen alternativen Suchpfad mitgeben.

Kanal-Hitparade

Der Rest von Listing 1 zum Einholen der fünf populärsten Videos aus meinem Youtube-Channel ist ein typischer Fall für die Youtube-API, deren Version 3 Zeile 7 hereinzieht, diesmal nicht von Github, sondern von den Google-Leuten auf golang.org. Zeile 21 erzeugt ein neues Service-Objekt zur Kommunikation mit dem Youtube-API-Server und übergibt ihm einen vorher von der Youtube-Developer-Console abgeholten und in der Murmur-Datei abgelegten API-Key, damit der Server auch weiß, mit wem er es zu tun hat. Fremden gegenüber zeigt er sich nämlich verschlossen. Wie man einen API-Key für Entwickler sowie die ChannelID eines Youtube-Kanals erhält, war Thema der Januar-Ausgabe Snapshots ([2]).

Die Funktion Search.List() in Zeile 23 listet zu einem vorgegebenen Youtube-Channel anhand dessen ChannelID die als MaxResults angegebenen fünf Videos, geordnet, wichtig, nach "viewCount", also Popularität. Zurück kommen normalerweise fünf Treffer, über die die for-Schleife ab Zeile 28 iteriert und die IDs gefundener Videos in den Array-Slice videoIDs steckt.

Da die Trefferliste zwar zu jedem Video einige Daten liefert, jedoch noch keine Klickzahlen, gibt Zeile 33 vorher gefundene IDs an einen weiteren Aufruf der List-Funktion weiter, die mit "statistics" die gesammelten Besucherstatistiken zu diesen Videos einholt. Mit diesen gewappnet, kann die for-Schleife ab Zeile 38 die ersehnte Top-5-Liste der fünf erfolgreichsten Videos des Channels mit ihren Besucherzahlen ausgeben (Abbildung 2).

Abbildung 2: Eine Applikation holt die 5 populärsten Videos eines Channels von Youtube

Doch in dieser Ausgabe soll der Fokus nicht auf Youtube, sondern auf dem selbstgeschnürten Paket murmur liegen, das mittlerweile auf Github liegt. Der Standard-Dreisprung zum Bauen des Hitparaden-Binaries aus Listing 1 zieht es heran, ganz wie vorher die Youtube-API. Abbildung 3 zeigt, wie go mod tidy das Paket auf Github in der Version 1.0.0 findet und diese einholt. Der Go-Compiler linkt alles zusammen und das fertig compilierte Binary yttop listet wie gewünscht die erfolgreichsten Videos des Channels (Abbildung 2).

Abbildung 3: Das hausgemachte Paket C zieht go von Github heran

Selbst gebaut

Wie nun sieht die selbstgebaute Paket-Library murmur aus und wie landet sie auf Github, damit beliebige Entwickler auf dem Internet, die go mod tidy aufrufen, sie finden und in ihren Code einbinden können? Der Konstruktor NewMurmur() ab Zeile 16 in Listing 2 kreiert eine Struktur vom Typ MurmurStore und gibt einen Pointer darauf an den Aufrufer zurück, der diesem fürderhin als Objekt für folgende Methodenaufrufe dienen wird.

Listing 2: murmur.go

    01 package murmur
    02 import (
    03   "fmt"
    04   "gopkg.in/yaml.v2"
    05   "io/ioutil"
    06   "os/user"
    07   "path"
    08 )
    09 const Version = "1.0.1"
    10 // Read secrets from a .murmur YAML file
    11 type Murmur struct {
    12   FilePath string
    13 }
    14 const StoreFileName = ".murmur"
    15 // Create a new instance
    16 func NewMurmur() *Murmur {
    17   return &Murmur{}
    18 }
    19 // Set the .murmur file path manually
    20 func (m *Murmur) WithFilePath(path string) *Murmur {
    21   m.FilePath = path
    22   return m
    23 }
    24 func homePath() (string, error) {
    25   u, err := user.Current()
    26   if err != nil {
    27     return "", err
    28   }
    29   p := path.Join(u.HomeDir, StoreFileName)
    30   return p, nil
    31 }
    32 // Look up a .murmur key by name and return its value
    33 func (m *Murmur) Lookup(name string) (string, error) {
    34   if len(m.FilePath) == 0 {
    35     path, err := homePath()
    36     if err != nil {
    37       return "", err
    38     }
    39     m.FilePath = path
    40   }
    41   dict, err := readYAMLFile(m.FilePath)
    42   if err != nil {
    43     return "", err
    44   }
    45   pass, ok := dict[name]
    46   if !ok {
    47     return "", fmt.Errorf("No entry found for %s", name)
    48   }
    49   return pass, nil
    50 }
    51 func readYAMLFile(path string) (map[string]string, error) {
    52   data := make(map[string]string)
    53   raw, err := ioutil.ReadFile(path)
    54   if err != nil {
    55     return data, err
    56   }
    57   err = yaml.Unmarshal(raw, &data)
    58   if err != nil {
    59     return data, err
    60   }
    61   return data, nil
    62 }

Einem Konstruktor Parameter mitzugeben (wie zum Beispiel die Lage der .murmur-Datei), ist in Go nicht standartisiert und wegen strenger Typisierung auch nicht sauber durch variable Parameterlisten lösbar. Das selbstgestrickte Paket murmur entscheidet sich dafür, den Konstruktor ohne Parameter zu definieren, und eine später optional aufgerufene Funktion WithFilePath() (ab Zeile 19) auf das Objekt anzubieten, die den Pfad zur Geheimnisdatei als String in der Objektstruktur setzt. Der Modifizierer gibt selbst auch wieder einen Pointer auf die Objektstruktur zurück, sodass sich später mehrere Modifizierer verketten lassen.

Stellt die Methode Lookup() ab Zeile 31 fest, dass der User einen Wert holen möchte, aber bislang noch kein Pfad vorliegt, sucht sie im Home-Verzeichnis des Users nach einer Datei namens .murmur und meldet einen Fehler falls dort nichts ist. Dies passiert nur beim ersten Aufruf, ab dann ist der Pfad in der Objektstruktur gesetzt.

Die JSON-Daten liest readYAMLFile() (kleingeschrieben weil nicht exportiert) ab Zeile 51 aus der Datei in eine Datenstruktur vom Typ map, und Zeile 45 prüft, ob der angegebene Schlüssel definiert ist. Liegt er vor, gibt Zeile 49 den zugehörigen Wert (also das Geheimnis) zurück, während bei erfolgloser Suche Zeile 47 dem Aufrufer einen Fehler meldet.

Listing 3: murmur_test.go

    01 package murmur
    02 import (
    03   "testing"
    04 )
    05 func TestLookup(t *testing.T) {
    06   mur := NewMurmur().WithFilePath("data/murmur.yaml")
    07   name := "foo"
    08   p, err := mur.Lookup(name)
    09   if err != nil {
    10     t.Log("name", name, "not found")
    11     t.Fail()
    12   }
    13   if p != "bar" {
    14     t.Log("name", name, "p", p, "mismatch")
    15     t.Fail()
    16   }
    17   name = "nonexist"
    18   p, err = mur.Lookup(name)
    19   if err == nil {
    20     t.Log("name", name, "found")
    21     t.Fail()
    22   }
    23 }

Ruhig schlafen mit Tests

Soweit das hoffentlich praktische neue Paket, der Code ist schön kompakt geblieben. Aber auch geübte Programmierer sehen einem Stück Code selten an, ob es tatsächlich funktioniert, also sollte ein auf Github abgestelltes Go-Paket immer Tests enthalten, die go test von der Kommandozeile ausführt und das OK gibt oder einen Fehler meldet.

Listing 3 definiert dazu in der Datei murmur_test.go (die Endung *_test.go ist verpflichtend) eine Funktion TestLookup() (auch der Präfix Test* ist vorgeschrieben), die den Konstruktor NewMurmur() aufruft, und in der zurückkommenden Objektstruktur (eigentlich ein Pointer darauf) mit WithFilePath() auf die im Testdatenverzeichnis data liegende YAML-Datei einnordet. Dort steht, wie Listing 4 zeigt, ein Eintrag zum Schlüssel foo, der auf den Wert bar zeigt, und genau dieses Ergebnis prüft das Testprogramm in Listing 3 in Zeile 13. Klappt alles, tut das Testprogramm nichts, tritt ein Fehler auf, meldet es diesen, und Gos Test-Framework testing wird auf t.Fail() hin die mit t.Log() abgesetzten Meldungen zum Einkreisen des Fehlers ausgeben. Abbildung 4 zeigt den Erfolgsfall im Verbose-Modus. Ohne -v liefe die Test Suite im Erfolgsfall wortlos durch.

Abbildung 4: Die Tests des neuen Pakets laufen durch.

Listing 4: murmur.yaml

    1 foo: bar
    2 some-key: "Quoted!"

Schon ein simpler Test ist viel besser als gar keiner, und Paketentwickler können nach zukünftigen Änderungen ruhigen Gewissens den neuen Release ausrollen, wenn die Testsuite noch klaglos durchläuft. So, was braucht eine Go-Library auf Github sonst noch, Dokumentation vielleicht? Nichts ist nerviger als auf ein interessantes Go-Paket auf Github zu stoßen, dessen Autor zu faul war, aufzuzeigen, wie man es im Detail verwendet.

Abbildung 5: Autogenerierte Dokumentation des Pakets

Go macht es dem Faulen einfach, denn Kommentarzeilen direkt über Typdefinitionen oder Funktionen interpretiert es als Dokumentation und zeigt diese auf Wunsch an, mitsamt der automatisch aus dem Sourcecode extrahierten Programmstrukturen. Keine Ausreden mehr! Stößt ein Suchender allerdings auf ein Paket auf Github, liegt selbiges noch nicht lokal vor, also funktioniert der sonst zur Sichtung der Doku übliche Aufruf von go doc auf der Kommandozeile noch nicht. Deshalb sollte einem Go-Projekt auf Github immer eine Datei README.md (im Markdown-Format) beiliegen, die neugierigen Besuchern die Nutzung des Pakets möglichst appetitanregend an einem Beispiel erklärt (Abbildung 6).

Abbildung 6: Das neu geschnürte Go-Paket auf Github

Aufgedröselt auf pkg.go.dev

Außerdem indiziert die Website pkg.go.dev alle Pakete auf Github, die nach Go aussehen, und dröselt autogenerierte Manualseiten detailliert auf. Um dem Server auf die Sprünge zu helfen, ist manchmal ein kurzer Besuch auf https://pgk.go.dev/github.com/user/repo notwendig, um ihm auf die Sprünge zu helfen. Allerdings besteht die Website darauf, dass dem Projekt eine gültige Lizenz in Form einer LICENSE-Datei beiliegt, go-murmur enthält dazu eine Kopie der Apache-2.0-Lizenz. Mit Lizenz formatiert die Dokuseite die Typen und Funktionen des Pakets für Endnutzer (Abbildung 7), ohne Lizenz meldet sie einen Fehler (Abbildung 8).

Abbildung 7: pkg.go.dev hat das Paket indiziert.

Abbildung 8: Die Lizenz muss stimmen, damit pkg.go.dev das Paket indiziert

Zur Nutzung des Pakets in anderen Projekten muss das Github-Repo eine Datei go.mod enthalten. Die in Abbildung 9 gezeigte Sequenz aus go mod-Kommandos erzeugt sie für den Autor des Pakets. Neben dem Modulnamen mit dem vollen Github-Pfad listet die neue Datei go.mod unter dem Stichwort require auch alle Pakete, von denen das Modul abhängt, im vorliegenden Fall benötigt go-murmur auch noch das YAML-Paket, das auf gopkg.in liegt. Mit dieser Definition kann der Go-Compiler später einen Dependency-Tree erzeugen und die zum Binden des Binaries benötigten Pakete der Reihe nach vom Netz holen und einbinden.

Abbildung 9: Das Projekt braucht eine go.mod-Datei

Damit User das neue Paket nutzen können, muss zusätzlich die aktuelle Version des Repos auf Github mit git tag ein Tag im Format v1.2.3 erhalten. So weiß go mod tidy auf der Client-Seite, welche Version gerade verfügbar beziehungsweise lokal installiert ist. Übrigens bietet Github auf der Homepage jedes Repos auch noch die Option, bestimmte Versionen als "Release" zu markieren. Die spielen allerdings für Go keine Rolle, der Compiler schaut immer nur nach den Git-Tags im Repo. Auch die Konstante Version in Zeile 9 von Listing 2 dient nur der internen Projektverwaltung und interessiert den Go-Compiler nicht.

Katze beißt Schwanz

Aber wird ein neuer Release auch bei anderen Usern funktionieren? Bevor Änderungen auf Github erscheinen, kann ein Testprogramm sie auch nicht von dort herunterladen, da beißt sich die Katze in den Schwanz. Ein go mod tidy zur Klärung der Abhängigkeiten wird auf Github kein go-murmur finden, wenn es dort noch nicht hochgeladen wurde. Auch bei neuen Releases wird go mod tidy noch die alte Version auf Github finden und in go.mod festzimmern, was ein nachfolgendes go build dazu bewegen wird, die alte Version zu testen und nicht den geplanten neuen Release.

Abhilfe schafft hier temporär das Schlüsselwort replace in go.mod, wie Abbildung 10 zeigt. Auf der rechten Seite nach dem => steht "..", also wird der Compiler später nicht auf Github schauen oder gar eine eventuell bereits heruntergeladene Github-Version des Pakets verwenden, sondern lokal im Verzeichnis .. danach suchen, wo sich hoffentlich die aktuelle *.go-Datei des neuen Releases befindet.

Abbildung 10: Die Direktive "replace" ersetzt die Github-Referenz testweise

Mittelsmann ausschalten

Wer mit git push Änderungen am Source-Code auf Github vornimmt, darf nicht erwarten, dass externe Clients wie der Go-Compiler diese sofort mitbekommen, sondern sollte sich auf lange Wartezeiten einstellen, da hier mehrere Caching-Ebenen ihren Dienst tun. Es kann schon mal ein halbes Stündchen dauern, bis alle Änderungen durchgesickert sind.

So kommt es vor, dass go mod tidy die neue Version auf Github nicht findet, sondern auf der alten, lokal installierten beharrt. Das liegt oft daran, dass der Go-Compiler go Github nicht direkt kontaktiert sondern über einen Mittelsmann, der in der Environment-Variablen GOPROXY gesetzt ist.

Dieser Service wird von Googles Go Team betrieben und soll verhindern, dass Millionen von laufenden Go-Buildern Github mit sich wiederholenden Anfragen überlasten. Während der Entwicklung neuer Versionen gilt es deshalb, den Mittelsmann auszuschalten, und das passiert mit der Environment-Variablen GOPROXY=direkt, die den Go-Compiler zwingt, direkt auf Github nach neuen Versionen zu suchen und sich nicht auf den Proxy mit seiner veralteten Information zu verlassen.

Ein weiterer Trick, um den cachenden Proxy bei go mod tidy auszuschalten, ist es, die in go.mod aufgelistete Version manuell hochzusetzen. Dann wird go mod tidy sofort und ohne Cache versuchen, an die neuere Version kommen.

Abbildung 11: Ins Repo hochgeladene Tags bestimmen den Release

Funktioniert alles nach Vorschrift? Dann ist es Zeit, die Dateien mit git push ins Repo auf Github hochzupumpen. Die Version des neuen Releases setzt git tag in Abbildung 11 lokal und ein anschließendes git push --tags schiebt das Tag auch ins Repo auf Github. Ab dann stürzen sich hoffentlich tausende User auf den Release. Verantwortungsvolle Paketautoren halten ihre Fans bei der Stange, in dem sie ab diesem Zeitpunkt keine inkompatiblen API-Änderungen mehr einbringen und eventuell gemeldete "Issues" blitzartig richten und sich artig bedanken.

Infos

[1]

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

[2]

Michael Schilli, "Kanalarbeiter", Linux-Magazin 01/24, <U>https://www.linux-magazin.de/ausgaben/2024/01/snapshot/<U>

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