Ein eigenes Command-Line-Interface in Go entwickeln
In diesem Artikel bauen wir gemeinsam ein Command-Line-Interface (CLI) anhand eines praktischen Beispiels: dem Auslesen einer Webseite. Ein CLI bietet einen direkten Weg, Aufgaben auszuführen, Einstellungen zu setzen und Dienste zu verwalten. Ein eigenes CLI in einer Sprache wie Go (Golang) zu schreiben, lohnt sich daher aus mehreren Gründen:

- Komplexe Abläufe lassen sich in einfache Befehle kapseln (Kapselung, Clean Code). Eine verständliche Oberfläche für komplexe Systeme erhöht die Chance, dass das Team das Werkzeug annimmt und nutzt.
- Ein CLI kann der Grundstein für standardisierte Arbeitsabläufe im Team sein.
- Es kann vorgegebene Abläufe und Reihenfolgen erzwingen und so Abweichungen und Fehler minimieren.
Oder man baut einfach ein CLI, weil grafische Oberflächen so Mainstream sind und man lieber tippt wie ein Hacker im Film :)
Los geht's
Für das CLI nutzen wir die großartigen Bibliotheken Cobra und Viper, die Befehlsstruktur und Konfigurationsverwaltung in Go deutlich vereinfachen.
Schau dir ihre GitHub-Seiten an, um alle Funktionen kennenzulernen, und lass einen Stern da, wenn sie dir helfen.
Übrigens: Auch das bekannte Kubernetes-Werkzeug kubectl ist mit diesen Bibliotheken gebaut.
- Framework für CLI-Werkzeuge - Cobra
- Konfiguration für das CLI - Viper
- Cobra-cli: Verwaltung von CLI-Anwendungen auf Cobra-Basis - Cobra-CLI
Der vollständige Code liegt im Repository My-First-Cli. Am Ende des Artikels zeige ich ein komplexeres Szenario, in dem die index.html einer Webseite ausgelesen wird.
Einrichtung
Ich verwende Go in Version 1.22 - go version.
Richten wir zuerst das CLI-Projekt sauber ein. Dazu legst du ein neues Go-Projekt an und installierst das Cobra-CLI-Werkzeug.
Mit diesen Schritten ist alles startklar:
# Ordner anlegen
mkdir my-first-cli
cd my-first-cli
# cobra-cli installieren
go install github.com/spf13/cobra-cli@latest
# Go-Projekt anlegen
go mod init github.com/raphaeldeveloperberlin/my-first-cli
# Abhängigkeiten installieren
go get -u github.com/spf13/viper
go get -u github.com/spf13/cobra@latest
# Cobra-CLI-Anwendung erzeugen
cobra-cli init --viper
# Prüfen, ob alle Abhängigkeiten geladen wurden
go mod tidy
- Command not found: cobra-cli. Siehe Ende des Artikels.
Die Ordnerstruktur sollte jetzt so aussehen:
my-first-cli/
│
├── cmd/
│ └── root.go # Viper-Konfiguration & Cobra-Root-Befehl
│
├── go.mod # Abhängigkeitsverwaltung
├── go.sum # Abhängigkeitsverwaltung
├── LICENSE # Deine Lizenz
└── main.go # Hauptprogramm
Viper-Konfiguration
Standardmäßig liest Viper die Konfiguration aus dem Home-Verzeichnis - $HOME/.my-first-cli.yaml.
Ich habe die Konfiguration lieber im Wurzelverzeichnis des Go-Projekts, das ist reine Vorliebe.
Dafür sind ein paar kleine Änderungen in der Viper-Konfiguration (root.go) nötig.
+ rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $PWD/.my-first-cli.yaml)")
- rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.my-first-cli.yaml)")
+ actualDirectory, err := GetActualDirectory()
- home, err := os.UserHomeDir()
+ viper.AddConfigPath(actualDirectory)
- viper.AddConfigPath(home)
# Folgende Funktion ans Ende der Datei anhängen
func GetActualDirectory() (string, error) {
originalDir, err := os.Getwd()
if err != nil {
return "", err
}
return originalDir, nil
}
Jetzt können wir die Datei .my-first-cli.yaml im Wurzelordner my-first-cli anlegen, und Viper liest sie automatisch als Standardkonfiguration.
Wer den Ort der Konfiguration beim Aufruf ändern möchte, nutzt dafür das vordefinierte Flag --config.
Den ersten Befehl anlegen
Den ersten Befehl erzeugst du mit:
# Im Wurzelordner (my-first-cli)
cobra-cli add scrape
Passe gern die Kurz- und Langbeschreibung in der scrape-Datei an, damit der Zweck klarer wird.
Zum Beispiel:
+ Short: "Scraping the index.html file from a website."
- Short: "A brief description of your command",
Ausprobieren
Probieren wir es aus:
# Im Wurzelordner (my-first-cli)
# Schneller Test
go run .
# Als Binary
go build .
# Testen
./my-first-cli
Wird kein Befehl angegeben, zeigt Cobra von Haus aus alle verfügbaren Optionen des CLI an.
Unser gerade angelegter scrape-Befehl ist ebenfalls dabei. Großartig! Also probieren wir scrape aus.
# Schneller Test
go run . scrape
# Als Binary
go build .
./my-first-cli scrape
# Ergebnis
scrape called
Einen Befehl anzulegen ist also recht einfach. Gehen wir ein echtes Beispiel durch, um auch komplexere Aspekte wie Flags und Konfiguration abzudecken.
Beispiel
Im Beispiel lesen wir eine Webseite aus und speichern das HTML an einem vordefinierten Ort.
Dafür brauchen wir ein Flag für die URL der Webseite. Der Speicherort kommt aus der Konfigurationsdatei (.my-first-cli.yaml).
Den Code zum Auslesen der Seite und zum Speichern auf der Festplatte im Detail zu erklären, würde den Rahmen des Artikels sprengen. Bei Fragen erreichst du mich jederzeit auf GitHub.
Flag website-url anlegen
Für das Flag ergänzen wir in der init-Funktion von scrape.go folgende Zeile.
Zuerst kommt der Name des Flags, dann der leere Standardwert, zuletzt eine kurze Beschreibung.
scrapeCmd.Flags().String("website-url", "", "Select a website-url")
Um das Flag als Pflichtangabe zu prüfen, kontrollieren wir, ob es gesetzt ist.
Wir lesen den Wert aus dem Befehl aus; ist er leer, geben wir einen Fehler aus und beenden das Programm.
Die Funktion kommt ans Ende der Datei scrape.go.
// Zum Import hinzufügen: "github.com/spf13/cobra"
func ValidateFlagWebsiteUrl(cmd *cobra.Command) string {
websiteUrlFlag, err := cmd.Flags().GetString("website-url")
if err != nil {
log.Fatalf("Exception while reading flags - %v", err)
}
if websiteUrlFlag == "" {
log.Println("Please add the flag website-url")
os.Exit(1)
}
return websiteUrlFlag
}
location in der Konfiguration anlegen
In der .my-first-cli.yaml legen wir ein neues Schlüssel-Wert-Paar an.
Den Pfad passt du natürlich an dein Dateisystem an.
location: /path/to/your/location/index.html
Um den Speicherort als Pflichtangabe zu prüfen, kontrollieren wir, ob er gesetzt ist.
Die Funktion kommt ans Ende der Datei scrape.go.
// Zum Import hinzufügen: "github.com/spf13/viper"
func ValidateLocationConfig() string {
location := viper.GetViper().GetString("location")
if location == "" {
log.Println("Please add the location to .my-first-cli.yaml")
os.Exit(1)
}
return location
}
Den scrape-Befehl definieren:
Hier führen wir alle Funktionen zusammen und starten das Hauptprogramm. Passe den folgenden Block in scrape.go so an:
var scrapeCmd = &cobra.Command{
Use: "scrape",
Short: "Scraping the index.html file from a website.",
Long: `Scraping the index.html from a website involves retrieving the main HTML...`,
Run: func(cmd *cobra.Command, args []string) {
websiteUrl := ValidateFlagWebsiteUrl(cmd)
location := ValidateLocationConfig()
log.Println("Start to scrape website %v to location %v", websiteUrl, location)
scrapeAndExtractHtml(websiteUrl, location) // Implementierung im nächsten Block
},
}
Für das Hauptprogramm nutzen wir die Bibliothek chromedp. Stelle sicher, dass sie heruntergeladen ist.
# Zum Import hinzufügen: "github.com/chromedp/chromedp"
go get github.com/chromedp/chromedp
Zum Schluss hängen wir den restlichen Code ans Ende der Datei scrape.go.
Das ist die Logik zum Auslesen und Speichern der html-Seite auf der Festplatte.
Der Code ist nicht weiter strukturiert.
Für ein größeres Projekt lohnt sich die Aufteilung in eigene Packages, das macht ihn übersichtlicher und wartbarer.
func scrapeAndExtractHtml(websiteUrl, location string) {
ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()
ctx, cancel = context.WithTimeout(ctx, 20*time.Second)
defer cancel()
var html string
GetHtmlFromMainDom(websiteUrl, ctx, &html)
saveToDisk(location, html)
}
func GetHtmlFromMainDom(webSiteUrl string, ctx context.Context, html *string) {
if err := chromedp.Run(ctx,
chromedp.Navigate(webSiteUrl),
chromedp.WaitVisible(`html`, chromedp.ByQuery),
chromedp.OuterHTML("html", html, chromedp.ByQuery),
); err != nil {
log.Fatal(err)
}
}
func saveToDisk(filepath, filecontent string) {
buffer := createBufferForHtmlString(filecontent)
bufferToDestination(buffer, filepath)
}
func createBufferForHtmlString(filecontent string) *strings.Builder {
buffer := new(strings.Builder)
buffer.WriteString(filecontent + "\n")
return buffer
}
func bufferToDestination(codeToCopyBuffer *strings.Builder, filepath string) {
CreateFoldersIfNotExist(filepath)
writeResultToDisk(codeToCopyBuffer, filepath)
}
func CreateFoldersIfNotExist(destinationFile string) {
destinationFolder := filepath.Dir(destinationFile)
err := os.MkdirAll(destinationFolder, os.ModePerm)
if err != nil {
log.Println("Error creating destination folder:", err)
return
}
}
func writeResultToDisk(toSaveContent *strings.Builder, pathFilename string) {
openFile, err := os.Create(pathFilename)
if err != nil {
log.Print("File couldn't be created or truncated.")
}
defer func() {
if err := openFile.Close(); err != nil {
panic(err)
}
}()
r := strings.NewReader(toSaveContent.String())
w := bufio.NewWriter(openFile)
buf := make([]byte, 1024)
for {
n, err := r.Read(buf)
if err != nil && err != io.EOF {
panic(err)
}
if n == 0 {
break
}
if _, err := w.Write(buf[:n]); err != nil {
panic(err)
}
}
if err = w.Flush(); err != nil {
panic(err)
}
}
Endergebnis
Probieren wir es aus! Zuerst bauen wir das CLI.
go build .
# Testen
./my-first-cli scrape --website-url https://example.com
Die Datei index.html wurde erfolgreich an den vordefinierten Ort heruntergeladen.
Es gibt noch weitere Themen wie die Strukturierung von Befehlen oder boolesche Flags. Passe dein CLI gern an deine eigenen Anwendungsfälle an. Viel Spaß beim Ausprobieren!
Errors
Hilfe bei möglichen Fehlern.
Command not found: cobra-cli
# sollte cobra-cli anzeigen
ls $GOPATH/bin
# Prüfen, ob GOPATH/bin in der PATH-Variable gesetzt ist
export GOPATH=$(go env GOPATH)
export PATH=$PATH:$GOPATH/bin