Try catch w Bash

Luty 3, 2024 | #error-handling , #bash

000001
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.

  1. 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.
  2. 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łu SIGINT (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]).
  3. 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.

  1. 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.

  2. 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.

  3. 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.

Akceptuję Ta strona zapisuje niewielkie pliki tekstowe, nazywane ciasteczkami (ang. cookies) na Twoim urządzeniu w celu lepszego dostosowania treści oraz dla celów statystycznych. Możesz wyłączyć możliwość ich zapisu, zmieniając ustawienia Twojej przeglądarki. Korzystanie z tej strony bez zmiany ustawień oznacza zgodę na przechowywanie cookies w Twoim urządzeniu.