Kapittel 15,
Automatisering
viste hvordan man skriver sitt første skript: en fil med kommandoer, en
shebang, en chmod +x, ferdig. Det første skriptet fungerer helt til
den dagen et filnavn har et mellomrom i seg, et argument mangler, eller en
kommando midt i skriptet feiler uten at noen legger merke til det. Denne
guiden handler om verktøyene bash gir deg for den dagen. Samme skall,
dypere vann.
Du trenger ingenting installert. Bash er standardskallet på Ubuntu, Fedora og Arch, og alt her er sjekket på bash 5.3 (5.3.9 på Ubuntu og Fedora, 5.3.15 på Arch) med identisk oppførsel hele veien.
Variabler og anførselstegn
Tilordninger har ingen mellomrom rundt =. Skriv name="my file.txt",
aldri name = "my file.txt" — med mellomrom leser bash name som en
kommando og svarer name: command not found.
Bruker du en variabel, skriver du $name. Å sette anførselstegn rundt den
er hele spillet:
#!/bin/bash
name="my file.txt"
ls $name
ls: cannot access 'my': No such file or directory
ls: cannot access 'file.txt': No such file or directory
Uten anførselstegn deles verdien til variabelen på mellomrom før ls i det
hele tatt kjører: bash så to filer der du så én. Med anførselstegn
overlever filnavnet uskadd:
ls "$name"
Vanen som lønner seg: anførselstegn rundt hver variabel, alltid, selv når du er sikker på at den ikke har noen mellomrom.
Aritmetikk er stedet anførselstegn tar en pause. Doble parenteser
regner ut uttrykket: $((2 + 3 * 4)) gir 14 (multiplikasjon binder
før addisjon), og resultatet kommer ut som ett ord, så
count=$((count + 1)) trenger ingen anførselstegn for å være trygt.
Når en variabel sitter limt fast til annen tekst, bruk krøllparenteser:
"${file}.txt" — ellers leser bash $file.txt som en variabel som heter
file.txt. Og ${name:-backup} gir backup når name ikke er satt
eller er tom, noe som gjør den til den høflige måten å håndtere valgfrie
argumenter på: dir="${1:-.}".
Exit-koder
Hver kommando avslutter med en exit-kode, og $? holder koden til den
siste. Null betyr suksess. Alt annet betyr at noe har gått galt, og tallet
sier hva. En feilende ls /nonexistent avslutter med 2 på GNU
ls. Du trenger ikke pugge tall — skill null fra ikke-null, så er du
trygg.
Skriptene dine får velge sin egen kode med exit. exit 0 erklærer
suksess. exit 1 er standardklagen. Avslutter et skript uten exit,
arver det koden til den siste kommandoen det kjørte.
&& og || bygger på dette. cmd && next kjører next bare hvis cmd
lykkes. cmd || fallback kjører fallback bare hvis den feiler. Kjeden
stopper ved første feil, noe som gjør at one-linere som
mkdir -p backups && cp -r files backups/ kan leses akkurat slik de
oppfører seg.
Tester: [ ] og [[ ]]
if-setningen kjører en kommando og reagerer på exit-koden. Som oftest er
den kommandoen en test:
if [ -f ~/.bashrc ]; then
echo "bashrc exists"
fi
De kjente operatorene: -f filen finnes, -d mappen finnes, -z tom
streng, -n ikke-tom streng, -eq og -lt for sammenligning av tall,
og ! foran snur hele testen:
[ ! -d "$dir" ] er sant når mappen mangler. Den mest siterte feilen
i shell-skripting:
#!/bin/bash
x="a b"
if [ $x = y ]; then
echo "equal"
fi
check.sh: line 3: [: too many arguments
Uten anførselstegn ble $x delt i to ord, og [ telte tre argumenter der
en sammenligning trenger to. (Stien i bash sine feilmeldinger er
skriptfilnavnet, og tallet er den skyldige linja, så
check.sh: line 3: peker på if-en. Lagre kodebiten som check.sh, så
ser du nøyaktig dette.) En tom variabel feiler annerledes:
check.sh: line 3: [: =: unary operator expected
Bash har en løsning. [[ ]] er den oppgraderte testen, og den deler ikke
variabler i ord i det hele tatt — [[ $x = y ]] virker med mellomrom, med
tomme verdier og uten et eneste anførselstegn i sikte. Inni [[ ]] kan du
også skrive && og || direkte. Inni [ ] er de syntaksfeil, og
skriptet dør med [: missing `]'.
Mønstersamsvar er noe som bare [[ ]] får lov til. På høyre side av ==
er en verdi uten anførselstegn et glob: [[ $s == hel* ]] er sant for
hello. Setter du anførselstegn rundt mønsteret, sammenlignes det
bokstavelig i stedet.
En felle verdt å kjenne til selv om du aldri trår i den: inne i [ ] er
tegnene < og > redireksjoner, ikke sammenligninger. [ "zzz" > out ]
returnerer sant og oppretter, som en bieffekt, en fil som heter out.
Inne i [[ ]] sammenligner de strenger. Kort versjon: i bash-skript, bruk
[[ ]].
Løkker
for-løkken går gjennom en liste:
for f in *.txt; do
echo "found: $f"
done
*.txt er et helt vanlig glob, så løkken får én runde per fil som treffer
— og koden inni løkken setter anførselstegn rundt "$f", fordi et filnavn
uten anførselstegn deler seg i ord når det har et mellomrom i seg. Å telle
fungerer på samme måte: for i in 1 2 3; do echo "$i"; done.
For å gå gjennom en fil linje for linje er redireksjonsformen den du bør
lære deg. Kapittel 13 viste > som sender utdata inn i en fil; < peker
samme pil den andre veien og mater en fil inn i en kommando. Og read line henter neste linje av fila inn i en variabel som heter line:
while read line; do
echo "got: $line"
done < notes.txt
To særegenheter, begge bekreftet på den harde måten. En siste linje uten
avsluttende linjeskift når aldri koden inni løkken — printf 'a\nb' gir
løkken én linje, og b forsvinner i det stille. Større overraskelse:
pipe-formen av denne løkken mister variablene dine.
counter=0
cat notes.txt | while read line; do
counter=$((counter+1))
done
echo "counter after pipe loop: $counter"
counter after pipe loop: 0
Tre linjer gikk inn, og telleren sto på 0 etterpå. Løkken på høyre side av
en pipe kjører i et underskall — en barnekopi av skriptet — og variablene
dens dør med det. done < file-formen unngår hele problemet, og derfor
bruker denne guiden den.
case
Når ett skript gjør flere jobber avhengig av ett ord, er case det
rette verktøyet:
#!/bin/bash
case "$1" in
start)
echo "starting"
;;
stop)
echo "stopping"
;;
*)
echo "Usage: $0 start|stop"
;;
esac
Mønstrene er globs, så h*) treffer hello og help. Første gren som
treffer kjører, og deretter avslutter case — den faller ikke videre til
senere grener. *)-grenen er den som tar alt, og en bruksmelding der er
måten skript lærer deg sitt eget grensesnitt på. ($0 er skriptets egen
sti: i en brukslinje skriver den ut det du skrev for å kjøre skriptet.)
;; mellom grenene er obligatorisk. Utelater du én midt inne, nekter bash
å kjøre hele skriptet med syntax error near unexpected token ')'. Den siste
;; før esac er valgfri. Skriver du den likevel, slipper du at grener
som er limt inn fra et annet sted biter deg.
Funksjoner
En funksjon er en navngitt blokk med kommandoer:
greet() {
echo "Hello, $1"
}
greet "Ada"
Inne i en funksjon er $1 funksjonens eget første argument — skriptets
$1 røres ikke av kallet. return 2 setter funksjonens exit-kode (sjekk
den med $? etter kallet). exit ville avsluttet hele skriptet, noe en
hjelpefunksjon sjelden bør gjøre.
Variabler opprettet inne i en funksjon er globale som standard, og det
lekker. Nøkkelordet local holder dem inne:
total=0
add() {
local amount="$1"
total=$((total + amount))
}
Å bruke local på alt en funksjon oppretter, er billig forsikring. Uten
den vil to funksjoner som begge bruker en variabel som heter count, en
dag overskrive hverandre på måter som tar en time å finne.
$@ — alle argumentene på én gang
Ét argument var nok for skriptene så langt, men skript som behandler
filer vil som regel ha alle sammen. Bash har en snarvei for hele listen:
"$@". Inn i anførselstegnene utvides den til hvert argument skriptet
fikk, med hvert ett helt — så vanen med anførselstegn gjelder her også.
#!/bin/bash
for f in "$@"; do
echo "processing: $f"
done
Kjør den med to filer, én av dem med et mellomrom i navnet, og løkken kjører to ganger med filnavnet intakt begge ganger:
$ bash process.sh "my file.txt" second.txt
processing: my file.txt
processing: second.txt
Uten anførselstegnene ville filen med mellomrommet blitt delt i to ødelagte
navn — hele grunnen til at del 1 var så nøye. Trenger du antallet i stedet
for verdiene, står det i $#.
Når skriptene går galt
To innstillinger gjør stille katastrofer om til høylytte, og de står som de to første linjene i de fleste skript som oppfører seg:
#!/bin/bash
set -eu
set -e stopper skriptet ved den første kommandoen som feiler, i stedet
for å stape videre som om ingenting hadde hendt. Med den satt, avslutter
en feilende ls skriptet der og da. Uten den skrives feilmeldingen ut,
skriptet fortsetter, og alt som var avhengig av fila som manglet,
produserer søppel to linjer lenger ned.
Den har unntak, og de er fornuftige så snart du ser dem. En kommando inne
i en if-betingelse får lov til å feile — det er det betingelsen er til
for. Det samme gjelder en kommando som ikke står sist i en &&-kjede:
false && true dreper ikke skriptet, men en feilende siste kommando i
kjeden gjør det. En while read-løkke som når slutten av fila si, er
heller ingen feil. Løkken tar bare slutt.
set -u gjør variabler som ikke er satt, til en feil i stedet for tomme
strenger. Med den stopper en skrivefeil som $naem skriptet med feilen
naem: unbound variable.
Uten set -u ville skrivefeilen lydløst blitt utvidet til ingenting, og
skriptet ville gått videre uten argumentet sitt. Det finnes flere av
samme slag — set -o pipefail er ett av dem — men med de to kommer et
lite skript langt.
trap: opprydding
Skript som oppretter ting, skylder verden en opprydding, selv når de dør
underveis. trap planlegger en kommando som skal kjøre når skriptet
avslutter:
#!/bin/bash
set -eu
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
echo "working in $tmp"
mktemp -d oppretter en fersk mappe med unikt navn og skriver ut stien
til den. Å pakke en kommando inn i $( ) kjører den og fanger utdataen
(samme triks som kapittel 15 brukte på datostempelet), så tmp ender
opp med mappenavnet.
Fellen på EXIT utløses når skriptet slutter, uansett hvordan det slutter
— nådd den siste linja, truffet en exit, eller drept av set -e etter
en feilende kommando. Opprydding skrevet én gang, øverst, dekker alle
veier.
Avbrudd har sin egen felle. trap 'echo interrupted' INT kjører
kommandoen sin når skriptet mottar SIGINT, avbruddssignalet. En
kuriositet: et skript som allerede ignorerte SIGINT da det startet,
holder fram med å ignorere det. Feller hører hjemme i dine egne skript,
ikke i skript som andre programmer starter og signaliserer til.
Backup-skriptet, nå voksen
Kapittel 15 sin versjon:
#!/bin/bash
# backup.sh — copy a directory to ~/backups with a date stamp
dir="$1"
stamp="$(date +%Y%m%d)"
cp -r "$dir" "$HOME/backups/$dir-$stamp"
echo "Backed up $dir to backups/$dir-$stamp"
Det fungerer, og det feiler på alle de stille måtene. Kjørt uten argument,
kopierer det ingenting og sier ingenting nyttig. Rettes mot en mappe som
ikke finnes, klager cp, og skriptet melder likevel suksess. Her er det
igjen:
#!/bin/bash
# backup.sh — copy a directory to ~/backups with a date stamp
set -eu
dir="${1:-}"
if [ -z "$dir" ]; then
echo "Usage: $0 directory"
exit 1
fi
if [ ! -d "$dir" ]; then
echo "No such directory: $dir"
exit 1
fi
stamp="$(date +%Y%m%d)"
mkdir -p "$HOME/backups"
cp -r "$dir" "$HOME/backups/$dir-$stamp"
echo "Backed up $dir to backups/$dir-$stamp"
Samme jobb. Forskjellene ligger alle i hvordan det feiler: et manglende argument gir nå en brukslinje, et feil navn gir en tydelig klage, en feilende kopi stopper skriptet før suksessmeldingen som lyver, og hver utvidelse står i anførselstegn. Det er det «holder mål» betyr i praksis — ikke flere funksjoner, bare færre stille feil.
Hvor du står nå
Skriptene dine kan ta imot argumenter uten å gjette, feile høylytt, rydde opp etter seg, og overleve filnavn med mellomrom i seg. Når et skript vokser ut av én skjerm, kjør det gjennom ShellCheck — lim det inn, og de klassiske feilene fra denne guiden blir påpekt ved navn. GNU Bash-manualen dokumenterer alt denne guiden dro over. Kapittel 15, Automatisering er fortsatt stedet for cron-delen av automatiseringen, og kommandolisten har one-linerne.