Wie baut man elegant mit Cobra ein CLI-Tool in Go in 15 Minuten?

22.07.2021Raffael Schneider
Cloud Go CLI Distributed Systems Kubernetes DevOps Framework Hands-on

Wenn man mit Go schonmal die ersten Gehversuche gemacht hat, weiss man, dass es nicht immer leicht ist die ideale Library zu finden, wenn man das Rad nicht immer neu erfinden möchte. In seiner relativen kurzen Lebensdauer (Golang gibt es seit 2009), hat Go genug Erfolg gehabt, dass sich eine globale Community und ein entsprechendes Library-Ökosystem gebildet hat. Es gibt beispielsweise eine gepflegte Liste aller Go-relevanten Projekten und Libraries unter https://github.com/avelino/awesome-go. Dadurch wurden auch viele Problemstellungen mehrmals gelöst und als Repository (das Dependency Management bei Go beruht immer noch auf Git-Repositories) bereitgestellt.

Wir bei b-nova sehen stets darauf ab die Best Breeds im Einsatz zu haben. In letzter Zeit entwickeln wir zudem vermehrt Go-Projekte und möchten heute aus diesem Grund unsere Bread-&-Butter Go-Library vorstellen: nämlich das CLI-Duo Cobra und Viper.

Wer ist spf13?

Bevor wir uns Cobra und Viper weiter anschauen, möchte ich ein paar Worte zu Steve Francia, auch bekannt unter seinem Kürzel spf13, verlieren. Steve ist eine bekannte Grösse in der Go-Community und ist der amtierende Product Lead der Go-Programmiersprache bei Google. Er ist zudem der Erfinder und Hauptentwickler von Hugo, dem Static-Site-Generator (erfahre mehr SSGs wie Hugo in unserem TechUp über JAMstack), Cobra und Viper.

Cobra – Die Library für die CLI

Cobra ist eine vielfach genutzte Library um flink CLI-fähige Software in Go zu schreiben. Beispielsweise nutzen neben Hugo auch die allseits bekannte Kubernete-CLI oder die Github-CLI die Cobra-Library. Dabei stellt Cobra die Möglichkeit zur Verfügung POSIX-konforme Flags, verschachtelte Command-Strukturen, globale wie lokale Parameter, kustomizierte Hilfeausgaben oder Auto-Completions für die bekannten Shells wie Bash, Zsh oder sogar Powershell zu verwenden.

Cobra verwendet dabei ein geschickt erdachtes Strategy-Pattern, um alle möglichen Command-Strukturen abzubilden. Was es damit auf sich hat schauen wir uns jetzt kurz an.

Command-Line und CLI-Strukturen und Unix-Philosophie

Ein CLI-Tool haben wir alle sicher schonmal genutzt. Dabei gibt es ganz einfache Commands wie etwa ls, cd oder cat, die man zur Navigation und/oder zum Lesen von Dateien nutzt. Es gibt aber auch CLI-Tools, die etwas mehr können und heutzutage oft zur Anwendung kommen. Ein gutes und bekanntes Beispiel wäre die CLI für Kubernetes: kubectl. Damit kann man sich an einen Kubernetes-Cluster verbinden und den Cluster komplett per Command-Line bedienen. Falls man noch nie darüber nachgedacht hat, folgen so ziemlich alle CLI-Tools einer gewissen Konvention, sodass man intuitiv diese Tools nutzen kann, auch wenn man die spezifische Implementation eines neuen CLI-Tools noch nicht genau kennen sollte.

Diese Konvention ist auch als die Unix-Philosophie bekannt. Kurz zusammengefasst, und ich zitiere Peter H. Salus aus dem Jahre 1994, unterliegt ein jedes Tool folgender Eigenschaften (frei übersetzt):

  • Write programs that do one thing and do it well: Schreibe Programme die genau ein Ding gut machen

  • Write programs to work together: Schreibe Programme die gut miteinander zusammen arbeiten

  • Write programs to handle text streams, because that is a universal interface: Schreibe Programme die Text-Streams nutzen, die als universelle Schnittstelle gelten

Dies werden wir bei der Gestaltung unserer CLI so gut wie möglich im Hinterkopf behalten. Wir werden sehen, warum dies so wichtig ist. Als Nächstes möchten wir uns anschauen wie ein Nutzung (Usage) einer CLI aussieht. Diese unterliegt auch Konventionen, die sich aus den Prinzipien der Unix-Philosophie ableiten lassen.

Anhand des Beispiels der kubectl werde ich kurz die Struktur erläutern. Zuerst den Top-Level Command. Das ist die Binary die aufgerufen wird. Im Beispiel oben ist dies democtl und kubectl ganz einfach kubectl. Danach kommt der eigentliche Command (wird auch oft als Verb bezeichnet) und kann beschreibt was gemacht werden muss.

1
jamctl {command} {subcommand} {args..} {flags..}

Die Begriffe werden oft auch anders genannt und können alternativ dem unten stehenden Format entsprechen. Dies ist in erster Linie Geschmackssache, aber sicher gut zu wissen, wenn man die beiden Schreibweisen kennt.

1
jamctl {verb} {resource} {resource-name} {parameters..}

Um Cobra anzuwenden, gilt es die Command-Struktur in den go-Dateien abzubilden. Dies kann für eine klassische CLI-Anwendung wie folgt aussehen:

1
2
3
4
5
6
7
├── commands
│   ├── commands.go
│   ├── cmd1.go
│   ├── cmd2.go
│   ├── cmd3.go
│   └── {...}.go
└── main.go

Die commands.go stellt dabei die unterste Ebene, die rootCmd, der CLI-Anwendung dar. Diese implementiert dann jeweils dessen Sub-Commands. Hier eine Vorlage wie die Implementation dieser Cobra-Commands aussehen kann:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// main.go
----------

func main() {
	err := commands.Execute()
	if err != nil && err.Error() != "" {
		fmt.Println(err)
	}
}


// commands.go
--------------
var (
	rootCmd = &cobra.Command{
		Use:           "democtl",
		Short:         "democtl – demonstration command-line tool",
		Long:          ``,
		SilenceErrors: true,
		SilenceUsage:  true,
	}
)

func Execute() error {
	return rootCmd.Execute()
}


// cmd1.go
----------

var (
	cmd1 = &cobra.Command{
		Use:   "cmd1",
		Short: "cmd1 does something",
		Long:  ``,
		Run:   cmdOne,
	}
)

func cmdOne(ccmd *cobra.Command, args []string) {
	// executes what cmdOne is supposed to do
}

Viper – Die Library für die Configs

Viper ist genau wie Cobra eine Go-Library die ganze Konfiguration der Applikation übernimmt. Genau wie Cobra ist auch Viper in vielen Go-Projekten im Einsatz und stellt mittlerweile den Gold-Standard für Applikationskonfigurationen dar.

Unterstützte Formate und Config-Datenquellen:

  • JSON

  • TOML

  • YAML

  • HCL

  • envfile

  • Java properties Config-Dateien

  • Environment-Variablen

  • Remote Config-Systems wie etcd oder Consul (plus Live-Watching)

  • Buffer-Reading

Wichtige Eigenschaften die man bei Viper berücksichtigen muss:

  • Viper agiert wie ein Singleton: Trotzdem kann Viper auch mehrmals instanziiert werden.

  • Viper komplett eigenständig anwendbar: Viper kann ganz ohne Cobra eingesetzt werden.

  • Config-Keys sind Case-Insensitive: Da unterschiedliche Quellen für die Konfiguration angezogen werden, ist es unpraktisch Case-Sensitiveness zu gewährleisten

Die wichtigsten Aufrufe bei Viper in Kürze sind folgende:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// liest die Configs aus dem angegeben Pfad
viper.SetConfigFile(cfgFile)

// setzt den erwarteten Pfad und Typ einer Config-Datei
home, err := os.UserHomeDir()
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigFile(".config")

// liest vorhandene Config aus Viper-Singleton
viper.GetString("stringkey")
viper.GetBool("booleanKey")
viper.Get("objectkey")

In Eigenregie

Da wir in letzter Zeit ausführlich über den JAMstack berichtet haben, bauen wir jetzt gemeinsam ein einfaches CLI-Tool welches folgende Funktionalitäten bereitstellten soll:

  • eine beliebige Content Git-Repository klonen

  • Markdown-Files innerhalb der Repo in HTML konvertieren

  • der konvertierte Content anzeigen lassen

Mit dieser CLI können wir somit Markdown aus einer Git-Repository lokal per Terminal konvertieren lassen. Diese Funktionalität ist das Grundgerüst eines vollwertigen Static-Page-Generators wie beispielsweise Hugo oder Gatsby.

jamctl – unser CLI mit Cobra und Golang

In diesem Projekt fokussieren wir uns auf drei möglichen Befehle, auch Command genannt:

  • add / create

  • get

  • list

  • update

  • help

Installieren der Libraries

In unserem Go-Projekt installieren wir uns zunächst einmal die beiden Libraries wie folgt:

1
2
❯ go get -t github.com/spf13/cobra
❯ go get -t github.com/spf13/viper

Für diese Übung habe ich eine Git-Repository auf Github, https://github.com/b-nova-techhub/jamctl, hochgeladen, welche Sie gerne dafür nutzen könnt. Diese beinhaltet die ganzen Go Source-Dateien, sowie ein Makefile womit man das Projekt durch bauen kann.

1
git clone https://github.com/b-nova-techhub/jamctl

Projektstruktur

Die Projektstruktur lehnt sich an das obige Beispiel an und implementiert unter cmd/ die vier oben definierten Commands.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
.
├── .gitignore
├── Makefile
├── LICENSE
├── pkg
│   ├── gen
│   ├── repo
│   └── util
├── cmd
│   ├── cmd.go
│   ├── add.go
│   ├── get.go
│   ├── list.go
│   └── update.go
└── main.go

add-Command

Der add-Command fügt eine neue Content-Repository hinzu. Dabei wird geprüft ob die Repository bereits im Zielverzeichnis vorhanden ist. Falls nicht, wird diese dort ins Verzeichnis geklont.

1
❯ jamctl add https://github.com/b-nova-openhub/jams-vanilla-content

Dies ist das cmd/add.go das wir dem cmd-Projektverzeichnis hinzufügen müssen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
var (
	addCmd = &cobra.Command{
		Use:   "add",
		Short: "Add git repository containing markdown content files",
		Long:  ``,
		Run:   add,
	}
)

func add(ccmd *cobra.Command, args []string) {
	if len(args) > 0 {
		repo.GetGitRepository(args[0], false)
		fmt.Printf("Repo added.\n")
		home, err := os.UserHomeDir()
		cobra.CheckErr(err)
		viper.WriteConfigAs(home + "/jamctl.yaml")
	} else {
		fmt.Fprintln(os.Stderr, "No repository is specified. Please specify a valid git repository url.")
		return
	}
}

get-Command

Der get-Command liest das gewünschte Content-Repository aus und konvertiert alle darin befindlichen Markdown-Dateien nach HTML und gibt diese im StdOut aus.

1
 jamctl get jams-vanilla-content

Dies ist das cmd/get.go das wir dem cmd-Projektverzeichnis hinzufügen müssen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var (
	getCmd = &cobra.Command{
		Use:   "get",
		Short: "Get content as a html rendered page",
		Long:  ``,
		Run: get,
	}
)

func get(ccmd *cobra.Command, args []string) {
	if len(args) > 0 {
		fmt.Print(gen.Generate(repo.ReadRepoContents(args[0])))
	} else {
		fmt.Fprintln(os.Stderr, "No repository is specified. Please specify a valid git repository url.")
		return
	}
}

list-Command

Der list-Command prüft ob und welche Repositories bereits im Zielverzeichnis vorhanden sind und gibt dessen Projektnamen (Ordnernamen) als Liste wieder.

1
2
❯ jamctl list
jams-vanilla-content

Dies ist das cmd/list.go das wir dem cmd-Projektverzeichnis hinzufügen müssen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
var (
	listCmd = &cobra.Command{
		Use:   "list",
		Short: "List all content as html rendered pages",
		Long:  ``,
		Run: list,
	}
)

func list(ccmd *cobra.Command, args []string) {
	repos, err := ioutil.ReadDir(viper.GetString("absolutePath"))
	if err != nil {
		log.Fatal(err)
	}
	for _, r := range repos {
		fmt.Println(r.Name())
	}
}

update-Command

Der update-Command prüft ob eine Repository bereits vorhanden ist. Falls ja, wieder das entsprechende Verzeichnis gelöscht und die Repository an gleicher Stelle neu geklont.

1
❯ jamctl update https://github.com/b-nova-openhub/jams-vanilla-content

Dies ist das cmd/update.go das wir dem cmd-Projektverzeichnis hinzufügen müssen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
var (
	updateCmd = &cobra.Command{
		Use:   "update",
		Short: "Update git repository containing markdown content files",
		Long:  ``,
		Run:   update,
	}
)

func update(ccmd *cobra.Command, args []string) {
	if len(args) > 0 {
		repo.GetGitRepository(args[0], true)
		fmt.Printf("Repo updated.\n")
		home, err := os.UserHomeDir()
		cobra.CheckErr(err)
		viper.WriteConfigAs(home + "/jamctl.yaml")
	} else {
		fmt.Fprintln(os.Stderr, "No repository is specified. Please specify a valid git repository url.")
		return
	}
}

help-Command

Der help-Command gibt alle möglichen Commands wieder und zeigt auch welche Flags für welchen Command erlaubt sind. Der help-Command wird durch Cobra automatisch generiert und liest die deklarierten Metadaten dazu aus den entsprechenden cobra.Command-Definitionen aus. Dieser help-Output wird auch beim einfachen Aufruf der ctl ausgegeben. Dieses Verhalten könnte man anpassen, aber in unserer Implementation ist dies so gelöst.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
❯ jamctl --help
jamctl – command-line tool to interact with jamstack

Usage:
  jamctl [command]

Available Commands:
  add         Add git repository containing markdown content files
  completion  generate the autocompletion script for the specified shell
  get         Get content as a html rendered page
  help        Help about any command
  list        List all content as html rendered pages
  update      Update git repository containing markdown content files

Flags:
  -c, --config string   config file (default is $HOME/.jamctl.yaml)
  -h, --help            help for jamctl
  -v, --version         version for jamctl

Use "jamctl [command] --help" for more information about a command.

completion-Command

Der completion-Command ist auch ein von Cobra automatisch generierter Command. Dieser gibt eine Shell-spezifische Autocompletion-Deklaration heraus welche man für seine Shell der Wahl (bash, fish, powershell oder zsh) generieren lassen kann. Sehr nützlich wie ich finde.

1
2
3
4
5
6
7
❯ jamctl completion zsh
#compdef _jamctl jamctl

# zsh completion for jamctl                               -*- shell-script -*-

__jamctl_debug()
...

Es sein angemerkt, dass Cobra auch automatisiert Dokumentation in unterschiedlichen Formaten generieren kann. Da wir dies hier jetzt noch gesondert behandeln können, empfehle ich einen Blick in die offizielle Dokumentation dazu: https://github.com/spf13/cobra/blob/master/doc/README.md

Verwendung der jamctl

Bevor wir die jamctl nutzen können müssen wir uns zuerst noch das Binary bauen. Dafür gibt es in der Git-Repository ein Makefile welches den Kompilierungssvorgang automatisiert. Es gibt wahlweise die Möglichkeit per make build das Binary nach bin/ bauen lassen oder per make install direkt nach den festgelegten $GOROOT, standardmässig unter $GOPATH/bin oder falls gesetzt $GOBIN, wobei die jamctl ohne direkten Verweis aufrufbar wird.

Der einfachheitshalber, entscheiden wir uns für den letzteren Fall:

1
❯ make install

Ab jetzt können wir jamctl im Terminal aufrufen und unseres Projektes durchtesten. Falls Ihnen keine Content-Repository mit dazugehörigem Markdown-Files zur Hand haben, können Sie auch eine unserer Test-Repos nutzen: https://github.com/b-nova-openhub/jams-vanilla-content. Darin befinden sich unter content/de/ exemplarische Markdown-Dateien mit einem entsprechenden Metadaten-Header.

Dafür nutzen wir unseren add-Command wie folgt:

1
2
3
4
5
6
7
8
❯ jamctl add github.com/b-nova-openhub/jams-vanilla-content
Target repository clone path: /tmp/jamctl/jams-vanilla-content
Target repository url: https://github.com/b-nova-openhub/jams-vanilla-content
Enumerating objects: 29, done.
Counting objects: 100% (29/29), done.
Compressing objects: 100% (25/25), done.
Total 29 (delta 6), reused 13 (delta 0), pack-reused 0
Repo added.

Jetzt können wir nachsehen, ob die Repository angelegt wurde und prüfen dass am besten mir der list-Command:

1
2
❯ jamctl list
jams-vanilla-content

Falls Sie den --targetPath nicht per Flag angeben haben, sollte die Repo unter /tmp/jamctl angelegt worden und dabei als Git-Repository geklont sein.

1
2
❯ ls /tmp/jamctl
jams-vanilla-content

Sehr gut, scheint zu passen. Jetzt müssen wir erinnert werden, dass der Header durch ein HTML-Tag gekennzeichnet ist.

Als Nächstes wollen wir den Content als HTML ausgegeben bekommen. Dafür haben wir den get-Command welcher die Repository nach Markdown-Dateien parst und diese konvertiert.

1
2
3
❯ jamctl get jams-vanilla-content
[{Praktische Einführung in Go b-nova.com/home/content/praktische-einfuhrung-in-go rschneider golang, microservices, handson, foss cloud, tech 2020-10-20 Go ist eine beliebte Sprache im Cloud-Umfeld.
...

Der Header dabei sieht wie folgt aus:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<content-header>
title: "Praktische Einführung in Go"
description: "Go ist eine beliebte Sprache im Cloud-Umfeld. Go könnte schon bald der neue Standard für Microservices und Container-fähigen Applikationen sein."
ogImage: "ogImage1.png"
date: '2020-10-20'
author: 'rschneider'
categories:
  - 'cloud'
  - 'tech'

tags:
  - 'golang'
  - 'microservices'
  - 'handson'
  - 'foss'

<content-header>

Wichtig dabei zu wissen, ist das die jetzige Implementation von jamctl auf den Delimiter hört und das Front-Matter (Metadaten) anhand davon ausliest. Dieser ist Default-mässig <content-header>, dh. wir müssen unserer CLI per Flag sagen, dass der Delimiter in diesem Fall anders ist, nämlich ---.

Als nächstes wollen wir den Content als HTML ausgegeben bekommen. Dafür haben wir den get-Command welcher die Repository nach Markdown-Dateien parst und diese konvertiert.

1
2
3
❯ jamctl get jams-vanilla-content
[{Praktische Einführung in Go b-nova.com/home/content/praktische-einfuhrung-in-go rschneider golang, microservices, handson, foss cloud, tech 2020-10-20 Go ist eine beliebte Sprache im Cloud-Umfeld.
...

Globales Config-File

Die Konfigurationen die zur Laufzeit über die Argumente und die Flags eingegeben werden, können auch in einer Config-Datei abgespeichert werden. Die Idee dabei ist, dass die Config-Datei in einem Home-Verzeichnis liegt und somit die Konfiguration der Applikation darüber vorgenommen werden kann, oder zumindest alternativ, dass die Config-Datei per Flag-Parameter beim Aufruf von jamctl angegeben werden kann.

Die Config-Datei wird demnach unter dem Home-Verzeichnis ~/ liegen und sollte folgende Einträge beinhalten. Diese sind alle Konfigurationseinträge die wir während der Laufzeit nutzen:

1
2
3
4
❯ cat ~/jamctl.yaml
delimiter: b-nova-content-header
relativepath: /content
targetpath: /tmp/jamctl

Fazit

So, jetzt haben wir zusammen eine CLI mit Cobra in Go geschrieben. Es sei schonmal gesagt, es gibt bei der Implementation von Cobra und Viper noch viel mehr zu erwähnen, denn der Teufel liegt bekanntermassen im Detail. Aber ich denke, hier haben wir bereits das Grundwissen gelegt, um weitere Einarbeitung in das Ökosystem von Go zu fördern. Falls wir ihr Interesse geweckt haben, zögern Sie nicht uns um das nötige Fachwissen bei Ihrem nächsten Go-Projekt fragen.

Nächste Woche wird uns Ricky das Unit-Testing in Go beibringen und die gängigsten Implementation eines Test-Frameworks vorstellen. Stay tuned!

Quelle der Gopher-Illustration im Artikel ist Takuya Ueda

https://github.com/spf13/cobra

https://cobra.dev/

https://github.com/spf13/viper

https://medium.com/@skdomino/writing-better-clis-one-snake-at-a-time-d22e50e60056

https://www.educative.io/edpresso/how-to-use-cobra-in-golang

https://www.linode.com/docs/guides/using-cobra/

https://blog.knoldus.com/create-kubectl-like-cli-with-go-and-cobra/

https://levelup.gitconnected.com/exploring-go-packages-cobra-fce6c4e331d6