De programmeertaal bash
is niet eenvoudig om mee te werken. Gelukkig bestaan er tools die het leven van programmeurs wat makkelijker kunnen maken. We geven hier een overzicht van problemen die vaak opduiken bij bash
-scripting en hoe je daarmee kunt omgaan.
TL;DR1
We raden de volgende best practices aan
- Gebruik
shellcheck
om je scripts te controleren op veelgemaakte fouten en mogelijke problemen.- Begin je scripts met
set -u -o pipefail -o noclobber
om veelgemaakte fouten te voorkomen.- Gebruik
mktemp
om tijdelijke bestanden aan te maken. Vergeet die bestanden dan ook niet te verwijderen, eventueel door middel van hettrap
commando.- Gebruik
bash -x
om je scripts in “debugmode” uit te voeren.- Bij twijfel: raadpleeg de bash scripting cheatsheet2.
De shellcheck
linter3 is een zeer handige tool voor algemene troubleshooting bij het schrijven van shell scripts. Voor kleine scripts kan je deze tool rechtstreeks in de browser gebruiken via de website4. Je kunt de tool echter ook lokaal installeren5. Als je Ubuntu of een andere Debian-distro gebruikt, dan gaat dit eenvoudigweg met
$ sudo apt install shellcheck
Je kunt shellcheck
vervolgens op gelijk welk bestand uitvoeren. Als je bijvoorbeeld een shell script hebt opgeslagen in een bestand test.sh
, dan kun je daarop shellcheck
uitvoeren met:
$ shellcheck test.sh
De shellcheck
linter produceert dan een aantal berichten die ofwel louter informatief zijn ofwel kunnen wijzen op echte fouten. De uitvoer ziet er bijvoorbeeld als volgt uit:
$ shellcheck test.sh
In test.sh line 6:
while [[ $teler -lt 5 ]]
^----^ SC2154 (warning): teler is referenced but not assigned (did you mean 'teller'?).
For more information:
https://www.shellcheck.net/wiki/SC2154 -- teler is referenced but not assig...
Hier worden we erop gewezen dat we in ons shell script test.sh
een variabele teler
gebruiken die nog niet gedefinieerd is. Het programma is ook slim genoeg om uit te vissen dat dit wellicht een typfout was, en dat we vermoedelijk teller
bedoelden in plaats van teler
.
In veel programmeertalen kun je een variabele pas gebruiken nadat die expliciet gedefinieerd is. Standaard maakt bash
echter geen onderscheid tussen variabelen die al gedefinieerd zijn en variabelen die nog niet gedefinieerd zijn. Dit leidt soms tot problemen als je een typfout maakt in de naam van een variabele. Beschouw bijvoorbeeld onderstaand shell script:
#!/bin/bash
teller=0
while ((teler < 5))
do
echo "$teller"
((teller += 1))
done
We hebben hier een typfout gemaakt op regel 4: teler
ipv teller
. De bash
interpreter zal ons hierover niet informeren, met als resultaat dat het script in een oneindige lus gaat. Dergelijke fouten kunnen heel moeilijk op te sporen zijn. Vooral omdat ze zich op heel verschillende manieren kunnen manifesteren. Dankzij het set
commando kunnen we bash
echter verplichten om te controleren of er reeds een waarde werd toegekend aan een variabele voordat ze gebruikt wordt:
#!/bin/bash
set -u
teller=0
while ((teler < 5))
do
echo "$teller"
((teller += 1))
done
Het set
commando is heel veelzijdig. Kijk dus zeker eens naar de documentatie6 voor andere handige opties. In het bijzonder zorgt set -u
ervoor dat niet-gedefinieerde variabelen niet gebruikt kunnen worden. Bovenstaand codefragment geeft dan ook volgende foutmelding:
line 6: teler: unbound variable
Je krijgt hier het specifieke regelnummer en de naam van de variabele die niet bestaat, wat voldoende informatie zou moeten zijn om de typfout op te sporen en te corrigeren.
Het zal al eens voorkomen dat je wil werken met data die te groot of te onpraktisch is om volledig in het geheugen te houden. In dat geval kan je gebruikmaken van tijdelijke bestanden: bestanden die alleen maar bestaan tijdens de uitvoering van een script en daarna niet meer op het bestandssysteem moeten bewaard worden. Beschouw bijvoorbeeld dit shell script:
#!/bin/bash
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt > boek.txt
cat boek.txt | \
tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
grep -E '.{4,}' | \
sort | uniq -c | \
sort -k1,1nr | head -n10
Hier slaan we een tekst van het internet op in een tekstbestand onder de naam boek.txt
. Dit bestand heeft geen andere functie dan kort gebruikt te worden in ons script om een lijst met veelgebruikte woorden te produceren. Het gaat hier dus duidelijk om een tijdelijk bestand, maar de manier waarop we er hier mee omgaan is problematisch omwille van een aantal redenen:
We gebruiken een hardgecodeerde naam boek.txt
. Als je bash
scripts schrijft, weet je meestal niet waar die scripts precies uitgevoerd gaan worden. Het is dus best mogelijk dat er in de huidige directory al een bestand bestaat met de naam boek.txt
. Als dit bestand waardevolle data bevat, zal de gebruiker die kwijt zijn nadat jouw script klaar is. Het bestand werd immers overschreven, zonder dat de gebruiker van je shell script daarop gewezen werd.
Je kan er niet van uitgaan dat de huidige directory schrijfpermissies heeft. Het is dus goed mogelijk, afhankelijk van waar jouw script uitgevoerd wordt, dat jouw script zal crashen bij het aanmaken van het tijdelijk bestand omdat je geen rechten hebt om bestanden aan te maken op die locatie.
Tijdelijke bestanden moeten op het einde altijd verwijderd worden, anders verspillen ze ruimte op het bestandssysteem.
We kunnen deze problemen makkelijk oplossen:
#!/bin/bash
bestand=$(mktemp)
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt > "${bestand}"
cat "${bestand}" | \
tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
grep -E '.{4,}' | \
sort | uniq -c | \
sort -k1,1nr | head -n10
rm "${bestand}"
We gebruiken hier het mktemp
commando om een tijdelijk bestand aan te maken (documentatie7). Dit commando dient speciaal voor situaties waar we op een veilige manier tijdelijke bestanden willen aanmaken. Als je dit commando uitvoert, krijg je bijvoorbeeld volgend resultaat:
$ mktemp
/tmp/tmp.OTuvHUq60B
Je zal merken dat dit bestand ook direct aangemaakt wordt op het bestandssysteem. Als je mktemp
aanroept, zal het dus eerst bepalen waar het veilig is om een tijdelijk bestand aan te maken (in dit geval onder de /tmp
directory). Vervolgens zal het een bestandsnaam genereren (in dit geval tmp.OTuvHUq60B
) en dit bestand ook aanmaken op het bestandssysteem. De absolute padnaam van het bestand wordt uitgeschreven naar stdout
, zodat je het in een variabele kunt opslaan om later te gebruiken. Vergeet achteraf ook niet om het tijdelijke bestand terug te verwijderen!
Let op
Telkens je
mktemp
aanroept, wordt er een nieuw bestand aangemaakt. Roep jemktemp
bijvoorbeeld drie keer aan in hetzelfde script, dan zal je drie verschillende tijdelijke bestanden krijgen. Het is dus de bedoeling dat jemktemp
slechts een keer aanroept per tijdelijk bestand dat je wil maken.
Bovenstaande aanpak is nog niet helemaal perfect. Wat gebeurt er namelijk als je een tijdelijk bestand aanmaakt, maar jouw script crasht of wordt afgesloten voordat je het bestand kunt verwijderen? Je kunt proberen om overal rm
commando’s te plaatsen, maar dit wordt snel onhandelbaar met veel tijdelijke bestanden. Bovendien levert het geen garantie omdat jouw script ook geforceerd kan afgesloten worden, bijvoorbeeld wanneer het geheugen op is of er een tijdslimiet overschreden is. Om dit op te lossen, kunnen we gebruikmaken het trap
commando om signalen op te vangen. Beschouw dit script:
#!/bin/bash
bestand=$(mktemp)
trap "rm ${bestand}" EXIT INT TERM
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt > "${bestand}"
cat "${bestand}" | \
tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
grep -E '.{4,}' | \
sort | uniq -c | \
sort -k1,1nr | head -n10
De syntaxis van het trap
commando is
trap [COMMANDO] [SIGNALEN...]
Hier gebruiken we trap "rm ${bestand}" EXIT INT TERM
om bash
te vragen om het commando rm $bestand
uit te voeren wanneer ons script de signalen EXIT
, INT
of TERM
ontvangt. Het EXIT
signaal wijst op een gewone beëindiging, bijvoorbeeld als het script zelf het exit
commando uitvoert of als het einde van het script bereikt wordt. De andere signalen, INT
en TERM
, worden vaak gebruikt als het script geforceerd wordt afgesloten door een ander proces (bv. kill
). Deze techniek geeft een veel betere garantie dat tijdelijke bestanden effectief terug verwijderd zullen worden.
Als je veel bestanden moet verwijderen, of als het opruimen redelijk complex is, kun je ook deze strategie gebruiken:
#!/bin/bash
function cleanup
{
# ... doe hier jouw cleanup ...
}
trap cleanup EXIT INT TERM
# ... rest van het script ...
Op deze manier zal de functie cleanup
opgeroepen worden wanneer het script afsluit, en kun je complexere code uitvoeren.
Het komt in bash
vaak voor dat je een reeks commando’s verbindt via pipes:
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt | \
tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
grep -E '.{4,}' | \
sort | uniq -c | \
sort -k1,1nr | head -n10
In dit voorbeeld downloaden we een bestand van het Internet en voeren we een hoop transformaties uit op de data. Wat gebeurt er echter als een van deze commando’s faalt? Het is goed mogelijk dat curl
er niet in slaagt het bestand te downloaden, omdat internetverbindingen onbetrouwbaar kunnen zijn. In dat geval zal curl
een exit status teruggeven die wijst op een connectieprobleem (zoals exit status 6 of 7; zie de documentatie8). De overige commando’s in de pipeline gaan echter deze exit status niet controleren. Bovendien kunnen ze allemaal perfect werken met lege data: ze doen dan immers gewoon niks en geven lege data terug. Het beste dat je kunt verwachten in dat geval is een foutmelding op de terminal, maar het script zal zich van geen kwaad bewust zijn. We kunnen dit op een aantal manieren oplossen:
Voer de aanroep naar curl
apart uit en controleer de exit status. Dit werkt voor curl
, maar wat als je een pipeline hebt waar verschillende tussentijdse commando’s op allerlei manieren kunnen falen? Dan zou je elk commando apart moeten uitvoeren en controleren, wat het nut van pipelines volledig ondermijnt.
Controleer of het eindresultaat van de pipeline niet leeg is. Dit is opnieuw geen elegante oplossing, omdat een leeg resultaat over het algemeen niet noodzakelijk een fout resultaat is. Dit is misschien OK voor dit specifieke voorbeeld, maar is zeker geen algemeen aanvaardbare techniek.
Controleer de exit status van de pipeline.
We bekijken de laatste optie in wat meer detail. Wat als we dit doen:
#!/bin/bash
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt | \
tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
grep -E '.{4,}' | \
sort | uniq -c | \
sort -k1,1nr | head -n10
if [[ $? -eq 0 ]]
then
echo "OK!"
else
echo "Error!"
fi
Je zal merken dat dit niet werkt: de exit status die we controleren in $?
is standaard de exit status van het laatst beëindigde commando, in dit geval head
. Het commando head
zal in deze context bijna nooit een fout geven, waardoor onze controle dus waardeloos wordt. Zelfs als alle commando’s behalve head
fouten geven, zullen we nog altijd OK!
uitschrijven.
Gelukkig bestaat er een manier om het gedrag van bash
te veranderen, zodat de exit status van een pipeline gelijkgesteld wordt aan de exit status van het laatste (meest rechtse) commando dat faalt. Dit kan met set -o pipefail
:
#!/bin/bash
set -o pipefail
curl --silent https://www.gutenberg.org/cache/epub/13214/pg13214.txt | \
tr -dc 'a-zA-Z ' | tr 'A-Z' 'a-z' | tr ' ' '\n' | \
grep -E '.{4,}' | \
sort | uniq -c | \
sort -k1,1nr | head -n10
if [[ $? -eq 0 ]]
then
echo "OK!"
else
echo "Error!"
fi
Als gelijk welk commando in de pipeline nu een niet-nul exit status teruggeeft, zal $?
ook niet nul zijn. Je kan ook nog strikter zijn en eisen dat bash
het script onmiddellijk eindigt zodra eender welk commando een niet-nul exit status geeft. Dit kan je doen met set -e
of (equivalent) met set -o errexit
. Merk op dat deze optie niets verandert aan het gedrag van voorwaardelijke statements (zoals if
en test
) of lussen (zoals for
, while
en until
), omdat de exit status daar een noodzakelijk onderdeel van de functionaliteit vormt. Anders zou het script afgebroken kunnen worden bij elke if
of while
en dat is natuurlijk niet de bedoeling.
Het kan soms verleidelijk zijn om een bestand dat je bewerkt direct te overschrijven met het resultaat. Neem bijvoorbeeld dit shell script:
#!/bin/bash
cat test.txt | tr 'a-z' 'A-Z' > test.txt
Het is de bedoeling om alle kleine letters in het bestand test.txt
om te zetten naar hoofdletters. Een onverwacht probleem dat je hier kunt tegenkomen, is dat het bestandtest.txt
leeg is nadat dit script klaar is. Ongeacht wat de oorspronkelijke inhoud was. Dit komt door een soort race condition: uit de hoorcolleges zal je je herinneren dat een pipeline van commando’s ervoor zorgt dat alle commando’s tegelijkertijd uitgevoerd worden. Dit is een soort optimalisatie, zodat de pipeline zo snel mogelijk uitgevoerd kan worden. Het zorgt er ook voor dat het bestand test.txt
tegelijkertijd geopend wordt met schrijfpermissies (voor de I/O redirection na tr
) en leespermissies (voor cat
). Dit kan voor problemen zorgen: tegelijkertijd lezen van en schrijven naar hetzelfde bestand is vragen om problemen. We kunnen dit probleem opnieuw oplossen met opnieuw een speciale optie van set
:
#!/bin/bash
set -o noclobber
cat test.txt | tr 'a-z' 'A-Z' > test.txt
De optie noclobber
zorgt ervoor dat we geen bestanden kunnen overschrijven die reeds bestaan, waardoor we deze race condition vermijden. Het script zal nu een foutmelding genereren in plaats de informatie uit het bestand verloren te laten gaan:
line 5: test.txt: cannot overwrite existing file
De optie is vernoemd naar het fenomeen clobbering9. Dat is de term die men in de Unix-wereld gebruikt voor het overschrijven van bestaande bestanden. Clobbering is vaak (maar niet altijd) een fout, en wordt hoe dan ook beschouwd als slechte programmeerstijl. Een betere oplossing is een bijkomend (tijdelijk) bestand te gebruiken in plaats van het originele bestand direct te overschrijven.
Soms kan je met de beste wil van de wereld, ondanks bovenstaande tips, een probleem met je script nog steeds niet vinden. Dan kan het helpen als bash
naar de terminal extra informatie uitschrijft over welke commando’s er precies uitgevoerd worden met welke argumenten, en welke waarden er aan de variabelen toegekend worden. Je kunt een dergelijke “debugmode” inschakelen met de -x
optie van bash
. Stel dat het bestand test.sh
dit shell script bevat:
#!/bin/bash
while read teller noemer woord; do
woordlengte=${#woord}
breuklengte=$((woordlengte * teller / noemer))
echo -n "${woord:0:${breuklengte}}"
done
echo
Stel dat bestand question.txt
deze inhoud heeft:
3 7 Manatee
3 7 cheetah
-4 7 hamster
We voeren het script test.sh
uit met dit bestand:
$ bash test.sh < question.txt
Mancheham
De verwachte uitvoer is Manchester
, maar we krijgen Mancheham
. Wat is er aan de hand? We bekijken de uitvoering van het shell script eens in debugmode:
$ bash -x test.sh < question.txt
+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ echo -n Man
Man+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ echo -n che
che+ read teller noemer woord
+ woordlengte=7
+ breuklengte=-4
+ echo -n ham
ham+ read teller noemer woord
+ echo
De fout doet zich voor na de derde read
, wanneer breuklengte=-4
. We hebben geen rekening gehouden met negatieve getallen. Dit lossen we als volgt op:
#!/bin/bash
while read teller noemer woord; do
woordlengte=${#woord}
breuklengte=$((woordlengte * teller / noemer))
if ((teller >= 0)); then
echo -n "${woord:0:${breuklengte}}"
else
echo -n "${woord:$((woordlengte + breuklengte))}"
fi
done
echo
Nu zien we dat alles correct werkt:
$ bash -x test.sh < question.txt
+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ (( teller >= 0 ))
+ echo -n Man
Man+ read teller noemer woord
+ woordlengte=7
+ breuklengte=3
+ (( teller >= 0 ))
+ echo -n che
che+ read teller noemer woord
+ woordlengte=7
+ breuklengte=-4
+ (( teller >= 0 ))
+ echo -n ster
ster+ read teller noemer woord
+ echo
$ bash test.sh < question.txt
Manchester
Zoals verwacht schrijft het script nu de tekst Manchester
uit.
Tenslotte raden we ook aan om de bash scripting cheatsheet10 bij de hand te houden. Raadplaag het als je twijfelt hoe je een bepaald stuk code moest schrijven. De cheatsheet documenteert veelgebruikte structuren in bash
, zoals while read
lussen en voorwaardelijke statements. Dit kan vaak sneller zijn dan de code iedere keer te googelen op StackOverflow of op te zoeken met ChatGPT.