Największa zaleta Linuxa - jak wydajnie korzystać z terminala
Ostatnio miałem do uporządkowania spory zestaw plików muzycznych. Utworów było ponad tysiąc i w każdym z nich trzeba było zmodyfikować metadane. Nie wyobrażam sobie ile by to zajęło, gdybym zmian musiał dokonywać w jakieś graficznej aplikacji; każdy plik z osobna otwierać i schematycznie edytować. Na szczęście można to było zrobić w terminalu, a skoro edycje były schematyczne, to można je było zautomatyzować. Później przypomniałem sobie, że prowadzę tę stronę, na której wypadałoby coś od czasu do czasu napisać. Tak więc jest to idealna okazja, żeby podzielić się sposobami rozwiązywania tego typu problemów, z mniej obeznanymi w tekstowym środowisku użytkownikami. Chciałbym dzisiaj omówić podstawowe funkcjonalności basha (oraz innych, kompatybilnych powłok), takie jak pętle i zmienne. Istnieją również inne obszary usprawnień, poprawiających wydajność pracy w terminalu, takie jak użycie innej powłoki, używanie bardziej zaawansowanych skrótów klawiszowych, stosowanie lepszego autouzupełnienia, lub podświetlanie składni. To jest jednak temat na osobny wpis. Poniższy tekst nie zrobi z czytelnika eksperta, bo sam też ekspertem nie jestem. Ci jednak, którzy nie mają żadnego doświadczenia większego, niż wklejanie komend z internetu, z pewnością będą mogli się tu czegoś nauczyć.
Spis treści
Dane wejściowe
Na początek przydałyby się jakieś pliki, na których można by zaprezentować późniejsze przykłady. Stwórzmy folder “test”. W środku powinno się znajdować 100 podfolderów od “przyk 1” do “przyk 100”. W każdym z tych podfolderów niech będą 4 pliki testowe: “a.txt”, “b.txt”, “c.txt” i “d.txt”.
Folder tworzymy poleceniem mkdir "nazwa folderu"
(w cudzysłowie, jeśli używamy spacji w nazwie). Folder oraz podfolder za jednym razem można utworzyć z flagą -p
: mkdir -p "nazwa folderu/nazwa podfolderu"
. To są rzeczy dość znane. Coś, co jest mniej znane, to fakt, że w nazwie można stosować zbiory i zakresy. Tak więc utwórzmy nasz “test” z setką podfolderów, przejdźmy do niego i wyświetlmy zawartość:
mkdir -p test/"przyk "{1..100}
cd test
ls
W odpowiedzi powinniśmy dostać:
'przyk 1/' 'przyk 20/' 'przyk 32/' 'przyk 44/' 'przyk 56/' 'przyk 68/' 'przyk 8/' 'przyk 91/'
'przyk 10/' 'przyk 21/' 'przyk 33/' 'przyk 45/' 'przyk 57/' 'przyk 69/' 'przyk 80/' 'przyk 92/'
'przyk 100/' 'przyk 22/' 'przyk 34/' 'przyk 46/' 'przyk 58/' 'przyk 7/' 'przyk 81/' 'przyk 93/'
'przyk 11/' 'przyk 23/' 'przyk 35/' 'przyk 47/' 'przyk 59/' 'przyk 70/' 'przyk 82/' 'przyk 94/'
'przyk 12/' 'przyk 24/' 'przyk 36/' 'przyk 48/' 'przyk 6/' 'przyk 71/' 'przyk 83/' 'przyk 95/'
'przyk 13/' 'przyk 25/' 'przyk 37/' 'przyk 49/' 'przyk 60/' 'przyk 72/' 'przyk 84/' 'przyk 96/'
'przyk 14/' 'przyk 26/' 'przyk 38/' 'przyk 5/' 'przyk 61/' 'przyk 73/' 'przyk 85/' 'przyk 97/'
'przyk 15/' 'przyk 27/' 'przyk 39/' 'przyk 50/' 'przyk 62/' 'przyk 74/' 'przyk 86/' 'przyk 98/'
'przyk 16/' 'przyk 28/' 'przyk 4/' 'przyk 51/' 'przyk 63/' 'przyk 75/' 'przyk 87/' 'przyk 99/'
'przyk 17/' 'przyk 29/' 'przyk 40/' 'przyk 52/' 'przyk 64/' 'przyk 76/' 'przyk 88/'
'przyk 18/' 'przyk 3/' 'przyk 41/' 'przyk 53/' 'przyk 65/' 'przyk 77/' 'przyk 89/'
'przyk 19/' 'przyk 30/' 'przyk 42/' 'przyk 54/' 'przyk 66/' 'przyk 78/' 'przyk 9/'
'przyk 2/' 'przyk 31/' 'przyk 43/' 'przyk 55/' 'przyk 67/' 'przyk 79/' 'przyk 90/'
Teraz poleceniem touch
, w każdym z tych 100 podfolderów utwórzmy pliki tekstowe:
touch "przyk "{1..100}/{a,b,c,d}.txt
Polecenie tree
powinno nam zaprezentować następującą strukturę:
.
├── przyk 1
│ ├── a.txt
│ ├── b.txt
│ ├── c.txt
│ └── d.txt
├── przyk 10
│ ├── a.txt
│ ├── b.txt
│ ├── c.txt
│ └── d.txt
├── przyk 100
│ ├── a.txt
│ ├── b.txt
│ ├── c.txt
│ └── d.txt
├── przyk 11
│ ├── a.txt
│ ├── b.txt
│ ├── c.txt
│ └── d.txt
├── przyk 12
│ ├── a.txt
│ ├── b.txt
│ ├── c.txt
...
Przekierowywanie wyjścia komendy
Zazwyczaj polecenia wywołane w standardowy sposób wypisują swoje wyjście na ekranie. Jest to przydatne, jeśli chcemy wyjście po prostu przeczytać. Jednak w przypadku automatyzacji zazwyczaj chcemy to wyjście dalej przetworzyć. Możemy do tego zastosować kilka mechanizmów:
>
zapisuje wyjście do pliku, usuwając jego poprzednią zawartość. Po wywołaniuls > "lista katalogów.txt"
pojawi się w folderze plik tekstowy zawierający listę katalogów.>>
dopisuje wyjście na końcu jakiegoś pliku. Po wywołaniuls >> "lista katalogów.txt"
, w pliku tekstowym będzie znajdowała się powtórzona 2 razy lista katalogów.|
przekazuje wyjście polecenia na wejście innego polecenia. Wywołaniels | wc
spowoduje wykonania komendywc
(word count - liczenie słów) na liście katalogów.$(...)
zostanie zastąpione wyjściem polecenia znajdującego się w nawiasie. Poleceniedate
podaje aktualny czas, np.sob, 28 wrz 2024, 11:30:00 CEST
. Wywołanieecho "Teraz jest $(date)."
wyświetli zdanieTeraz jest sob, 28 wrz 2024, 11:30:00 CEST.
.
Pętla for
No więc zajmijmy się naszymi plikami a, b, c i d. Załóżmy, że do każdego pliku chcemy dodać aktualny czas. Dla pliku przyk 1/a.txt
zrobilibyśmy to tak: date >> "przyk 1/a.txt"
. Żeby to polecenie wykonać dla każdego pliku we wszystkich podkatalogach możemy użyć pętli for:
for plik in */*.txt; do
date >> "$plik"
done
Wykona ona polecenia znajdujące się pomiędzy do
i done
dla każdego pliku pasującego do wyrażenia */*.txt
(gdzie *
jest rozumiana jako ciąg jakichkolwiek znaków), przy każdym wywołaniu zapisując ścieżkę obecnego pliku do zmiennej plik
. Dostęp do zmiennych odbywa się przez poprzedzenie ich symbolem $
. Można śmiało pisać pętle w terminalu i naciskać enter - bash będzie czekał z wykonywaniem czegokolwiek dopóki nie zobaczy done
.
Często przydatna jest również możliwość podmiany tekstu w zmiennej. Poniższą pętlą możemy wyświetlić listę wszystkich plików, zamieniając .txt
na .md
:
for plik in */*.txt; do
echo "${plik/.txt/.md}"
done
Oprócz podmiany są dostępne również inne operacje .
Iterator
Co jeśli chcemy, żeby dane zapisywane do pliku się w jakich sposób zmieniały? Dopiszmy do każdego pliku linię “to jest plik nr x”, gdzie zamiast “x” powinien być kolejny numer pliku. Będzie nam do tego potrzebna jakaś zmienna, zwiększająca się z każdym wywołaniem pętli, czyli iterator. Stwórzmy nową zmienną o wartości 1: i=1
. Możemy ją wykorzystać w poleceniu przez $
, tak jak dla zmiennej plik
. Na końcu każdego wywołania pętli musimy zwiększyć wartość tej zmiennej o 1. Możemy do tego wykorzystać operator wyrażenia arytmetycznego $(())
: i=$(($i+1))
. Gotowa pętla powinna wyglądać następująco:
i=1
for plik in */*.txt; do
echo "to jest plik nr $i" >> "$plik"
i=$(($i+1))
done
Co jeśli chcemy zapisać, który jest to plik nie w całym test
, a w danym podfolderze? Jednym z rozwiązań tego problemu może być zastosowanie dwóch pętli:
for folder in przyk*; do
i=1
for plik in "${folder}"/*.txt; do
echo "to jest plik nr $i w podfolderze" >> "$plik"
i=$(($i+1))
done
done
W drugiej pętli użyłem nawiasów klamrowych, żeby oddzielić nazwę zmiennej (folder) od reszty testu.
Iterator można wykorzystać również do bardziej skomplikowanych zastosowań. Załóżmy, że mamy plik z listą nazw odpowiadających każdemu z modyfikowanych przez nas plików:
echo "jeden
dwa
trzy
cztery" > "lista plików.txt"
Wykorzystując polecenie head -3
możemy wyświetlić 3 pierwsze linie danych wejściowych, a następnie poleceniem tail -1
ostatnią linię:
$ head -3 "lista plików.txt" | tail -1
trzy
Wykorzystajmy to w pętli:
for folder in przyk*; do
i=1
for plik in "${folder}"/*.txt; do
echo "$(head -$i "lista plików.txt" | tail -1): $plik"
i=$(($i+1))
done
done
Otrzymujemy:
jeden: przyk 1/a.txt
dwa: przyk 1/b.txt
trzy: przyk 1/c.txt
cztery: przyk 1/d.txt
jeden: przyk 10/a.txt
dwa: przyk 10/b.txt
trzy: przyk 10/c.txt
...
Pętla while
Z powyższym przykładem można zauważyć pewien problem. Spróbujmy wyświetlić zawartość przyk 1/a.txt
:
$ cat "przyk 1/a.txt"
sob, 28 wrz 2024, 11:30:00 CEST
to jest plik nr 45
to jest plik nr 1 w podfolderze
Plik a.txt
, w podfolderze przyk 1
ma numer 45. Wynika to z faktu, że pliki przechodzące przez for są posortowane w porządku alfabetycznym, a nie liczbowym. Pętla:
for plik in */*.txt; do
echo "$plik"
done
Zwraca:
przyk 100/a.txt
przyk 100/b.txt
przyk 100/c.txt
przyk 100/d.txt
przyk 10/a.txt
przyk 10/b.txt
przyk 10/c.txt
przyk 10/d.txt
przyk 11/a.txt
przyk 11/b.txt
przyk 11/c.txt
przyk 11/d.txt
przyk 12/a.txt
przyk 12/b.txt
przyk 12/c.txt
przyk 12/d.txt
przyk 13/a.txt
...
Aby uzyskać prawidłową kolejność, możemy przekazać listę katalogów (ls -d */
wyświetla tylko katalogi, bez utworzonego wcześniej lista katalogów.txt
) do polecenia sort
z flagami -n
(sortuj numerycznie) i -k 2
(po kolumnie (słowie) drugiej):
ls -d */ | sort -nk 2
W odpowiedzi uzyskujemy:
przyk 1
przyk 2
przyk 3
przyk 4
przyk 5
przyk 6
przyk 7
przyk 8
przyk 9
przyk 10
przyk 11
przyk 12
...
Ze względu na spacje w nazwach nie możemy jednak użyć tej listy w pętli for. Pętla zostanie wywołana osobno dla każdego słowa, a nie dla każdej linii:
for folder in $(ls -d */ | sort -nk 2); do
echo "$folder"
done
Wypisze:
przyk
1/
przyk
2/
przyk
3/
przyk
4/
przyk
5/
przyk
6/
...
W takim przypadku możemy użyć pętli while
z poleceniem read
:
ls -d */ | sort -nk 2 | while read -r linia; do
echo "$linia"
done
Co tutaj się dzieje? Pętla while
wykonuje zawarte w niej polecenia, dopóki warunek, w tym przypadku read -r linia
, jest spełniony. Polecenie read -r linia
odczytuje dane wejściowe, czyli wyjście naszej komendy ls -d */ | sort -nk 2
, linia po linii. Za każdym wykonaniem obecna linia jest zapisywana do zmiennej linia
. Gdy dane wejściowe się kończą, kończona jest również pętla while
.
Spróbujmy teraz wstawić poprawne numery plików:
i=1
ls -d */ | sort -nk 2 | while read -r folder; do
for plik in "${folder}"/*.txt; do
echo "poprawny nr pliku to $i" >> "$plik"
i=$(($i+1))
done
done
Zobaczmy czy zadziałało:
$ cat "przyk 1/a.txt"
sob, 28 wrz 2024, 11:30:00 CEST
to jest plik nr 45
to jest plik nr 1 w podfolderze
poprawny nr pliku to 1
Dla przyk 47/c.txt
powinno być to 46 * 4 + 3, czyli 187:
$ cat "przyk 47/c.txt"
sob, 28 wrz 2024, 11:30:00 CEST
to jest plik nr 167
to jest plik nr 3 w podfolderze
poprawny nr pliku to 187
If
Czasem przy przetwarzaniu wielu plików niezbędne jest sprawdzenie czegoś i uzależnienie dalszych działań od wyniku. W bashu, tak jak w zwykłych języka programowania, dostępny jest warunek if
. Konstrukcja jest dość standardowa, pomijając fakt, że bloki są otoczone przez then
i fi
, a nie przez nawiasy, tak w większości języków.
if WARUNEK; then
POLECENIE
elif WARUNEK; then
POLECENIE
else
POLECENIE
fi
Warunkiem jest jakieś polecenie. Jeśli jest spełniony, to wykonywany jest pierwszy blok, jeśli nie to blok else
(elif
to skrót od “esle if”, czyli kolejnego sprawdzenia po elsie). Ale co to właściwie znaczy, że polecenie jest spełnione? Sprawdzany jest zwrócony kod błędu. Jeśli program zwraca 0, to został zakończony pomyślnie, jeśli zwraca inną wartość, to oznacza, że wystąpił błąd. Możemy to przetestować wysyłając kody 0 i 1 poleceniem exit
:
if (exit 1); then
echo "Prawda"
else
echo "Fałsz"
fi
Powinniśmy otrzymać “Fałsz”. Kod 0 natomiast powoduje zwrócenie opcji “Prawda”:
if (exit 0); then
echo "Prawda"
else
echo "Fałsz"
fi
Test
Jako warunek w ifach najczęściej stosowane jest polecenie test, które zwraca kod 0 lub 1, zależnie od tego, czy podane mu wyrażenie jest spełnione. Jednym z takich wyrażeń jest porównanie tekstu operatorem =
:
tekst="napis"
if test $tekst = "napis"; then
echo "Prawda"
else
echo "Fałsz"
fi
Fragment ten zwraca “Prawda”. Inną składnią dla test WYRAŻENIE
jest [[ WYRAŻENIE ]]
, więc dla powyższego równoważnym jest:
tekst="napis"
if [[ $tekst = "napis" ]]; then
echo "Prawda"
else
echo "Fałsz"
fi
Oprócz =
dostępne są również inne operatory. Możemy na przykład sprawdzić, czy 5 jest większe od 2 poprzez -gt
(greater than):
piec=5
if [[ $piec -gt 2 ]]; then
echo "Prawda"
else
echo "Fałsz"
fi
Pełna lista możliwych wyrażeń jest dostępna w dokumentacji test
, którą można wyświetlić poleceniem man test
.
Grep, sed, awk
Oprócz wspomnianych wcześniej poleceń jest jeszcze kilka innych, które się przydają w tego typu zadaniach.
Grep
Grep jest narzędziem przeznaczonym do wyszukiwania w danych wejściowych ciągów znaków:
$ grep trz "lista plików.txt"
trzy
Z pomocą odpowiednich flag można na przykład:
Wyświetlić listę plików zawierających, lub nie zawierających danego ciągu znaków:
$ grep -l rzy *.txt
lista katalogów.txt
lista plików.txt
$ grep -L jeden *.txt
lista katalogów.txt
Wyświetlić pasujące linie razem z numerem:
$ grep -n y "lista plików.txt"
3:trzy
4:cztery
Lub przeszukać rekursywnie wszystkie pliki w danym folderze:
$ grep -r 45 .
./przyk 1/a.txt:to jest plik nr 45
./przyk 12/a.txt:poprawny nr pliku to 45
./przyk 37/a.txt:poprawny nr pliku to 145
./przyk 42/a.txt:to jest plik nr 145
./przyk 62/a.txt:poprawny nr pliku to 245
./przyk 65/a.txt:to jest plik nr 245
./przyk 87/a.txt:poprawny nr pliku to 345
./przyk 88/a.txt:to jest plik nr 345
./lista katalogów.txt:przyk 45
Sed
Sed służy do modyfikowania tekstu wejściowego linia po linii. Najczęściej wykorzystywaną operacją jest podmiana poprzez 's/CO/NA CO/FLAGI'
:
$ echo "Ala ma kota" | sed 's/kota/psa/'
Ala ma psa
Sed zamienia tylko pierwsze wystąpienie w każdej linii, jeśli chcemy zamienić wszystkie, musimy użyć flagi g
:
$ echo "Ala ma kota, kota, kota" | sed 's/kota/psa/'
Ala ma psa, kota, kota
$ echo "Ala ma kota, kota, kota" | sed 's/kota/psa/g'
Ala ma psa, psa, psa
Uwaga! Sed akceptuje regex, więc symbole o specjalnym znaczeniu muszą być poprzedzone \
:
$ echo "Ala ma psa $ kota" | sed 's/$/&/'
Ala ma psa $ kota
$ echo "Ala ma psa $ kota" | sed 's/$/\&/'
Ala ma psa $ kota&
$ echo "Ala ma psa $ kota" | sed 's/\$/\&/'
Ala ma psa & kota
Awk
Awk jest narzędziem tak zaawansowanym, że można je uznać za język programowania. Podstawy jednak nie są zbyt skomplikowane. Typowa składnia skryptu w awku to warunek {polecenia}
, gdzie domyślnym warunkiem jest dopasowanie do każdego wiersza, a domyślnym poleceniem jest wyświetlenie całego wiersza.
Kilka przykładów:
Wyświetlenie drugiej kolumny (słowa) każdego wiersza:
$ ls | awk '{print $2}'
katalogów.txt
plików.txt
1
10
100
11
12
13
14
15
...
Wyświetlenie każdego wiersza, w którym pierwsza kolumna pasuje do wyrażenia przy
(akceptowany regex)
$ ls | awk '$1~/przy/'
przyk 1
przyk 10
przyk 100
przyk 11
przyk 12
przyk 13
przyk 14
przyk 15
przyk 16
Wyświetlenie całego wiersza ($0
), oraz sumy kolumn 3 i 4:
$ echo "1 1 2 3" | awk '{print $0, $3 + $4}'
1 1 2 3 5
Paste
Paste służy do łączenia kolumnami kilku plików. Spróbujmy utworzyć dwa pliki z cyframi zapisanymi liczbowo i słownie, i je połączyć:
echo "1
2
3
4" > cyfry.txt
echo "jeden
dwa
trzy
cztery" > nazwy.txt
paste cyfry.txt nazwy.txt
Otrzymamy:
1 jeden
2 dwa
3 trzy
4 cztery
Można również określić jakim znakiem oddzielić kolumny:
$ paste -d ";" cyfry.txt nazwy.txt
1;jeden
2;dwa
3;trzy
4;cztery