Résoudre efficacement les problèmes d'expansion de variables Bash
Les scripts Bash échouent souvent à cause d'erreurs subtiles d'expansion de variables. Ce guide complet analyse les problèmes courants comme les guillemets incorrects, la gestion des valeurs non initialisées et la portée des variables dans les sous-shells et les fonctions. Apprenez les techniques de débogage essentielles (`set -u`, `set -x`) et maîtrisez les modificateurs d'expansion de paramètres puissants (comme `${VAR:-default}`) pour écrire des scripts d'automatisation robustes, prévisibles et sans erreur. Arrêtez de déboguer des chaînes vides mystérieuses et commencez à coder en toute confiance.
Résoudre efficacement les problèmes d'expansion de variables Bash
Les bugs d'expansion de variables Bash ressemblent souvent à un comportement aléatoire : un chemin avec des espaces devient deux chemins, un caractère générique dans un nom de fichier se développe en la moitié du répertoire, une variable définie dans une boucle disparaît, ou une variable d'environnement manquante se transforme silencieusement en chaîne vide. Le shell n'est pas aléatoire. Il suit des règles d'expansion faciles à oublier lorsque vous êtes concentré sur la tâche que le script est censé accomplir.
Le modèle mental utile est le suivant : Bash ne se contente pas de remplacer $name par du texte et d'exécuter la commande. Il développe les variables, peut diviser le résultat en mots, peut développer les globs, puis exécute finalement une commande avec la liste d'arguments résultante. La plupart des correctifs consistent à contrôler ces étapes.
Les variables non définies deviennent vides sauf si vous les arrêtez
Par défaut, ce script affiche une valeur vide et continue :
printf 'Déploiement de %s\n' "$APP_VERSION"
Si APP_VERSION était requis, c'est un bug. Utilisez l'expansion de paramètres lorsque la variable est obligatoire :
: "${APP_VERSION:?APP_VERSION doit être définie}"
printf 'Déploiement de %s\n' "$APP_VERSION"
Le : au début est la commande no-op. L'expansion effectue la vérification. Si la variable est non définie ou vide, Bash imprime le message et quitte depuis un shell non interactif.
Pour les valeurs optionnelles, rendez la valeur par défaut évidente :
log_level=${LOG_LEVEL:-INFO}
retry_count=${RETRY_COUNT:-3}
Le deux-points est important. ${VAR:-default} utilise la valeur par défaut lorsque VAR est non définie ou vide. ${VAR-default} utilise la valeur par défaut uniquement lorsque VAR est non définie. Cette distinction est importante si une chaîne vide est une valeur de configuration valide.
set -u peut également détecter les variables non définies :
set -u
C'est utile dans de nombreux scripts, mais ce n'est pas un substitut à une validation claire. Cela peut aussi vous surprendre lorsque vous travaillez avec des paramètres positionnels optionnels, des tableaux ou des variables dont l'existence est intentionnellement vérifiée. Utilisez ${1:-} lorsqu'un argument peut être absent :
mode=${1:-help}
Mettez les variables entre guillemets sauf si vous voulez la division et le globbing
C'est le problème d'expansion le plus courant :
file="Rapport trimestriel *.txt"
rm $file
Sans guillemets, Bash développe d'abord $file, puis le divise sur les espaces, puis traite * comme un caractère générique. La commande peut recevoir plusieurs arguments que vous n'aviez pas prévus. Avec guillemets, elle reçoit exactement un argument :
rm -- "$file"
Le -- protège les commandes des valeurs commençant par un tiret. Cela est important pour les noms de fichiers tels que -rf.
Utilisez des guillemets doubles pour les variables, les substitutions de commandes et la plupart des expansions de paramètres :
cp "$source_file" "$destination_dir/"
printf 'Utilisateur : %s\n' "$user_name"
Les guillemets simples sont différents. Ils empêchent toute expansion :
printf 'Home est $HOME\n' # imprime le texte littéral
printf "Home est $HOME\n" # imprime la valeur
Si vous voyez un script construire des chaînes comme 'prefix-$value', c'est probablement un bug. Utilisez des guillemets doubles lorsque la valeur doit se développer.
Les tableaux résolvent de nombreux problèmes de construction d'arguments
Beaucoup de Bash cassé provient du stockage de plusieurs options de commande dans une seule chaîne :
opts="-a --delete --exclude *.tmp"
rsync $opts "$src/" "$dest/"
Cela repose sur la division en mots et peut échouer lorsqu'un argument d'option contient des espaces. Utilisez un tableau :
opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$src/" "$dest/"
"${opts[@]}" développe chaque élément du tableau comme son propre argument. C'est exactement ce dont la plupart des constructions de commandes ont besoin.
La même chose s'applique lors de la collecte de noms de fichiers :
files=("$report_dir"/*.txt)
for file in "${files[@]}"; do
[[ -e $file ]] || continue
process_report "$file"
done
La garde [[ -e $file ]] || continue gère le cas où aucun fichier ne correspond et où le glob reste littéral, selon les options du shell.
La substitution de commande supprime les nouvelles lignes de fin
$(commande) capture la sortie standard, mais Bash supprime les caractères de nouvelle ligne de fin. C'est généralement acceptable pour une chaîne de version et incorrect pour des données où les nouvelles lignes finales sont importantes.
version=$(git describe --tags --always)
printf 'Version : %s\n' "$version"
Pour une sortie orientée ligne, préférez mapfile lorsque vous avez besoin d'un tableau :
mapfile -t names < <(find "$base_dir" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for name in "${names[@]}"; do
printf 'log=%s\n' "$name"
done
Évitez for item in $(ls). Cela casse sur les espaces, les caractères glob et les noms de fichiers inhabituels. Bouclez sur les globs ou utilisez find avec des délimiteurs soigneux.
Les variables dans les pipelines peuvent être dans un sous-shell
Cela surprend les gens car la boucle semble s'exécuter correctement :
count=0
printf '%s\n' a b c | while IFS= read -r line; do
count=$((count + 1))
done
printf 'count=%s\n' "$count"
Dans de nombreuses configurations Bash, la boucle while dans un pipeline s'exécute dans un sous-shell. L'incrément a lieu, mais le count du shell parent reste inchangé.
Utilisez plutôt la substitution de processus :
count=0
while IFS= read -r line; do
count=$((count + 1))
done < <(printf '%s\n' a b c)
printf 'count=%s\n' "$count"
Ou faites en sorte que le pipeline produise la valeur dont vous avez besoin et capturez cette valeur directement.
Les variables locales empêchent les écrasements accidentels
Les variables dans les fonctions Bash sont globales sauf si elles sont déclarées local. Cela peut transformer une fonction d'assistance en source de bugs d'expansion étranges :
env=prod
load_config() {
env=dev
}
load_config
printf '%s\n' "$env" # dev
Utilisez local pour les valeurs temporaires :
load_config() {
local env=dev
printf 'chargement des valeurs par défaut pour %s\n' "$env"
}
local est une fonctionnalité Bash. C'est acceptable dans les scripts Bash, mais c'est une autre raison pour laquelle le script ne devrait pas être exécuté avec sh.
Utilisez des accolades lorsque les noms touchent d'autres textes
$prefix_file signifie une variable nommée prefix_file, pas $prefix suivi de _file. Utilisez des accolades pour clarifier la limite :
prefix=app
printf '%s\n' "${prefix}_file"
Les accolades sont également nécessaires pour de nombreuses opérations d'expansion de paramètres :
path=/var/log/nginx/access.log
printf 'dir=%s\n' "${path%/*}"
printf 'file=%s\n' "${path##*/}"
${path%/*} supprime le suffixe correspondant le plus court. ${path##*/} supprime le préfixe correspondant le plus long. Ce sont utiles, mais ne les utilisez pas trop lorsque dirname ou basename rendraient le script plus clair pour votre équipe.
Déboguez l'expansion en imprimant les vrais arguments
set -x montre les commandes après expansion. Améliorez la trace avec les numéros de ligne :
PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $file $target_dir
set +x
La trace révélera si la commande est devenue mv Quarterly Report *.txt /tmp/out ou mv 'Quarterly Report *.txt' /tmp/out. Gardez xtrace loin des secrets.
Pour une vérification manuelle plus sûre, imprimez les valeurs avec %q :
printf 'file=%q\n' "$file" >&2
printf 'target_dir=%q\n' "$target_dir" >&2
%q rend les espaces et les caractères spéciaux visibles d'une manière plus facile à lire que echo simple.
Une liste de contrôle pratique
Lorsqu'une variable Bash se développe mal, vérifiez ces points dans l'ordre :
- Le script s'exécute-t-il sous Bash, pas
sh? - La variable est-elle réellement définie ? Utilisez
${VAR:?message}pour les valeurs requises. - Chaque expansion est-elle entre guillemets sauf si la division est intentionnelle ?
- Utilisez-vous un tableau pour plusieurs arguments ?
- Un pipeline a-t-il mis votre boucle dans un sous-shell ?
- Une fonction a-t-elle écrasé une variable globale parce que
localmanquait ? - Des accolades sont-elles nécessaires pour séparer le nom de la variable du texte adjacent ?
Ces vérifications sont ennuyeuses de la meilleure façon. Elles transforment la plupart des bugs d'expansion de « Bash est bizarre » en une règle spécifique et réparable.
L'expansion indirecte et les namerefs méritent une prudence supplémentaire
Bash peut développer une variable dont le nom est stocké dans une autre variable :
name=APP_ENV
printf '%s\n' "${!name}"
Cela imprime la valeur de APP_ENV. C'est puissant, mais cela rend les scripts plus difficiles à lire et peut devenir dangereux si le nom de la variable provient d'une entrée utilisateur. Si vous avez seulement besoin d'une correspondance entre noms et valeurs, un tableau associatif est plus clair :
declare -A endpoints=(
[dev]='https://dev.example.test'
[prod]='https://api.example.com'
)
printf '%s\n' "${endpoints[$env]:?environnement inconnu}"
Bash a également des namerefs avec declare -n, souvent utilisés dans les fonctions d'assistance. Ils sont utiles dans les scripts de type bibliothèque, mais ils peuvent créer des effets secondaires surprenants. Utilisez-les uniquement lorsque passer un tableau ou une variable par référence simplifie vraiment le code.
La suppression de motif n'est pas une correspondance d'expression régulière
Les opérateurs d'expansion de paramètres tels que ${file%.log} et ${path##*/} utilisent des motifs shell, pas des expressions régulières. Cette différence est importante.
file='access.log'
printf '%s\n' "${file%.log}"
Cela supprime un suffixe .log. Cela ne signifie pas « supprimer tout ce qui correspond à une regex ». Pour les vérifications regex, utilisez [[ ... =~ ... ]] :
if [[ $port =~ ^[0-9]+$ ]]; then
printf 'numérique\n'
fi
Même là, citez soigneusement. Le côté droit de =~ est généralement laissé sans guillemets lorsque vous voulez qu'il soit traité comme une regex. La variable de gauche ne devrait pas avoir besoin de guillemets à l'intérieur de [[ ]], car [[ ]] n'effectue pas la division en mots comme le fait [ ].
Exportez uniquement ce dont les processus enfants ont besoin
Définir une variable dans Bash ne la rend pas automatiquement disponible pour les commandes que le script démarre :
APP_ENV=prod
./run-app
run-app ne verra pas APP_ENV sauf s'il est exporté ou fourni en ligne :
export APP_ENV=prod
./run-app
# ou
APP_ENV=prod ./run-app
C'est une source courante de confusion lorsqu'un script imprime la bonne valeur mais qu'un processus enfant se comporte comme si la valeur était manquante. La variable existe dans le shell ; elle n'a jamais été placée dans l'environnement pour l'enfant.
L'inverse est également vrai : un processus enfant ne peut pas changer les variables du shell parent. Si un script d'assistance imprime export TOKEN=..., l'exécuter normalement ne mettra pas à jour l'appelant. Vous devriez le sourcer, et le sourçage devrait être réservé au code shell de confiance.
Une revue du monde réel avant de livrer
Avant de déclarer un script ou une configuration de conteneur terminé, lisez-le une fois comme si vous étiez la prochaine personne qui devra le déboguer à 2 heures du matin. Cela change ce que vous remarquez. Une invite qui avait du sens lors de l'écriture du script peut être ambiguë lorsqu'elle apparaît dans un journal CI. Un nom de service Docker qui semblait évident peut ne pas correspondre au nom de variable dans l'application. Une valeur par défaut Bash peut être sûre pour le développement et dangereuse pour la production.
J'aime faire un court essai à sec avec des valeurs délibérément gênantes. Utilisez un chemin avec des espaces. Utilisez une valeur optionnelle vide. Essayez un nom de fichier qui commence par un tiret. Exécutez le script depuis un répertoire de travail différent. Démarrez le conteneur sans une variable d'environnement attendue. Ces tests ne sont pas sophistiqués, mais ils attrapent les hypothèses qui cassent généralement en premier.
Vérifiez également le message d'échec. Si la seule sortie est échec, les conseils de l'article ne se sont pas rendus dans l'implémentation. Un échec utile indique quelle valeur a été utilisée, quelle vérification a échoué et ce que l'opérateur peut changer. Cela ne signifie pas vider chaque variable d'environnement ou imprimer des secrets. Cela signifie être spécifique là où la spécificité aide : le chemin de configuration, le nom de commande manquant, le nom du réseau, le nom d'hôte du service ou le port que le processus a essayé de lier.
La dernière habitude est de garder les exemples proches de la façon dont le système est réellement exécuté. Si la production utilise Compose, testez avec Compose. Si un script est lancé par systemd, testez-le avec systemd ou avec un environnement tout aussi minimal. Si une commande est censée être sûre pour le copier-coller, incluez les guillemets, les séparateurs -- et la validation dans l'exemple lui-même. Les lecteurs copient les modèles de travail plus souvent qu'ils ne copient les avertissements.
Cette revue n'est pas de la bureaucratie. C'est ainsi que la petite automatisation reste ennuyeuse. L'ennui est ce que vous voulez des invites shell, des chargeurs de configuration, de l'expansion de variables, des diagnostics de conteneurs et du réseau Docker. Moins le comportement est surprenant, plus il est facile pour le prochain opérateur de lui faire confiance.
Pour l'expansion de variables spécifiquement, ajoutez une habitude supplémentaire à cette revue : imprimez le nombre d'arguments lorsqu'une commande se comporte étrangement. Un petit assistant peut rendre l'invisible visible :
show_args() {
local i=1
for arg in "$@"; do
printf 'arg[%d]=%q\n' "$i" "$arg" >&2
i=$((i + 1))
done
}
show_args mv $file $target_dir
show_args mv "$file" "$target_dir"
Le premier appel montre ce que la commande cassée recevrait ; le second montre la version corrigée. Une fois que vous voyez la liste d'arguments, les bugs de guillemets cessent d'être mystérieux.