Für elegantes Design ähnlicher Funktionen bietet Go den Interface-Mechanismus. Anhand eines Auffrischungsprogramms für lokale Git-Repo-Kopien zeigt Mike Schilli den praktischen Einsatz.
Urlaubszeit, Reisezeit! Auf Langstreckenflügen nehme ich gern den Laptop mit, schließlich kommen mir die besten Ideen für neue Artikel immer zur Unzeit. Ohne Internetanschluss funktionieren aber weder Google noch ChatGPT, und auch Github fällt für Code-Ideen aus. Selbst was ich bislang so geschrieben habe, steht in Git-Repos, deren Kopien auf dem Laptop nicht immer auf dem neuesten Stand sind, und das ist ärgerlich. Es führt zu versehentlich ausgeführter Doppelarbeit oder mindestens zu nerviger Reißverschlussintegration mit potenzieller Konfliktbereinigung, wenn ich die vorher in luftiger Höhe eingecheckten Texte später wieder mit dem Cloud-Repo in Einklang bringen will.
Wäre es nicht schön, vor der Abreise kurz ein Programm auf dem Laptop anzuwerfen, das die lokalen Kopien aller ausgecheckten Git-Repos auf den neuesten Stand bringt? Dabei sollte es nicht nur bereits existierende Klone auf dem Laptop auf den neuesten Cloud-Stand vorwärts rollen bis Git “up-to-date” meldet, sondern auch bislang nur auf meinem Heimcomputer ausgecheckte Repos auf dem Laptop klonen, sodass dieser alles liefert, was ich daheim so griffbereit habe.
Meta-Format
Nun kann das Helferlein (noch) nicht hellsehen und allesaufspielen, was mir wichtig ist. Deswegen nutze ich eine Meta-Datei nach Abbildung 1, die angibt, welche Repos unter welchen Verzeichnisnamen ich in jeder neuen Entwicklungsumgebung vorzufinden wünsche. Sie enthält ein Array im YAML-Format, dessen Elemente unter den Stichworten “dir” und “url” jeweils das Klon-Verzeichnis und die URL der wichtigsten Repos führt. Da es sich um Metadaten der Github-Repos handelt, soll das Format aus historischen Gründen [1] »GMF« (Github Meta Format) heißen, denn vor 15 Jahren habe ich es schon einmal in Perl geschrieben und hier vorgestellt. Nun bietet sich die Gelegenheit, es in Go umzuschreiben und gleich nebenbei Gos coole Interface-Technik zu demonstrieren.
Listing 1
repos.gmf
- dir: bondy
url: mschilli@shared01.somehoster.com:git/bondy.git
- type: github-user-repos
user: mschilli
- type: ssh-user-repos
host: shared01.somehoster.com
path: git
Denn das Meta-Format gibt nicht nur einzelne Repos an, sondern kann ganze Sammlungen in einem Rutsch definieren wie “alle Github-Repos dieses Users” oder “alle Unterverzeichnisse auf einem Host mit SSH-Anschluss”. Diese Einzelmeldungen oder Kollektionen soll der GMF-Parser in Go der Reihe nach aus der GMF-Datei auslesen, entsprechende Objekte daraus erzeugen, die alle die Methode »Expand()« beherrschen, aus der zu klonende Verzeichnisse mit ihren URLs herauspurzeln.
Der GMF-Parser geht also später durch alle Einträge der YAML-Datei in Listing 1 und ruft für jeden die Methode »Expand()« auf. Im ersten Eintrag in den Zeilen 1 und 2 bleibt nicht viel zu tun, denn das YAML-Element gibt schon den Git-URL sowie das Klon-Verzeichnis vor, also reicht »Expand()« einfach die Kombination an den anschließend laufenden Kloner weiter.
Der zweite Eintrag in Listing 1 referenziert ab Zeile 3 mit dem Typ »github-user-repos« die Repos eines Github-Users, also muss »Expand()« in dem Fall auf Github nachsehen, die Repos des Users einholen und daraus das Ergebnis zimmern, eine Liste von Git-URLs und deren Klonverzeichnissen. Der dritte Eintrag in Listing 1 ab Zeile 5 ist vom Typ »ssh-user-repos« und veranlasst »Expand()« hingegen, sich auf dem angegebenen Host per SSH einzuloggen, dort die Git-Verzeichnisse aufzulisten und eine Liste davon als Ergebnis zu liefern.
Ohne Spaghetti-Code
Dreimal ruft der Parser »Expand()« auf, aber jedes Mal kommt dieser auf unterschiedlichen Wegen zum Ergebnis. Nun könnte der Expandierer freilich in einem Labyrinth von »if-else«-Zweigen auf den aktuell richtigen Algorithmus zusteuern, aber das ist unübersichtlich, schwer auf Korrektheit zu testen, zu erweitern und zu warten. Objektorientiert schreibt sich die Lösung des Problems besser durch verschiedene Klassen, deren »Expand()«-Methoden unterschiedliche Dinge tun. Aus den Einträgen der YAML-Datei kommt eine Liste unterschiedlicher Objekte, deren »Expand()«-Methoden artspezifisch das Richtige tun. Eine Schleife iteriert anschließend über alle Objekte und ruft deren »Expand()«-Methoden auf, ohne sich explizit darum zu kümmern, von welchem Typ das Objekt nun eigentlich ist.
Vielgestaltig in Go
Dieses Verfahren heißt in der objektorientierten Programmierung Polymorphismus, vom griechischen Wort für Vielgestaltigkeit. Ein Bezeichner, also eine Variable, die ein Objekt enthält, kann demnach Instanzen unterschiedlicher Klassen annehmen, die aber alle dieselbe Methode beherrschen. Diese lösen zwar abhängig vom aktuell verwendeten Typ unterschiedliche Aktionen aus, heißen aber eben gleich und liefern Ergebnisse im gleichen Format.
Listing 2
gmf.go
package main
import (
"errors"
"os"
)
type Cloneable struct {
URL string
Dir string
}
type Gitmeta struct {
Cloneables []Cloneable
}
type PluginIf interface {
Applicable(e GitMetaEntry) bool
Expand(e GitMetaEntry) ([]Cloneable, error)
}
func (g *Gitmeta) FindPlugin(m GitMetaEntry) (PluginIf, error) {
plugins := []PluginIf{
NewGMFEntry(),
NewGMFGithubUser(),
NewGMFSSH(),
}
for _, plugin := range plugins {
if plugin.Applicable(m) {
return plugin, nil
}
}
return nil, errors.New("No applicable plugin found")
}
func NewGitmeta() *Gitmeta {
return &Gitmeta{
Cloneables: []Cloneable{},
}
}
func (g *Gitmeta) AddGMF(f *os.File) error {
entries, err := g.parseGMF(f)
if err != nil {
return err
}
for _, e := range entries {
p, err := g.FindPlugin(e)
if err != nil {
return err
}
cloneables, err := p.Expand(e)
if err != nil {
return err
}
for _, cloneable := range cloneables {
g.Cloneables = append(g.Cloneables, cloneable)
}
}
return nil
}
func (g *Gitmeta) AllCloneables() []Cloneable {
return g.Cloneables
}
Nun hat sich Go allerdings strenge Typen auf die Fahne geschrieben, und ein und dieselbe Variable kann niemals unterschiedliche Typen aufnehmen. In die Bresche springt hier der Interface-Typ von Go, der festlegt, welche Funktionen eine Variable eines Typs beherrscht. Diese Variablen lassen sich dann wie andere auch in Arrays stecken oder herumreichen, und wer ihre Methoden aufruft, bekommt das gewünschte Ergebnis, egal von welchem Typ das darunter versteckte Objekt nun tatsächlich ist.
Plugin je nach Aufgabe
Damit nun der GMF-Parser in Listing 2 alle Einträge unterschiedlicher Typen über einen Kamm scheren kann, definiert er ab Zeile 13 den Typ »PluginIf«, der drei Funktionen beherrscht. »Applicable()« prüft, ob das Plugin mit dem aktuellen GMF-Eintrag aus der Meta-Datei etwas anfangen kann. Gibt es ein »true« zurück, ruft das Hauptprogramm die Funktion »Expand()« des Plugins auf, um ein Array von klonbaren Repositories zu erhalten. Kommt »false« zurück, versucht es das Hauptprogramm mit dem nächsten Plugin.
Was der Aufruf von »Expand()« nun macht, hängt vom jeweiligen Plugin ab. Das Plugin für Einzeleinträge mit Repo-URL und Klon-Verzeichnis, wie ab Zeile 1 in Listing 1, gibt lediglich einen Array mit einem einzigen Element zurück, nämlich eine Variable vom Typ »Cloneable«, die diese Werte enthält. Den Code für dieses simple Plugin zeigt später Listing 4. Allen Plugins ist gemein, dass sie einen Konstruktor anbieten (zum Beispiel »NewGMFEntry()«), der ein Objekt des Typs zurückgibt, mit dem das Hauptprogramm später die im Interface standardisierten Funktionen aufrufen kann.
Plugin gesucht
Liest später der GMF-Parser einen YAML-Eintrag aus, kommt dieser als Variable vom Typ »GMFMetaEntry« zurück, und »FindPlugin()« ab Zeile 17 in Listing 2 klappert dann alle bekannten Plugins ab, um das dafür zuständige zu finden. Das Array-Slice »plugins« ab Zeile 18 in Listing 2 enthält drei Plugin-Referenzen, die es alle durch Aufrufen von deren Konstruktoren mit »New…()« erzeugt.
Dann iteriert die »for«-Schleife ab Zeile 23 durch alle bekannten Plugins, ruft für jedes dessen »Applicable()«-Funktion auf, bis eines »true« zurückgibt. Die gefundene Referenz reicht »FindPlugin« in Zeile 25 an den Aufrufer zurück oder meldet in Zeile 28 einen Fehler, falls sich kein passendes Plugin fand.
Der GMF-Parser in Listing 2 kommt nun selbst objektorientiert daher, und Zeile 30 definiert seinen Konstruktor. Als Instanzdaten enthält die Struktur »Gitmeta« in Zeile 11 einen Array-Slice von »Cloneable«-Typen, die jeweils zu klonende Repositories mit deren Ziel-Verzeichnissen enthalten (Abbildung 1). Die Funktion »AddGMF()« ab Zeile 35 nimmt einen File-Deskriptor auf eine geöffnete GMF-Datei entgegen, liest ihren YAML-Inhalt mit »parseGMF()« aus (später in Listing 3), das einen Array-Slice von »GitMetaEntry«-Strukturen zurückgibt.
Sachbearbeiter gefunden
Für jedes Element sucht dann »FindPlugin()« in Zeile 41 den richtigen Sachbearbeiter, und der Aufruf von »Expand()« in Zeile 45 lässt das Plugin herausfinden, welche tatsächlichen Repos vom Typ »Cloneable« denn der aktuelle Eintrag tatsächlich meint.
Jeden Treffer hängt die »for«-Schleife ab Zeile 49 an das Instanz-Array »Cloneables« an, und die exportierte Funktion »AllCloneables()« ab Zeile 55 tut nichts weiter, als das bisherige Ergebnis als Array-Slice dem Aufrufer zurückzureichen.
Listing 3
parser.go
package main
import (
"gopkg.in/yaml.v2"
"io/ioutil"
"os"
)
type GitMetaEntry struct {
URL string `yaml:"url"`
Type string `yaml:"type"`
Dir string `yaml:"dir"`
User string `yaml:"user"`
Path string `yaml:"path"`
Host string `yaml:"host"`
}
func (g *Gitmeta) parseGMF(f *os.File) ([]GitMetaEntry, error) {
records := []GitMetaEntry{}
data, err := ioutil.ReadAll(f)
if err != nil {
return records, err
}
err = yaml.Unmarshal(data, &records)
if err != nil {
return records, err
}
return records, nil
}
Welche Einträge als Elemente der langen Liste der YAML-Datei erlaubt sind, definiert Listing 3. Ein Eintrag kann einen Typ angeben. Falls dieser fehlt, handelt es sich um eine einfache Repo-Definition aus URL und Zielverzeichnis.
Der Go-Typ »GitMetaEntry« definiert ab Zeile 7 alle erlaubten Feldnamen der Einträge in der ».gmf«-Datei. Neben dem optionalen Typ kann ein Eintrag die Felder »URL« und »Dir« für einen Einzeleintrag enthalten oder »user« für ein Github-Repo oder »host« und »path« für einen ssh-Host, der Git-Repos speichert (Abbildung 3).
Die Funktion »parseGMF()« ab Zeile 15 nimmt einen offenen File-Deskriptor entgegen, liest die Daten, macht aus der YAML-Konfiguration eine Liste von »GitMetaEntry«-Objekten und reicht diese an den Aufrufer zurück.
Einfacher Plugin
Als erstes widmet sich das Plugin in Listing 4 einfachen Einträgen, die gleich die URL und das Zielverzeichnis eines zu klonenden Repos enthalten. Es kommt – wie später seine großen Brüder – objektorientiert daher und definiert in Zeile 3 den Konstruktor »NewGMFEntry()«, der wie in Go üblich eine Struktur zurückgibt, die als Instanzdatenhalter dient. In dem simplen Fall bleibt die Struktur einfach leer, dient aber dem Aufrufer später als Referenz zum Aufruf weiterer Plugin-Funktionen.
Listing 4
gmf-entry.go
package main
type GMFEntry struct {}
func NewGMFEntry() GMFEntry {
return GMFEntry{}
}
func (g GMFEntry) Applicable(e GitMetaEntry) bool {
return e.Type == ""
}
func (g GMFEntry) Expand(e GitMetaEntry) ([]Cloneable, error) {
return []Cloneable{
{URL: e.URL, Dir: e.Dir},
}, nil
}
Die Funktion »Applicable()« ab Zeile 6 prüft, ob der YAML-Definition des Repos ein Feld mit dem Namen »Type« beiliegt. Fehlt dieses, handelt es sich um einen Fall für das Plugin, und sie gibt »true« zurück, weil »e.Type == “”« in Zeile 7 einen wahren Wert ergibt. Aus dem YAML-Eintrag eine Liste von Strukturen zu generieren und zurückzugeben, ist im simplen Fall in »Expand()« ab Zeile 9 reine Formsache. So, das erste Plugin wäre geschafft!
Plugin für Github
Wie sieht es mit einer Github-Referenz auf die Repos eines Users aus? Listing 5 zeigt das nach demselben Schema konstruierten Plugin.
Listing 5
gmf-ghuser.go
package main
import (
"encoding/json"
"fmt"
"net/http"
)
type Repository struct {
Name string `json:"name"`
Fork bool `json:"fork"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:html_url"`
}
type GMFGithubUser struct {}
func NewGMFGithubUser() GMFGithubUser {
return GMFGithubUser{}
}
func (g GMFGithubUser) Applicable(e GitMetaEntry) bool {
return e.Type == "github-user-repos"
}
func (g GMFGithubUser) Expand(e GitMetaEntry) ([]Cloneable, error) {
clones := []Cloneable{}
res, err := githubProjectsOfUser(e.User)
if err != nil {
return clones, err
}
for _, repo := range res {
clones = append(clones, Cloneable{URL: repo.SSHURL, Dir: repo.Name})
}
return clones, nil
}
func githubProjectsOfUser(user string) ([]Repository, error) {
results := []Repository{}
url := "https://api.github.com/users/" + user + "/repos"
resp, err := http.Get(url)
if err != nil {
return results, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return results, fmt.Errorf("Status %s", resp.Status)
}
var repos []Repository
if err := json.NewDecoder(resp.Body).Decode(&repos); err != nil {
return results, err
}
for _, repo := range repos {
if !repo.Fork {
results = append(results, repo)
}
}
return results, err
}
Es bietet in Zeile 14 gleichfalls einen Konstruktor an und gibt dem Aufrufer eine leere Struktur vom Typ »GMFGithubUser« zurück. Die Interface-Funktion »Applicable()« ab Zeile 17 prüft das »type«-Feld des Eintrags und springt auf den String »github-user-repos« an. Später kontaktiert »Expand()« ab Zeile 20 über die Hilfsfunktion »githubProjectsOfUser()« ab Zeile 31 die Github-API und lässt sich die Repos des angegebenen Users im JSON-Format auflisten.
Zurück kommt, wie in Abbildung 1 zu sehen, ein Wust von Daten, der sämtliche Repos des Users mit allerlei Parametern auflistet, so zum Beispiel ob es sich um einen Fork eines anderen Projekts handelt. Da der Kloner nur an Original-Repos interessiert ist, verwirft Zeile 47 alle Forks. Schließlich gibt »Expand()« wiederum eine Liste von »Cloneable«-Strukturen an das Hauptprogramm zurück.
Plugin für SSH
Listing 6
gmf-ssh.go
package main
import (
"bufio"
"bytes"
"fmt"
"os/exec"
"strings"
)
type GMFSSH struct {}
func NewGMFSSH() GMFSSH {
return GMFSSH{}
}
func (g GMFSSH) Applicable(e GitMetaEntry) bool {
return e.Type == "ssh-user-repos"
}
func (g GMFSSH) Expand(e GitMetaEntry) ([]Cloneable, error) {
clones := []Cloneable{}
res, err := sshGitDirs(e.Host, e.Path)
if err != nil {
return clones, err
}
for _, dir := range res {
ldir := strings.TrimSuffix(dir, ".git")
clones = append(clones, Cloneable{
URL: fmt.Sprintf("%s:%s/%s", e.Host, e.Path, dir),
Dir: ldir,
})
}
return clones, nil
}
func sshGitDirs(host string, path string) ([]string, error) {
results := []string{}
cmd := exec.Command("ssh", host, "ls", path+"/*/config")
output, err := cmd.Output()
if err != nil {
return results, err
}
reader := bytes.NewReader(output)
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, "/")
if len(parts) > 2 {
results = append(results, parts[len(parts)-2])
}
}
return results, err
}
Aus den Pfaden zu den »config«-Dateien gefundener Repos schneidet Zeile 44 den letzten Pfadteil ab und fügt den Namen des Verzeichnisses ans Ende des String-Array-Slices »results« an. Anschließend schneidet Zeile 23 noch den oft verwendeten Anhang ».git« ab und macht so daraus den Namen, den der Git-Kloner für die lokale Kopie verwenden soll. Die URL zum Repo zimmert Zeile 25 aus dem Hostnamen, dem Pfad und dem Repo-Verzeichnis zusammen. Fertig ist das Ergebnis einer Liste von »Cloneable«-Strukturen.
Und … Action!
Dem Hauptprogramm in Listing 7 bleibt nun nur noch, in »main()« den Namen der zu bearbeitenden GMF-Datei von der Kommandozeile einzulesen, sie zu öffnen und mit »AddGMF()« dem Parser vorzulegen. Zurück kommt im Erfolgsfall eine Liste von »Cloneable«-Strukturen, die die »for«-Schleife ab Zeile 28 abklappert und jeweils »cloneOrUpdate()« aus Listing 8 aufruft.
Listing 7
gitmeta.go
package main
import (
"fmt"
"os"
"os/user"
"path/filepath"
)
func main() {
if len(os.Args) != 2 {
panic(fmt.Errorf("usage: %s repos.gmf", os.Args[0]))
}
cfg := os.Args[1]
gmf := NewGitmeta()
f, err := os.Open(cfg)
if err != nil {
panic(err)
}
gmf.AddGMF(f)
usr, err := user.Current()
if err != nil {
panic(err)
}
gitDir := filepath.Join(usr.HomeDir, "git")
err = os.Chdir(gitDir)
if err != nil {
panic(err)
}
for _, c := range gmf.AllCloneables() {
err := cloneOrUpdate(c, gitDir)
if err != nil {
panic(err)
}
}
}
Diese Funktion nimmt eine »Cloneable«-Struktur und den Pfad entgegen, unter dem alle Git-Klone landen. Vorher hat das Hauptprogramm dazu »gitPath« auf das Verzeichnis »git« unter dem Home-Verzeichnis des aktuellen Users gesetzt; wer etwas anderes wünscht, ändert dies entsprechend.
Listing 8
clone.go
package main
import (
"os"
"os/exec"
"path"
)
func cloneOrUpdate(c Cloneable, gitPath string) error {
fullPath := path.Join(gitPath, c.Dir)
_, err := os.Stat(fullPath)
if os.IsNotExist(err) {
cmd := exec.Command("git", "clone", c.URL, fullPath)
_, err := cmd.CombinedOutput()
if err != nil {
return err
}
}
err = os.Chdir(fullPath)
if err != nil {
return err
}
cmd := exec.Command("git", "pull")
_, err = cmd.CombinedOutput()
return err
}
Beim eigentlichen Klon-Vorgang gibt es nun zwei Möglichkeiten: Entweder existiert das Verzeichnis mit dem Klon schon und muss mit »git pull« nur auf den neuesten Stand gebracht werden. Oder aber es existiert noch nicht, und »git clone« in Zeile 11 erzeugt eine neue lokale Kopie des Remote-Repos.
Zusammenzimmern
Aus allen Listings ein Binary zu bauen, geht wie immer über den Dreisprung »git mod init gitmeta; git mod tidy; go build«, der ein neues Go-Modul definiert, alle Abhängigkeiten von externen Bibliotheken auflöst und alles zu einem Executable zusammenlinkt. Nun noch schnell eine ».gmf«-Datei mit allen zu klonenden Repos erzeugt, und die Reise kann losgehen! (uba)
Infos
- Michael Schilli, “Überall Projekte”: Linux-Magazin 08/2010, https://www.linux-magazin.de/ausgaben/2010/08/ueberall-projekte/








