Cvičení: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14.

Na tomto cvičení si rozšíříme znalosti o skriptování v shellu. Seznámíme se s řídicími strukturami a dalšími střípky, díky nimž budou naše shellové skripty výkonnější.

Pro vysvětlení nových konstrukcí opět použijeme průběžný příklad.

Téma druhého testu na cvičení bude také zahrnovat konstrukce uvedené v tomto cvičení. Úloha pro test bude mnohem menší než náš průběžný příklad, ale můžete očekávat, že budete muset použít některé konstrukce uvedené na tomto cvičení.

Ačkoliv se jedná o pravděpodobně nejdelší cvičení podle počtu slov, nových konceptů je tu poměrně málo. Proto se, prosím, nelekejte délky posuvníku :-).

V mnoha příkladech budeme explicitně používat Bash, protože ve většině funkcí používáme lokální proměnné. Všechny ukázky budou fungovat, i když váš shell tuto konstrukci nepodporuje: stačí vypustit klíčové slovo local - ačkoli nepoužíváme žádné kolidující názvy proměnných, věříme, že local pěkně zdůrazňuje záměr proměnné.

Předstartovní kontrola

  • Umíte používat shellové proměnné a tzv. command substitution.
  • Pamatujete si význam exit kódu (návratová hodnota programu) a víte, co znamená, když má hodnotu 0.
  • Pamatujete si, k čemu se používá Pandoc.

Průběžný příklad

Nyní se ještě vrátíme k našemu příkladu s generováním webu. Zdrojové kódy jsou opět v našem repozitáři příkladů, ale jak vidíte v 09/web, je zde nyní mnohem více stránek a soubory jsme rozdělili do více podadresářů.

Je zde adresář content se vstupními soubory v jazyce Markdown, adresář static se souborem CSS a případně dalšími soubory, které budou zkopírovány na webový server, a je tu též adresář templates se šablonami Pandoc pro naše stránky.

Nyní vytvoříme slušný shellový skript, který dokáže tento web vytvořit, a také web zkopíruje na webový server, aby byl veřejně dostupný.

Uznáváme, že existují speciální nástroje určené přesně na toto. Říká se jim statické generátory stránek (nebo jen SSG - static site generators) a je jich k dispozici obrovské množství. Tato úloha ale tvoří velmi dobré hřiště, na kterém si můžeme ukázat, čeho je shell schopen :-).

Začneme triviálním generátorem, který je v podstatě kopií generátoru webu z jednoho z předchozích cvičení.

Důrazně doporučujeme, abyste fragmenty zkopírovali z našeho repozitáře do svého (klidně použijte váš repozitář pro odevzdávání úkolů) a vytvořili revizi (commit) pro každou verzi aplikace. Použijte git z příkazového řádku a používejte dobré popisy commitů.

A když si pro každou část aplikace vytvoříte na Gitlabu samostatné issue, které později uzavřete pomocí commit zprávy, procvičíte si také dobré praktiky softwarového inženýrství.

Načítání konfigurace (. a source)

Již jsme viděli, že chování našich skriptů můžeme měnit pomocí parametrů (připomeňme proměnné $1, $2, …) nebo nastavením proměnných před jejich spuštěním (připomeňme náš skript ze cvičení 07 a volání ve tvaru html_dir=out_www ./build.sh).

Ale co kdybychom chtěli více takových nastavení? Nemůžeme je uložit do souboru?

To je ve skutečnosti docela obvyklá situace. Uložme tedy konfiguraci html_dir do souboru config.rc (přípona .rc je docela běžná a může odkazovat třeba na běhovou konfiguraci – runtime configuration).

html_dir=out_www

Pokud nyní přidáme následující řádek do našeho skriptu build.sh, bude se chovat, jako by obsah souboru config.rc byl součástí hlavního skriptu. Proměnná bude nastavena a může být používána ve zbytku skriptu.

# Oba řádky jsou ekvivalentní, v reálu bychom použili jen jeden z nich
. config.rc
source config.rc

Pravdou je, že uživateli dáváme mnohem víc než jen obyčejný konfigurační soubor: náš config.rc se bude spouštět jako shellový skript. Pokud by v config.rc byly příkazy, spustily by se. Pokud tohle nechceme, můžeme se vždy uchýlit ke cut a grep pro načtení nastavení, ale pro shellové skripty je tohle většinou v pohodě.

Toto je vlastně také způsob, jakým se konfiguruje samotný shell. Vzpomeňte si, jak jsme do soubrou ~/.bashrc přidali proměnnou EDITOR. Tento soubor se vykoná při spuštění Bashe. Nyní už by vám měl být jasný význam následujícího úryvku, který ~/.bashrc často obsahuje:

if [ -f /etc/bashrc ]; then
    . /etc/bashrc
fi

Pokud daný soubor existuje (ke správné syntaxi se dostaneme později na tomto cvičení), zahrneme ho pomocí source, tj. importujeme obsah souboru na toto místo. Takže jsme importovali globální konfiguraci Bashe, která je uložená v adresáři /etc.

source se chová, jako kdyby místo řádku source byl skutečně vložen obsah zahrnovaného souboru. Bash nemá žádnou podporu jmenných prostorů ani nic podobného.

Speciální příkazy . a source lze také použít k načtení kódu knihovny. Například v souboru logging.sh můžete mít následující funkci, kterou pak můžete načíst do dalších skriptů, aniž byste museli znovu a znovu definovat funkci msg.

msg() {
    echo "$( date '+%Y-%m-%d %H:%M:%S |' )" "$@" >&2
}

Soubory, které mají být zahrnuty prostřednictvím source, obvykle neobsahují shebang a obvykle nejsou spustitelné. Je to většinou proto, aby se zdůraznila skutečnost, že se nejedná o samostatné spustitelné soubory, ale spíše o “knihovny”.

Totéž platí i pro moduly Pythonu: v hlavním programu obvykle vidíte shebang (a nastavený bit x), zatímco skutečné moduly (které importujete pomocí import) jsou často bez shebangů a mají pouze oprávnění rw-.

Pokračování průběžného příkladu

Náš příklad přepracujeme na přizpůsobitelné řešení, ve kterém náš skript přečte konfiguraci webu poskytnutou uživatelem.

V adresáři s webovou stránkou vytvoříme následující soubor ssg.rc.

# My site configuration

site_title="My site"

build_page "index.md"
build_page "rules.md"
build_page "alpha.md"

A náš hlavní skript upravíme, aby vypadal takto.

#!/bin/bash

set -ueo pipefail

msg() {
    echo "$( date '+%Y-%m-%d %H:%M:%S | SSG |' )" "$@" >&2
}

get_version() {
    git rev-parse --short HEAD 2>/dev/null || echo unknown
}

build_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".md" ).html"
    msg "Generating $input_file => $output_file"
    pandoc \
        --template templates/main.html \
        --metadata site_title="$site_title" \
        --metadata page_version="$( get_version )" \
        "src/$input_file" >"$output_file"
}

site_title="$( whoami )'s site"

mkdir -p public

source ssg.rc

cp -R static/* public/

Co jsme to vytvořili? Náš konfigurační soubor ssg.rc obsahuje triviální doménově specifický jazyk (DSL – domain-specific language), který řídí generování webových stránek. Náš hlavní skript poskytuje funkci build_page, která se volá z konfiguračního souboru.

Uvnitř této funkce sestavíme název výstupního souboru (zkuste, co udělá basename input.md .md!) a spustíme Pandoc.

Ve skutečnosti se jedná o velmi jednoduchý kus kódu, ale podařilo se nám rozdělit konfiguraci a vlastní generování do samostatných souborů a vytvořit opakovaně použitelný nástroj. Porovnejte, kolik práce by to dalo v jiném jazyce. Jen si představte, kolik práce by dalo analyzovat konfigurační soubor…

Než budete pokračovat, ujistěte se, že rozumíte, jak výše uvedený kód funguje. Měli byste zvládnout odpovědět na následující otázky:

  1. Proč není ssg.rc spustitelný (a proč nemá/nemusí být)?
  2. Proč je proměnná $site_title nastavená před vložením ssg.rc?
  3. Co by se stalo, pokud bychom vložili ssg.rc před volání mkdir -p public?

Řídicí struktury v shellových skriptech

Nejprve musíme zmínit, že více příkazů může být odděleno pomocí ; (středníku). Ačkoliv v shellových skriptech je lepší psát každý příkaz na samostatný řádek, v interaktivním módu se často může hodit zapsat více příkazů na jeden řádek (třeba už jen proto, že to umožňuje rychlejší pohyb v historii pomocí šipky nahoru).

Středníky uvidíme v řídicích strukturách na různých místech v roli oddělovače.

Příkazy řízení toku představíme spíše stručně: jejich účel je stejný jako v jiných jazycích. Věnujte však pozornost tomu, jak jsou řízeny: to, co vypadá jako podmínka, je ve skutečnosti často spuštění programu.

for cykly

For cykly v shellu vždy iterují přes sadu hodnot, které jsou předány při startu cyklu.

Formát je obecně následující:

for PROMENNA in VAL1 VAL2 VAL3; do
    tělo cyklu
done

Typickým příkladem použití cyklu for je iterování přes seznam souborů. Tento seznam je často vygenerovaný pomocí expandovaných wildcardů.

Pojďme se podívat na příklad, který spočítá počet číslic ve všech souborech *.txt:

for i in *.txt; do
    echo -n "$i: "
    tr -c -d '0-9' <"$i" | wc -c
done

Všimněte si, že ve výrazu for je jméno proměnné i uvedeno bez $. Dále můžeme vidět, že proměnná může být expandovaná i uvnitř přesměrování stdin (nebo stdout).

Když tento výraz začneme psát přímo v interaktivním shellu, prompt se změní na jednoduché > (tedy pravděpodobně, závisí to na vašem nastavení). Shell tím naznačuje, že na vstupu očekává zbytek cyklu.

Celý skript můžeme také dostat na jednu řádku (to se ale hodí pouze pro jednorázové skripty):

for i in *.txt; do echo -n "$i: "; tr -c -d '0-9' <"$i" | wc -c; done

Pokud chceme iterovat přes hodnoty obsahující mezery, musíme hodnoty uvést v uvozovkách. Expanze wildcardů je v tomto ohledu bezpečná a bude fungovat i když názvy souborů mezery obsahují.

for i in one "two three"; do
    echo "$i";
done

Co vypíše následující kód, pokud předpokládáme, že neexistuje žádný *.txxt soubor v aktuálním adresáři?

for i in *.txxt; do
    echo "$i"
done

Nápověda.

Odpověď.

if a else

Podmínka if je v shellu trochu složitější.

Důležité je si zapamatovat, že podmínka je vždy příkaz, který se vykoná, a jehož výsledek (tzn. exit kód) určí, zda je podmínka splněná či ne.

Podmínka tedy ve skutečnosti nikdy není v tradičním formátu a rovná se b, protože if se vždy řídí exit kódem.

Syntaxe podmínky if-then-else obecně vypadá takto:

if prikaz_kontrolujici_podminku; then
    uspech
elif jiny_prikaz_pro_vetev_else_if; then
    dalsi_uspech
else
    prikazy_v_else_vetvi
fi

Příkaz if musí být ukončený pomoci fi a větve elif a else jsou volitelné.

Jednoduché podmínky lze vyhodnotit pomocí příkazu test, který již známe. Podívejte se na příkaz man test a zjistěte, co všechno lze testovat.

Pojďme se podívat, jak použít if a test pro kontrolu, jestli se nacházíme v Git-ovém projektu:

if test -d .git; then
    echo "Jsme v korenovem adresari Git projektu."
fi

Můžeme použít i trochu elegantnější syntaxi: [ (levá hranatá závorka) je synonymem pro test a chová se přesně stejně, až na to, že vyžaduje jako svůj poslední argument ]. Pomocí této syntaxe může náš příklad vypadat následovně:

if [ -d .git ]; then
    echo "Jsme v korenovem adresari Git projektu."
fi

I v tomto případě je ale [ pouze obyčejný příkaz, jehož exit kód určuje, která z větví příkazu if se provede.

Mimochodem, když se zkusíte podívat do /usr/bin, zjistíte, že se tam opravdu nachází spustitelný soubor pojmenovaný [. Navíc Bash implementuje [ i jako vestavěný příkaz, takže je o něco rychlejší, než kdybychom ho spouštěli jako externí program.

Občas se můžete setkat i s následujícím kódem:

if [[ -d .git ]]; then
    echo "Jsme v korenovem adresari Git projektu."
fi

Tyto dvojité závorky [[ ... ]] jsou zase jiným konstruktem a úzce souvisí se syntaxí $(( ... )) pro aritmetické výrazy. Tuto podmínku vyhodnocuje přímo Bash. Její syntaxe je o něco silnější, ale funguje pouze v nejnovějších verzích Bashe a pravděpodobně tedy nebude fungovat v jiných shellech.

Tady budeme používat pouze tradiční variantu s [.

Cyklus while

While cykly vypadají následovně:

while prikaz_kontrolujici_cyklus; do
    prikazy_ke_spusteni
done

I v tomto případě se podmínka vyhodnotí jako pravdivá, pokud prikaz_kontrolujici_cyklus vrátí exit kód 0.

Následující příklad hledá první ještě nezabrané jméno pro logovací soubor. Pozor – tento kód není imunní vůči souběhům (race conditions), pokud je spuštěn paralelně. Může být tedy spuštěn vícekrát, ale nikdy ve více procesech současně.

counter=1
while [ -f "/var/log/myprog/main.$counter.log" ]; do
    counter=$(( counter + 1 ))
done
logfile="/var/log/myprog/main.$counter.log"
echo "Loguji do souboru $logfile" >&2

Aby byl program odolný vůči chybám souběhu (tj. při běhu ve více instancích paralelně), museli bychom použít příkaz mkdir. Tento příkaz nahlásí chybu v případě, že složka již existuje (a je dostatečně atomický na to, abychom pomocí něho dokázali zjistit, že náš program byl opravdu úspěšný a nekrade tedy soubor někomu jinému).

Všimněte si, že pro invertování úspěšnosti (exit-kódu) programu v následujícím kódu používáme vykřičník !.

counter=1
while ! mkdir "/var/log/myprog/log.$counter"; do
    counter=$(( counter + 1 ))
done
logfile="/var/log/myprog/log.$counter/main.log"
echo "Loguji do souboru $logfile" >&2

break a continue

Stejně jako v jiných jazycích je možné z cyklu vyskočit pomocí příkazu break. Obdobně můžete používat i příkaz continue.

Switch (aneb case ... esac)

Shell nabízí i konstrukci case pro případy, ve kterých potřebujeme rozvětvit náš program na základě hodnoty proměnné. Tato konstrukce je v zásadě podobná konstrukci switch v jiných jazycích, ale case má pár shellových specifik.

Syntaxe je následující:

case hodnota_pro_vetveni in
    option1) prikazy_pro_prvni_moznost ;;
    option2) prikazy_pro_druhou_moznost ;;
    *) vychozi_vetev ;;
esac

Podobně jako u if musíme příkaz uzavřít stejným klíčovým slovem zapsaným pozpátku a u každé možnosti musíme ukončit sadu příkazů pomocí dvou středníků ;;.

Jednotlivé možnosti v rámci case mohou kromě přímých hodnot obsahovat i zástupné znaky (wildcards) a |, aby bylo porovnávání trochu flexibilnější.

Jednoduchý příklad může vypadat takto:

case "$EDITOR" in
    mcedit) echo 'Midnight Commander je nejlepší' ;;
    joe) echo 'Malý, ale šikovný' ;;
    emacs|vi*) echo 'Wow :-)' ;;
    *) echo "To někdo opravdu používá $EDITOR?" ;;
esac

Pokračování průběžného příkladu

Vyzbrojeni znalostmi o dostupných řídicích konstrukcích můžeme náš skript pro generování stránek ještě vylepšit.

Zbavíme uživatele břemene ručního zadávání seznamu vstupních souborů a místo toho soubory vyhledáme sami. Uživatelský konfigurační soubor díku tomu bude zcela volitelný.

Náš skript změníme takto.

build_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".md" ).html"
    msg "Generating $input_file => $output_file"
    pandoc \
        --template templates/main.html \
        --metadata site_title="$site_title" \
        --metadata page_version="$( get_version )" \
        "$input_file" >"$output_file"
}

...

if [ -f ssg.rc ]; then
    source ssg.rc
fi

for page in src/*.md; do
    if ! [ -f "$page" ]; then
        continue
    fi

    build_page "$page"
done

Upravili jsme build_page tak, aby při spouštění pandoc nepřidával src k názvu souboru, a sami projdeme přes soubory Markdown v adresáři src.

Znak ! je builtin příkaz, který obrátí význam výstupního kódu, tj. chová se jako booleovský operátor negace.

Proč testujeme -f uvnitř smyčky? Odpověď.

A ano: skript jsme dost upravovali. To je normální. Často máte jen mlhavou představu o tom, co potřebujete mít. Vycházíte z jednoduchých scénářů a podle potřeby je rozšiřujete.

Přesměrování větších částí shellu

Celé řídící struktury (např. for, if nebo while se všemi příkazy uvnitř) se chovají jako jeden příkaz. Můžeme na ně tedy použít přesměrování jako na celek.

Pro ilustraci můžeme zprávu transformovat na velká písmena takto.

if test -d .git; then
    echo "We are in a root of a Git project"
else
    echo "This is not a root of a Git project"
fi | tr 'a-z' 'A-Z'

Parametry skriptů a getopts

Připomeňme, že když skript shellu obdrží parametry, můžeme k nim přistupovat prostřednictvím speciálních proměnných $1, $2, $3. Pro přístup ke všem parametrům existuje také proměnná $@ (připomeňme, že proměnná $@ musí být pro správnou funkci obalena uvozovkami; vysvětlení přesahuje rámec tohoto kurzu).

Speciální proměnná $# obsahuje počet argumentů na příkazovém řádku a $0 označuje skutečný název skriptu.

getopts

Potřebuje-li náš skript jen jeden argument, stačí přistupovat přímo k $1. Potřebujeme-li ale rozpoznávat různé přepínače, bude zpracování argumentů složitější. Shell k tomu poskytuje příkaz getopts, který nám s jejích zpracováním pomůže.

Ačkoliv getopts je standardní způsob práce s přepínači, není příliš uživatelsky přívětivý. Na druhou stranu, použití getopts není něco, co byste si měli pamatovat: je to přesně ten kus kódu, který budete kopírovat ze skriptu do skriptu a v případě potřeby ho jen aktualizovat.

Bohužel getopts neumí zpracovat dlouhé volby (např. --version). Existuje nestandardní rozšíření getopt (bez S na konci), které také podporuje dlouhé volby, ale není dostupné ve všech prostředích.

Ukážeme si, jak lze getopts použít na jednoduchém skriptu: přijme seznam souborů a převede je pomocí Pandocu do HTML (na standardní výstup). Bude také podporovat -V pro vypsání své verze a -o pro zadání alternativního výstupního souboru (místo stdout).

getopts neumožňuje míchání přepínačů/voleb (např. -o) a normálních argumentů. Při spuštění skriptu jako ./example.sh *.txt -o out.html se zpracování zastaví u prvního souboru a -o a out.html budou považovány za normální názvy souborů.

Specifikace přepínačů getopts je jednoduchá. Vyjmenujeme názvy přepínačů, za těmi, které vyžadují argument, následuje dvojtečka. Posledním argumentem je jméno proměnné (bez dolaru!), do které bude vlastní volba uložena.

getopts "Vho:" opt

Příkaz bude vracet návratovou hodnotu 0 (jako svůj exit kód), dokud budou zpracovávány přepínače. Po dokončení nám proměnná $OPTIND sdělí, kolik parametrů bylo skutečně zpracováno.

#!/bin/sh

usage() {
    echo "Usage: $1 [-V] [-o filename] [-h] input [files]"
    echo " -V          Print program version and terminate."
    echo " -o filename Store output into filename instead to stdout."
    echo " -h          Print this help and exit."
}

output_file="/dev/stdout"
print_version=false

while getopts "Vho:" opt; do
    case "$opt" in
        h)
            usage "$0"
            exit 0
            ;;
        V)
            print_version=true
            ;;
        o)
            output_file="$OPTARG"
            ;;
        *)
            usage "$0" >&2;
            exit 1
            ;;
    esac
done
shift $(( OPTIND - 1))

if $print_version; then
    echo "My script, version 0.0.1"
    exit 0
fi

cat "$@" | pandoc -t html >"$output_file"

Některé části skriptu si zaslouží vysvětlení.

true a false nejsou logické hodnoty, ale lze je jako takové použít. Vzpomeňte si, jak jsme je používali na cvičení 07 (a že existují /bin/true and /bin/false).

exit okamžitě ukončí shellový skript. Jeho volitelný parametr určuje návratovou hodnotu skriptu.

shift je speciální příkaz, který posune proměnné $1, $2, … o jedno místo. Po jeho zavolání se hodnota $3 přesune do $2, $2 se přesune do $1 a hodnota $1 bude zahozena. "$@" se voláním shift také upraví. S parametrem posune proměnné vícekrát.

Všechny argumenty pak předáme programu cat. Všimněte si, že to bude fungovat, i když nebudou předány žádné parametry a skript pak bude číst standardní vstup. Proč? Odpověď.

Pokračování průběžného příkladu

Upravíme náš skript tak, aby akceptoval přepínač -w, s nímž program bude neustále sledovat změny v souborech src/*.md, a při každé takové změně web přegeneruje.

Pokud nepoužíváte disk od nás, musíte si nainstalovat program inotifywait, který je obvykle součástí balíčku s názvem inotify-tools.

Nejprve skript změníme, aby používal getopts, a poté přidáme podporu pro -w.

#!/bin/bash

set -ueo pipefail

usage() {
    echo "Usage: ..."
}

msg() {
    echo "$( date '+%Y-%m-%d %H:%M:%S | SSG |' )" "$@" >&2
}

get_version() {
    git rev-parse --short HEAD 2>/dev/null || echo unknown
}

build_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".md" ).html"
    $LOGGER "Generating $input_file => $output_file"
    pandoc \
        --template templates/main.html \
        --metadata site_title="$site_title" \
        --metadata page_version="$( get_version )" \
        "$input_file" >"$output_file"
}

generate_web() {
    for page in src/*.md; do
        if ! [ -f "$page" ]; then
            continue
        fi
        build_page "$page"
    done

    cp -R static/* public/
}

LOGGER=:
watch_for_changes=false

while getopts "hvw" opt; do
    case "$opt" in
        h)
            usage "$0"
            exit 0
            ;;
        v)
            LOGGER=msg
            ;;
        w)
            watch_for_changes=true
            ;;
        *)
            usage "$0" >&2;
            exit 1
            ;;
    esac
done
shift $(( OPTIND - 1))

site_title="$( whoami )'s site"

mkdir -p public

if [ -f ssg.rc ]; then
    source ssg.rc
fi

generate_web

Pro skutečnou podporu přepínače -w použijeme inotifywait, což je speciální program, který obdrží seznam souborů a ukončí se v okamžiku, kdy je některý ze souborů změněn. Náš skript tedy ve skutečnosti nebude dělat nic, dokud nebude soubor změněn, protože inotifywait do té doby “zablokuje” jeho provádění.

Do našeho skriptu přidáme následující kód, který poběží dokola, bude sledovat změny a automaticky obnovovat web. Když je skript spuštěn s přepínačem -w, musíme pro ukončení použít klávesy Ctrl-C.

...

if [ -f ssg.rc ]; then
    source ssg.rc
fi

generate_web

if $watch_for_changes; then
    while true; do
        $LOGGER "Waiting for file change..."
        inotifywait -e modify src/* src static static/*
        generate_web
    done
fi

getopts možná není příliš uživatelsky přívětivý, ale pro většinu skriptů je to dostatečně dobrý nástroj.

Začněte přidávat minimální kostru, kterou jsme si ukázali, do všech vašich skriptů. Výhoda spuštění ./your-script.sh -h a zobrazení krátké nápovědy stojí za tu námahu.

Příkaz read

Doposud naše skripty buď vůbec nepotřebovaly standardní vstup, nebo vstup zcela přenechávaly jiným programům.

Pokud však potřebujete samostatně zpracovávat řádky ze vstupu, je možné v shellu standardní vstup číst po řádcích.

Potřebujeme-li ve skriptu číst ze stdin do proměnné, můžeme použít vestavěný příkaz read:

read FIRST_LINE <input.txt
echo "$FIRST_LINE"

Příkaz read typicky používáme v cyklu while pro iterování přes celý vstup. read taky dokáže rozdělit řádek na části oddělené bílými znaky a přiřadit každou z nich do samostatné proměnné.

Máme-li vstup v tomto formátu, následující cyklus spočítá průměr čísel.

/dev/sdb 1008
/dev/sdb 1676
/dev/sdc 1505
/dev/sdc 4115
/dev/sdd 999
count=0
total=0
while read device duration; do
    count=$(( count + 1 ))
    total=$(( total + duration ))
done
echo "Average is about $(( total / count ))."

Jak můžete uhádnout z úryvku výše, read vrací 0, dokud je schopný načítat hodnoty ze vstupu do proměnných. Nenulovou návratovou hodnotou je oznámeno dosažení konce souboru.

Pro některé vstupy se read občas může chovat příliš chytře – například interpretuje zpětná lomítka. Pro potlačení tohoto chování lze použít read -r.

Další užitečné parametry jsou -t nebo -p: použijte read --help pro zobrazení jejich popisu.

Pokud chceme číst z konkrétního souboru (předpokládejme, že jeho název je uložen v proměnné $input), můžeme také přesměrovat vstup pro celou smyčku a skript zapsat takto:

while read device duration; do
    count=$(( count + 1 ))
    total=$(( total + duration ))
done <"$input"

To je vlastně docela běžné použití vzoru while read.

Zkontrolujte, zda rozumíte tomu, jak funguje příkaz read

Předpokládejme, že máme následující textový soubor data.txt.

ONE
TWO

Máme také následující skript reader.sh:

#!/bin/sh

set -ueo pipefail

read -r data_one <data.txt
read -r data_two <data.txt
read -r stdin_one
read -r stdin_two

echo "data_one=${data_one}"
echo "data_two=${data_two}"
echo "stdin_one=${stdin_one}"
echo "stdin_two=${stdin_two}"

Vyberte všechna pravdivá tvrzení o výstupu následujícího volání.

./reader.sh <data.txt
You need to have enabled JavaScript for the quiz to work.

Větší cvičení I

Tuto implementaci použijeme později v našem průběžném příkladu (zatím tedy ne).

Představte si, že máme vstupní data s výsledky zápasů v následujícím formátu (tým, vstřelené branky, dvojtečka, branky vstřelené druhým týmem, druhý tým).

alpha 2 : 0 bravo
bravo 0 : 1 charlie
alpha 5 : 4 charlie

Napište shellový skript, který vypíše tabulku se souhrnnými výsledky.

Za vítězství přidělte 3 body, za remízu 1 bod. Váš program nemusí řešit situaci, kdy mají dva týmy stejný počet bodů.

Řešení

Začneme funkcí, která obdrží dva argumenty - počty gólů, které dala každá strana v jednom zápasu - a vypíše počet přidělených bodů.

get_points() {
    local goals_mine="$1"
    local goals_opponent="$2"
    if [ "$goals_mine" -eq "$goals_opponent" ]; then
        echo 1
    elif [ "$goals_mine" -gt "$goals_opponent" ]; then
        echo 3
    else
        echo 0
    fi
}

Další funkce pak spočítá body z každého zápasu.

preprocess_scores() {
    local team_one team_two
    local goals_one goals_two

    while read -r team_one goals_one colon goals_two team_two; do
        if [ "$colon" != ":" ]; then
            echo "WARNING: ignoring invalid line $team_one $goals_one $colon $goals_two $team_two" >&2
            continue
        fi
        echo "$team_one" "$( get_points "$goals_one" "$goals_two" )"
        echo "$team_two" "$( get_points "$goals_two" "$goals_one" )"
    done
}

Tyto dvě funkce společně transformují vstup na následující:

alpha 3
bravo 0
bravo 0
charlie 3
alpha 3
charlie 0

Na těchto datech bychom mohli zavolat náš známý skript group_sum.py, nebo si posčítání můžeme napsat sami v shellu. Při implementaci v shellu budeme počítat s tím, že data jsou již seřazena podle klíče, abychom implementaci zjednodušili.

sum_by_sorted_keys() {
    local key value
    local prev_key=""
    local sum=0

    while read -r key value; do
        if [ "$key" != "$prev_key" ]; then
            if [ -n "$prev_key" ]; then
                echo "$prev_key $sum"
            fi
            prev_key="$key"
            sum=0
        fi
        sum=$(( sum + value ))
    done
    if [ -n "$prev_key" ]; then
        echo "$prev_key $sum"
    fi
}

Proč potřebujeme, aby data byla setříděná? Mohli bychom si je setřídit sami? Fungovala by následující úprava (pouze změna tohoto řádku)?

    # replacing "while read -r key value; do"
    sort | while read -r key value; do
Odpověď.

Jaká změna uvnitř této funkce by tedy fungovala? Odpověď.

Tyto funkce dohromady tvoří stavební kameny pro vyřešení celé skládačky:

preprocess_scores | sum_by_keys | sort -n -k 2 -r | column -t

Je otázkou názoru, zda by tento úkol nebyl lépe řešitelný v jiném programovacím jazyce. Vše závisí na kontextu a dalších požadavcích.

Shell obvykle vyniká v situacích, kdy potřebujeme kombinovat data z více souborů, které jsou v nějakém textovém (nejlépe řádkovém) formátu.

Výhodou shellu je jeho interaktivita. Dokonce i funkce v něm lze definovat interaktivně (tj. neukládat je nejprve do žádného souboru). Výslednou pipeline můžeme snadno sestavovat postupně a po přidání každého kroku průběžně kontrolovat výstup.

Poznámka na okraj: jak se publikují webové stránky

Nyní si uděláme malou odbočku do oblasti (historie) publikování webových stránek. Publikování webových stránek dnes obvykle znamená pronájem webového prostoru, kam můžete nahrát své soubory HTML (nebo PHP), nebo dokonce pronájem nakonfigurované instance nějaké webové aplikace, například Wordpress.

Tradičně také uživatelé často obdrželi webový prostor jako součást unixového účtu na nějakém sdíleném počítači. Nastavení se obvykle provádělo tak, že cokoli se objevilo ve vašem $HOME/public_html, bylo dostupné pod stránkou example.com/~LOGIN.

Možná jste se s takovými stránkami také setkali, typicky na univerzitních stránkách jednotlivých profesorů.

S rozvojem virtualizace (a cloudu) bylo snazší nedávat uživatelům přístup jako skutečným uživatelům systému, ale vložit další vrstvu, kde uživatel může manipulovat pouze s určitými soubory, aniž by měl přístup k shellu.

Webové stránky na laboratorních strojích

Naše laboratorní stroje (např. u-pl*) tuto základní funkci také nabízejí.

Pomocí SSH se připojte k jednomu z nich (připomeňte si seznam adres z cvičení 05) a vytvořte adresář ~/WWW.

Vytvořte jednoduchý soubor HTML v adresáři WWW (přeskočte, pokud jste sem již předtím nahráli nějaké soubory).

echo '<html><head><title>Hello, World!</title><body><h1>Hello, World!</h1></body></html>' >index.html

Tato stránka bude k dispozici jako http://www.ms.mff.cuni.cz/~LOGIN/.

Poznamenejme, že je třeba přidat potřebná oprávnění souborového systému AFS pomocí příkazu fs setacl.

fs setacl ~/WWW www rl
fs setacl ~/. www l

SCP a rsync

Pro kopírování souborů mezi dvěma počítači se systémem Linux můžeme použít příkaz scp. Ten si uvnitř vytvoří spojení SSH a zkopíruje přes něj soubory.

Syntaxe je velmi jednoduchá a odpovídá použití obyčejného cp:

scp local_source_file.txt user@remote_machine:remote_destination_file.txt
scp user@remote_machine:remote_source_file.txt local_destination_file.txt

Nedostatky programu SCP

Pro ty, kterým záleží na bezpečnosti, bychom měli poznamenat, že protokol SCP má některé bezpečnostní chyby (byť ve velmi specifických případech). Ty lze využít k napadení místního počítače v případě, že se připojí ke škodlivému serveru.

SCP je ve skutečnosti velmi starý protokol, na kterém je vidět jeho stáří. Mezi lepší náhrady patří SFTP (pozor, liší se od FTPS – FTP přes SSL/TLS) a Rsync.

Více informací o tomto tématu naleznete na LWN.net a v tomto vlákně na StackOverflow.

Rsync

Mnohem silnějším nástrojem pro kopírování souborů je rsync. Podobně jako scp běží přes spojení SSH, ale musí být nainstalován na obou stranách (což obvykle není problém).

Rsync umí kopírovat celé stromy adresářů, zpracovávat symbolické odkazy (symlinky), přístupová práva a další atributy souborů. Dokáže také rozpoznat, že některé soubory již na druhé straně existují (přesně nebo přibližně), a přenést pouze rozdíly.

Syntaxe jednoduchého kopírování je stejná jako u cp a scp:

rsync local_source_file.txt user@remote_machine:remote_destination_file.txt
rsync local_source_file.txt user@remote_machine:remote_destination_directory/

Pokračování průběžného příkladu

Použijte manuálovou stránku rsync a rozšiřte průběžný příklad o přepínač -u, se kterým skript nahraje vygenerované soubory na vzdálený server zadaný pomocí $rsync_target.

Jako rozumný cíl pro nahrávání do počítačů v laboratoři Rotunda můžete použít LOGIN@u-pl1.ms.mff.cuni.cz:WWW/nswi177.

Řešení.

Větší cvičení II

Vytvořili jsme skript pro výpočet bodovací tabulky. Bylo by dobré ji vygenerovat během generování celého webu.

Rozšiřte náš průběžný příklad o následující funkce. Každý soubor *.bin v souboru src/ bude považován za skript, který se spustí a jeho výstup se uloží do stejnojmenného souboru HTML.

Než se podíváte na naše řešení, vyzkoušejte si to nejprve sami.

Připomeňme, že přípona souboru není důležitá a že přípona .bin je tak obecná, že se pod ni může schovat jakýkoli (interpretovaný) programovací jazyk (pokud má skript správný shebang). Ve skutečnosti bude fungovat i pro kompilované (C, Rust a podobné) programy.

Následující skript se pohybuje na hranici mezi tím, kdy stačí shellový skript a kdy by bylo lepší použít sofistikovanější jazyk (z hlediska nabízených datových struktur a typů).

V některých částech shell jednoznačně vyniká – práce s více soubory, volání externích programů atd. V některých částech je ale řešení poněkud křehké.

Někdy je nejlepším přístupem skicnout rychlý prototyp v shellu, jako jsme to udělali my. Tím zjistíme, jaké funkce skutečně potřebujeme.

Řešení

Změna je poměrně jednoduchá. Pro přehlednost jsme také přejmenovali build_page na build_markdown_page.

build_dynamic_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".bin" ).html"
    $LOGGER "Generating $input_file => $output_file"
    "$input_file" >"$output_file"
}

generate_web() {
    local page
    for page in src/*.md; do
        if ! [ -f "$page" ]; then
            continue
        fi
        build_markdown_page "$page"
    done

    local script
    for script in src/*.bin; do
        if ! [ -f "$script" -a -x "$script" ]; then
            continue
        fi
        build_dynamic_page "$script"
    done

    cp -R static/* public/
}

A náš skript pro generování tabulek můžeme rozšířit na následující.

...

as_markdown_table() {
    echo
    echo '| Team | Points |'
    echo '| ---- | -----: |'
    while read team score; do
        echo '|' "$team" '|' "$score" '|'
    done
    echo
}

. ssg.rc

(
    echo '---'
    echo 'title: Scoring table'
    echo '---'

    echo '# Scoring table'

    preprocess_scores <scores.txt | sum_by_keys | sort -n -k 2 -r | as_markdown_table
)  | pandoc \
        --template templates/main.html \
        --metadata site_title="$site_title" \
        --metadata page_version="$( git rev-parse --short HEAD 2>/dev/null || echo unknown )"

Další vylepšení skriptu

Vypadá to dobře?

To sotva. V kódu se opakovaně objevuje fragment volání Pandoc. Náš generátor stránek není dokonalý.

Pojďme jej vylepšit.

Jako druhou verzi skriptu proveďte rozšíření, které rozliší skripty *.md.bin a *.html.bin. Od skriptů s příponou .html.bin se očekává, že budou generovat přímo HTML, zatímco .md.bin bude generovat Markdown, který budeme zpracovávat sami.

Řešení

...

pandoc_as_filter() {
    pandoc \
        --template templates/main.html \
        --metadata site_title="$site_title" \
        --metadata page_version="$( get_version )" \
        "$@"
}

build_markdown_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".md" ).html"
    $LOGGER "Generating $input_file => $output_file"
    pandoc_as_filter "$input_file" >"$output_file"
}

build_dynamic_html_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".html.bin" ).html"
    $LOGGER "Generating $input_file => $output_file"
    "$input_file" >"$output_file"
}

build_dynamic_markdown_page() {
    local input_file="$1"
    local output_file="public/$( basename "$input_file" ".md.bin" ).html"
    $LOGGER "Generating $input_file => $output_file"
    "$input_file" | pandoc_as_filter >"$output_file"
}

generate_web() {
    local page
    for page in src/*.md; do
        if ! [ -f "$page" ]; then
            continue
        fi
        build_markdown_page "$page"
    done

    local script
    for script in src/*.md.bin; do
        if ! [ -f "$script" -a -x "$script" ]; then
            continue
        fi
        build_dynamic_markdown_page "$script"
    done
    for script in src/*.html.bin; do
        if ! [ -f "$script" -a -x "$script" ]; then
            continue
        fi
        build_dynamic_html_page "$script"
    done

    cp -R static/* public/
}

...

A náš skript pro generování tabulek table.md.bin teď lze výrazně zjednodušit.

...

echo '---'
echo 'title: Scoring table'
echo '---'

echo '# Scoring table'

preprocess_scores <scores.txt | sum_by_keys | sort -n -k 2 -r | as_markdown_table

Poslední vylepšení

Jako poslední cvičení rozšiřte náš skript tak, aby umožňoval práci s opakovaně použitelnými skripty. Před spuštěním skriptů *.bin bychom měli rozšířit $PATH o adresář bin/ v našem adresáři SSG.

Proč to chceme udělat? V tuto chvíli je cesta k bodovací tabulce napevno vložená (hard-coded) uvnitř skriptu table.md.bin a skript nelze použít pro generování více tabulek (představte si, že bychom měli dvě oddělené skupiny týmů). Pokud bychom ale měli tento skript v $PATH pod jménem score_table.sh, mohli bychom ze souboru obsahujícího výsledky zápasů udělat také “skript” s následujícím shebangem, a díky tomu použít score_table.sh pro zpracování více tabulek.

#!/usr/bin/env score_table.sh
alpha 2 : 0 bravo
bravo 0 : 1 charlie
alpha 5 : 4 charlie

Jistě, toto už hraničí se zneužitím shebangu, protože jsme ze souboru s daty udělali skript, ale mohou existovat i jiné případy použití než naše primitivní SSG, kde by takové rozšíření mělo smysl.

Zde si osvěžte paměť na env, shebangy a $PATH.

Řešení

Změny jsou ve skutečnosti triviální.

build_dynamic_html_page() {
    ...
    env PATH="$PATH:$PWD/bin" "$input_file" >"$output_file"
}

build_dynamic_markdown_page() {
    ...
    env PATH="$PATH:$PWD/bin" "$input_file" | pandoc_as_filter >"$output_file"
}

A soubor bin/score_table.sh bude také upraven na jednom řádku.

grep -v '#' "$1" | preprocess_scores | sum_by_keys | sort -n -k 2 -r | as_markdown_table

Ze vstupu vypustíme všechny řádky obsahující #, čímž se určitě vypustí shebang, s tím že neočekáváme, že by název týmu mohl obsahovat znak # (později si ukážeme regulární výrazy, které by umožnily přesnější filtrování, ale zatím to stačí takhle).

Úlohy k ověření vašich znalostí

Očekáváme, že následující úlohy vyřešíte ještě před příchodem na cvičení, takže se budeme moci o vašich řešeních na cvičení pobavit.

Program magick z ImageMagick umí převádět obrázky mezi různými formáty pomocí magick source.png target.jpg (s téměř libovolnými příponami souborů).

Poznamenejme, že starší verze používaly příkaz convert, který je teď již zastaralý.

Převeďte všechny obrázky PNG (s příponou .png) v aktuálním adresáři na JPEG (přípona .jpg).

Řešení.

Rozšiřte předchozí příklad tak, aby nedocházelo k přepsání existujících souborů.

Řešení.

Poslední příklad s ImageMagickem. Pomocí -resize 800x600 lze změnit velikost obrázku tak, aby se vešel do zadaných rozměrů.

Vytvořte nástroj, který vytvoří náhledy ze souborů zadaných na příkazovém řádku, přičemž změní název souboru z dir/filename.ext na dir/filename.thumb.ext.

Řešení.

Rozšiřte náš skript bodovací tabulky z průběžného příkladu tak, aby správně řešil situace, kdy více týmů má stejný počet bodů a my potřebujeme rozlišit jejich pořadí podle počtu vstřelených branek (což je poměrně časté pravidlo v mnoha turnajích).

Z následujících údajů bychom proto rádi vypočítali poněkud odlišnou tabulku.

alpha 2 : 0 bravo
bravo 7 : 1 charlie
alpha 1 : 4 charlie
| Team | Points | Goals |
| ---- | -----: | ----: |
| bravo | 3 | 7 |
| charlie | 3 | 5 |
| alpha | 3 | 3 |

Řešení.

Napište shellový skript pro kreslení sloupcového (řádkového) grafu. Uživatel zadá data v následujícím formátu:

12  First label
120 Second label
1 Third label

Skript vytiskne pak takovýhle graf:

First label (12)   | #
Second label (120) | #######
Third label (1)    |

Skript dostane soubor s daty jako první argument a šířku určí podle velikosti obrazovky. Také zarovná popisky jak je vidět v grafu výše.

Můžete předpokládat, že vstupní soubor bude vždy existovat a můžete ho několikrát číst. Žádné další argumenty není třeba řešit.

Nápověda

Šířka obrazovky je uložena v proměnné $COLUMNS. Předpokládejte hodnotu 80 pokud není nastavena. (Můžete předpokládat, že bude buď prázdná (nenastavená) nebo bude obsahovat platné číslo).

Graf bude nakreslen tak, aby vyplnil celou šířku obrazovky (tj. zvětšen/zmenšen).

Můžete slít více mezer do jedné (i pro popisky), první a druhý sloupeček je oddělen mezerou (mezerami).

Podívejte se, co dělá wc -L.

Všimněte si, že první testy používají popisky stejné délky, abyste mohli lépe odstartovat vaši implementaci.

Zvažte použití printf pro tištění zarovnaných popisků.

Následující zajistí, že bc bude počítat s racionálními čísly ale zobrazí výsledek jako integer (což se hodí pro složitější shellové výpočty).

echo 'scale=0; (5 * 2.45) / 1' | bc -l

Příklady

2 Alpha
4 Bravo
# COLUMNS=20
Alpha (2) | ####
Bravo (4) | ########
2 Alpha
4 Bravo
16 Delta
# COLUMNS=37
Alpha (2)  | ###
Bravo (4)  | ######
Delta (16) | ########################

Tento příklad vám může zkontrolovat GitLab skrz automatizované testy. Uložte vaše řešení jako 09/barplot.sh a commitněte ho (pushněte) do GitLabu.

Napište skript pro výpis velikosti souborů.

Skript bude částečně imitovat chování ls: bez argumentů vypíše informace o souborech v aktuálním adresáři, argumenty bude brát jako seznam souborů, o kterých má zjišťovat více.

Příklad spuštění může vypadat takto:

./09/dir.sh /dev/random 09/dir.sh 09
/dev/random  <special>
09/dir.sh          312
09               <dir>

Druhý sloupeček zobrazí velikost souboru pro normální soubory, <dir> pro adresáře a <special> pro jiné soubory. Velikost souboru lze získat třeba pomocí programu stat(1).

Neexistující soubory budou hlášeny jako SOUBOR: no such file or directory. na stderr.

Můžete předpokládat, že máte přístup ke všem souborům vyjmenovaným na příkazové řádce.

Asi se vám bude hodit prográmek column, především následující spuštění:

column --table --table-noheadings --table-columns FILENAME,SIZE --table-right SIZE

Můžete také předpokládat, že názvy souborů budou rozumné (bez mezer). Pro zjednodušení nebudeme kontrolovat, že návratový kód je nenulový, pokud některý soubor neexistuje.

Tento příklad vám může zkontrolovat GitLab skrz automatizované testy. Uložte vaše řešení jako 09/dir.sh a commitněte ho (pushněte) do GitLabu.

Vzpomeňte si na nástroj ping, který jsme probírali ve cvičení 05.

Vaším úkolem je vytvořit nástroj, který bere následující argumenty a vypíše stav počítače na základě výstupu ping (samozřejmě je třeba použít ping v režimu, kdy odesílá pouze jeden požadavek a má krátký timeout).

  • -d který akceptuje řetězec používaný k oddělení sloupců na výstupu, výchozí hodnota je mezera
  • -v kdy se výstup příkazu ping vypíše na standardní chybový výstup (ve výchozím nastavení se výstup pingu neobjeví vůbec)
  • -w pro zadání jiného časového limitu, než je výchozí jedna sekunda

Obyčejnými parametry jsou DNS jména nebo IP adresy, které se mají kontaktovat prostřednictvím ping a u kterých vypíšete jejich stav.

Exit kód označuje množství strojů ve stavu DOWN (můžete předpokládat, že nikdy nebude více než 126 parametrů a nemusíte tedy řešit, zda je ukončovací kód signed nebo unsigned byte atd.).

Očekáváme, že použijete getopts pro zpracování voleb na příkazové řádce.

Následující příklady ukazují volání s různými parametry a očekávaný výstup.

Výchozí spuštění

09/ping.sh seznam.cz google.com google.comx
seznam.cz UP
google.com UP
google.comx DOWN

Použití -d a -v

09/ping.sh seznam.cz -d : google.com -v

Poznamenejme, že výstup obsahuje jak stdout, tak stderr.

PING seznam.cz (77.75.77.222) 56(84) bytes of data.
64 bytes from www.seznam.cz (77.75.77.222): icmp_seq=1 ttl=56 time=4.46 ms

--- seznam.cz ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 4.460/4.460/4.460/0.000 ms
seznam.cz:UP
PING google.com (142.251.36.78) 56(84) bytes of data.
64 bytes from prg03s10-in-f14.1e100.net (142.251.36.78): icmp_seq=1 ttl=114 time=3.64 ms

--- google.com ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 3.642/3.642/3.642/0.000 ms
google.com:UP

Tento příklad vám může zkontrolovat GitLab skrz automatizované testy. Uložte vaše řešení jako 09/ping.sh a commitněte ho (pushněte) do GitLabu.

Učební výstupy a kontrola po cvičení

Tato část podává zhuštěný souhrn základních konceptů a dovedností, které byste měli umět vysvětlit a/nebo použít po každém cvičení. Také obsahují absolutní minimum, které je potřebné pro pochopení navazujících cvičení (a dalších předmětů).

Znalosti konceptů

Znalost konceptů znamená, že rozumíte významu a kontextu daného tématu a jste schopni témata zasadit do většího rámce. Takže, jste schopni …

  • vysvětlit, jak exit kódy umožňují řídit podmínky a cykly v shellových skriptech

  • vysvětlit pro konstrukci shellu if true; then echo "true"; fi, jaké příkazy se v ní provádějí a jakým způsobem se vyhodnocuje

  • vysvětlit, jaká hlediska jsou důležitá při rozhodování mezi použitím shellu a Pythonu

Praktické dovednosti

Praktické dovednosti se obvykle týkají použití daných programů pro vyřešení různých úloh. Takže, dokážete …

  • používat v shellových skriptech řídicí struktury (for, while, if, case)

  • použít příkaz read

  • používat getopt pro parsování argumentů příkazové řádky

  • používat . a source k načtení funkcí z různých souborů

  • použít scp na kopírování souborů mezi místním a vzdáleným počítačem

  • volitelné: použít rsync pro synchronizaci celých adresářů

Seznam změn na této stránce

  • 2025-04-08: Aktualizace příkazu na nastavení AFS.

  • 2023-04-14: Varování ohledně --delete u rsync.