Professionelle Softwareentwicklung setzt auf Tools und auf automatisierte Checks. Das gibt es auch für die Shell.
Shell Scripting läuft häufig im Fire-and-Forget-Modus ab. Per Skript verändern Admins komplizierte Dinge an Dateien, oft einmalig. Kommt es dabei zu Fehlern, gibt es hoffentlich ein Log, um sie wieder auszubügeln. So weit geht das Vorgehen in Ordnung – für solche Einwegskripte brauche ich weder Ausnahmebehandlung noch muss ich alle infrage kommenden Sonderfälle abbilden. Das wäre, wie mit Kanonen auf Spatzen zu schießen.
Ganz anders sieht die Situation aus, wenn ich Skripte schreibe, die dauerhaft oder zumindest unbeobachtet laufen sollen, etwa eine Service-Definition oder ein Backup-Skript. Hier muss ich mich gleich um einen ganzen Zoo an zusätzlichen Vorkehrungen kümmern. Definitiv brauche ich gutes Logging, selbstverständlich durch Logrotate verwaltet, um volle Festplatten zu verhindern. Außerdem benötige ich Fallunterscheidungen, Ausnahmebehandlungen und womöglich auch Überwachung durch ein Monitoring-System. Vor allem aber muss ich solche Skripts von Grund auf anders aufbauen.
Es genügt dazu nicht, einfach 120 Zeilen Bash-Code in eine Datei zu kippen. Vielmehr ist strukturiertes Arbeiten gefordert. Für sich autarke Codeblöcke müssen zu Funktionen werden. Auf globale Variablen muss ich weitestgehend verzichten, zugunsten von Funktionsparametern; Konstanten sollten nur lesbar sein und vieles, vieles mehr. Ein ausgezeichneter Einstieg ist nach wie vor der Advanced Bash Scripting Guide [1]. In LM 02/2023 [2] bin ich bereits auf Mechanismen der Bash eingegangen, die bei der Fehlersuche unterstützen. Diesmal lege ich den Fokus auf Helfer-Tools, die Ihnen aktiv beim Scripting unter die Arme greifen: Code Formatter, Linter und Frameworks für das Unit Testing.
Uniform
In Entwicklerteams ist einheitlicher Code absolut notwendig, weswegen Code Formatter längst fest zu modernen Entwicklungsumgebungen gehören. Die Konsequenzen von Wildwuchs liegen auf der Hand: Von widersprüchlichen Adressen bis hin zu massiven Merge-Konflikten bei der Versionskontrolle gibt es eine breite Palette an Fehlerquellen. Die Platzhirsche Eclipse, Visual Studio (Code) oder IntelliJ IDEA winken schon seit geraumer Zeit mit Funktionen, die den Code bereits während des Schreibens einheitlich formatieren, aber auch später beim Speichern.
Debian bietet in den Paketquellen das Tool Shfmt an, daher ist die Installation trivial. Das Werkzeug liefert durchaus brauchbare Ergebnisse und lässt sich obendrein bequem per EditorConfig-Datei [3] steuern. Listing 1 stammt aus der Shfmt-Manpage und verdeutlicht, wie Sie projektweit die Formatierung konfigurieren.
Listing 1
EditorConfig-Datei für Shell-Skripte
[*.sh] # like -i=4 indent_style = space indent_size = 4 shell_variant = posix # --language-variant binary_next_line = true switch_case_indent = true # --case-indent space_redirects = true keep_padding = true function_next_line = true # --func-next-line # Ignore the entire "third_party" directory. [third_party/**] ignore = true
Vim-Nutzer können in ihrer ».vimrc« mit der Zeile aus Listing 2 veranlassen, dass Shfmt bei Shell-Dateien die Formatierung übernimmt, falls der Vim-Befehl »gq« auf die Datei oder ein Textobjekt angewendet wird.
Listing 2
Zeile für .vimrc
autocmd BufNewFile,BufRead *.sh set formatprg=shfmt
In Listing 3 finden Sie ein Skript, das für die Einrückung einerseits Tabulatoren und Leerzeichen mischt und andererseits unterschiedlich viele Leerzeichen verwendet. Lasse ich das Skript durch den Befehl »shfmt evenodd.sh« verarbeiten, wendet Shfmt die Regeln aus Listing 1 darauf an. Damit verwandeln sich alle Tabs in je vier Leerzeichen, und die öffnende Funktionsklammer aus der zweiten Zeile wandert in die dritte Zeile (Listing 4).
Die Festlegung auf die Shell-Variante »posix« aus Zeile 5 in Listing 1 sorgt dafür, dass im Skript Bash-Spezifika zu Fehlern führen. Das heißt, dass ich Funktionen nicht mit dem Schlüsselwort »function« einleiten darf, um POSIX-kompatibel zu bleiben.
Listing 3
Skript mit chaotischer Einrückung
#!/bin/bash
evenodd() {
local counter=0
while (( $counter < $1 )); do
echo -n "$counter:"
if [ $(( $counter % 2 )) -eq 0 ]; then echo "even" ; else echo "odd"; fi
((counter++))
done
}
evenodd 10
Listing 4
Einheitlich formatiertes Skript
#!/bin/bash$
evenodd()
{
local counter=0
while (($counter < $1)); do
echo -n "$counter:"
if [ $(($counter % 2)) -eq 0 ]; then echo "even"; else echo "odd"; fi
((counter++))
done
}
evenodd 10
Statische Analyse
Formatter helfen dabei, Code visuell zu vereinheitlichen. Gut lesbarer, einheitlicher Code lässt sich besser warten als Code, der auf Formatierungsrichtlinien verzichtet. Doch Formatter liefern keine Erkenntnisse über potenzielle Fehlerquellen oder gar vorhandene Fehlermuster.
Hier kommen Linter ins Spiel: Ein Linter führt eine statische Analyse auf Code aus. Statisch ist die Analyse, weil der untersuchte Code dabei nicht selbst ausgeführt wird, sondern lediglich betrachtet. Sie erfordert, dass der Linter auf ausgefeilte Methoden zurückgreift. Ähnlich wie Lexer und Parser erzeugt er einen abstrakten Syntaxbaum und identifiziert dabei unter Umständen Bugs, Programmierfehler und verdächtige Konstrukte. Linter sind in der Lage, typische Fehler aufzudecken wie nicht initialisierte Objekte (Null-Referenzen) und unpassende Datentypen.
Zu den verbreiteten Lintern für Linux-Shells gehört ShellCheck [4]. Dass das Tool mir einige Kopfschmerzen ersparen kann, wurde mir sofort klar, als es mir empfahl, Bash-Variablen in doppelte Hochkommas zu packen, um Globbing und Splitting zu vermeiden. Splitting bedeutet, dass die Shell aus einem String mit Leerzeichen mehrere Strings erzeugt; Globbing bedeutet, dass die Shell Joker-Zeichen durch Dateinamen ersetzt. Eigentlich ein No-Brainer – im Eifer des Gefechts passiert es trotzdem gelegentlich, dass ich so etwas übersehe. Damit stößt das Tool jedoch noch längst nicht an seine Grenzen: ShellCheck spürt ebenso unerreichbaren Code auf, etwa solchen, der sich hinter einem bedingungslosen »exit …« befindet.
ShellCheck verfügt außerdem über eine Auswahl an nützlichen Schaltern, die auch außerhalb des Platzhirschen Bash funktionieren. Die Bash mag zwar die meist genutzte Shell sein, aber inzwischen dürften viele Distributionen (Debian, Ubuntu, Mint) einen Großteil der Skripte auf die Debian Almquist Shell optimiert haben. Die Dash braucht bei völlig ausreichendem Funktionsumfang immerhin nur 10 Prozent des Speicherplatzes, den die Bash für sich beansprucht.
Darüber hinaus kann ich ShellCheck über den Schalter »–shell=Shell-Name« anweisen, das Augenmerk auf einen ganz bestimmten Dialekt zu legen. Ebenso begrüßenswert ist der Parameter »–severity«. Können Sie auf Style-Checks oder Warnungen verzichten, sind Sie mit »–severity=error« gut bedient. Dadurch werden lediglich grobe Fehler bemängelt. Auch beim Ausgabeformat stehen mehrere Optionen zur Verfügung. Der Parameter »–format« erlaubt es, die Ausgabe nicht nur im Default-TTY auszugeben, es gibt auch die Möglichkeit GCC-artiger Ausgabe sowie ein Diff. Überdies lockt die Software mit dem XML-basierten Checkstyle-Format und zwei JSON-Varianten.
Weitaus interessanter als die Ausgabeformate sind die Parameter »–include« und »–exclude«. Durch sie beziehen Sie bestimmte Checks anhand ihrer IDs mit ein beziehungsweise schließen sie aus. Gerade das Ausschließen erweist sich als überaus hilfreich, wenn Sie ein größeres Portfolio an älteren Skripten überprüfen und beispielsweise die Deprecation-Warnungen zu Backticks (SC2006) ausblenden möchten. Umgekehrt wollen Sie vielleicht dringend auf schwerere Patzer hingewiesen werden, zum Beispiel auf den Versuch, ein »SIGKILL« zu trappen (SC2173).
Selbstverständlich gestattet ShellCheck, Konfigurationsdateien zu verwenden. Sie heißen grundsätzlich ».shellcheckrc« und enthalten zeilenweise Paare im Format »key=value«. ShellCheck sucht die Dateien im Arbeitsverzeichnis und jedem seiner Elternverzeichnisse. Damit ist es möglich, fein abgestufte Codeprüfungen auf verschiedenen Ebenen laufen zu lassen und dabei auf bestimmte Unterordner mit Legacy-Code besonders einzugehen.
Vim-Usern empfehle ich unbedingt das Addon Syntastic [5]: Es findet eine Vielzahl von vorinstallierten Lintern und führt damit automatisch beim Speichern bestimmte Checks aus. Neovim-Nutzer sollten sich in diesem Kontext Neomake anschauen. Um sich ein Bild der Möglichkeiten von ShellCheck zu machen, suchen Sie am besten die Gallery of Bad Code [6] auf.
Divide et impera
Java-Entwickler sind seit Jahrzehnten mit JUnit vertraut, in der Python-Welt ist Unittest ein alter Bekannter. Sämtliche Frameworks machen sich zunutze, dass sich wohldefinierte Codeblöcke wie Funktionen einfacher testen lassen als ein großes Programm. Intuitiv weiß jeder, dass eine Funktion »verdoppeln(x)« wohl wiederholbar denselben Wert für dasselbe »x« liefert. In der Informatik heißt das Determinismus. Deterministische Programme liefern bei derselben Eingabe stets dieselbe Ausgabe. Nondeterministische Programme tun das nicht. Ein typisches Beispiel dazu sind Datenbankabfragen: Ein SQL-Statement muss bei denselben Kriterien eben nicht das dasselbe Ergebnis liefern.
Unit-Tests sollten deterministisch gestaltet sein. Konkret heißt das: Sie legen die zu erwartenden Ausgaben nebst der dazugehörigen Eingaben fest, bevor Sie die Funktion implementieren. Nicht besonders weit verbreitet scheint mir das Wissen zu sein, dass sich Unit-Tests auch für Shell-Skripte entwickeln lassen. Hier springt Shunit2 [7] in die Bresche.
Die Syntax der Unit-Tests dürfte für Entwickelnde mit JUnit-Erfahrung kaum Überraschungen bergen. Listing 5 zeigt die Datei »suite_test.sh« aus den bei der Installation mitgelieferten Beispielen. Hier baut das Skript eine Suite auf, also eine ganze Testreihe. Dabei können Sie darauf verzichten, die Funktionen für die Testfälle mit dem Präfix »test« einzuleiten. Getestet wird mithilfe der verbreiteten Assert-Funktionen: »assertEquals« fordert die Gleichheit, »assertNotEquals« die Ungleichheit.
Listing 5
Unit-Test suite_test.sh
#!/bin/sh
# file: examples/suite_test.sh
#
# This test demonstrates the use of suites. Note: the suite functionality is
# deprecated as of v2.1.0, and will be removed in a future major release.
# suite is a special function called by shUnit2 to setup a suite of tests. It
# enables a developer to call a set of functions that contain tests without
# needing to rename the functions to start with "test".
#
# Tests that are to be called from within `suite()` are added to the list of
# executable tests by means of the `suite_addTest()` function.
suite() {
# Add the suite_test_one() function to the list of executable tests.
suite_addTest suite_test_one
# Call the suite_test_two() function, but note that the test results will not
# be added to the global stats, and therefore not reported at the end of the
# unit test execution.
suite_test_two
}
suite_test_one() {
assertEquals 1 1
}
suite_test_two() {
assertNotEquals 1 2
}
# Load and run shUnit2.
. ../shunit2
In Listing 6 sehen Sie, wie ein Skript mit eingebauten Unit-Tests aussehen kann. Vordefiniert sind drei Funktionen »is_odd«, »is_even« und »is_positive«. Alle sind so gestaltet, dass sie boolesche Werte nach »stdout« schreiben. Daraufhin folgen die zugehörigen Unit-Tests. Da es in Bash schwerfällt, innerhalb verschachtelter Funktionsaufrufe Parameter korrekt zuzuordnen, weise ich die Ausgaben der zu testenden Funktionen zunächst der Variable »result« zu, die dann wiederum an die Assert-Funktionen von Shunit2 gehen. In Zeile 30ff, Zeile 37ff und Zeile 44ff können Sie ablesen, wie Sie das Ergebnis von Funktionen an Shunit2 weitergeben.
Listing 6
Ein Unit-Test im Shell-Skript
#!/bin/bash
function is_odd() {
local number=$1
if [ $((number % 2)) -eq 0 ]; then
echo 1 # 1=false
else
echo 0 # 0=true
fi
}
function is_even() {
local number=$1
if [ $((number % 2)) -eq 0 ]; then
echo 0 # 0=true
else
echo 1 # 1=false
fi
}
function is_positive() {
local number=$1
if [ "${number}" -ge 0 ]; then
echo 0;
else
echo 1;
fi
}
testOdd() {
result="$(is_odd 1)"
assertEquals "is_odd: ungerade Zahl" 0 "${result}"
result="$(is_odd 2)"
assertEquals "is_odd: keine ungerade Zahl" 1 "${result}"
}
testEven() {
result="$(is_even 1)"
assertEquals "is_even: keine gerade Zahl" 1 "${result}"
result="$(is_even 2)"
assertEquals "is_even: gerade Zahl" 0 "${result}"
}
testPositive() {
result="$(is_positive 100)"
assertEquals "Positive Zahl:" 0 "${result}"
result="$(is_positive -321)"
assertNotEquals "Keine positive Zahl:" 0 "${result}"
}
. shunit2
Fazit
Wer Shell-Skripte schreibt, die über die Einwegnutzung hinaus gehen und vielleicht sogar ständig in Gebrauch sein werden, sollte dringend auf die Unterstützung durch Tools setzen. Alle drei vorgestellten Programme erleichtern den Entwicklungsprozess erheblich und helfen bei der Pflege von Software. Überdies sollten Sie sie in den gängigen Distributionen finden.
Das Umstellen eines reinen Batch-Skripts auf prozedurale respektive strukturierte Programmierung mit Funktionen und lokalen Variablen ist selten eine triviale Aufgabe. Unit-Tests funktionieren jedoch erst, wenn Subroutinen zum Einsatz kommen. Wer einen Linter nutzt, erhält zudem beim Schreiben der Tests Unterstützung seitens des Werkzeugs. (csi)
Infos
- Advanced Bash Scripting Guide: https://tldp.org/LDP/abs/html/
- Admin-as-a-Service: Thomas Reuß, “Triangel und Dirigent”, LM 02/2023, S. 58, https://www.lm-online.de/48646
- EditorConfig: https://editorconfig.org
- ShellCheck: https://www.shellcheck.net
- Syntastic: https://github.com/vim-syntastic/syntastic
- Gallery of Bad Code: https://github.com/koalaman/shellcheck/blob/master/README.md#user-content-gallery-of-bad-code
- Shunit2: https://github.com/kward/shunit2






