Risolvere Efficacemente i Problemi di Espansione delle Variabili in Bash

Gli script Bash spesso falliscono a causa di sottili errori di espansione delle variabili. Questa guida completa analizza i problemi comuni come l'uso errato delle virgolette, la gestione di valori non inizializzati e la gestione dell'ambito delle variabili all'interno di subshell e funzioni. Impara tecniche di debug essenziali (`set -u`, `set -x`) e padroneggia i potenti modificatori di espansione dei parametri (come `${VAR:-default}`) per scrivere script di automazione robusti, prevedibili e a prova di errore. Smetti di fare debug di stringhe vuote misteriose e inizia a scrivere script con sicurezza.

Risolvere Efficacemente i Problemi di Espansione delle Variabili in Bash

I bug di espansione delle variabili in Bash spesso sembrano comportamenti casuali: un percorso con spazi diventa due percorsi, un carattere jolly in un nome file si espande in metà directory, una variabile impostata all'interno di un ciclo scompare, o una variabile d'ambiente mancante si trasforma silenziosamente in una stringa vuota. La shell non è casuale. Sta seguendo regole di espansione che è facile dimenticare quando sei concentrato sul compito che lo script dovrebbe svolgere.

Il modello mentale utile è questo: Bash non si limita a sostituire $name con del testo ed eseguire il comando. Espande le variabili, può dividere il risultato in parole, può espandere i glob, quindi esegue finalmente un comando con la lista di argomenti risultante. La maggior parte delle correzioni deriva dal controllo di questi passaggi.

Le variabili non impostate diventano vuote a meno che non le blocchi

Per impostazione predefinita, questo script stampa un valore vuoto e continua:

printf 'Distribuzione %s\n' "$APP_VERSION"

Se APP_VERSION era obbligatoria, questo è un bug. Usa l'espansione dei parametri quando la variabile è obbligatoria:

: "${APP_VERSION:?APP_VERSION deve essere impostata}"
printf 'Distribuzione %s\n' "$APP_VERSION"

Il : iniziale è il comando no-op. L'espansione esegue il controllo. Se la variabile non è impostata o è vuota, Bash stampa il messaggio ed esce da una shell non interattiva.

Per valori opzionali, rendi ovvio il valore predefinito:

livello_log=${LOG_LEVEL:-INFO}
numero_ritentativi=${RETRY_COUNT:-3}

I due punti sono importanti. ${VAR:-default} usa il valore predefinito quando VAR non è impostata o è vuota. ${VAR-default} usa il valore predefinito solo quando VAR non è impostata. Questa distinzione è importante se una stringa vuota è un valore di configurazione valido.

set -u può anche catturare variabili non impostate:

set -u

È utile in molti script, ma non sostituisce una convalida chiara. Può anche sorprenderti quando lavori con parametri posizionali opzionali, array o variabili che vengono intenzionalmente controllate per l'esistenza. Usa ${1:-} quando un argomento potrebbe essere assente:

modalità=${1:-help}

Racchiudi le variabili tra virgolette a meno che non desideri suddivisione e globbing

Questo è il problema di espansione più comune:

file="Quarterly Report *.txt"
rm $file

Senza virgolette, Bash prima espande $file, poi lo divide sugli spazi, poi tratta * come un carattere jolly. Il comando potrebbe ricevere diversi argomenti che non intendevi. Con le virgolette, riceve esattamente un argomento:

rm -- "$file"

Il -- protegge i comandi da valori che iniziano con un trattino. Questo è importante per nomi file come -rf.

Usa le virgolette doppie per variabili, sostituzioni di comandi e la maggior parte delle espansioni di parametri:

cp "$file_sorgente" "$directory_destinazione/"
printf 'Utente: %s\n' "$nome_utente"

Le virgolette singole sono diverse. Impediscono completamente l'espansione:

printf 'Home è $HOME\n'   # stampa il testo letterale
printf "Home è $HOME\n"   # stampa il valore

Se vedi uno script che costruisce stringhe come 'prefisso-$valore', questo è probabilmente un bug. Usa le virgolette doppie quando il valore deve espandersi.

Gli array risolvono molti problemi di costruzione degli argomenti

Molti Bash rotti derivano dalla memorizzazione di diverse opzioni di comando in una singola stringa:

opts="-a --delete --exclude *.tmp"
rsync $opts "$src/" "$dest/"

Questo si basa sulla suddivisione in parole e può rompersi quando un argomento di opzione contiene spazi. Usa un array:

opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$src/" "$dest/"

"${opts[@]}" espande ogni elemento dell'array come proprio argomento. Questo è esattamente ciò di cui la maggior parte delle costruzioni di comandi ha bisogno.

Lo stesso vale quando si raccolgono nomi file:

files=("$report_dir"/*.txt)
for file in "${files[@]}"; do
  [[ -e $file ]] || continue
  processa_report "$file"
done

La guardia [[ -e $file ]] || continue gestisce il caso in cui nessun file corrisponde e il glob rimane letterale, a seconda delle opzioni della shell.

La sostituzione di comando rimuove le nuove righe finali

$(comando) cattura stdout, ma Bash rimuove i caratteri di nuova riga finali. Di solito va bene per una stringa di versione ed è sbagliato per dati in cui le nuove righe finali sono importanti.

versione=$(git describe --tags --always)
printf 'Versione: %s\n' "$versione"

Per output orientato alle righe, preferisci mapfile quando hai bisogno di un array:

mapfile -t nomi < <(find "$directory_base" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for nome in "${nomi[@]}"; do
  printf 'log=%s\n' "$nome"
done

Evita for item in $(ls). Si rompe con spazi bianchi, caratteri glob e nomi file insoliti. Itera sui glob o usa find con delimitatori attenti.

Le variabili nelle pipeline potrebbero trovarsi in una subshell

Questo coglie le persone perché il ciclo sembra funzionare correttamente:

conteggio=0
printf '%s\n' a b c | while IFS= read -r riga; do
  conteggio=$((conteggio + 1))
done
printf 'conteggio=%s\n' "$conteggio"

In molte configurazioni Bash, il ciclo while in una pipeline viene eseguito in una subshell. L'incremento avviene, ma il conteggio della shell padre rimane invariato.

Usa invece la sostituzione di processo:

conteggio=0
while IFS= read -r riga; do
  conteggio=$((conteggio + 1))
done < <(printf '%s\n' a b c)
printf 'conteggio=%s\n' "$conteggio"

Oppure fai in modo che la pipeline produca il valore di cui hai bisogno e cattura quel valore direttamente.

Le variabili locali prevengono sovrascritture accidentali

Le variabili nelle funzioni Bash sono globali a meno che non siano dichiarate local. Questo può trasformare una funzione di supporto in una fonte di strani bug di espansione:

ambiente=prod

carica_config() {
  ambiente=dev
}

carica_config
printf '%s\n' "$ambiente"  # dev

Usa local per valori temporanei:

carica_config() {
  local ambiente=dev
  printf 'caricati valori predefiniti per %s\n' "$ambiente"
}

local è una funzionalità di Bash. Va bene negli script Bash, ma è un altro motivo per cui lo script non dovrebbe essere eseguito con sh.

Usa le parentesi graffe quando i nomi toccano altro testo

$file_prefisso significa una variabile chiamata file_prefisso, non $file seguito da _prefisso. Usa le parentesi graffe per rendere chiaro il confine:

prefisso=app
printf '%s\n' "${prefisso}_file"

Le parentesi graffe sono anche richieste per molte operazioni di espansione dei parametri:

percorso=/var/log/nginx/access.log
printf 'dir=%s\n' "${percorso%/*}"
printf 'file=%s\n' "${percorso##*/}"

${percorso%/*} rimuove il suffisso corrispondente più corto. ${percorso##*/} rimuove il prefisso corrispondente più lungo. Questi sono utili, ma non abusarne quando dirname o basename renderebbero lo script più chiaro per il tuo team.

Debug dell'espansione stampando gli argomenti reali

set -x mostra i comandi dopo l'espansione. Migliora la traccia con i numeri di riga:

PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $file $directory_destinazione
set +x

La traccia rivelerà se il comando è diventato mv Quarterly Report *.txt /tmp/out o mv 'Quarterly Report *.txt' /tmp/out. Tieni xtrace lontano dai segreti.

Per un controllo manuale più sicuro, stampa i valori con %q:

printf 'file=%q\n' "$file" >&2
printf 'directory_destinazione=%q\n' "$directory_destinazione" >&2

%q rende visibili spazi e caratteri speciali in un modo più facile da leggere rispetto al semplice echo.

Una checklist pratica

Quando una variabile Bash si espande in modo errato, controlla questi in ordine:

  1. Lo script è eseguito sotto Bash, non sh?
  2. La variabile è effettivamente impostata? Usa ${VAR:?messaggio} per valori obbligatori.
  3. Ogni espansione è tra virgolette a meno che la suddivisione non sia intenzionale?
  4. Stai usando un array per argomenti multipli?
  5. Una pipeline ha messo il tuo ciclo in una subshell?
  6. Una funzione ha sovrascritto una variabile globale perché local mancava?
  7. Sono necessarie parentesi graffe per separare il nome della variabile dal testo vicino?

Questi controlli sono noiosi nel modo migliore. Trasformano la maggior parte dei bug di espansione da "Bash è strano" in una regola specifica e risolvibile.

L'espansione indiretta e i nameref meritano cautela extra

Bash può espandere una variabile il cui nome è memorizzato in un'altra variabile:

nome=APP_ENV
printf '%s\n' "${!nome}"

Questo stampa il valore di APP_ENV. È potente, ma rende gli script più difficili da leggere e può diventare pericoloso se il nome della variabile proviene dall'input dell'utente. Se hai solo bisogno di una mappatura da nomi a valori, un array associativo è più chiaro:

declare -A endpoints=(
  [dev]='https://dev.example.test'
  [prod]='https://api.example.com'
)

printf '%s\n' "${endpoints[$ambiente]:?ambiente sconosciuto}"

Bash ha anche i nameref con declare -n, spesso usati nelle funzioni di supporto. Sono utili negli script in stile libreria, ma possono creare effetti collaterali sorprendenti. Usali solo quando passare un array o una variabile per riferimento semplifica genuinamente il codice.

La rimozione di pattern non è la corrispondenza di espressioni regolari

Gli operatori di espansione dei parametri come ${file%.log} e ${percorso##*/} usano pattern della shell, non espressioni regolari. Questa differenza è importante.

file='access.log'
printf '%s\n' "${file%.log}"

Questo rimuove un suffisso .log. Non significa "rimuovi qualsiasi cosa corrisponda a una regex." Per controlli regex, usa [[ ... =~ ... ]]:

if [[ $porta =~ ^[0-9]+$ ]]; then
  printf 'numerico\n'
fi

Anche lì, usa le virgolette con attenzione. Il lato destro di =~ è solitamente lasciato senza virgolette quando vuoi che sia trattato come una regex. La variabile a sinistra non dovrebbe aver bisogno di virgolette all'interno di [[ ]], perché [[ ]] non esegue la suddivisione in parole come fa [ ].

Esporta solo ciò di cui i processi figli hanno bisogno

Impostare una variabile in Bash non la rende automaticamente disponibile ai comandi che lo script avvia:

APP_ENV=prod
./run-app

run-app non vedrà APP_ENV a meno che non sia esportata o fornita inline:

export APP_ENV=prod
./run-app

# oppure
APP_ENV=prod ./run-app

Questa è una fonte comune di confusione quando uno script stampa il valore corretto ma un processo figlio si comporta come se il valore mancasse. La variabile esiste nella shell; non è mai stata inserita nell'ambiente per il figlio.

Vale anche il contrario: un processo figlio non può cambiare le variabili della shell padre. Se uno script di supporto stampa export TOKEN=..., eseguirlo normalmente non aggiornerà il chiamante. Dovresti eseguirlo con source, e l'esecuzione con source dovrebbe essere riservata a codice shell fidato.

Una revisione del mondo reale prima di spedire

Prima di chiamare uno script o una configurazione di container finita, leggilo una volta come se fossi la prossima persona che dovrà fare debug alle 2 del mattino. Questo cambia ciò che noti. Un prompt che aveva senso mentre scrivevi lo script potrebbe essere ambiguo quando appare in un log CI. Un nome di servizio Docker che sembrava ovvio potrebbe non corrispondere al nome della variabile nell'applicazione. Un valore predefinito Bash potrebbe essere sicuro per lo sviluppo e pericoloso per la produzione.

Mi piace fare un breve dry run con valori deliberatamente scomodi. Usa un percorso con spazi. Usa un valore opzionale vuoto. Prova un nome file che inizia con un trattino. Esegui lo script da una directory di lavoro diversa. Avvia il container senza una variabile d'ambiente prevista. Questi test non sono fantasiosi, ma catturano le ipotesi che di solito si rompono per prime.

Controlla anche il messaggio di errore. Se l'unico output è fallito, il consiglio dell'articolo non è arrivato nell'implementazione. Un errore utile dice quale valore è stato usato, quale controllo è fallito e cosa l'operatore può cambiare. Questo non significa scaricare ogni variabile d'ambiente o stampare segreti. Significa essere specifici dove la specificità aiuta: il percorso di configurazione, il nome del comando mancante, il nome della rete, il nome host del servizio o la porta che il processo ha tentato di associare.

L'abitudine finale è mantenere gli esempi vicini al modo in cui il sistema viene effettivamente eseguito. Se la produzione usa Compose, testa con Compose. Se uno script è avviato da systemd, testalo con systemd o con un ambiente altrettanto minimale. Se un comando dovrebbe essere sicuro per copia e incolla, includi le virgolette, i separatori -- e la convalida nell'esempio stesso. I lettori copiano i pattern funzionanti più spesso di quanto copino gli avvertimenti.

Quella revisione non è burocrazia. È come la piccola automazione rimane noiosa. Noioso è ciò che vuoi da prompt della shell, caricatori di configurazione, espansione di variabili, diagnostica dei container e networking Docker. Meno sorprendente è il comportamento, più facile è per il prossimo operatore fidarsene.

Per l'espansione delle variabili in particolare, aggiungi un'altra abitudine a quella revisione: stampa il conteggio degli argomenti quando un comando si comporta in modo strano. Un piccolo aiutante può rendere visibile l'invisibile:

mostra_args() {
  local i=1
  for arg in "$@"; do
    printf 'arg[%d]=%q\n' "$i" "$arg" >&2
    i=$((i + 1))
  done
}

mostra_args mv $file $directory_destinazione
mostra_args mv "$file" "$directory_destinazione"

La prima chiamata mostra cosa riceverebbe il comando rotto; la seconda mostra la versione corretta. Una volta che vedi la lista degli argomenti, i bug di quoting smettono di sembrare misteriosi.