[linux@localhost:~/pb176_prednasky] sed "s/UID=.*/UID=$(id -u)/g" < docker-compose.yml > compose-local.yml
[linux@localhost:~/pb176_prednasky] __mezera__ env GITLAB_CREDENTIALS=xlogin:heslo docker-compose -f compose-local.yml up
Lze očekávat vydýchaný vzduch, deadline na krku a pokud to fakulta povolí, pizzu a kávu. Pokud budete nemocní (a řádně omluvení), najdeme nějaké jiné téma jak podobný zářez splnit. Cílem rozhodně není Vám udělit X, pokud byste v 19:05 usnuli vyčerpáním. Pokud v tomto plánu pozorujete nějaký problém, budeme jej řešit individuálně.
Plán je obsadit D1 v noci z pátku na sobotu.↩
Úkol pro bystrého posluchače: najít po 6 měsících od odevzdání správnou verzi.
Základní otázkou, kterou je pro vyřešení předchozího příkladu zapotřebí odpovědět, je shoda - tedy spíše zobrazení rozdílů - mezi soubory. Jistě, pokud by byl autor předvídavý, mohl by si úkol usnadnit například tak, že si ke každému adresáři přidá do názvu datum. Ale ani to neřeší vše. V praxi nezřídkakdy máme situaci, kdy můžeme stejného výsledku dosáhnout různým zápisem. Nezbude tedy, než porovnat jednotlivé soubory.
Jedním ze základních nástrojů k porovnávání obsahu souborů je diff (který možná již znáte z Unixu, Linuxu, ...). Pro začátek tedy porovnejme soubory z adresářů "a" a "b".
test -d priklady || git clone https://gitlab.fi.muni.cz/pb176/private/2022-priklady-todo.git priklady
pushd priklady/blok1_diff > /dev/null
diff a b
popd > /dev/null
Znak <
ve výpisu nám říká že dotyčný řádek pochází z argumentu více vlevo.
Analogicky, znak >
pak značí řádek ze souboru více vpravo.
Ve výstupu jsou poznačena i čísla řádků, která se navzájem liší.
Takové 4c5
skrývá myšlenku že se řádek 4 v 1. souboru změnil na řádek 5 ve druhém souboru.
Podobně 2a3
znamená vložený řádek mezi řádky 2 a 3. 12,13d12
pak zase značí smazání řádků č. 12 a 13.
Jinými slovy, výstup diffu říká, co se musí v 1. souboru změnit, aby se z něj stal 2. soubor.
A nyní, o postup výroby čeho tedy jde? Co když bude v souboru změn více? Co když někdo soubor přeformátuje či přejmenuje, budeme z takto poznačené změny schopni ještě jednoznačně novou verzi odvodit? Existuje ale pěknější zápis, ve kterém by bylo i vidět kontext takové změny?
Ano, jde o unified diff format.
Klíčovým okamžikem bylo rozšíření diffu o možnosti odrážet změny v pojmenování souborů zavedené v BSD 2.8.
To přineslo myšlenku poznačení změny ne před blokem se změnou, ale přímo na konkrétních řádcích.
Do výstupu tak přibyl na každý řádek 0. znak - buď +
, -
a nebo !
(přidání, odebrání, změna).
Odtud už byl jen krůček k formátu, který je dnes důvěrně znám každému vývojáři.
Pojďme se na něj podívat podrobněji.
pushd priklady/blok1_diff > /dev/null
diff -rupN a b
popd > /dev/null
Oproti předchozímu příkladu máme na začátku výstupu souboru hlavičku s kontextem. Tato má zprvu relativně volný formát - dokud není nalezen řádek identifikující původní soubor, je obsah ignorován. Jinými slovy, výstup začíná neformátovaným komentářem, který může mít i více řádků. Jeho typickým obsahem je příkaz, který vedl k vygenerování tohoto rozdílu, případně servisní informace nástroje.
---
identifikuje původní soubor. Jinými slovy, výstup za cestou k němu následuje volitelné pole původně určené pro datum poslední modifikace původního souboru. +++
identifikuje cílový soubor. Formát tohoto řádku je totožný s formátem pro původní soubor.@@
označuje oblast změn - hunk.Pro každý hunk dále platí, že obsahuje změněné řádky a jejich okolí. Každý řádek dostane jako 0. znak identifikaci, zda jde o nezměněný řádek (` - mezera), odstraněný řádek (
-) či řádek přidaný (
+`). Změna v existujícím řádku je pak vyjádřena odstraněním původního a vložením nového řádku.
Formát unified diff format je použit i pro distribuci záplat (anglicky patch).
Práce s rozdíly v textovém formátu není vždy nejpřehlednější, existuje tedy i řada nástrojů pro vizualizaci změn. Najdete je často jako součást složitějších platforem (např. github, gitlab, IDE) - ale i jako samostatné nástroje určené jen pro porovnávání souborů a adresářů. V této části se budeme jednomu z nich letmo věnovat - nástroji Meld.
pushd priklady/blok1_diff > /dev/null
meld a b
popd > /dev/null
Meld může operovat buď nad dvojicí souborů, nebo adresářů 1 , přičemž dvojici souborů můžete uvažovat za speciální případ rozdílu dvou adresářů. Pokud je Meld spuštěn nad dvojicí adresářů, rekurzivně je prohledá a barevně vyznačí odlišné soubory.
V editačním okně pak můžete jednotlivé změny mezi soubory přesouvat klepnutím na malou šipku při středovém panelu. Pokud naopak chcete nějaký řádek odebrat, stačí stisknout levý shift. Pro rozsáhlejší změny je pak možné přidávat synchronizační body, které - jsou-li správně nastaveny v obou editorech - usnadní orientaci porovnáváním skupin změn.
V nastavení je pak možno nastavit filtr ignorovaných změn pomocí regulárních výrazů a filtr ignorovaných souborů.
Takovým porovnáním říkáme třícestná a narazíte na ně obvykle u verzovacích systému s více uživateli.
Toto je ale pouhou podmnožinou dovedností Meldu. V praxi totiž velice často potřebujete porovnat ne 2 soubory (či adresáře), ale hned 3.↩
Nyní tedy již víte, jak se ze dvou různých úprav téhož souboru stane záplata, umožňující nám přejít z jedné verze do druhé. Pojďme se podívat na opačný směr, na aplikaci změn.
V druhém příkladu jste si mohli všimnout, že se sice původní i upravený soubor jmenují stejně, ale v různých adresářích. Toto může být záměr při přesunu souboru do jiného adresáře, avšak soubory se mění častěji, než se přesouvají. Pokud tedy budeme vycházet z předpokladu, že jde o záznam vývoje nějakého souboru, chtěli bychom hlavičku tvaru odpovídajícímu přibližně (prosím o ignorování chyby v komentáři):
pushd priklady/blok1_diff > /dev/null
diff -rupN a b | filterdiff --strip 1
popd > /dev/null
Čímž sice zachováme adresář, ale komentář s příkazem teď nedává příliš smysl. Navíc, který ze souborů je novější verzí? Časové razítko poslední změny nám v orientaci pomáhá jen trochu, nemáme nikde garantováno, že jde poslední změnu logiky v něm obsažené. Přece jen, jména adresářů své výhody měla. Tento argument navíc podtrhne situace, kdy uvážíme rekurzivní diff nad původním motivačním mincomatem.
Naštěstí má program patch i přepínač -p N
, který způsobí ignorování prvních N komponent cesty k souboru.
Pro úplnost, patch předpokládá že záplatu obdrží na standardním vstupu.
pushd priklady/blok1_diff > /dev/null
diff -rupN a b > postup.patch
rm -rf c
cp -r a c
cd c
patch -p 1 < ../postup.patch
md5sum ../b/postup_vyroby.txt ./postup_vyroby.txt
popd > /dev/null
Bystrého čtenáře jistě napadlo, že do tohoto okamžiku byla řeč vždy jen o textových souborech či zdrojových kódech. Nezřídka ale vyvstává nutnost zobrazit, resp. zaznačit (a distribuovat) změnu v binárním souboru. Ve srovnání s unified diff format ale nelze říci že existuje nějaký univerzální formát a tak záleží na sadě nástrojů, kterou použijete, přesto existuje de facto standard, který si popíšeme níže.
V systémech odvozených od BSD je k dispozici utilita bspatch
, která na očekává cesty k původnímu a novému souboru a dále pak i záplatu v nativním formátu.
Záplaty v tomto formátu pak získáte programem bsdiff
.
Binární záplaty se od záplat běžných tedy odlišují formátem a typicky nejsou člověku čitelné.
Mnohdy se tak setkáte s označením delta pro binární záplaty a patch pro záplaty textové.
pushd priklady/blok1_diff > /dev/null
bsdiff a/postup_vyroby.txt b/postup_vyroby.txt postup.bspatch
hexdump -C postup.bspatch
popd > /dev/null
pushd priklady/blok1_diff > /dev/null
mkdir d
bspatch a/postup_vyroby.txt d/postup_vyroby.txt postup.bspatch
md5sum b/postup_vyroby.txt d/postup_vyroby.txt
popd > /dev/null
Zcela přirozeně, nic nám nebrání záplaty řetězit a aplikovat postupně. Vývoj projektu tak lze pomocí záplat stopovat od počátečního, prázdného, projektu až k aktuální verzi. Každá záplata tak obnáší minimální příspěvek do projektu proti předešlé záplatě -- commit. Takové posloupnosti záplat říkáme historie, posloupnost commitů (pro jednoduchost zatím uvažujme jen lineární historie, tedy takové kde má každý commit právě jednoho předka, s výjimkou commitu počátečního).
Co vlastně takový commit obsahuje?
Jaká je ale velikost nějaké logické změny (sady změn - changesetu), kdy ještě má smysl hovořit o jediném commitu? Absolutní měřítko pro něco takového není, dá se ale řídit podle následujících pravidel:
Zůstává nám ale jedna nezodpovězená otázka - kdo vlastně udržuje přehled o dostupných commitech.
Historicky existuje řada verzovacích systémů, s více či méně podobnými prvky organizace commitů a spolupráce autorů. Mezi (historicky) nejrozšířenější lze nejspíše s přehledem zařadit:
Jako mnoho jiných otázek mezi programátory, i zde existují svaté války (tabs vs. spaces, vim vs. emacs) - v tomto případě Git vs. Mercurial. 1 V tomto předmětu se budeme z praktických důvodů opírat o Git, zprvu za pomoci příkazové řádky.
V klasickém modelu klient-server je udržována centralizovaná informace o stavu projektu a jeho kódu (repozitáře) na straně serveru, v platformně závislé podobě - může tedy jít jak o databázi, tak o plnohodnotnou kopii projektu. Git je ale strukturován jako decentralizovaný systém, tedy každá kopie repozitáře může fungovat jako klient i server. Přesto jsou zde jistá praktická omezení.
Pokud se detailněji podíváme na kopii (klon, downstream) existujícího projektu (upstream), najdeme v něm skrytý adresář s názvem .git
.
Tento adresář obsahuje režijní data gitu, která (zatím) nebudeme příliš rozepisovat, sic není dobrou praxí tyto upravovat ručně.
Vězme, že pokud bychom tento adresář odstranili, ztratíme tím i párování mezi repozitářem a zbytkem adresáře.
Právě uložení režijních informací gitu v pomocném adresáři (a jeho odstranění) nám může přijít velmi vhod, například pokud chceme koncovému zákazníkovi předat zdrojové kódy, aniž bychom mu prozradili způsob, jakým způsobem (a kdy) vznikaly, kdo všechno se na nich podílel, že o některých problémech můžeme vědět již řadu let... Zkrátka a jednoduše, vše co má zůstat za zavřenými dveřmi.
Správné odpovědi vyznačeny kurzívou.↩
ls -aihl
ls -aihl .git
Zkusme si tedy založit klon existujícího repozitáře, například toho s doprovodnými příklady:
**VAROVÁNÍ**: Toto může poškodit lokální kopii přednášek, buďte opatrní pokud jste si dělali nějaké poznámky do adresáře s příklady. Ostatně, toto platí pro všechny příklady v tomto dokumentu.
test -d priklady/.git || git clone https://gitlab.fi.muni.cz/pb176/private/2022-priklady-todo.git priklady
Protože je sám klon repozitáře současně také plnohodnotným repozitářem a "serverem", můžeme jej dále klonovat. A to zcela nezávisle na tom, co se mezitím děje v původním zdroji. Nový klon se totiž bude odkazovat jen na místo, odkud byl sám naklonován.
pushd priklady > /dev/null
git remote show origin
git checkout master
popd > /dev/null
test -d priklady-lokalni-klon && rm -rf priklady-lokalni-klon
git clone ./priklady ./priklady-lokalni-klon
pushd priklady-lokalni-klon > /dev/null
git remote show origin
ls -aihl
popd > /dev/null
Nyní zkusme do takto vytvořené kopie repozitáře zanést vlastní příspěvek - vytvoříme nový souboru Readme.txt s oblíbenou větou "Ahoj světe", který zkusíme propagovat směrem k původnímu repozitáři:
pushd priklady-lokalni-klon > /dev/null
echo "Ahoj světe" > Readme.txt
git add Readme.txt
git commit -m "Add Readme holding a first hello world example, that will fail soon." Readme.txt
git push
popd > /dev/null
Pojďme si projít příklad příkaz po příkazu a vysvětlit si, k čemu zde vlastně došlo:
echo "Ahoj světe" > Readme.txt
- Tento příkaz v sobě žádné tajemství neskrývá, jde o obyčejné vytvoření souboru.git add Readme.txt
- Z principu je potřeba rozlišovat soubory, které jsou sledovány verzovacím systémem (verzovány) a těmi, které jsou vedlejšími produkty a které verzovány být nemají. Příkaz git add
tak dělá dvě věci:
a. Doposud nesledované soubory jsou nově označeny jako sledované.
b. Soubor je poznačen jako součást obsahu nadcházejícího commitu.git commit -m "Zdůvodnění" [případná explicitní soupiska souborů, které mají být do commitu zahrnuty]
- Tento příkaz vytvoří samotný commit a upraví referenci na vrchol historie. Zdůvodnění commitu může být u triviálních změn jednořádkové, složitější zprávy je ale žádoucí komentovat obšírněji. Pokud přepínač -m
nezadáte, otevře se textový editor daný proměnnou prostředí EDITOR
s šablonou zdůvodnění, čítající zakomentovanou soupisku souborů v commitu zahrnutých. Zda na konci příkazu uvedeme soubor Readme.txt
či nikoliv nemá v tomto konkrétním případě vliv, sic tento soubor již byl ke commitu poznačen příkazem git add
.Doposud jsme viděli repozitáře se soubory, které chceme verzovat a režijními informacemi gitu uloženými v podadresáři .git
.
Takovým repozitářům říkáme repozitáře s pracovním adresářem.
Tyto jsou samozřejmě repozitáři a lze je klonovat.
Ale - a je to velmi důležité ale - obsahují soubory k nějaké verzi kódu, v nějakém (blíže nespecifikovaném) stavu rozpracování.
Pokud bychom nahrávali své změny do takového repozitáře, mohli bychom vytvořit nekonzistenci mezi aktuálním stavem a vrcholem historie.
A právě takové nekonzistenci se git snaží zabránit.
Jak ale z této problémové situace ven? Řešením jsou holé (bare) repozitáře, které obsahují jen a pouze režijní data a historii, nikoliv však pracovní soubory. Právě v podobě bare repozitářů jsou typicky projekty uloženy na synchronizačních serverech.
Takové repozitáře mezi sebou pak můžeme synchronizovat z druhé strany, stažením změn. 1 Toto si ale ukážeme později.
Ano, push lze i vynutit, ale to není dobrý nápad.↩
Na chvíli teď opusťme představu toho, že máme nějaký server a více různých kopií, které potřebujeme synchronizovat. Git lze totiž používat i lokálně, například pro udržování konfigurace serveru a zdůvodňování (a logování) změn. Pro takové případy je možné vytvářet repozitáře i lokálně, bez asistence nějaké 3. strany či kopie.
rm -rf priklady-lokalni-init
mkdir priklady-lokalni-init
pushd priklady-lokalni-init > /dev/null
git init .
echo "Ahoj světe" > Readme.txt
git add Readme.txt
git commit -m "Add Readme holding a first hello world example." Readme.txt
popd > /dev/null
Jak jsme již naznačili v případě výše, existence více kopií téhož repozitáře, spolu s tím že commit je vždy jen do aktuální kopie vede nevyhnutelně na problém synchronizace jednotlivých kopií (slovo kopie je kopírováno zcela záměrně).
Zatím ale nebudeme uvažovat projekty s více vývojáři a konflikty mezi jejich změnami, což nám situaci přece jen zjednoduší.
Vraťme se tedy k příkladu, kde jsme napoprvé narazili na chybu.
Tentokrát ale zkusíme párování repozitářů doplnit o zpětný směr.
Operace, kterou budeme chtít provést se jmenuje fetch, přesněji tedy git fetch
.
pushd priklady > /dev/null
git remote add kopie2 ../priklady-lokalni-klon
git fetch kopie2
popd > /dev/null
To nevypadá vůbec zle, že? Kam ale zmizel problém synchronizace pracovního adresáře a indexu? Tajemství tkví v tom, že jsme k sobě pouze pouze přenesli informace o existenci změn v jiné kopii spolu se změnami - ty jsme ale nepromítli do svého pracovního adresáře. Pokud by v tomto repozitáři nečekaly žádné nedořešené změny, mohli bychom se vskutku na "nejnovější" verzi přepnout. Nuž, je na čase provést kontrolu:
pushd priklady > /dev/null
git status
popd > /dev/null
Odpověď verzovacího systému je vcelku jednoznačná. Pokud bychom byli zvláště paranoidní, mohli bychom ještě zkusit ověřit co vše git sleduje:
pushd priklady > /dev/null
git ls-files
popd > /dev/null
Nyní tedy k samotnému přepnutí na importované změny:
pushd priklady > /dev/null
git reset --hard kopie2/master
popd > /dev/null
Tento postup je nicméně poněkud komplikovaný a navíc se opírá o některé předpoklady, které nebudou splněny takřka nikdy. Praxe bývá o malinko komplikovanější. Existuje nicméně příkaz, který toto udělá za vás a dokonce se i pokusí odhadnout, do jakého stavu byste kód rádi dostali. Před samotným příkazem bude ještě rychlý reset repozitáře do jednoho z předešlých stavů:
pushd priklady-lokalni-klon > /dev/null
git log -n 2
git reset --hard origin/master
git log -n 2
popd > /dev/null
Pamatujete? Origin odkazuje na původní repozitář, ze kterého jsme klonovali. V tomto repozitáři jsme ale žádné změny nestahovali, nemá tedy jak vědět, že mezitím co jsme si vysvětlovali látku někdo odtud změny vykopíroval. Máme tak zastaralou představu o tom, v jakém stavu projekt je. Tu ale můžeme rychle napravit.
pushd priklady-lokalni-klon > /dev/null
git pull
popd > /dev/null
Třetím příkazem pro synchronizaci, který jste již potkali je git push
.
Zde již zbývá jen doplnit, že pro synchronizaci jiné než výchozí kopie repozitáře stačí uvést jméno tohoto repozitáře jako další argument příkazu git.
Kupříkladu git push kopie2
.
Z nejdůležitějších základních příkazů pro práci s gitem nám chybí poslední - sice k prohlížení historie.
Ten jste ale již také mohli vidět v akci - je jím git log
.
Pro úplnost tedy:
git log -n 5
Podobně jako u řady příkazů které možná znáte z Linuxu, i zde máme přepínač -n
, sice s obvyklým omezením počtu záznamů které se mají vypsat.
Co tedy ve výpisu vidíme?
-m
příkazu git commit.
Pro případné zájemce o doporučující čtení si dovolím odkázat krásný článek na githubu, rozepisující jak je to v gitu interně s hashi, co vše se hashuje, co jsou to objekty, stromy - a spousta dalšího.↩
Nyní tedy umíme - alespoň hypoteticky - verzovat svůj kód. Kdyby to bylo ovšem takto jednoduché, mohli bychom zůstat v prehistorii s CVS. Ale není. Zásadní otázka totiž zní: Jak nerozbít to co už funguje při práci na tom, co fungovat teprve možná bude? Jinými slovy, občas potřebujeme aby vedle sebe mohly fungovat dvě kopie téhož projektu, v různých stádiích rozpracovanosti. A právě k tomu slouží větve.
Vcelku jednoduchý model, častý na nekomplikovaných projektech, je model dvou větví - stabilní a vývojové.
Ty se sice mohou jmenovat různě, často se ale jmenují master
a devel
, případně stable
a master
, či ještě jinak.
Pro jednoduchost budeme uvažovat master
a devel
. Vycházet můžeme z předchozího případu, nejprve ale zameteme a seznámíme se s existujícími větvemi v repozitáři s příklady.
rm -rf priklady-lokalni-kopie
pushd priklady > /dev/null
git branch
git checkout master
git reset --hard origin/master
git branch -D devel 2> /dev/null
git branch -D devel2 2> /dev/null
popd > /dev/null
pushd priklady > /dev/null
git branch
git log -n 1
git checkout -b devel
git branch
git branch devel2
git log -n 1
popd > /dev/null
Příkaz git log
již znáte, nováčci jsou zde ale git checkout
a git branch
.
git checkout
má dva operační režimy, oba v zásadě do pracovního adresáře nahrají soubory, které jsou gitem sledovány k nějaké verzi. Jeho detailní chování se ale liší podle přítomnosti přepínače -b
a toho, zda argument (pathspec) vypadá jako cesta k souboru:-b
s argumentem neobsahujícím znak /
, dojde k přepnutí na danou větev pokud tato existuje. Jinak je argument považován za jméno souboru.-b
s argumentem obsahujícím znak /
, dojde k nasazení nejnovější commitnuté verze souboru, který je argumentem příkazu. Řečeno jinak, git tímto příkazem zahodí naši rozpracovanou verzi. Cestu k souboru je nutno uvažovat buď jako relativní jméno, nebo za pomoci relativního adresáře .
(tedy git checkout ./Readme.txt
obnoví stav Readme.txt v poslední verzi).-b
není v následujícím argumentu jméno souboru, který se má obnovit, ale název cílové větve, která má být nově vytvořena. Její HEAD
(vrchol) je nastaven dle vrcholu větve, ve které jsme se nacházeli původně.git branch
má opět dva režimy. Bez argumentu jen vypíše lokálně obsluhované větve. S argumentem naopak zakládá novou větev, dle stejných pravidel jako git checkout -b
. Je zde nicméně rozdíl - nová větev je sice založena a její vrchol nastaven, nedojde ale ke změně obsahu pracovního adresáře.pushd priklady > /dev/null
git checkout devel
echo "Ahoj světe" > Readme.txt
git add Readme.txt
git commit -m "Add Readme holding a first hello world example to the development branch." Readme.txt
git log -n 2
popd > /dev/null
Máme tedy dvě různé historie, kde jedna z druhé vychází, sice master
a devel
(devel2
odteď pomiňme).
V okamžiku, kdy je soubor změn pro začlenění kompletní můžeme podniknout další krok - začlenit změny z develu do masteru.
To už jsme ale jednou viděli, kde to jen bylo?
Jistě, slučování master
u s kopie2/master
.
pushd priklady > /dev/null
git log -n 2
git checkout master
git reset --hard origin/master
git branch -D old-master
git branch old-master
git reset --hard devel
git log -n 2
popd > /dev/null
Zde ale dojde k promazání obsahu adresáře, což není jiné cesty?
Nezoufejte, máme tady git merge
.
Pojďme se nejprve podívat, co se stane pokud využijeme výchozí chování:
pushd priklady > /dev/null
# nejprve vraťme repozitář do stavu bez předešlého přepnutí
git checkout master
git reset --hard old-master
# výše jen reset příkladu
git log -n 2
git merge devel
git log -n 3
popd > /dev/null
Vida, obešli jsme se tedy i bez resetu celého pracovního adresáře.
Jaké jsou tedy možnosti příkazu git merge
, oproti git reset
?
To první a nejdůležitější je porozumění tomu, k čemu doopravdy příkaz git reset
slouží - a to je návrat aktuální větve ke stavu odpovídajícímu nějaké konkrétní revizi.
Má dva operační režimy, které se svým fungováním významně liší, byť jednu část mají společnou.
Sice úpravu představy gitu o tom, na kterém commitu se právě nacházíte.
git reset --soft
neupravuje žádné soubory v pracovním adresáři, manipuluje jen index - tedy představu o tom, jaká verze byla vybalena do pracovního adresáře. Je to tedy přesně ten příkaz, který použijete, pokud jste udělali v commitu na vrcholu nějakou chybu. V takovém případě můžete místo hashe uvést speciální řetězec HEAD^
, který znamená rodiče aktuálního vrcholu. To vám umožní udělat commit znovu a lépe, například jej rozdělit do dvou menších změn. Jen je potřeba si uvědomit, že pokud toto uděláte někde hlouběji v historii, přestanou vám správně fungovat návaznosti potomků. O tom ale více za chvíli.git reset --hard
se postará o to, aby gitem sledované soubory odpovídaly obsahu k danému commitu a upraví HEAD
aktuální větve. Efektivně to znamená, že pokud vám na původní HEAD
neukazuje žádná větev, zahodíte tím celý vývoj mezi cílovým commitem a původní HEAD
.Oproti uvedenému, sloužícímu k přepisování a nahrazování historie, je git merge
určen k začleňování změn v (cizí) historii do mé aktuální.
Pokud začleňujeme historii, která je naším přímým potomkem - tedy první commit který je v začleňované historii navíc má za předka aktuální HEAD
, je začlenění vcelku přímočaré - aplikujeme změny a posuneme vrchol. Svět ale nebývá tak jednoduchý.
Vývojář (klasickým pojmenováním Alice) může pracovat na jednom souboru, Bob na druhém. Oba provedou commit a následně potřebují své změny v projektu sloučit. Doposud jsme se ale bavili, že commit má jednoznačného předka. Toto ale neplatí pro merge commity - ty spojují divergující historie. Některé z nich mohou být automatizovány, zejména pokud se týkají různých souborů, v různých místech. Platí, že pokud vzniká merge commit, musí mít zdůvodnění.
pushd priklady > /dev/null
git checkout devel
#
printf '\n\nHARD\n\n'
#
git reset --hard old-master
echo "Ahoj světte s překlepem" > Readme.md
git add Readme.md
git commit -m "Add Readme holding a first hello world example to the development branch." Readme.md
git log -n 2
git show HEAD
#
printf '\n\nSOFT\n\n'
#
cat Readme.md
git reset --soft HEAD^
echo "Ahoj světe bez překlepu" > Readme.md
git commit -m "Add Readme holding a first hello world example to the development branch." Readme.md
git log -n 2
#
printf '\n\nMERGE\n\n'
#
git log -n 3 master old-master devel
git checkout master
git merge devel -m "Alice and Bob agreed that everybody will maintain his/her"\
" own Readme, as a sign of excellent teamwork."
git log -n 4
popd > /dev/null
Mnohem častěji ale dojde na situaci, které říkáme konflikt. Situaci, kdy dva commity upravují tentýž řádek. Z principu věci nemá git jak vědět, která z obou úprav je lepší a kterou má zachovat. Vyřešení takové situace pak nechává na programátorovi, sice výzvou k manuálnímu sloučení rozbitého souboru. Pokud k takové úpravě dojde někde hluboko v historii, obvykle si git dokáže s následnými konflikty dobře poradit, může se ale i stát, že se objeví znovu. V dřívějších verzích se mohlo stát i to, že k vyřešení konfliktu budou prezentovány totožné soubory, toto chování ale přednášející osobně nepozoroval již pár let.
Pojďme se podívat na příkazy, které budeme pro řešení konfliktu potřebovat:
git merge --abort
Tento příkaz nám umožňuje v kterémkoliv bodě merge zrušit celou operaci. Ať už proto, že dospějeme k názoru, že to bude lepší celé udělat znovu a pořádně, nebo narazíme na něco, nad čím bude potřeba déle přemýšlet.git mergetool -t <nástroj>
Ve výchozím stavu git rozpracuje automatické slučování a vytvoří několik pomocných souborů s pomocnými jmény BASE
, LOCAL
, REMOTE
(odpovídající rozslučovanému kódu, verzi aktuální větve po zapracování konfliktů z předešlých commitů, verzi z pohlcované větve). Do souboru BASE
git vyznačí konfliktní oblasti pomocí <<<< HEAD
, ====
a >>>> pohlcovana
. To ale není nejpohodlnější způsob a tak nám git umožňuje použít i některé předkonfigurované nástroje k třícestnému sloučení.git commit
Tento příkaz sice již znáte, avšak: po manuálním vyřešení konfliktu je potřeba udělat commit a následně zavolat další příkaz - viz níže. Jinak by git nevěděl, zda konflikt chcete řešit posloupností více commitů (pamatujte na poučku o granularitě commitů).git merge --continue
Signál gitu že konflikt byl vyřešen a má se pokusit pokračovat ve slučování.Příklad tentokrát nebudeme vytvářet po jednotlivých commitech, naopak jej najdeme v připravených větvích blok1_mkonflikt
, blok1_mkonflikt_a
a blok1_mkonflikt_b
pushd priklady > /dev/null
git log -n 5 --graph --decorate --oneline origin/blok1_mkonflikt_work origin/blok1_mkonflikt_a origin/blok1_mkonflikt_b
#
#
git checkout blok1_mkonflikt_work
git reset --hard origin/blok1_mkonflikt_work
git merge origin/blok1_mkonflikt_a
git merge origin/blok1_mkonflikt_b
#
cat blok1_mkonflikt/Readme.txt
# následující řádek v tichosti předpokládá GNU sed a nevedou ke správnému výsledku, jde jen o hack
sed -i '/^<<</,/^>>>/cmeld\rToto je nové a krásné ale poněkud stručné Readme, vzniknuvší pro demonstraci problémů s merge.' blok1_mkonflikt/Readme.txt
cat blok1_mkonflikt/Readme.txt
git add blok1_mkonflikt/Readme.txt
git commit -m "Merged the example (A)"
git log -n 6 --graph --decorate --oneline blok1_mkonflikt_work origin/blok1_mkonflikt_a origin/blok1_mkonflikt_b
popd > /dev/null
Takto pak vypadá slučování změn a jejich výsledek, pokud ve výše uvedeném příkazu použijeme pro vyřešení konfliktu git mergetool -t meld
.
U rozsáhlejších změn ale přijde vhod i to, že meld umí zobrazovat rozdíly v řádcích explicitním zvýrazněním sytější barvou.
Bloky můžete kopírovat mezi soubory stiskem tlačítka šipky.
Pokud naopak chcete nějaký blok změn odstranit, stiskněte klávesu shift a šipka se změní v křížek.
pushd priklady > /dev/null
git log -n 5 --graph --decorate --oneline origin/blok1_mkonflikt_work origin/blok1_mkonflikt_a origin/blok1_mkonflikt_b
#
git checkout blok1_mkonflikt_work
git reset --hard origin/blok1_mkonflikt_work
git merge origin/blok1_mkonflikt_a
git merge origin/blok1_mkonflikt_b
#
git mergetool -t meld
# následující řádek v tichosti předpokládá GNU sed a nevedou ke správnému výsledku, jde jen o hack
git add blok1_mkonflikt/Readme.txt
git commit -m "Merged the example (meld)"
cat blok1_mkonflikt/Readme.txt
git log -n 6 --graph --decorate --oneline blok1_mkonflikt_work origin/blok1_mkonflikt_a origin/blok1_mkonflikt_b
popd > /dev/null
Jak jste si možná všimli, merge začíná na společném předkovi větví. Ač se git interně snaží (vcelku důmyslnou strategií) minimalizovat počet zásahů, často přijde vhod mít o společném předkovi a změnách ve stabilnější větvi přehled. Kupříkladu proto, že sami nemusíme být na větvi devel, ale na nějaké další, odvozené. A v té může probíhat vývoj jen jediného vylepšení. Do develu se pak v tomto modelu sbírají jednotlivá vylepšení, která jsou pak dále propagována k masteru.
Lineární, nedivergující historie má jednu krásnou vlastnost - velmi (relativně) rychle můžeme binárním hledáním zjistit, který commit nám vnesl chybu, který vývojář kdy upravil (a s jakým zdůvodněním) který řádek. Jako rozcvičení si tedy nejprve ukážeme prstem na viníka:
pushd priklady > /dev/null
git checkout blok1_blame # větev s příkladem
git blame blok1_mkonflikt/Readme.txt
popd > /dev/null
V řadě případů jsme ale postaveni před zcela opačný problém. Nevíme dopředu, co vlastně hledáme. Víme jen, že v některých případech poznáme co hledáme, když to najdeme.
Zní to nepředstavitelně? A co tento popis:
Pokud bychom měli historii lineární, tedy bez merge commitů, bylo by hledání relativně snadné - stačilo by použít důvěrně známý algoritmus binárního hledání a nalézt první verzi, která je rozbitá.
Seznamte se, princip algoritmu za příkazem git bisect
.
Tento má hned několik podpříkazů, jejichž detailní soupis získáte příkazem man git-bisect
.
Pro začátek tedy:
git bisect start bad-commit good-commit
- Tímto příkazem zahájíte hledání v rozsahu uvedených commitů. Po zadání tohoto příkazu git zvolí commit poblíž pomyslného středu, který rozbalí do pracovního adresáře. Následně vám předá řízení, abyste mohli stav projektu zhodnotit. Následně provedete jeden ze dvou příkazů níže.git bisect good
či git bisect bad
- Tato dvojice příkazů slouží k poznačení aktuálního commitu za novou mez binárního hledání. Těmito příkazy cyklicky pokračujete, dokud se hledání nezúží na jediný commit.git bisect reset
- Pokud máte hotovo, bývá užitečné pracovní adresář do stavu před bisectem.Zkrátka a jednoduše, lineární historie má výhody. Jak jí ale dosáhnout (i) při souběžném vývoji mnoha lidmi? Vzpomeňte si na komentář k tomu, co pro git znamená datum. Není totiž důležité datum, ale pořadí commitů. Seznamme se s přepisováním historie.
První z příkazů, se kterými se v tomto oddílu seznámíme je git rebase
.
Cílem tohoto příkazu je odvodit změny v aktuální větvi od jiného commitu - tedy například aktuálního vrcholu develu.
pushd priklady > /dev/null
git rebase --abort 2> /dev/null # reset prikladu
git log -n 5 --graph --decorate --oneline origin/blok1_mkonflikt_a origin/blok1_mkonflikt_b
#
# provedeme lokalni checkout obou vetvi
git checkout blok1_mkonflikt_work 2> /dev/null # reset prikladu
git branch -D blok1_mkonflikt_rebased_b 2> /dev/null # reset prikladu
git checkout -b blok1_mkonflikt_rebased_b $(git rev-parse origin/blok1_mkonflikt_b)
git rebase origin/blok1_mkonflikt_a
#
# nyní postupujme analogicky k merge
#
cat blok1_mkonflikt/Readme.txt
# následující řádek v tichosti předpokládá GNU sed a nevedou ke správnému výsledku, jde jen o hack
sed -i '/^<<</,/^>>>/cmeld\rToto je nové a krásné ale poněkud stručné Readme, vzniknuvší pro demonstraci problémů s merge.' blok1_mkonflikt/Readme.txt
cat blok1_mkonflikt/Readme.txt
git add blok1_mkonflikt/Readme.txt
env EDITOR=cat git commit #-m "Rebase the example (sed)"
git rebase --continue
cat blok1_mkonflikt/Readme.txt
git log -n 7 --graph --decorate --oneline blok1_mkonflikt_rebased_b origin/blok1_mkonflikt_a origin/blok1_mkonflikt_b
popd > /dev/null
Porovnejte si výpis historie s tím, co vidíte po aplikaci merge.
Tentokrát sice nemáme žádný merge commit, ale za to máme commity původně z větve blok1_mkonflikt_b duplikovány (což je ale jen logický důsledek nutnosti pokrýt novou historii).
Získali jsme ale významnou výhodu - lineární historii.
Příkaz git rebase
ale získá zcela novou sílu s přepínačem -i
, tedy interaktivní rebase.
Podstatou interaktivního rebase je to, že historie kterou začleňujeme není jen pasivně přejata, ale můžeme do ní zasáhnout. Co to znamená?
Tato kombinace možností má jeden nečekaný důsledek - v pracovním repozitáři můžete mít soukromou větev s velkými desítkami commitů, které pak při přípravě na začlenění do vývojové či stabilní větve setřepete na hromádku, vyčistíte od svých poznámek a vyrobíte z nich sadu změn o (např.) 10 commitech, které jsou věcné a všeobecně působí profesionálním dojmem.
Velkou nevýhodou rebase je, že ztrácíme vazbu na původní historii. Každý commit získává totiž nový hash, nesouvisející s původním. Zde se skrývá velké nebezpečí, protože zatím co v případě merge je nový vrchol aktuální větve potomkem původního, zde bude jejich vztah odpovídat spíše sourozencům.
Představme si, co by se stalo, pokud bychom provedli rebase větve, která je již vykopírována jinam.
test -d priklady-rebase-misuse && rm -rf priklady-rebase-misuse
pushd priklady > /dev/null
git checkout blok1_mkonflikt_a
git branch -D blok1_rebase_misuse 2> /dev/null
git branch blok1_rebase_misuse $(git rev-parse origin/blok1_mkonflikt_b)
popd > /dev/null
#
# priklady ted obsahuje vetev odvozenou z blok1_mkonflikt_b
# repozitar naklonujeme a v klonu provedeme rebase nad zmeny v blok1_mkonflikt_a
# a nasledne zkusime repozitare synchronizovat
#
git clone ./priklady ./priklady-rebase-misuse
pushd priklady-rebase-misuse > /dev/null
git checkout blok1_rebase_misuse
git rebase origin/blok1_mkonflikt_a
sed -i '/^<<</,/^>>>/cmeld\rToto je nové a krásné ale poněkud stručné Readme, vzniknuvší pro demonstraci problémů s merge.' blok1_mkonflikt/Readme.txt
git add blok1_mkonflikt/Readme.txt
env EDITOR=cat git commit > /dev/null
git rebase --continue
# rebase hotovo, muzeme synchronizovat
git push
# push neprosel, v nadrazenem repozitari jsou zmeny
git pull
sed -i '/^<<</,/^>>>/cmeld\rToto je nové a krásné ale poněkud stručné Readme, vzniknuvší pro demonstraci problémů s merge.' blok1_mkonflikt/Readme.txt
git add blok1_mkonflikt/Readme.txt
git commit -m "Yet another?"
git log -n 10 --graph --decorate --oneline
popd > /dev/null
Na internetu najdete různá GUI pro git, mnohdy i jako součást některých IDE. Explicitně si dovolím některá zmínit:
.gitignore
, raději explicitní ignorování než wildcard. Verzujte .gitignore
.git cherry-pick
(Blok 3)git config --global
, git config --local
- uživatelská nastavení gitu, globální a per-repogit remote
- správa vazeb mezi repozitářigit merge -s ours
strategie merge, merge s neaplikováním 2. větvegit am
accept merge - začleňování patchů odjinudgit diff
- diff proti verzi známé gitu.gitignore
, .gitattributes
- maska souborů, kterých si git nemá všímat + atributy (např. vypnutí ošetření \r\n)git reflog
- log změn/checkoutů větví a commitů git rev-parse
- z reference získá hashgit commit --amend
- úprava nejnovějšího commitugit commit --sign-off
- schvalování commitů konkrétním vývojářemgit stash
- privátní dočasné commity (stashe) pro odložení rozpracovaného kódugit merge-base
- společný předek pro mergegit tag
- aliasování commitů (zafixování verze)git gc
a git fsck
- velmi důležité pro obnovu ztracených datls -aihl .git/hooks
- události v repozitářiman git-commit
pro nápovědu k příkazu git commit
.Proč lidé nakupují i dnes více zásob, než bezprostředně spotřebují? Protože jsme v zásadě tvorové líní. Protože je občas snazší koupit polotovar s unifikovanou chutí, než uvařit něco z čerstvých surovin dle neurčitého návodu plného frází jako "dosolíme dle chuti". Proč by programování a vývoj mělo být jiné?
Richard Feynman se ve své vzpomínkové (a snad i poněkud humoristické) knize vrací (mimo jiné) k popisu fungování výpočetního centra laboratoří Los Alamos, kde popisuje i systém kterým tehdy řešili paralelizaci úloh i korekce výpočtů. Takový popis rozhodně stojí za to, ale - přiznejme si - postup fungování výpočetního centra z něj reprodukovatelný není. Přesto ale byl někde zdokumentován, aby byl nasaditelný i v dalších pobočných výzkumných laboratořích v USA.
Přesto, stejně jako dnešní dokumentace - trpí na jeden zásadní problém - a to je aktuálnost dokumentace. Typicky totiž takovou dokumentaci udržujeme stranou od dokumentovaného díla, což sice zvyšuje přehlednost, ale přináší problémy se synchronizací.
Vezměme v úvahu už jen instrukce pro zprovoznění jupyter notebooku na aise, které jste měli možnost vidět naživo na první přednášce. Ruku na srdce, replikovatelnost takovéhoto postupu, zejména pokud s ním nemáte předchozí zkušenosti - bude mizivá. Pokud by se ale povedlo celé instrukce zjednodušit do jediného příkazu, byla by úspěšnost obratem vyšší.
Než se ale pustíme do něčeho obsáhlejšího, přijde vhod krátká rekapitulace příkazové řádky na *NIXových systémech.
Pozn.: Tato část je jen velmi rychlým crashkurzem, pro komplexní vysvětlení látky uvažte předměty PB152, PV004, PV065.
Příkazová řádka typicky nabývá podoby:
prompt > příkaz --přepínač argument
Pojďme popořadě:
PATH
.
** Absolutní či relativní cesty ke spouštěným programům, i těm v PATH
.--
či -
je nicméně zvykem označovat jako přepínače. Navzájem jsou odděleny znakem mezery, tuto lze escapovat - typicky \
, nebo ji uvést uvnitř uvozovek či apostrofů.Aby mohly jednotlivé příkazy na sebe navazovat, předávat si data, užívá se dále pomocných konstrukcí:
|
(pipe, svislítko, roura) a její kombinovaná varianta |& - pokyn pro shell, že má standardní výstup jednoho procesu pro standardní vstup druhého.>
, 2>
, 2>&1
, >>
, 2>>
, <
- přesměrování standardního výstupu (1-5) procesu do souboru (přepsat, přepsat soubor standardním chybovým výstupem, sloučit chybový výstup do standardního výstupu, přidávat k souboru a přidávat chybový výstup`; potažmo přesměrování souboru na standardní vstup (6).Skripty se začínají objevovat ruku v ruce s prvními operačními systémy a jejich shelly. Nejjednodušší skripty jsou jen sérií příkazů shellu, zapsaných za sebe, ať již předem, či jen zkopírováním toho co kdo kam zadával. Ty složitější již mohou vytvářet velmi komplikované a nečitelné programy.
Z ukázek kódu v tomto dokumentu můžeme snadno sestavit skript instalující na aisu tyto přednášky a Jupyter, prostým vložením příkazů do souboru (viz priklady/blok2_buildsh1/build.sh
).
#!/bin/bash
module load python3-3.7.4-aisa
pip3.7 install --user notebook
pip3.7 install --user bash_kernel
python3 -m bash_kernel.install
rm -rf ~/pb176_workdir
mkdir -p ~/pb176_workdir
cd ~/pb176_workdir
git clone https://gitlab.fi.muni.cz/pb176/prednasky.git
cd prednasky
git clone https://gitlab.fi.muni.cz/pb176/private/2022-priklady-todo priklady
Souběžně s build.sh
můžeme vytvořit i skript odpovědný za spuštění notebooku - typicky run.sh
.
#!/bin/bash
cd ~/pb176_workdir/prednasky
git pull
jupyter-notebook --port 12348 --no-browser
Co se ale stane, pokud takový skript pustíme vícekrát po sobě?
Jednotlivé řádky jsou více či méně nezávislé, přesto předpokládají úspěšné dokončení těch předchozích.
Mohou proto selhat, tím že jsme část práce "automatizovali" jsme ale přišli právě o možnost reagovat na to, jak se skripty chovají.
Přitom na vině nemusí být nic složitějšího než změna pracovního adresáře.
Naštěstí můžeme závislost mezi jednotlivými řádky vyjádřit již ve skriptu - jednoduše podmíníme vykonání nového příkazu úspěchem předešlého.
Nejjednodušší způsob, jak toho docílit v interaktivním shellu je vložit mezi oba příkazy operátor logického součinu - &&
.
Náš skript to poněkud... poznamená.
#!/bin/bash
module load python3-3.7.4-aisa && \
pip3.7 install --user notebook && \
pip3.7 install --user bash_kernel && \
python3 -m bash_kernel.install && \
\
echo "zkuste si nasledujici radek zakomentovat a pustit tento skript 2x po sobě" && \
rm -rf ~/pb176_workdir && \
mkdir -p ~/pb176_workdir && \
cd ~/pb176_workdir && \
\
git clone https://gitlab.fi.muni.cz/pb176/prednasky && \
cd prednasky && \
git clone https://gitlab.fi.muni.cz/pb176/private/2022-priklady-todo priklady && \
cd ..
Účel to sice splnilo, ale není to řešení, ba naopak, celou věc silně komplikujeme [^sete:]. Navíc závisíme na konkrétní verzi pythonu3 - python3-3.7.4-aisa. Místo jednoduché soupisky instrukcí končíme se skriptem, ve kterém jen narůstá podíl režijního kódu. A právě z tohoto důvodu (a řady jiných) vzešla idea "sepisovat primárně závislosti". O zbytek, ať se postará někdo jiný - buildsystém.
Jako drobné cvičení se zamyslete, zda jde o vestavěný příkaz či externí program.
Z výše uvedené myšlenky vychází i nástroj make
a jeho konfigurační soubor Makefile
.
Původní make vychází z PWB/UNIX 1.0 a datuje se do roku 1976, takže můžeme směle prohlásit že by dnešním dialektům a systémům příliš nerozuměl.
Zpravidla se tak setkáte s GNU make, který má pár triků v rukávu navíc.
Soubor Makefile
se skládá z několika prvků:
make
se stará o to, aby byly cíle novější než jejich závislosti. Závislosti lze v souboru uvádět průběžně a stejně tak je lze i opakovat (jsou uvažovány jako množina).
Každý řádek pravidla je spuštěn v separátním shellu. Pravidla mohou běžet paralelně, o jejich souběh se stará make, který pravidla spouští tak aby byly uspokojeny závislosti mezi cíli.#!/usr/bin/make -f
PROMENNA=hodnota
PROMENNA2:=hodnota pokud neni nastavena z venci
# komentář, první pravidlo je výchozím pro provedení při spuštění make bez explicitního cíle
all: soupiska cilu
soupiska: soupiska.c
cilu: cilu.c
clean:
rm -rf cilu soupiska
#<tabulátor>rm -rf cilu soupiska
pushd prednasky > /dev/null
cat priklady/blok2_makefile_syntax/Makefile
popd > /dev/null
Pojďme se podívat, jak by - zapsány formou receptu (recipe) vypadal původní skript build.sh
#!/usr/bin/make -f
run: pb176_prednasky
cd ~/pb176_workdir/prednasky
git pull
jupyter-notebook --port 12348 --no-browser
pb176_prednasky: jupyter
mkdir -p ~/pb176_workdir
cd ~/pb176_workdir
git clone https://gitlab.fi.muni.cz/pb176/prednasky
cd prednasky
git clone https://gitlab.fi.muni.cz/pb176/private/2022-priklady-todo priklady
jupyter:
module load python3-3.7.4-aisa
pip3.7 install --user notebook
pip3.7 install --user bash_kernel
python3 -m bash_kernel.install
Tento recept nicméně nefungoval (ne tak jak jsme očekávali), proč? Důvodů je několik:
make
To první co můžeme udělat je převést samostatné řádky pravidel do dlouhého příkazu. Při té příležitosti můžeme Makefile i zobecnit, kupříkladu když by se na aise potkalo více studentů na jednou - každý bude pro svůj notebook potřebovat unikátní port.
pushd prednasky > /dev/null
cat priklady/blok2_make2/Makefile
popd > /dev/null
Toto samo o sobě ale ještě nestačí, protože máme nadále příkazy, které budou selhávat.
Nezávisle na tomto nyní musí každý pro unikátní číslo portu editovat Makefile, čímž jsme nic navíc nezískali.
Další úprava substitucí je tedy nasnadě - nemít číslo portu zadrátováno v Makefile, ale převzít jej zvenčí.
To nám zajistí nahrazení =
v definici proměnných za ?=
.
pushd prednasky > /dev/null
cat priklady/blok2_make3/Makefile
popd > /dev/null
Make pak zavoláme příkazem:
pushd prednasky > /dev/null
cd priklady/blok2_make3
make PORT=9988 # listen on 9988/tcp
popd > /dev/null
Jsou nicméně situace, kdy stávající struktura pravidel neodpovídá našim představám. Typicky tomu bývá v případech:
První z položek je v Makefile dosažen speciálními cíli a zdroji, atributy. Příkladem takového speciálního cíle, který je sestaven vždy, je cíl .PHONY . Pokud tedy chceme nějaký cíl znovu vytvořit s každým jeho zařazením mezi závislosti, uvedeme jej v závislostech cíle .PHONY.
.PHONY: clean
clean:
rm -rf index/index
Cíl clean je tedy make znovu sestaven pokaždé, když o něj někdo zažádá.
Druhý bod je o něco pragmatičtější, bez speciálních triků. Nikdo nám totiž nebrání - v rámci pravidla zavolat... make. Volání se zde malinko liší dle platformy, zatím co GNU make má přepínač -C, make na jiných platformách jej mít nemusí. Vzpomeňme ale, že každý řádek je vykonán v samostatném shellu. A tedy:
.PHONY: index
index:
cd index ; make
Poslední z uvedených položek je triviální. Jednoduše stačí cíl zapsat jako relativní cestu.
index/demo: demo
ln -s ../demo index/demo
Prostředím zpravidla rozumíme nějaký soubor softwarových nástrojů a hardware, jejich propojení, nastavení a zdroje jimi zpřístupněné. V přeneseném smyslu slova lze tedy do této definice hravě skrýt ledasco. Zkusme tedy, pro usnadnění orientace, problém obrátit - místo otázky "co všechno je ještě prostředí?" uvažujme test "je toto součást prostředí?"
Pro začátek shell.
Spouštíme pomocí něj programy.
Pokud budeme mít starou verzi bashe, která neumí přesměrování výstupu pomocí |&
, sotva nám bude fungovat skript tento operátor využívající.
Jinými slovy, náš skript závisí na nějaké minimální verzi bashe.
Tento typ závislosti je vcelku přímočarý, existují ale i nějaké jiné typy závislostí než "mít nainstalovaný program AB ve verzi XY?"
Vzpomeňme na podobu skriptu. Udělali jsme tam jednu (možná?) chybu - nikde jsme nenapsali, kde se mají programy které v něm voláme hledat. Náš postup docela vystihuje filozofii "nějak to zařiď". Jednoduše se spoléháme na to, že shell nějak vybere ten správný program. A shell se spoléhá na to, že program který po něm chceme, bude umístěn v některém z adresářů uvedených v proměnné PATH. Přesněji řečeno, PATH je proměnnou prostředí.
echo $PATH
Proměnné prostředí v posixovém shellu nastavujeme příkazem export (pak se vztahují i na shell samotný), nebo příkazem env (pak se vztahují jen na dceřiný proces).
#!/bin/bash
env PATH=/usr/bin:/bin bash -c 'echo $PATH'
echo $PATH
#vs.
export MINION_DEP=banana
bash -c 'echo $MINION_DEP'
echo $MINION_DEP
Podívejme se tedy na některé proměnné prostředí, které mohou ovlivnit naše fungování:
LD_LIBRARY_PATH
, avšak prohledávána v čase kompilace. Obsahují ale proměnné prostředí i něco jiného než výčty adresářů?
less
vs. more
).A konečně,
\[\033[01;32m\]\u@\h\[\033[01;34m\] \w \$\[\033[00m\]
Proč ale máme tolik proměnných s cestami, které zdánlivě jen kopírují standardní cesty k programům?
Protože nám to nabízí alternativu ke standardním cestám, kterou můžeme ovlivnit z prostředí uživatele bez nutnosti kontaminace prostředí celého systému a jiných uživatelů.
Ba co více, i v rámci vlastního prostředí můžeme měnit to co se kde použije pouhou změnou proměnné prostředí v potomkovi našeho shellu.
Můžeme mít nainstalováno mnoho různých verzí téhož programu, jen v různých cestách.
Konec konců, právě proměnné prostředí jsou to co umožňuje fungovat příkazu module
, který znáte z aisy.
Pod pojmem prostředí jsme ale doteď diskutovali pouze softwarové prvky. Závislosti mezi instalovanými programy, jejich verze, proměnné ovlivňující chování. Pokud nám ale přijde od zákazníka oznámení, že "ten náš nový systém je příliš pomalý", "trvá to dlouho" apod., nebude nám povědomí o softwarové konfiguraci nejspíše stačit. Je tedy nutné uvažovat i hardware, jeho výkon, časování, zapojení. A to bez ohledu na to, zda jde o hardware virtualizovaný (VirtualBox, KVM, OpenNebula - stratus, ...), či fyzický. Jako platformu tedy můžeme uvažovat zobecnění prostředí i na prvky fyzického světa. Tedy donedávna jsme mohli, masivní rozvoj architektur typu platform as a service a virtualizace, zefektivňování návrhu hardware - to vše vede ke stírání rozdílů mezi software a hardware. Pod pojmem platforma tedy dnes najdete i ryze softwarová řešení. Proto tento pojem ponechme spíše synonymem.
Od historického modelu, kdy docházelo k aktualizacím kompletní výměnou (nejlépe sálového počítače) se svět software vydal se vznikem a rozvojem OS v 80. letech cestou modularizace. Vydefinování rozhraní mezi moduly - ve světě UNIXu často formou souboru s více či méně rigidní strukturou - umožnilo vylepšovat systém jako celek posupně, po částech.
Pojmem toolchain tedy rozumíme kolekci nástrojů, které mezi sebou navzájem interagují a kde se výsledky jednoho nástroje stávají vstupy nástroje jiného. To je často i díky tomu, že jednotlivé nástroje toolchainu mají často stejného výrobce. Příklady toolchainu mohou být:
V některých případech lze navíc jednotlivé části toolchainů kombinovat - nikdo vám nebrání použít GNU Make a javac, gdb a clang, či jiné kombinace. Toto v praxi umožňuje poměrně velkou variabilitu.
Doposud naše úvahy neobnášely externí knihovny, přesto je jich svět pln.
Ve světě unixových systémů dlouho platil za základní nástroj balíček pkg-config, který funguje jako nápověda pro programátora, jak má upravit přepínače kompilátoru pro užití externí knihovny.
To díky tomu, že nutnost uvádět konkrétní argumenty pro danou platformu přenáší z koncového programátora na autora knihovny.
Ta musí poskytnout odpovídající soubor s příponou .pc
, který pkg-config hledá ve svých standardních cestách.
Jeho použití je pak prosté, pro získání přepínačů pro kompilaci lze užít přepínače pkg-configu --cflags
, pro argumenty linkeru pak --libs
.
V obou případech je nutno přepínač doplnit o jméno hledané knihovny.
cd prednasky
pushd priklady/blok2_pkgconfig > /dev/null
cat Makefile
make
echo
./uuid_demo
popd > /dev/null
Ne vždy ale chceme na nějaké knihovně záviset povinně.
Krásným příkladem takové volitelné, platformně závislé vazby jsou multimediální API.
Zatím co na Windows je DirectShow, na Linuxu je video4linux.
Ač make jako takový umožňuje do jisté míry větvení dle hodnot proměnných, zdaleka se flexibilitou nevyrovná shellovému skriptu build.sh
.
Nabízí se tedy řešení, skriptem nesestavovat přímo kýžený produkt, ale vygenerovat Makefile na míru.
Takový skript se zpravidla jmenuje configure.
Vracíme se s ním ale zpět do situace, kdy jsme museli řešit co na dané platformě máme k dispozici. A právě z tohoto důvodu se objevily snahy nějakým způsobem tyto skripty unifikovat, psát univerzálně. Zástupcem těchto snah mohou být GNU Autotools. Samotný configure se tak ale stává... předmětem výstupu dalšího generátoru. Představuji vám tedy configure.ac a autoconf.
Soubor configure.ac je psán v makrojazyce M4 a platformně závislý kód tak generují makra. Díky tomu je podivnou směsicí skriptů shellu a fragmentů maker, což umožňuje snadno a rychle implementovat i kontroly, které ještě nejsou součástí nástroje jako takového. Soubor se sestává z logických sekcí:
Configure, tak jak jej pojímá autoconf, je nicméně založen na substitucích.
Než tedy vygeneruje Makefile, případně i další soubory, jako demo.pc (konfigurační soubor pro pkg-config), musí dostat šablonu ve které má substituce provést.
V případě Autotools jsou jí soubory stejného názvu jako cílové, jen s dodatečnou příponou .in
.
Než se tedy pustíme do ilustrace configure.ac, pojďme se podívat na takový Makefile.in, odpovídající předchozí úloze.
cat priklady/blok2_autoconf/Makefile.in
V souboru se něco málo přece jen změnilo - kompilátor, jeho přepínače a přepínače linkeru teď závisí na konstrukci @PROMENNA@
, kterou nahradí autoconfem vytvořený configure za cílový obsah.
Vedle této změny se objevila ale ještě jedna - všechny sestavované soubory začaly záviset na souboru config.h, jehož generování má autoconf rovněž v gesci.
Tuto šablonu ale nemusí dodat autor projektu, autoconf ji vytvoří sám v momentě, kdy generuje configure
z configure.ac
.
cat priklady/blok2_autoconf/config.h.in
cat priklady/blok2_autoconf/configure.ac
Projděme si tedy ukázku, i když poněkud na přeskáčku:
Co ale dělá takové AC_ARG_ENABLE
?
Jde o volitelné závislosti.
Typicky máme 3 stavy - explicitní on, explicitní off - a výchozí.
Toto makro má za úkol provést v případě přítomnosti argumentu --enable-
Podobně jako v předešlém příkladu s pkg-config v Makefile, i zde vyvstanou přepínače pro překladač a linker.
Tyto jsou přidány k existujícím a následně substituovány do Makefile.
Pokud by závislost na uuid uspokojena nebyla, provede se 4. větev makra PKG_CHECK_MODULES
(její důsledek je ponechán k odpozorování čtenáři).
Soubor configure
ale ještě musíme vygenerovat. O to se stará kombinace příkazů autoheader
(generuje config.h.in
) a autoreconf (generuje configure
).
pushd priklady/blok2_autoconf > /dev/null
autoheader
autoreconf
./configure
popd
Krátká rekapitulace:
V tento okamžik nastupuje automake, který celý Makefile.in
redukuje na Makefile.am
.
Cílem automake je vygenerovat Makefile, se správnými závislostmi mezi cíli tak, abychom nejlépe jen napsali které programy chceme sestavit ze kterých zdrojových souborů.
Syntaxe i možnosti automake jsou výrazně větší než pokrytí v tomto předmětu, nebývá tak než pro zvědavost odkázat předmět PV065.
Automake nicméně vyžaduje zavést i do configure.ac
, naštěstí jde ale o jediný řádek bezprostředně po AC_INIT
.
cat priklady/blok2_automake/Makefile.am
echo
diff -rupN priklady/blok2_auto{conf,make}/configure.ac
echo
Automake nicméně vyžaduje ještě několik dodatečných souborů s dokumentací projektu. Jde o:
Protože ale automake vyžaduje dodatečné příkazy, typicky narazíte i na skript autogen.sh
, jehož jediným smyslem je zavolat příkazy automake a autoreconf (s doprovodnými příkazy) ve správném pořadí a delegovat své argumenty na nově vzniklý configure
.
cat priklady/blok2_automake/autogen.sh
Makefile vygenerovaný automake přidal do našich pravidel ještě několik dalších cílů.
Jedním z nich je i cíl install
, který je odpovědný za instalaci do cílového umístění v systému.
Ne vždy ale chceme nutně instalovat přímo do prostředí ve kterém proběhlo sestavení.
Proto je s tímto spojena i proměnná DESTDIR
, která udává cílový adresář do kterého instalace proběhne.
Nadále ale bude projekt odkazovat (potenciálně absolutní) cesty dle původního prostředí sestavení.
K úpravě cest slouží přepínač configure --prefix=cesta
, který se stará o nastavení pozic do podadresářů v cílové instalaci.
Pokud tedy bude sestavený program pojmenovaný uuid_demo
, bude jeho lokace v cílovém systému /usr/bin/uuid_demo
(přičemž cesta /usr
je prefix).
Pokud bychom jej tedy chtěli instalovat přímo v lokálním systému do systémových cest, můžeme provést make install DESTDIR=/
.
Autotools jsou nicméně velmi silně spjaty s komunitou okolo GNU. Toto je konec konců patrné i na přístupu ke shellu, skriptům a dokumentaci vyžadované automake. Jednou z alternativ, dále rozvíjejících myšlenku "popis, nikoliv instrukce", je CMake.
CMake zakládá popis závislostí a součástí do jediného textového souboru, CMakeLists.txt
, přičemž (sic to není vyžadováno), odděluje adresář se zdrojovými kódy od adresáře ve kterém probíhá sestavení (build
, obecně builddir).
V tomto adresáři si CMake vygeneruje podobnou adresářovou strukturu jako má původní projekt, přičemž jejímu kořenu vládne textová cache proměnných.
Do této si CMake poznačuje jednak volby zadané při prvním průchodu konfigurací, druhak i automaticky zjištěné hodnoty.
Z této cache pak CMake vychází když generuje Makefile v odpovídajících podadresářích.
Jinými slovy, CMake explicitně odděluje průzkum systému a prostředí pro které se bude sestavovat od generování Makefile.
Tento přístup nabízí některé výhody, mimo jiné i rychlejší reakci na změny v projektu.
Po stránce vnitřní architektury mají jednotlivé výstupní artefakty vlastní cíle, ke kterým se váží přepínače, nastavení a (typované) proměnné.
Tyto je možno buď explicitně poznačit k uložení v cache, nebo je použít jen jako neveřejné.
Krom proměnných nastavených přímo v CMakeLists.txt
jsou jimi proměnné samotného CMake (např. CMAKE_BUILD_TYPE
), nebo také proměnné nastavené jednotlivými balíčky a pomocnými moduly.
V řadě situací si bohatě vystačíte s předinstalovanými moduly, případně moduly které CMake vygeneruje ke knihovnám jím samotným sestaveným. Přesto není občas zbytí (zejména pokud křížíte buildsystémy) a nezbývá než si vlastní modul napsat.
Zatím co rozšíření funkcionality configure stálo na shellových skriptech, resp. dodatečných makrech pro autoconf, je použití CMake v některých ohledech přímočařejší.
Doplňkové moduly pro CMake totiž bývají uloženy v souborech tvaru Find<Module>.cmake
, přičemž tyto CMake zpracuje v okamžiku, kdy se v CMakeLists.txt objeví řádek tvaru find_package(Module [options])
.
Samotný CMake je ale svou interní strukturou relativně omezený, přesto však dovoluje spouštět i externí programy.
V tomto směru budiž čtenář odkázán na dvě nápovědy na konci této kapitoly a soubor FindPkgConfig.cmake
(na Linuxu typicky instalován v /usr/share/cmake/Modules/UsePkgConfig.cmake
.
Po nestandardních modulech se pak CMake typicky poohlíží v podadresáři projektu cmake
, tedy za předpokladu že na tento nasměruje CMakeLists.txt proměnnou CMAKE_MODULE_PATH
.
Proměnné v cache lze upravovat a nastavovat 3 způsoby:
cmake -DCMAKE_C_COMPILER=clang
Toto typicky znamená nutnost přegenerovat (i kdyby jen částečně) cache a v případě ccmake i explicitní pokyn pro generování Makefile. V případě cmake pak může být potřeba spustit dvakrát tentýž příkaz.
Pozor, změna překladače typicky vede k zahození celé cache, včetně dříve ručně nastavených proměnných.
Konečně, příklad výše sepsán pomocí CMake vypadal by takto:
cat priklady/blok2_cmake/CMakeLists.txt
pushd priklady/blok2_cmake > /dev/null
mkdir -p build
cd build
cmake ..
make
./uuid_demo
popd > /dev/null
Podmíněný kód pak můžeme do CMakeLists.txt vložit podobně pohodlným způsobem, jako v případě configure.ac. Varianta příkladu s volitelnou podporou libuuid je přibalena v repozitáři také.
cat priklady/blok2_cmake_cond/CMakeLists.txt
pushd priklady/blok2_cmake_cond > /dev/null
mkdir -p build
cd build
cmake -DUUID_ENABLED=NO ..
make
./uuid_demo
popd > /dev/null
To poslední, stran závislostí, co zatím nezaznělo zní: "Kde se berou?" Doposud jsme totiž vždy měli knihovny instalovány lokálně a buildsystém pouze konfiguroval již přítomné. Současně známe i nástroje, které umí instalovat software z online katalogů (vzpomeňte už jen obyčejný PIP). Takový software je známý jako manažer balíčků, katalogu balíčků pak repozitář. V tradičním modelu, který můžete znát z Linuxových distribucí, je balíčkovací systém oddělený od buildsystému.
Třetí typ buildsystému tak přebírá nejen roli samotného sestavování, ale i mapování závislostí. Příklady takových buildsystémů jsou npm nebo maven (s tím se můžete blíže seznámit v PB162).
Zamysleme se na chvíli - jak se vlastně do programů dostanou chyby? Zdrojů je více, ale můžeme sestavit orientační soupisku:
Podobně, jako u leteckých nehod jde ale často o souběh více okolností. Což ovšem nic nemění na tom, že chyby je potřeba řešit. Nebo ještě lépe, jim předcházet. A právě o tom bude tento blok.
Počítače, tak jak je dnes známe a používáme jsou v principu deterministické stroje. Vykonávají jednotlivé instrukce, operující nad daty v paměti. Přesto se ale setkáváme s nahodile se vyskytujícími chybami. Odkud se tedy může v (jinak) deterministickém systému vzít náhoda?
Odpovědí je samotná fyzikální podstata, tepelný šum a částice s vysokou energií. Díky tomu může dojít k chybě v jediném bitu, který ale může zcela změnit obsah paměti - uvažte pouhé přehození znaménkového bitu celého čísla.
Většina (nedeterministických) chyb se kterými se ale v praxi setkáte má daleko prozaičtější zdroj:
Deterministické chyby jsou oproti tomu relativně nudné, k jejich replikování v laboratorních podmínkách typicky stačí jen znát správné vstupy a prostředí. Samy o sobě nicméně nestačí, pokud by byly překladače jen pouhými přepisovači kódu z vyšších jazyků do nižších, byly by výsledné programy velmi pomalé, s velkou fluktuací koncového výkonu. Jinými slovy, je nutno brát v úvahu i stupeň optimalizace vykonávaného kódu překladačem. Spuštění v debuggeru tak, abychom mohli prozkoumat stavy jednotlivých proměnných může vést k nutnosti optimalizace vypnout a tak nemusí dojít k replikaci chyby. Překladače by nicméně měly i při kompilaci s optimalizacemi zachovávat ekvivalenci operací v optimalizovaném i neoptimalizovaném kódu.
Nedeterministické chyby zahrnují to vše a k tomu navíc náhodu. A tak se v praxi můžete setkat i s několika neformálními označeními, typicky pojmenovaných po velikánech fyziky.
Díky svému významu tyto dva typy chyb mívají paměťové a synchronizační chyby v řadě případů i specializované debuggery -- memory debugger a concurrency debugger. Jako příklad memory-debuggeru můžete uvažovat (snad již dostatečně) známý Valgrind či cuda-memcheck. Jako příklad concurrency debuggeru pak lze uvažovat např. Valgrind s nástrojem Hellgrind, či již dnes nedostupný Jinx.
Principiálně jsme nicméně nadále v situaci, kdy se chyba může, ale nemusí projevit. Právě technikám, které se snaží detekovat výskyt chyby za běhu, říkáme dynamická analýza.
Její protějšek, statická analýza program nespouští. Buduje si postupně síť vzájemných ovlivnění operací a proměnných, kterou porovnává s modelem fungování programovacího jazyka. Samozřejmě, technické možnosti - zejména u systémů navržených k potenciálně nekonečným běhům - jsou poněkud omezené. Přesto však nabízí statická analýza možnosti, jak hledat chyby i v kódu, který se nemusí vykonat nikdy.
Pro úspěšné vyřešení bugů je ale klíčová i jiný myšlenka. Tou je replikovatelnost. Řada bugů totiž závisí na konkrétním prostředí, časování, paměťovém rozložení a použitých datech. Pokud by nezávisela, nejspíše by již byla objevena nástroji statické kontroly před testováním a dynamické kontroly při testování.
Co tedy můžeme udělat (jako uživatelé) proto, aby námi zpozorovanou chybu mohli vývojáři replikovat?
Ze strany vývojáře či managementu kvality je pak potřeba mít připraven systém pro reportování chyb. Toto dnes typicky obnáší nějaký bug tracker (např. Bugzilla, mantis, GitHub/GitLab/BitBucket), ve kterém mohou uživatelé zakládat tickety k chybám. Nenechte se ovšem zmýlit, takový systém nemusí mít jen podobu webového formuláře, používány jsou i maillistové fronty (rt), či privátní systémy. Co by tedy měl takový sytém umět?
Dále lze učinit opatření, která jdou chybovým hlášením naproti - kupříkladu skript, který v prostředí posbírá relevantní informace a připraví bundle k odeslání a analýze. Nebo šablonu k vyplnění v ticketovacím systému.
Ač je mnohým jistě radostí jen tak něco programovat, spoléhaje se na instinkt a doplňovat program podle toho co by se zrovna hodilo, praxe bývá zpravidla jiná. Někdo (říkejme mu třeba zákazník) má nějakou potřebu, kterou se rozhodne realizovat naším prostřednictvím. Pak ale musíme vědět, co máme dělat; někdo systém navrhne, rozepíše jednotlivé požadavky a nakonec vytvoří funkční specifikaci pro jednotlivé dílčí funkcionality. Testování samo o sobě pak dělíme dle granularity funkcionality a toho, zda máme či nemáme přístup ke zdrojovým kódům:
Opíráme se (takřka pouze) o specifikaci, do implementace nevidíme. Můžeme ale analyzovat specifikaci, najít okrajové podmínky chování a na základě této analýzy definovat konkrétní testovací vstupy a výstupy, v souladu se specifikací. Pokud tedy specifikace říká, že vstupem z jejího pohledu jsou argumenty funkce, budou jednotlivé dílčí testy (test case) definovány jako hodnoty argumentů funkcí. Pokud specifikace hovoří o souboru na vstupu, bude testovacím vstupem konkrétní obsah souboru. Analogicky pak můžeme hovořit o výstupech.
Pozor, vstupy nemusíme fixovat jen na konkrétní obsah - pokud má vstup či výstup splňovat konkrétní invarianty, můžeme testovat i tyto. Tohoto se zejména užívá při randomizovaném testování, kde se vstupy generují dle (předpokládaného) pravděpodobnostního rozložení. Takové testování nicméně klade značné nároky na logování, tedy zaznamenání konkrétních (fixních) vstupů a výstupů. Jinak nelze výstup interpretovat jinak, než "možná je to rozbité".
Velkou výhodou této formy testování je to, že zjišťujeme zda kód opravdu dělá to co má. Nevýhodou pak to, že může dojít k emisi příliš mnoha jinak zbytečně komplikovaných vstupů, testujících tytéž bloky implementace.
Oproti blackboxovému přístupu zde máme k dispozici zdrojový kód, tedy lze zamířit testy tak, aby došlo k spuštění každého bloku programu. U tohoto typu testů se nám automaticky nabízí vcelku přirozený pojem pokrytí větví (coverage), který udává procento testy vyzkoušených větví. Je ale nutné si uvědomit, že pokud se do zdrojového kódu podíváme, nevyhnutelně jsme jím ovlivněni a snadno se stane, že začneme vstupy uzpůsobovat kódu, vzdalující se specifikaci.
Druhým významným pojmem spojeným s whitebox testy jsou unit (jednotkové) testy. Tyto se opírají o konkrétní funkce (v zásadě každou, i pomocné, avšak nikoliv nutně) a operují s uměle sestaveným stavem paměti, nad které má být podprogram vykonán, přičemž vzájemné volání funkcí je spíše minimální. Jejich pojmenování pak odkazuje k překladovým jednotkám, které můžete znát jako jednotlivé soubory se zdrojovým kódem. Nad jednotkovými testy jsou pak testy integrační, které testují propojení a spolupráci jednotek (komponenty vnitřně). Integrační testy předpokládají, že jednotlivé dílčí funkce již byly otestovány jednotkovými testy. Výše se pak nachází systémové testy, které - jak již název napovídá - testuje celý systém. A konečně, end-to-end testy, které testují jednotlivé interakční scénáře, včetně propojení s jinými systémy. V praxi se nicméně (nejspíše) potkáte nejčastěji s unit testy.
Protože oba přístupy mají své výhody i nevýhody, setkáte se v praxi s graybox testováním, které spojuje výhody i nevýhody obou systémů. Máte tak například k dispozici implementaci a využíváte i informaci o pokrytí, ale vstupy definujete hrubozrnné (tedy ne pro každou funkci a proti specifikaci).
Zatím co u interpretovaných jazyků lze získat informaci o pokrytí pouhým provedením testů, pouze přepnutím režimu interpretu, vyžadují kompilované jazyky (nejen tyto) instrumentaci, byť automatizovanou překladačem.
Pokud uvážíme toolchain překladače gcc, je dvojicí nástrojů které nám podají smysluplnou informaci dvojice gcov
a lcov
.
Tato ukázka staví nad kódem 4. cvičení předmětu PB071, obohaceným o Makefile pro získání informace o pokrytí.
GCC nejprve přidá do binárního kódu informace o blocích, větveních, ze kterých vykonávaná část pochází. Tyto jsou doplněny o čítače četností průchodů (tedy proměnná pro každou hranu mezi větveními). Při spuštění takto upraveného programu je pak zapsán soubor, obsahující tyto pomocné proměnné (resp. aktualizován o aktuální běh). Tuto informaci je nutno dále zpracovat.
pushd priklady/blok3_code_pb071_sem4 > /dev/null
cat Makefile
printf "\n\ncommence make\n"
make
popd > /dev/null
Samotný gcov
vypisuje pouze souhrn informace o navštívených větvích, který nemusí být přehledný.
Proto je za něj zařazen lcov
, který načte data gcovu a vizualizuje je formou statické webové stránky.
Pro usnadnění je tedy vhodné utility kombinovat.
Vraťme se na chvíli k hlášení chyb. Uvažme situaci, kdy byl ve vašem produktu objeven bug. Tento se vám povede zreplikovat, z bugu se tak stane potvrzený bug. Vaším cílem - zcela přirozeně - je tento opravit. Avšak pokud půjde o nějaký komplexnější problém, může se snadno stát že se časem objeví znovu, postupně navrácen na první pohled nesouvisejícími změnami. Takový stav je znám jako regrese (bugu); stav, který jen velmi neradi vidíte.
Regresní testování je tedy technika, kdy se proaktivně bráníte regresi tím, že pro každou (velkou) změnu vytvoříte test, jehož cílem je detekovat situaci kdy je rozbito něco, co (dříve) fungovalo. U velmi striktního výkladu tak jde o testy proti každému v minulosti učiněnému commitu.
V praxi je nicméně regresní testování vyskytuje jako součást řízení chyb, tedy regrese bugů.
Z historie jsou známy případy, kdy jinak vhodná praxe byla zničena, ať už technickým či manažerským rozhodnutím. Není pro ně nutno chodit daleko. Kupříkladu v roce 2012 se firma Oracle rozhodla zrušit framework mysql-test, který byl de facto databází regresních testů k databázi MySQL (tuto Oracle získal jako vedlejší projekt při akvizici společnosti Sun).
Pokud se nicméně budeme držet slovníku odborné společnosti, resp. její starší verze můžeme se opřít o pojmy:
Testovacích frameworků je pochopitelně celá řada, dají se ale rozdělit do několika rovin, uvažme dvě základní:
Často frameworky zavádí i vlastní pojmy a doplňující názvosloví, i dle zvyklostí konkrétních jazyků - např. fixture - obslužné rutiny vykonávající přípravu počátečního stavu (setup) a úklid po testu (teardown). Mimo samotnou organizaci spouštění jednotlivých testů je u dobrého testovacího programu žádoucí ještě jedna vlastnost - izolace jednotlivých testů. U jazyků s přímou možností ovlivnit obsah celé paměti (C/C++ a obecně jazyky s ukazatelovou aritmetikou) jde zejména o to, aby jeden test nerozbil ostatní, i když sám projde (pokud program obsahuje paměťovou chybu, je výsledek nedefinovaný - včetně předstírání že vše funguje). Obecně pak jde o kompenzaci efektů hystereze (jev, kdy k návratu do původního stavu nestačí pouze udělat všechny kroky v obráceném pořadí - například kvůli vnitřním stavům a testům skrytým proměnným). Toto typicky znamená spouštění testů v samostatných procesech.
Testování software vzniklo jako snaha minimalizovat škody, způsobené nekvalitním dílem. A podobně jako v jiných oborech, i zde se časem objevila snaha garantovat profesní kvality testerů - tedy vzniku různých profesních komor.
V případě testerů se můžeme bavit o International Software Testing Quality Board - mezinárodní těleso, které se stará mimo jiné o certifikaci testerů. Právě požadavky na certifikáty této organizace tvoří často část popisků pracovních míst pro QA (quality assurance) pozice.
Dále (doplněno po přednášce M. Drengubiaka) se můžete v Brně setkat s:
U funkcí (i těch testovaných) se můžeme bavit o více různých vlastnostech. Za poznámku stojí zejména:
Tento blok, ač se možná nezná, bude hodně o Dockeru. Protože věci zjednodušuje. Tedy, pokud s ním umíte.
Začneme ovšem poněkud z jiného soudku - místo toho abychom diskutovali jak zařídit aby aplikace fungovala se budeme bavit o tom, jak zařídit aby nefungovala. Řeč je o tom, co má ve slangové češtině označení k**vítko, odborněji killswitch.
Killswitch je prostředek, jak zajistit že od určitého okamžiku nebude daná věc fungovat. Řada těchto prostředků byla implementována s cílem aplikaci či zařízení vyřadit z provozu, ať už z důvodu generační obměny, nebo jako pojistka proti neoprávněnému užití. Nenechte se nicméně zmást, killswitch není nutně jen software, má své hardwarové ekvivalenty, i v podobě součástek, které časem odejdou.
Krásným příkladem z oblasti hardware je elektrolytický kondenzátor. Časem (a ano, nevhodným umístěním - např. poblíž zdroje tepla - lze toto urychlit) dochází k vysychání elektrolytu v kondenzátoru, až tento jednoho dne přestane fungovat. Funkce zařízení je pak buď významně (kvalitativně) omezena, nebo selže zcela. Nemusí přitom jít vůbec o úmysl, některé součástky mají jednoduše řečeno životnost danou svým principem a na místě potřeba jsou.
Jindy jde o důsledek dopadu protipirátské ochrany. Pokud byste dnes chtěli aktivovat čerstvou instalaci Windows XP v prostředí bez sítě, budete mít problém. Telefonní čísla, uvedená v pokynech k aktivaci, již nejsou provozována. I "neomezená" licence na software nemusí stačit, pokud jsou aktivační servery výrobce vypnuty či jejich DNS záznamy zrušeny.
Pro úplnost, využití killswitche může být i pro obecně prospěšnou věc, nikoliv jen vynucování ekonomických záměrů výrobce. Takřka každý větší obchod s aplikacemi obsahuje kód pro vzdálené vyřazení aplikace, která byla auditem označena jako škodlivá. I toto je forma killswitche.
Killswitch ale nemusí být spojen ani s ochranou práv dodavatele. Může jím být i jinak neškodný mechanismus, který má naopak chránit uživatele. Součástí předinstalovaných distribucí Androidu bývá framework Google Play Services (balík gms), jehož součásti jsou klíčové pro běh aplikací Google na telefonu. Tento balíček je z výkonnostních důvodů kompilován nativně - běží tedy přímo na CPU, nikoliv v rámci Dalvik VM (de facto Java Virtual Machine). Součástí balíčku Play Store je pak služba, která na pozadí tento balíček aktualizuje, s možností vynucené aktualizace kvůli bezpečnostním záplatám. Jinými slovy, Play Store se stará o to, abyste měli ozáplatovaný runtime od Google.
Přesto se může tento subsystém stát killswitchem.
Když se Google rozhodl vyřadit podporu pro Armv6 z Androidu (2016), došlo u některých balíků (v závislosti na nastavení buildsystému) ke změně verze v manifestu.
Místo přechodu armeabi
(Armv6) a armeabi-v7l
(Armv7) došlo k produkci armeabi
(Armv7) a armeabi-v7l
(Armv7).
Jedním z ovlivněných balíků byly i právě Google Play Services.
A ty začaly aktualizovat z gms-9.2.56
na gms-9.4.52
.
Pokud by bylo možno aktualizaci zakázat, měl by problém snadné řešení.
Takto byl ale důsledek (v kombinaci s automatickým restartem spadnuvších služeb) jiný - telefony přestaly přecházet do režimu spánku a naopak se začaly přehřívat a vybíjet baterie.
Snad nezamýšlený, přesto killswitch.
To s jakou motivací kdo killswitch vytvoří - nechám na něm. K vám mám ale jeden apel - těm nezáměrným se vyhýbejte, a u těch záměrných se postarejte o to, aby - až přijde čas - nebyly překážkou. Uložte si obrazy aktivačních serverů, místo IP adres se opírejte o doménová jména. Používejte tlačítka živosti, která - v případě že už tak nebudete sto sami učinit - alespoň uvolní vaše dílo světu.
Pokud se podíváme na historii výpočetní techniky optikou 20. století, jsme hodně vázáni jednotlivými platformami. Zejména zúčastněným hardware. Vývoj programovacích jazyků, od strojového kódu přes symbolické adresy, knihovny a extrakci funkcionality do operačních systémů a dnešních vysokoúrovňových jazyků - to vše šlo cestou pryč od hardware. Cestou zvyšování abstrakce, oddělení hardware a software. Toto umožnilo zkrátit vývojový cyklus a obecně jej zlevnit - přece jen, snáze se upravuje software, než hardware.
Tohoto faktu si nicméně byli vědomi i výrobci hardware, a tak se začaly objevovat abstrakce nejen v software, ale i v doméně ryze fyzické - hardware. Vyčlenění operací s plovoucí řadovou čárkou do koprocesoru byl jen první krok. Brzy přišly další. Konec konců, díky tomu že se CISC systémy inspirovaly růstem RISC se objevil mikrokód (o kterém jste s trochou štěstí již slyšeli od doc. Brandejse). Tedy, instrukční sada procesoru se stala do značné míry virtualizovanou.
Proč se ale zastavovat? Proč nevyužít například myšlenku FPGA k tomu, aby si procesor nakonfiguroval architekturu vhodnou pro konkrétní úlohu? Ač toto může znít nezkušeným jako hudba budoucnosti, faktem je že tyto technologie jsou přítomny již dnes, byť stále limitovány cenou. A kdo tomu nevěří, ten ať raději nehledá pojmy jako "Management Engine" či "Active Management Technology".
Zkrátka a jednoduše, dnes již nelze tak snadno nakreslit čáru mezi hardware a software.
Pokud svolíme z požadavku, aby naše CPU bylo fyzické, dostáváme se do oblasti virtualizace či emulace. Inu, pro pořádek:
V případě jádra OS Linux to znamená, že jsou klíčové prvky, klíčové subsystémy, rozděleny a (za běhu) duplikovány. Aby se zabránilo konfliktům mezi duplikáty, jsou tyto odděleny. Tomuto konceptu říkáme jmenné prostory. Jejich účelem není nic jiného, než izolovat instance abstrakcí zdrojů.
Mezi základní jmenné prostory Linuxového jádra patří:
Spuštěný proces z těchto prostorů získává zdroje, které se k němu váží. Ač lze (relativně) snadno spravovat mapování ručně pomocí virtuálního souborového systému sysfs, je přece jen pohodlnější spravovat kontejnery nějakým nástrojem, kterému stačí říci co chceme, nikoliv nutně jak to má zařídit.
V současnosti nejspíše nejrozšířenější technologií pro správu kontejnerů je Docker. Tento se dočkal rozšíření i na platformy OSX a Windows. Zatím co na OSX je nutný mezilehlý virtuální stroj, a tak nelze připojovat svazky z hostitele bez komplikovaných hacků, ve Windows je nyní v aktuálních verzích již nativní podpora. Nás ale bude zajímat primárně domovský systém Dockeru - Linux, sice pro dostupnost plného portfolia toho, co Docker umí.
Architektonicky se Docker sestává z následujících částí:
dockerd
, jehož rolí je vytvářet samotné kontejnery a umisťovat je do relevantních prostorů, přidělovat jim počáteční zdroje.docker-proxy
, jehož úlohou je vystavit konkrétní síťové porty (tcp/udp) na hostiteli dovnitř kontejneru.docker nástroj [argumenty]
, podobně jako to již znáte z gitu.Kontejnery vznikají tak, že se vybalí do podadresáře v cestách dockeru postupně jednotlivé vrstvy obrazu kontejneru.
Tyto jsou tvořeny archivy se změnami v souborovém systému oproti předchozí vrstvě, doplněné popisným manifestem.
Obraz je pak právě kolekcí vrstev doplněnou o metadata.
Vytvoření obrazu je řízeno konfiguračním souborem - Dockerfile
.
Z konfigurace se pak obraz sestaví příkazem docker build -t smysluplne_jmeno .
pushd priklady/blok4_docker_01_simple > /dev/null
cat Dockerfile
printf "\n\nA nyní build\n"
docker build -t pb176-blok4-docker-01-simple .
popd > /dev/null
Přepínač -t
udává tag, označení které nám napomáhá v orientaci mezi jednotlivými obrazy.
Jako identifikátory totiž docker běžně používá hashe právě obrazů, resp. konfigurací kontejnerů.
Tag je ve formátu název[:verze], přičemž pokud není uvedena konkrétní verze, je automaticky uvažována verze latest
.
Jeho konkrétní popis získáte příkazem man docker-image-tag
.
Užitá konfigurace obnáší tyto direktivy:
scratch
.ADD
a COPY
platí, že co direktiva, to pomocná vrstva.docker container run --rm -i -t tag argument1 argument2
, jsou argument1
a argument2
předány entrypointu.Zatím co docker build
obrazy vytváří, docker run
je spouští (pro úplnost, totéž - jen trochu jinak - dělá i příkaz docker container start
).
Podobně jako u gitu, i zde získáte nápovědu příkazem man docker-příkaz-podpříkaz
.
Co ale znamenají jednotlivé argumenty runu?
-i
- kontejner bude mít připojen stdin, stdout a stderr.-t
- kontejneru bude přidělena tty (virtuální terminál).--rm
- ukončením entrypointu dojde ke smazání kontejneru a jeho dat.
Opětovné zavolání příkazu docker run
povede k vytvoření nového kontejneru, nezávislého na historii právě zaniknuvšího kontejneru.Ve specifikaci nám ale schází jakákoliv zmínka o uživateli, znamená to snad že proces v kontejneru běží pod identitou uživatele, který run zavolal? Nikoliv, pamatujte - jde o jiný jmenný prostor. Běží pod superuživatelem (byť s některými omezeními). To se ale dá snadno změnit.
pushd priklady/blok4_docker_02_user > /dev/null
cat Dockerfile
printf "\n\nA nyní build\n"
docker build -t pb176-blok4-docker-02-user .
popd > /dev/null
Oproti předchozímu příkladu je zde zavedeno hned několik direktiv navíc. Tentokrát je okomentujme od konce:
WORKDIR
, tak USER
mohou být v Dockerfile uvedeny opakovaně, s platností počínaje místem uvedení dále. RUN chown
, RUN useradd
- správa uživatelů a práv uvnitř kontejneru.
Tímto způsobem je i vytvořen uživatel, který bude později uveden v USER
.Tento příkaz ale není bezpečný, jakmile se totiž něco jednou stane součástí vrstvy, už jí navždy zůstane - a tedy i nakopírovaný privátní klíč.
Tomu se nelze vyhnout, ale lze to moderovat.
Pro začátek můžeme uvážit další z vlastností dockeru - buildargs.
Argumenty se použijí v době sestavení obrazu, na rozdíl od proměnných prostředí se ale nutně nemusí stát jeho součástí.
Prostudujte si prosím příklad níže, včetně dalších obrazů které sestaví skript build.sh
v odpovídajícím adresáři.
Ke studiu budete potřebovat ještě příkazy docker image inspect
a docker image save
, se kterými vás blíže seznámí jejich manuálová stránka.
Přečtěte si i samotný skript build.sh
.
pushd priklady/blok4_docker_03_buildargs > /dev/null
cat Dockerfile.2
printf "\n\n\nA věru nebezpečná varianta\n"
cat Dockerfile.3
popd > /dev/null
V těchto souborech jsou 2 nové direktivy:
docker run
.
Jejich hodnoty platné v době vytváření obrazu jsou zařazeny do metadat obrazu.
Neměly by tedy obsahovat citlivé údaje.
Při příkazu docker run
se nastavují přepínačem -e
.Jako nejlepší řešení se nicméně jeví použít vlastnost novějších verzí dockeru - multistage Dockerfile
.
Ten oproti klasickému Dockerfile
obnáší více direktiv FROM
, přičemž do koncového obrazu jsou zahrnuty pouze ty vrstvy, které jsou přímo dosažitelné z poslední direktivy FROM
a vytvořené jí následujícími příkazy.
To, co potřebujete skrýt tak provedete v první části, pak pomocí nového FROM
založíte nový obraz a z předchozího stage jen vykopírujete konkrétní soubory pomocí upravené direktivy COPY
.
pushd priklady/blok4_docker_04_multistage > /dev/null
cat Dockerfile
printf "\n\nA nyní build\n"
docker build -t pb176-blok4-docker-04-multistage .
popd > /dev/null
Zatím jsme pracovali s intuitivním významem slova kontejner. Pojďme jej ale ukotvit. Kontejner je proces a prostředí ve kterém běží, izolovaný od zbytku OS (a jeho prostředí). Jinými slovy, kontejner existuje jen tak dlouho, dokud běží jeho entrypoint. Jeho doběhnutím je kontejner zastaven, opětovným spuštěním vzkříšen.
Docker nabízí nástroj, jak se do prostředí kontejneru dostat i ze strany - pokud běží entrypoint, můžeme použít příkaz docker exec
, který nám umožňuje v prostředí běžícího kontejneru pustit pomocné procesy, prohlédnout data aj.
Docker(d) jako takový se stará nicméně jen o spuštění kontejneru, nijak jej dále nekoordinuje1. Umožňuje ale zakonzervovat prostředí ve kterém služba běží. V důsledku toho je tak trochu jedno, kde konkrétně běží. A to nás přivádí k orchestrátorům. Zjednodušeně řečeno, orchestrátory jsou služby hlídající kontejnery, distribuující je mezi zúčastněné uzly a starající se, aby byla služba dostupná v nějakém předvoleném režimu. Orchestrátory nicméně nejsou předmětem tohoto kurzu, setkáte se s nimi (docker swarm) ale například v PB138.
Toto není pravda, ale patří mezi lži-dospělým↩
Tedy až na docker-compose
.
Docker-compose je orchestračním nástrojem, který vám umožní mezi sebou koordinovaně provázat skupinu kontejnerů tak, aby vytvářely jednu logickou službu (např webový server + PHP engine + MariaDB/MySQL server).
Naneštěstí, verzování compose a Dockeru není navzájem zrcadleno, byť koná podobné inkrementální kroky.
Jeden z hlavních důvodů proč se docker-compose používá i v malém je že Vám umožní místo opakovaného ručního pouštění kontejneru s mapováním portů, svazků a sítě ručně spouštět jen příkaz docker-compose up
.
Toho je dosaženo díky konfiguračnímu souboru docker-compose.yml
, který podrobněji popisuje vazby mezi kontejnery a jejich nastavení.
Po syntaktické stránce jde o formát YAML, který sice vyžaduje trochu cviku, ale strukturálně se podobá známějšímu formátu JSON (je nicméně člověku čitelnější).
Vzpomeňme příklad s kontejnerem z minulé přednášky (notebook v kontejneru).
Pokud jsme chtěli namapovat adresář hypervizora dovnitř kontejneru, bylo nutno zadávat ručně větší množství přepínačů.
Takto pro uvedený příklad vypadá jeho compose (#
je zakomentovaný řádek):
cat priklady/blok4_compose_01_simple/docker-compose.yml
Ve výchozí konfiguraci mívají kontejnery vyhrazen diskový prostor v rámci hostitele (typicky /var/lib/docker
).
Aby nebyl zbytečně zabírán cenný diskový prostor, je využit princip copy-on-write - data obrazu jsou uvažována jen pro čtení a nad obrazem je pro každý kontejner vytvořen overlay, do kterého probíhají zápisy.
Overlay ale můžete využít i pro vlastní potřeby a vlastní data. Kupříkladu PostgreSQL server má onu nepříjemnou vlastnost, kdy potřebuje možnost zapisovat i do databáze, kde jinak probíhá jen čtení. Obyčejné vykopírování tak nepřipadá v úvahu, neboť by se nám kopírované soubory měnily pod rukami. Nezoufejte, můžete totiž:
mount -o lowerdir=data_ro,upperdir=upper,workdir=work none data
).Díky tomuto lehkému triku pak nemusíte neustále přesouvat velké objemy dat, ale pouze propagovat overlay do původní kopie.
Jde nicméně o špinavý hack, věřím že zálohování databází nebudete muset nikdy1 řešit.
pushd priklady/blok4_compose_02_multi > /dev/null
printf "Služba pro systemd\n"
cat mtbmap-cz.service
printf "\n\nA odpovídající compose\n"
cat docker-compose.yml
popd > /dev/null
Přemýšleli jste někdy, odkud se vzala jména jako "Bionic Beaver", "Spherical Cow", či "Ice Cream Sandwich"? Takové kódové označení má několik základních rolí:
Zatím co kódové označení je tedy spíše nějakým souborným označením, verzovací čísla mají za úkol zcela jednoznačnou identifikaci verze (podobně jako hash commitu). Na rozdíl od hashů commitů ale slouží i pro orientaci člověka - kýženým prvkem je tedy i pozvolný nárůst a snadná rychlá lidská i strojová orientace.
Existuje mnoho různých, více či méně logických schémat. Podívejme se ale na pár nejčastějších.
MAJOR.MINOR.REVISION
; přičemž:MAJOR
rozbíjí kompatibilitu (zpětnou), či je jiným významným milníkem (např. přidání nové platformy, změna šifrovacího schématu, aj.).MINOR
typicky značí přidání funkcionality, bez rozbití kompatibility.REVISION
typicky řeší nějaký bug. Je také známa jako PATCH
MAJOR.MINOR.REVISION-a.1
- a
pro alfa, b
pro beta....90+
- např 5.4.98
.5.5.0-rc4
).CURRENT:REVISION:AGE
, typicky používaná pro verzování API dle Libtool ; přičemž:CURRENT
.REVISION
značí revizi, rozšíření existujícího API bez nutnosti úpravy vazeb a kódu.AGE
- říká s jak starými implementacemi je API kompatibilní.
Jinými slovy, s aktuální verzí budou fungovat klienti předpokládající verzi CURRENT
-AGE
, CURRENT
-AGE
+1 .. CURRENT
.(YY)YYMMDD
, případně doplněno o číselnou revizi či hash commitu: 2021-05-16.01-deadbeef
.YY.MM[.REVISION]
ostré vydání je číslováno 2 čísly, navazující LTS (long-term support) pak třemi; tedy Ubuntu 18.04 (Bionic Beaver) se mění v Ubuntu 18.04.1 LTS (Bionic Beaver).
Tento systém je pak dvojnásob verzovaný i v tom smyslu, že přídavné jméno kódového označení starší verze lexikograficky předchází přídavné jméno kódového označení verze novější.Jako speciální případ pak platí číslování nightly buildů. Nightly build - jak už název napovídá - je každonoční sestavování projektu z jeho aktuálních zdrojových kódů. Toto má dvě základní výhody.
Představují nicméně drobný oříšek z hlediska číslování verze.
Nadále bychom totiž rádi udrželi žádoucí vlastnosti, jako možnost jednoznačně najít párování na zdrojový kód či obratem říci, která verze je novější.
Můžeme ale zkombinovat přístupy výše.
Není tak výjimkou, že se setkáme s verzí tvaru 1.5-nightly-20210511-deadbeef
.
Pravda, je malinko delší, ale identifikuje jednoznačně (1.5 je v tomto případě označení typicky větve ze které se vychází, nikoliv příští).
Takové číslo můžeme snadno získat i v configure.
pushd priklady/blok4_nightly > /dev/null
autoheader && autoreconf
./configure --prefix=/usr && make
printf "\n\nVýsledek:\n"
./uuid_demo
popd > /dev/null
Možná už jste si někdy kladli otázku "Kdo hlídá hlídače?" Na úrovni zdrojového kódu, ze kterého lokálně zkompilujete systém (nemusíme hned propagovat Gentoo Linux je to snadné, řada lidí ale nemá ani zdroje, ani čas, ani odbornou kapacitu se o vlastní stroj takto starat. A tak sáhne po binárním systému. Jak ale garantovat že dotyčné binární soubory opravdu pochází z proklamovaných zdrojových kódů, například při bezpečnostním auditu? Že vývojáři nikdo nezaviroval stroj a že při zveřejňování binárních souborů v tichosti nešíří vir?
Řešením je reprodukovatelné sestavení, tedy takové podchycení událostí a proměnných reálného světa, které mohou ovlivnit výsledek. To na první pohled vypadá jednoduše, je tomu ale opravdu tak? Stačí se podívat, co vše mohlo být jinak v příkladu výše.
.git
- například kvůli exportu repozitáře do zipu.Je tedy nezbytné naprosto přesně zmapovat co do sestavení vchází a v jakých verzích. Jaké byly přepínače konfiguračního skriptu, neponechávat věci autodetekci jako explicitnímu povolení/zakázání. V tomto směru může pomoci použití zafixované sady nástrojů a závislostí, například kontejnerem někoho důvěryhodného.
P.S. Pokud by snad byly uváženy náhodnostní testy či otázky časových razítek, mějte je konfigurovatelné zvenčí a deterministické. Reprodukce postupu a dosažení identického výsledku za to stojí.