Try catch w Bash
Luty 3, 2024 | #error-handling , #bash
010000
000010
111010
Programowanie w powłoce ma swoją specyfikę a języki takie jak Bash mogą być nieintuicyjne dla programistów przyzwyczajonych do innych języków programowania więc, żeby dobrze obsłużyć błędy w skryptach powłoki należy w pierwszej kolejności zrozumieć działanie kilku kluczowych mechanizmów.
Opcja "e" w skrypcie bash
Ustawiając w skrypcie opcję set -e
programista spodziewa się, że każde wystąpienie błędu zakończy dalsze przetwarzanie skryptu.
#!/bin/bash
set -e
echo "The script starts"
# This is error
false
echo "OK"
$ bash ./set_e.sh
The script starts
Dotyczy to także kodu w ciele funkcji.
#!/bin/bash
set -e
function example () {
echo "First command"
false
echo "Last command"
}
example
$ bash ./set_e_func.sh
First command
A także kodu wewnątrz bloku.
#!/bin/bash
set -e
{
echo "First command"
false
echo "Last command"
}
$ bash ./set_e_block.sh
First command
W każdej chwili możemy zmienić obsługę błędów ustawiając inną wartość parametru e
.
#!/bin/bash
set -e
echo "The script starts"
{
set +e
echo "First command"
custom_command
echo "Last command"
}
custom_command
echo "OK"
$ bash ./set_e_change.sh
The script starts
First command
./set_e_change.sh: line 10: custom_command: command not found
Last command
./set_e_change.sh: line 14: custom_command: command not found
OK
Ustawienia na set +e
zapobiega natychmiastowemu przerwaniu skryptu w przypadku wystąpienia błędu. Zmiana ta obowiązuje od momentu wywołania tej instrukcji, aż do końca skryptu pomimo tego, że została zastosowana wewnątrz bloku wyznaczonego nawiasami klamrowymi {}
.
Ustawienie opcji e
ma niebagatelne znaczenie w procesie obsługi błędów albowiem myśląc o konstrukcji try ... catch zazwyczaj mamy na myśli coś więcej niż proste zakończenie skryptu. Gdybyśmy chcieli zaimplementować mechanizm gwarantujący wykonanie instrukcji przed zakończeniem działania skryptu moglibyśmy użyć do tego mechanizmu pułapek trap
.
Pułapki w kodzie Bash
#!/bin/bash
set -e
function on_exit() {
echo "Exit from the script"
}
echo "Terminate the script by pressing ctrl + c"
trap 'on_exit' EXIT
sleep 10
# This is an error
false
echo "OK"
$ bash ./handle_exit.sh
Terminate the script by pressing ctrl + c
Exit from the script
Jeśli uruchomimy powyższy skrypt i przerwiemy go kombinacją klawiszy Ctrl+c
(w konsoli w Linux) otrzymamy wynik jak wyżej. Gdybyśmy nie przerwali "ręcznie" tego skryptu, to z uwagi na ustawioną opcję -e
został by on przerwany w chwili wystąpienia błędu false
. Natomiast gdyby błąd nie wystąpił funkcja on_exit
i tak zostałaby wywołana na końcu skryptu - trochę przypomina to działanie konstrukcji try-catch-finally ale ma pewne ograniczenia.
- Pułapka
trap 'on_exit' EXIT
reaguje na wystąpienie sygnału wyjścia a więc zareaguje na błąd tylko wtedy kiedy spowoduje on zatrzymanie skryptu. Jeśli opcja-e
nie jest ustawiona wystąpienie błędu nie uruchomi pułapki. - Funkcja
on_exit
wykona się zawsze przy wyjściu ze skryptu. Niezależnie od tego czy wyjście to zostało wywołane błędem, wysłaniem sygnałuSIGINT
(Ctrl+c
),SIGTERM
,SIGHUP
itd., czy po prostu zwyczajnym zakończeniem skryptu. (Chyba, że "zabijemy" proces wykonujący skrypt wysyłając do niego sygnałSIGKILL
komendąkill -9 [PID]
). - Po wywołaniu pułapki i podpiętej do niej funkcji nie możemy w skrypcie wykonać już żadnej innej instrukcji.
Ostatnie ograniczenie możemy obejść wyłączając pułapkę po przejściu przez kluczowe dla nas linie kodu - trap - EXIT
, ale wtedy musimy sami zadbać o wywołanie w odpowiednim momencie funkcji on_exit
.
#!/bin/bash
set -e
function on_exit() {
echo "Clear before exit"
}
trap 'on_exit' EXIT
echo "Do something wery important"
echo "without any errors..."
trap - EXIT
# Call manualy on_exit as finally
on_exit
echo "Continue script execution"
function on_exit_2() {
echo "Just exit"
}
trap 'on_exit_2' EXIT
echo "The end of the script"
$ bash ./multiple_handle_exit.sh
Do something wery important
without any errors...
Clear before exit
Continue script execution
The end of the script
Just exit
Natychmiastowe wyjście z kodu po błędzie to nie zawsze pożądane zachowanie. W skryptach Bash można zdefiniować pułapki reagujące stricte na wystąpienie błędu.
#!/bin/bash
function handle_error() {
echo "An error occurred on line $1. Ignore it."
}
trap 'handle_error $LINENO' ERR
{
ls non-existent-file
rm non-existent-file
}
trap - ERR
echo "File does not exist or was removed."
$ bash ./handle_error.sh
ls: cannot access 'non-existent-file': No such file or directory
An error occurred on line 10. Ignore it.
rm: cannot remove 'non-existent-file': No such file or directory
An error occurred on line 11. Ignore it.
File does not exist or was removed.
Pułapka trap 'handle_error $LINENO' ERR
faktycznie przechwytuje błędy tylko naszą intencją było stworzenie takiego mechanizmu, który w przypadku wykrycia błędu w pierwszej linii bloku kodu, nie wykona już kolejnych instrukcji z tego bloku. Niestety połączenie obu instrukcji logicznym operatorem &&
nie załatwia sprawy.
#!/bin/bash
function handle_error() {
echo "An error occurred on line $1. Ignore it."
}
trap 'handle_error $LINENO' ERR
{
ls non-existent-file && rm non-existent-file
}
trap - ERR
echo "File does not exist or was removed."
$ bash ./handle_error_and.sh
ls: cannot access 'non-existent-file': No such file or directory
File does not exist or was removed.
Co prawda druga z instrukcji się nie wykonała tak jak tego oczekiwaliśmy ale nie została też wykonana funkcja obsługi błędów handle_error
.
Rozwiązaniem jest wywołanie kodu w podprocesie.
#!/bin/bash
function handle_error() {
echo "An error occurred on line $1. Ignore it."
}
trap 'handle_error $LINENO' ERR
(
set -e
ls non-existent-file
rm non-existent-file
)
trap - ERR
echo "File does not exist or was removed."
bash ./handle_error_in_subprocess.sh
ls: cannot access 'non-existent-file': No such file or directory
An error occurred on line 13. Ignore it.
File does not exist or was removed.
Udało się osiągnąć funkcjonalność try-catch. Ale dlaczego to w ogóle działa? W tym przypadku zmiana opcji -e
obowiązuje jedynie w zakresie wyznaczonym nawiasami okrągłymi ()
, które oznaczają, że zawarty w nich kod został uruchomiony w podprocesie. Dlatego po zamknięciu nawiasu okrągłego zasięg działania opcji -e
już nie obowiązuje i wyświetlany jest ostatni komunikat ze skryptu. Nie mniej użycie podprocesu ma swoje konsekwencje.
Kod Bash uruchamiany w podprocesie
Kod w nawiasach okrągłych uruchamiany jest w osobnym procesie co ma kilka istotnych konsekwencji.
Izolacja: Kod w podprocesie Bash nie ma możliwości zmiany wartości zmiennych zdefiniowanych w procesie nadrzędnym. Zmienne zdefiniowane w podprocesie Bash są lokalne dla tego podprocesu i nie mają wpływu na zmienne w powłoce nadrzędnej.
Nowa przestrzeń robocza: Podproces Bash działa w swojej własnej przestrzeni roboczej, co oznacza, że może tworzyć, modyfikować i usuwać pliki i katalogi niezależnie od powłoki nadrzędnej.
Odrębne procesy: Podproces Bash jest osobnym procesem systemowym, co oznacza, że ma własne identyfikatory PID (Process ID) i jest niezależny od procesu nadrzędnego.
Problem zasięgu zmiennych związany z izolacją obrazuje poniższy przykład.
#!/bin/bash
a="Foo",
c=$(
echo "Value of a in subrocess: $a"
a="Bar"
b="Baz"
echo "$b"
)
echo "Value of a: $a"
echo "Value of b: $b"
echo "Value of c: $c"
$ bash ./subprocess_variable_scope.sh
Value of a: Foo,
Value of b:
Value of c: Value of a in subrocess: Foo,
Baz
Zmienne z procesu nadrzędnego są dostępne w podprocesie (var a
) ale na odwrót już nie (var b
). Ponadto procesy podrzędne choć mogą czytać zmienne procesów nadrzędnych (var a
) to nie mogą zmieniać ich wartości. Wartość wyjścia z podprocesu można przypisać do zmiennej (var c
), ale znajdzie się w niej wszystko co przekażemy do stdout
. Dlatego zmienna c
zawiera nie tylko Bar
ale także tekst Value of a in subrocess: Foo
.
Rozwiązanie jednego problemu okazało się źródłem innego. W implementacji realnego problemu może to nie mieć żadnego znaczenia, ale może też być blokerem wykluczającym zastosowanie tego podejścia.
Użycie instrukcji warunkowych do obsługi błędów w Bash
Rozwinięciem banalnej instrukcji false || echo "ok"
, jest skrypt poniżej, który obrazuje koncepcję użycia operatora OR
w procesie obsługi błędów.
#!/bin/bash
{
false
} || {
echo "Error ocurred"
}
$ bash ./or.sh
Error ocurred
Prawidłowe użycie tego operatora okazuje się jednak problematyczne kiedy chcemy go zmusić do pracy z więcej niż jednym poleceniem wewnątrz bloku.
#!/bin/bash
set -e
{
echo "First instruction"
error
echo "OK"
} || {
echo "Error ocurred"
}
$ bash ./set_e_or.sh
First instruction
./set_e_or.sh: line 7: error: command not found
OK
Pomimo wystąpienia błędu i ustawienia opcji -e
skrypt nie przerwał swojego działania, a do tego nie została wyświetlona informacja Error ocurred
symulująca kod obsługi błędów.
Gdy używasz konstrukcji {}
z operatorem OR
(||
), Bash traktuje cały blok {}
jako jedno polecenie. Jeśli jakiekolwiek polecenie wewnątrz tego bloku zakończy się niepowodzeniem, Bash normalnie przerwałby skrypt z powodu set -e
. Jednak w tym przypadku, ponieważ blok jest bezpośrednio połączony z operatorem OR
, Bash traktuje całą konstrukcję jako część obsługi błędów i kontynuuje wykonanie skryptu, przechodząc do bloku po ||
w przypadku błędu.
Ponieważ jednak blok jest traktowany jako całość o wartości wyjścia z bloku decyduje kod wyjścia ostatniej instrukcji. W powyższym skrypcie ostatnia instrukcja w bloku kończy się powodzeniem więc blok po ||
nie zostaje uruchomiony.
Co należy uwzględnić używając konstrukcji dwóch bloków z operatorem OR
aby uzyskać działanie zbliżone do try-catch znane z innych języków programowania?
Połączyć wszystkie instrukcje wewnątrz bloku {}
połączyć w jedną za pomocą operatora AND
(&&
)
#!/bin/bash
{
echo "First instruction" &&
false &&
echo "Second instruction" &&
} || {
echo "Error ocurred"
}
$ bash ./or_and.sh
First instruction
Error ocurred
Operator AND
nie dopuści do wykonania instrukcji po prawej jego stronie jeśli ta po lewej zakończy się niepowodzeniem a całość wyrażenia i tym samym bloku również będzie miała wartość różną od 0 i spowoduje wywołanie kodu w drugim bloku.
Błędy każdego polecenia wewnątrz bloku {}
należy obsługiwać bezpośrednio.
#!/bin/bash
{
echo "First instruction"
false || exit 1
echo "Second instruction"
} || {
echo "Error ocurred"
}
echo "OK"
$ bash ./or_exit.sh
First instruction
Dalsze wykonanie kodu w bloku zostanie przerwane. Jednak, z uwagi na użycie exit 1
, cały skrypt zakończy się w momencie wystąpienia błędu, co może nie być idealne, jeśli ten fragment jest częścią większego skryptu.
Chcąc obsłużyć błędy lokalnie bez zatrzymywania całego skryptu, można posiłkować się subshell-em ...
(
echo "First instruction"
false || exit 1
echo "Second instruction"
) || {
echo "Error ocurred"
}
echo "OK"
... uwzględniając wszystkie wady podprocesów - o czym napisano wyżej.
Innym rozwiązaniem jest zastosowanie funkcji
#!/bin/bash
function try {
echo "First instruction"
false || return 1
echo "Second instruction"
}
try || {
echo "Error ocurred"
}
echo "OK"
$ bash ./or_return.sh
First instruction
Error ocurred
OK
Instrukcja return pozwala przerwać działanie funkcji po miejscu wystąpienia błędu. W Bash nie ma funkcji anonimowych ale można funkcję o tej samej nazwie definiować wielokrotnie więc nic nie szkodzi aby funkcja zastępująca blok kodu w tym kontekście zawsze miała nazwę try
.
Rozpoznawanie rodzaju błędu w Bash
#!/bin/bash
function try {
echo "First instruction"
md ./foo/bar || return $?
echo "Second instruction"
}
try || {
status=$?
case $status in
127)
echo "Expected error ocurred with status $status"
;;
$status)
echo "Unexpected error occured with status $status"
;;
esac
}
$ bash ./catch_errors.sh
First instruction
./catch_errors.sh: line 5: md: command not found
Expected error ocurred with status 127
Kod wyjścia ostatniej instrukcji jest zapisywany w zmiennej $?
. Pozwala to na przechwycenie go i przekazanie do bloku obsługi błędów
Podsumowanie
Jak przedstawiono powyżej w Bash nie ma prostej alternatywy dla konstrukcji try-catch-finally znanej z innych języków programowania, a jej symulowanie nie jest wcale sprawą banalną. Stąd liczne artykuły i porady jak to robić prawidłowo oraz liczne komentarze użytkowników stwierdzających, że u nich to nie działa. Nie ma jednego uniwersalnego przepisu na przechwytywanie i obsługę błędów w Bash. Dobór paradygmatu i mechanizmów języka jakie należy użyć aby prawidłowo obsłużyć takie sytuacje zależy w dużej mierze od potrzeb i stopnia skomplikowania skryptu. W prostych zastosowaniach nie trzeba sięgać od razu po rozbudowane i skomplikowane próby implementacji konstrukcji try-catch w Bash , które - jak udowadnia niniejszy artykuł - wcale nie muszą się sprawdzić w bardziej wymagających scenariuszach.