Einfach Shell-Skripte in der kompilierenden Programmiersprache Go schreiben? In der Praxis funktioniert die vermeintliche Schnapsidee ausgesprochen gut, birgt allerdings einige Stolperfallen.
Um die Log-Dateien auf einem System zu zählen, genügt ein kleines Shell-Skript wie der folgende Einzeiler, der uns hier als Beispiel dienen soll:
$ find /var/log -name "*.log" | wc -l
Oft führen solche Shell-Skripte mit wenig Code zu schnellen Ergebnissen. Bei etwas komplexeren Aufgaben mutieren Shell-Skripte allerdings häufig zu einem kryptischen Zeichensalat. Bereits der Einzeiler aus unserem Beispiel erweist sich als nicht ganz selbsterklärend: find liefert jede Datei mit der Endung .log in einer eigenen Zeile zurück. Anschließend zählt wc -l die Treffer – eigentlich sollte es sich Wörter vorknöpfen. Wer die Parameter von Wc nicht auswendig kennt, könnte daher von einem falschen Verhalten ausgehen.
Beim Einlesen in vorhandene Skripte benötigen Sie deshalb oft das Bash-Handbuch, die Manpages von Kommandozeilenwerkzeugen und eine gute Internet-Suchmaschine. Die kryptischen Befehle stehen nicht nur dem Verständnis im Weg, sie erschweren zudem das Testen. Bei komplexen Aufgaben setzt darüber hinaus oft der Funktionsumfang der Werkzeuge gewisse Grenzen, was nicht selten in hässlichen Workarounds mündet. Wer regelmäßig in anderen Sprachen programmiert, muss zudem noch zwischen unterschiedlichen Syntaxen hin und her springen.
Langsam und schnell
Die Befehle im Shell-Skript arbeitet ein Interpreter geruhsam nacheinander ab. Hinzu kommt, dass viele Shell-Skripte ihre Daten ineffizient durch die Pipes schieben. Beides bremst spätestens dann, wenn die Skripte wiederholt anlaufen oder es gilt, zeitaufwendige Aufgaben zu erledigen. Ein kompiliertes Go-Programm würde sich deutlich schneller durch umfangreiche Log-Daten wühlen als die eingangs vorgestellte Zeile [1].
Da liegt die Idee nahe, direkt Go als Skriptsprache zu verwenden. Auf diese Weise beschleunigen Sie nicht nur die Ausführung, Sie kommen auch in den Genuss einer einheitlichen Syntax. Obendrein können Sie auf zahlreiche externe Bibliotheken zugreifen, mit denen sich deutlich mehr Aufgaben flexibel umsetzen lassen.
Der Go-Compiler bietet eine Typprüfung und fängt viele Programm- und Flüchtigkeitsfehler ab. In Shell-Skripten würden Ihnen solche Fehler erst zur Laufzeit um die Ohren fliegen. Darüber hinaus besitzt das produzierte Binary keine Abhängigkeiten. Anders als bei Shell-Skripten müssen Sie somit noch nicht einmal sicherstellen, dass auf dem Zielrechner sämtliche verwendeten Werkzeuge oder eine spezielle Shell vorliegen.
Kurzum: Mit Go als Skriptsprache sparen Sie sich Arbeit, Hirnschmalz und Zeit – gäbe es da nicht ein paar kleinere Haken.
Vereinfachte Syntax
Das Go-Programm aus Listing 1 entspricht dem Shell-Einzeiler-Beispiel, zählt also ebenfalls die Log-Dateien im Verzeichnis /var/log/. Aufgrund der C-ähnlichen Syntax und der recht systemnahen Funktionen aus der mitgelieferten Standardbibliothek benötigt es dazu allerdings deutlich mehr Code als der Shell-Befehl.
Listing 1
Log-Dateien zählen in Go
package main
import (
"fmt"
"io/fs"
"path/filepath"
)
func main() {
var count int = 0
var dir string = "/var/log"
var name string = "*.log"
filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
islog , _ := filepath.Match(name, d.Name())
if islog { count++ }
return nil
})
fmt.Println(count)
}
Go wäre also von Haus aus kein guter Ersatz für flott geschriebene Shell-Skripte. Das würde sich erst ändern, sobald man in Go kurz und knackig so etwas wie das Folgende schreiben könnte:
script.FindFiles("/home/tim").Match(".log").CountLines()
Genau das ermöglicht die Go-Bibliothek Script [2]. Sie bildet in Teilen das Verhalten der Shell sowie einiger Linux-Kommandos nach (Abbildung 1). Match() ersetzt beispielsweise Grep, CountLines() zählt wie wc -l die Zeilen.
Script simuliert über den Punkt-Operator sogar Pipes. Davon macht auch das obige Beispiel regen Gebrauch: Zunächst übernimmt FindFiles() die Rolle von Find und liefert alle Dateien aus sämtlichen Unterverzeichnissen des angegebenen Ordners zurück. Diese Dateinamen wandern über eine Pipe weiter zu Match(), das die passenden Dateinamen herausfiltert und an CountLines() übergibt. Als angenehmer Nebeneffekt glänzt der Go-Code sogar noch mit besserer Lesbarkeit als das Shell-Pendant.

Abbildung 1: Die Bibliothek Script lässt sich in regulären Go-Programmen nutzen, um beispielsweise schnell Textdateien zu verarbeiten.
Um die Hilfe von Script in Anspruch zu nehmen, importieren Sie lediglich das entsprechende Package und verstauen den Skript-Code in der für Go-Programme obligatorischen Funktion main(). Listing 2 zeigt den kompletten Quellcode, der die Log-Dateien im Verzeichnis /var/logs zählt. Gegenüber dem Shell-Skript vom Anfang gibt es damit zwar immer noch viele zusätzliche Zeilen, die jedoch Go-Kenner schnell eintippen.
Listing 2
logcount.go
package main
import (
"fmt"
"github.com/bitfield/script"
)
func main() {
count, _ := script.FindFiles("/var/log").Match(".log").CountLines()
fmt.Println(count)
}
Feintuning
Die im Beispiel gewählte Funktion Match() erweist sich jedoch als nur mäßig gute Wahl: Sie prüft lediglich, ob die übergebene Zeichenkette irgendwo im Treffer auftaucht. Listing 2 würde daher fälschlicherweise die Datei der.logarithmus.txt mitzählen. Daher empfiehlt es sich, MatchRegexp() einzusetzen (Listing 3, erste zwei Zeilen). Diese Funktion verarbeitet die in Go bereits verfügbaren regulären Ausdrücke und demonstriert so gleichzeitig, wie sich Script in Go-Programme integriert.
Listing 3
Nützliche Go-Konstrukte
regex := regexp.MustCompile(`\.log$`)
count, _ := script.FindFiles("/var/log").MatchRegexp(regex).CountLines()
script.File("error.log").Match("Panic").Stdout()
script.Exec("ip a").Match("UP").Stdout()
script.Args().ExecForEach("xz -9 {{.}}").Stdout()
Das Werkzeug ist vor allem auf die Verarbeitung von Texten ausgerichtet. Beispielsweise lesen Sie mit dem Befehl File() schnell eine Datei ein, ohne mit den File-Deskriptoren von Go hantieren zu müssen. Das Kommando aus der dritten Zeile von Listing 3 fischt alle Zeilen aus der Datei error.log, in denen das Wort Panic vorkommt, und gibt die Fundstücke per Stdout() auf der Standardausgabe aus.
Externe Programme führt Exec() aus, das außerdem die Kommandosubstitution ersetzt. Das Beispiel aus der vierten Zeile von Listing 3 lässt sich von ip den aktuellen Status der Netzwerkschnittstellen liefern, aus dem dann Match() alle aktiven Schnittstellen heraussucht.
Möchten Sie ein Kommando mehrfach mit unterschiedlichen Parametern ausführen, helfen ExecForEach() und die Go-Templates [3]. Das Kommando aus Zeile 5 von Listing 3 filtert mithilfe des Tools Xz alle Dateien heraus, deren Dateinamen Sie dem Go-Programm als Parameter übergeben haben.
Dabei schiebt Args() zunächst die Parameter in eine Pipe, über die sie zu ExecForEach() rutschen. Das Template {{.}} ersetzt Go durch den jeweils nächsten Parameter – im Beispiel durch einen Dateinamen. Abschließend ruft ExecForEach() das damit in den Anführungszeichen entstandene Kommando auf. Das Ganze wiederholt sich, bis alle Parameter abgearbeitet wurden. Innerhalb von ExecForEach() können Sie die Go-Template-Syntax voll ausnutzen.
Viele weitere Shell-Befehle und ihre Entsprechungen in Script fasst die Tabelle “Shell-Befehle und ihre Script-Pendants” zusammen. Besonders nützlich ist dabei der Curl-Ersatz. Beispielsweise stellt Get() eine HTTP-Anfrage, deren zurückgelieferte Daten Sie zu einem String formen und im Go-Programm weiterverarbeiten können (Listing 4).
|
Shell/Tool |
Script |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Listing 4
Curl in Go nutzen
weather, _ := script.Get("https://wttr.in/Berlin?format=3").String()
fmt.Println(weather)
Berg- und Talfahrt
Wenn Sie ein wenig mit Script experimentieren, stellen Sie schnell fest, dass die simulierten Pipes weit weniger Flexibilität bieten als ihre Vorbilder aus der Shell. Script unterscheidet strikt zwischen Quellen, Filtern und Senken.
Quellen bilden den Ausgangspunkt der Verarbeitungskette. Dazu zählen unter anderem Args(), File() und FindFile(). Diese Funktionen erzeugen jeweils eine Pipe und lassen sich selbst nicht in eine Pipe einbauen. Folglich weigert sich Args(), einen als Parameter übergebenen Ordner an FindFile() weiterzureichen. Das folgende Kommando führt daher zu einem Fehler:
script.Args().FindFile().Match(".log").Stdout()
Filter wie Match() nehmen Eingaben entgegen und verändern sie. Anders als Quellen und Senken dürfen Sie Filter beliebig oft hintereinander schalten. Senken stehen am Ende der Pipeline und geben die durchgelaufenen Daten in irgendeiner Form aus; Stdout() druckt sie zum Beispiel auf die Standardausgabe.
Sollten Sie einen Filter vermissen, rüsten Sie ihn mit dem universellen Filter() nach. Dazu übergeben Sie Filter() eine eigene Funktion, die wiederum zwei Parameter entgegennimmt: Über einen Reader erhält sie die zu verarbeitenden Daten, über den übergebenen Writer gibt sie ihre Ergebnisse zurück. In Listing 5 schiebt der Filter einfach alle Daten aus der Datei error.log unverändert durch den Filter in die Standardausgabe.
Listing 5
Eigener Filter
script.File("error.log").Filter(func (r io.Reader, w io.Writer) error {
_, e := io.Copy(w, r)
return e
}).Stdout()
Übersetzungshürden
Neben Script gibt es noch zahlreiche weitere hilfreiche Bibliotheken. So kopiert Copy in einem Rutsch komplette Verzeichnisbäume [4], während Cobra (Abbildung 2) unter anderem beim Auswerten von Kommandozeilenparametern hilft [5]. Da der Go-Compiler sämtliche Abhängigkeiten selbst auflöst und die Bibliotheken statisch linkt, bleibt der Code in jedem Fall plattformunabhängig. Davon ausgenommen sind lediglich externe Packages, die Cgo verwenden und somit C-Code einmischen.

Abbildung 2: Cobra nimmt übergebene Parameter auseinander und hilft sogar bei der Erstellung von Manpages.
Go-Code müssen Sie immer zuerst durch den Go-Compiler laufen lassen, bevor Sie ihn in Form des erzeugten Programms starten dürfen. Diese zweistufige Prozedur zerrt insbesondere dann an den Nerven, wenn sich der Code während der Entwicklung häufig ändert. Praktischerweise kennt der Go-Compiler das Kommando run, mit dem er den Go-Code in einem Rutsch übersetzt und startet.
Allerdings bringt ein Aufruf wie go run logcount.go gleich mehrere Nachteile mit sich. Zum Beispiel tippen Sie immer noch mehr Zeichen ein als beim Start eines Shell-Skripts. Dessen Speicherort können Sie in die Umgebungsvariable PATH aufnehmen, sodass sich das Skript von einem beliebigen Ort aufrufen lässt. Um dasselbe mit go run zu erreichen, müssten Sie die vom Go-Compiler verwendeten Umgebungsvariablen umständlich mehrfach umbiegen.
Weg mit der Raute!
Deutlich angenehmer wäre es, wenn man logcount.go direkt wie ein Shell-Skript aufrufen könnte. Damit das bei einem Shell-Skript funktioniert, deponieren Sie darin in der ersten Zeile hinter dem sogenannten Shebang den zu verwendenden Interpreter, etwa #!/bin/bash. Das Linux-System wertet diese erste Zeile aus und startet im Beispiel das Shell-Skript mit der Bash. Man könnte jetzt auf die Idee kommen, einfach im Go-Code ein analoges Shebang wie #!/usr/bin/go run in der ersten Zeile zu hinterlegen.

Abbildung 3: Eine Shebang-Zeile am Anfang des Go-Quellcodes verwirrt den Go-Compiler, der umgehend mit einem Fehler aussteigt.
Kennzeichnen Sie die zugehörige Go-Datei als ausführbar und rufen dieses Go-“Skript” auf, erhalten Sie den Fehler aus Abbildung 3. Die Shell wertet zunächst die erste Zeile aus und stößt wie gewünscht go run an. Diesem Befehl übergibt die Shell das komplette Go-Skript. Dessen Code knöpft sich der Go-Compiler vor, der dann direkt über die erste Zeile mit dem Shebang stolpert. Bereits das Rautenzeichen # stellt keinen gültigen Go-Code dar, was der Compiler bemängelt und seinen Dienst quittiert.
Mit einigen Kniffen verändern Sie die Shebang-Zeile jedoch so, dass sie sowohl der Shell als auch dem Go-Compiler schmeckt:
///usr/bin/go run "$0" "$@"; exit "$?"
Die beiden ersten Schrägstriche leiten für den Go-Compiler einen Kommentar ein, sodass er den Rest der Zeile ignoriert. Für die Shell handelt es sich dagegen um ein Kommando. Den dreifachen Schrägstrich /// interpretiert sie dabei als einen einzigen, sodass sich der Pfad zum Programm go ergibt. Dem übergibt die Shell das Kommando run, gefolgt vom Namen des Go-Skripts sowie allen übergebenen Parametern. Der Name des aufgerufenen Go-Skripts steckt in der Shell-Variablen $0, die an das Go-Skript übergebenen Parameter lagern in $@.
Sobald der Go-Compiler seine Arbeit verrichtet hat, übernimmt wieder die Shell, die umgehend und unermüdlich die übrigen Zeilen im Go-Skript abarbeitet. Im Fall von Listing 2 würde die Shell versuchen, package main aufzurufen, und damit krachend scheitern. Um das zu verhindern, bricht im modifizierten Shebang das Kommando exit die Verarbeitung der Shell vorzeitig ab.
Die alternative Shebang-Zeile entstand durch die Zusammenarbeit in der Community, vor allem in entsprechenden Diskussionen auf Stackoverflow [6]. Dort finden sich zudem weitere Modifikationen und Anregungen. Eine gute Zusammenfassung liefert der Entwickler Eyal Posener auf seiner Github-Seite [7]. Ein Problem beseitigt die optimierte erste Zeile allerdings nicht: den falschen Exit-Code.
Geh-Hilfe
Sobald sich ein Programm beendet, gibt es einen Exit-Code an die Shell zurück. Mit ihm meldet das Programm, ob es bei seiner Aufgabe erfolgreich war oder ob ein Fehler auftrat. Wenn Sie go run logcount.go aufrufen, erhalten Sie den Exit-Code von go run und nicht den des Go-Programms logcount.go. Sie erfahren also ausschließlich, ob der Compiler erfolgreich gearbeitet hat. Auch die (modifizierte) Shebang-Zeile nutzt go run, weshalb das komplette Go-Skript ebenfalls nur den Exit-Code des Compilers zurückliefert.
Abhilfe schafft das Tool Gorun [8]. Es erlaubt Ihnen, die herkömmliche Shebang-Zeile #!/usr/bin/gorun am Anfang eines Go-Programms zu verwenden. Mit ihr aktiviert die Shell beim Start des Go-Skripts Gorun. Das kleine Werkzeug entfernt die erste Zeile aus dem Quellcode und verfüttert den Rest an den Go-Compiler. Anschließend ruft das Tool das erzeugte Binary auf, wobei es alle Parameter passend weiterreicht. Zu guter Letzt sorgt Gorun dafür, dass das Go-Skript seinen eigenen Exit-Code zurückliefert.
Da Gorun kaum einer Distribution beiliegt, müssen Sie die Software manuell auf allen Systemen installieren, auf denen das Go-Skript zum Einsatz kommen soll. Durch die Shebang-Zeile lässt sich der Code außerdem nicht mehr direkt an den Go-Compiler übergeben. Dank eines Tricks verzichten Sie aber zumindest auf Linux-Systemen auf die Shebang-Zeile.
Kernel-Tricks
Mithilfe weniger Handgriffe sieht der Linux-Kernel alle Dateien mit der Endung .go als Programme an und führt sie beim Aufruf automatisch mit Gorun aus. Das Verfahren wandte das Unternehmen Cloudflare bereits 2018 an. In einem Blog-Beitrag [9] finden Sie ausführliche Informationen dazu.
Ausgangspunkt ist das Kernel-Modul binfmt_misc, das Programme anhand ihrer Dateinamenserweiterung erkennt und anschließend ein hinterlegtes Wrapper-Programm startet. Die Konfiguration gelingt über die Datei /proc/sys/fs/binfmt_misc/register. In sie schreibt man die gewünschte Einstellung in einem relativ unleserlichen Format. Den für die Go-Skripte notwendigen Befehl zeigt Listing 6.
Listing 6
Go-Konfiguration für binfmt_misc
$ echo ':golang:E::go::/usr/local/bin/gorun:OC' | sudo tee /proc/sys/fs/binfmt_misc/register
:golang:E::go::/usr/local/bin/gorun:OC
Der Abschnitt /usr/bin/gorun steht darin für den Pfad zu Gorun. Wie das sudo im Befehl andeutet, erfordern die Anpassungen Root-Rechte, doch die lassen sich nicht auf jedem System erlangen. Darüber hinaus überdauern die Einstellungen keinen Systemneustart.
Weitere Hürden
Damit das Go-Skript auf anderen Systemen läuft, benötigt man auch dort den Go-Compiler in der passenden Version. Der Compiler selbst belegt zwar nur wenig Platz, steht aber nicht immer zur Verfügung. Darüber hinaus muss der Compiler den Quellcode übersetzen können. Dazu benötigt er Schreibrechte, die vor allem auf Embedded-Systemen fehlen. Während der Übersetzung lädt der Go-Compiler zudem automatisch alle benötigten Bibliotheken herunter; folglich ist auf dem jeweiligen System eine Internet-Verbindung Pflicht.
Aufgrund dieser Hürden empfiehlt es sich, auf die Shebang-Zeile zu verzichten und den Go-Code auf klassischem Weg zu übersetzen. Das erfordert zwar während der Entwicklung zusätzliche Handgriffe, das erzeugte Programm besitzt aber keine Abhängigkeiten und lässt sich leichter auf Embedded-Systeme verteilen. Dank der eingebauten Cross-Compiler-Fähigkeit von Go erzeugen Sie auf dem eigenen Arbeitssystem die Fassungen für verschiedene Architekturen. Der Compiler ist mit dem Kommando go install sogar in der Lage, das erstellte Programm zu bauen und anschließend im System zu installieren.
Schritt für Schritt
Anders als etwa Python und Perl kennt Go keinen interaktiven Modus (Read-Eval-Print-Loop, REPL). Man kann folglich nicht an einem speziellen Prompt einzelne Befehle eintippen, die Go dann jeweils umgehend auswertet. Einige Entwickler versuchen, dieses Manko mit einem eigenen Go-Interpreter zu beheben. Den Anfang machten Neugram [10] und Gomacro [11]. Beide Interpreter unterstützen jedoch nicht die komplette Go-Syntax und liegen schon seit Längerem auf Eis.
Aktiv voran treibt derzeit das Traefik-Projekt seinen Go-Interpreter Yaegi [12]. Trotz der niedrigen Versionsnummer lässt er sich bereits produktiv einsetzen, versteht die komplette Go-Spezifikation und lässt sich sogar bequem in eigene Go-Programme einbetten (Abbildung 4). Dort führt dann die von Yaegi angebotene Funktion Eval() je nach Bedarf eine oder mehrere Go-Codezeilen aus. Auf diesem Weg ergänzen Sie die eigene Software leicht um Skripting-Möglichkeiten.

Abbildung 4: Yaegi bietet einen Interpeter mit REPL-Modus, der wie hier einzelne Go-Befehle direkt ausführt.
Fazit
Wer Go beherrscht, kann mit der Programmiersprache recht komfortabel Shell-Skripte ersetzen. Bibliotheken wie Script sparen Tipparbeit und sorgen für knackigen, lesbaren Code. Trotz dieser Hilfen fällt der Go-Quellcode länger aus als entsprechende Shell-Pendants. Des Weiteren ist der Go-Compiler nicht für den Skript-Betrieb ausgelegt. Um den Go-Code wie ein Shell-Skript zu starten, müssen Sie in der ersten Codezeile tricksen oder das Tool Gorun heranziehen. Beides können Sie sich jedoch sparen, wenn Sie alte Shell-Denkmuster ablegen und den Go-Code direkt in ein leicht zu verteilendes Binary gießen. (csi/jlu)
Infos
-
Energieeffiziente Software: Tim Schürmann, “Go Green?”, LM 03/2023, S. 30, https://www.lm-online.de/48614
-
Go Templates: https://pkg.go.dev/text/template
-
Cobra: https://cobra.dev/
-
“What’s the appropriate Go shebang line?”: https://stackoverflow.com/questions/7707178/whats-the-appropriate-go-shebang-line
-
“Story: Writing Scripts with Go”: https://gist.github.com/posener/73ffd326d88483df6b1cb66e8ed1e0bd
-
“Using Go as a scripting language in Linux”: https://blog.cloudflare.com/using-go-as-a-scripting-language-in-linux/
-
Neugram: https://github.com/neugram/ng
-
Gomacro: https://github.com/cosmos72/gomacro





