URL: https://linuxfr.org/news/revue-des-techniques-de-programmation-en-shell Title: Revue des techniques de programmation en shell Authors: Michaël Benoît Sibaud, Nils Ratusznik, palm123, Nÿco, Yves Bourguignon et Pierre Jarillon Date: 2014年09月18日T20:45:46+02:00 License: CC By-SA Tags: programmation, shell, script_shell et ligne_de_commande Score: 114 L'été est fini, c'est la rentrée ! Pour se remettre en forme, rien de tel qu'une revue des techniques de programmation en *shell* — ces techniques sont pour le shell de Bourne `/bin/sh` mais peuvent être utilisées avec les shells compatibles qui permettent parfois des solutions plus faciles. ---- [ShellCheck (en ligne ou localement)](http://www.shellcheck.net/) [Obsolete and deprecated syntax (Bash Hackers wiki)](http://wiki.bash-hackers.org/scripting/obsolete) ---- *Note: comme ce texte est déjà très long, je propose que ceux qui ont aussi des choses à dire sur ce sujet le fassent dans des dépêches supplémentaires.* # Le dernier qui a fait ça chez nous est en train de sécher dans un faux plafond ============================================================================== Si vous souhaitez faire don de votre corps à un taxidermiste, utilisez l'une des techniques suivantes dans votre programme: les *backquotes*, la commande *echo* ou bien analysez la sortie de la commande *ls*. ## Les backquotes, backticks ou contr'apostrophes N'écrivez jamais ```sh candidates=`awk '{print(2ドル)}' /etc/fstab` ``` mais plutôt ```sh candidates=$(awk '{print(2ドル)}' /etc/fstab) ``` parce que c'est plus facile à lire, plus facile de présenter correctement une longue fonction, plus facile à emboiter, bref les *backquotes* sont une technologie obsolète. À propos, il est souvent plus facile et plus lisible de définir une fonction auxiliaire: ``` list_mount_points() { awk ' /^#/||/^$/{next} {print(2ドル)} ' /etc/fstab } candidates=$(list_mount_points) ``` Quand on a plus de place, c'est plus facile de mieux présenter son code, d'écrire un code plus robuste et le nom de la fonction décrit son rôle. ## La commande echo La fonction `echo` n'est utile que pour afficher des messages de diagnostic, si la fonction `printf` est disponible, il faut toujours préférer cette dernière, qui est plus puissante et plus facile à utiliser! On peut par exemple imprimer des codes de caractère arbitraires: ```sh TAB=$(printf '\t') CR=$(printf '015円') ``` et imprimer correctement toutes les variables ```sh attrape_nigaud='Pan!\cPan!' printf '>>> %s <<<\n' "$attrape_nigaud" echo "$attrape_nigaud" ``` L'appel à `printf` est bien défini mais pas celui à `echo` qui produit `Pan!` sous Debian/Jessie mais `Pan!\cPan!` sous FreeBSD. Rappelons la définition bien pratique de la fonction `eprintf` permettant d'écrire sur la sortie d'erreur standard : ``` eprintf() {>&2 printf "$@" } ``` ## Analyse de la sortie de la commande `ls` En bref la règle d'or est que la commande `ls` sert uniquement pour l'utilisation interactive du shell, dans le cas d'un script shell, on utilise soit le *globbing* soit la commande `find` en conjonction avec `xargs`. La raison est que la commande `ls` est inutilisable dès que les fichiers contiennent des caractères exotiques. Il est inutilement difficile d'analyser correctement la sortie de `ls` et il est tellement facile d'utiliser un *globbing*: ```sh for file in $(ls); do ← Exemple à ne pas suivre! printf '%s\n' "$file" done ``` est corrigé en ```sh for file in *; do printf '%s\n' "$file" done ``` ou bien, pour des recherches plus compliquées: ```sh NUL=$(printf '8f872742767daab9354ff276b9d5504afb94060400') find /path/to/dir -type d -name '* *' -print0 \ | while IFS="$NUL" read file; do printf '%s\n' "$file" done ``` L'information fournie par `ls -l` est facilement accessible avec `stat`. ## Construction de la ligne de commande Un problème analogue à celui de l'analyse la sortie de la commande `ls` est celui de la préparation de l'appel à une commande du type ``` utility -file file1 -file file2 ``` La version vite-fait mal fait consiste à dire: ```sh argv='' for file in *; do argv="$argv -file $file" ← Exemple à ne pas suivre done utility $argv ``` Évidemment, comme vos noms de fichiers utilisent un codage binaire où le ' ' représente ```0``` et le retour à la ligne représente ```1```, tout va de travers! La façon indestructible d'appeler *utility* est d'utiliser ```xargs -0``` comme dans: ```sh job_strategy() { local file for file in *; do printf '-file000円%s000円' "$file" done } job_strategy | xargs -0 utility ``` # Programmation structurée Les débutants commettent souvent l'erreur consistant à vouloir répliquer les structures d'enregistrement (les `struct` de C ou C++, les `record` de Pascal) dans des variables du shell. Mais ce n'est pas comme ça que ça marche! Un bonne règle pour l'organisation des données dans un programme shell est de de n'utiliser les variables que pour les objets du monde UNIX: chemins de dossiers, chemins de fichiers, pids, uids, etc. Tout le reste, en particulier les structures d'enregistrement, va dans des fichiers ou des *pipes*, sous forme de table séparée par des `|` ou par des caractères ASCII `US=$(printf '037円)` pour ceux qui vivent au pays des gens utilisant `|` dans leurs données — et les traitements complexes sont effectués par des filtres. Si ça ne convient pas à votre programme, changez de langage, utilisez OCaml, Perl ou Python. Bien-sûr, tout le monde n'est pas de cet avis, en particulier, les maîtres de la programmation shell. Les maîtres ont une caractéristique importante qui démultiplie leur faculté de jugement bien au delà de ce que peut imaginer un débutant : ce sont des maîtres. Si vous êtes un débutant, ne vous laissez pas impressionner par les maîtres qui ont déjà écrit un interpréteur Scheme en `sed` et utilisent la commande `printf` pour insérer du code shell auto-modifiant dans le noyau Linux. N'hésitez pas à utiliser un autre langage pour résoudre votre problème. ## Exemple du renommage de fichier Illustrons cette technique sur l'exemple classique du renommage de fichier en masse. La procédure finale se décompose en ```sh job_select | job_strategy | job_perform ``` On a trois fonctions correspondant aux étapes suivantes: 1. `job_select` prépare une liste des fichiers à renommer ; 2. `job_strategy` prépare une donnée tabulaire avec deux colonnes, l'une contenant le nom initial, l'autre le nouveau nom ; 3. `job_perform` qui fait la modification finale. On fait l'hypothèse paranoïaque que les caractères `/n` et `|` n'apparaîssent pas dans les noms de fichiers. Si ce n'est pas le cas, utilisez un vrai langage de programmation — mais les maîtres, eux, savent comment faire! La fonction `job_select` ressemble typiquement à ```sh job_select() { find /path/to/directory -name '*@!*' -print0 } ``` La commande `job_strategy` prépare un plan de travail: ```sh job_strategy() { sed -e' h s/@!/--censored--/g H x s/\n/|/' } ``` Ceux qui n'ont pas encore écrit d'interpréteur Scheme en `sed` peuvent lire le *walkthtough* suivant, en anglais parceque je ne sais pas dire *walkthrough* ou *pattern space* en français et que personne n'en comprendrait les traductions: ``` h Save pattern space to hold space s/.../.../ Edit the name in the pattern space H Append the edited name to the hold space x Exchange hold and pattern space s/\n/|/ Replace the newline by a field separator ``` Bien-sûr on peut utiliser n'importe quel autre langage que `sed` pourvu qu'on soit capable d'écrire un filtre transformant ``` Quel @! de patron ``` en ``` Quel @! de patron|Quel --censored-- de patron ``` Finalement la procédure de renommage ressemble à ```sh job_rename() { local oldname newname while IFS='|' read oldname newname; do if [ "$oldname" = "$newname" ]; then printf 'Skipping %s\n' "$oldname">&2 else mv "$oldname" "$newname" fi done } ``` Oui je sais, on peut faire la même chose avec `rename '/@!/--censored--/' *` ou avec la commande `@!` de `zsh`, mais il s'agit d'un exemple un peu scolaire pour illustrer la méthode. Un effet secondaire très appréciable de cette organisation est qu'on peut tester chaque morceau individuellement et faire du `mocking` facilement. Ainsi, si au lieu d'exécuter `job_select | job_strategy | job_perform` à la fin de mon script j'utilise simplement `job_select` je peux m'assurer que la liste de fichiers est correcte, et avec `job_select | job_strategy` je peux vérifier que mon plan de travail est correct. Aussi, je peux créer des données artificielles pour tester `job_strategy`, par exemple ```sh mock_issue_42_job_select() { cat <<'eof' This is an example of pathological input causing issue 42 EOF } mock_issue_42_job_select | job_strategy ``` ## Exemple de l'analyse de contenu de fichier vidéo Un exemple plus amusant est celui de cette fonction, issue d'un programme de vidéothèque que j'ai écrit en shell. ```sh # cinema_identify FILE # Identify the given file. # # Identifying the file causes a content index to be dumped on # stdout. This content index describes the content of the file. # There is two types of records, for Tracks and for Attachments. # # Track|INDEX|TYPE|CODEC # Attachment|INDEX|TYPE|SIZE|FILENAME # # An output example is: # # Track|0|video|V_MPEG4/ISO/AVC # Track|1|audio|A_VORBIS # Track|2|audio|A_VORBIS # Track|3|audio|A_VORBIS # Track|4|subtitles|S_VOBSUB # Track|5|subtitles|S_VOBSUB # Attachment|1|image/jpeg|93405|+POSTER # Attachment|2|text/plain|54|+INDEX cinema_identify() { env LANG=C mkvmerge --identify "1ドル" \ | sed -e ' s/'//g /^File/{ d } /^Track/{ s/ ID /|/ s/: /|/ s/ (\(.*\))/|/ } /^Attachment/{ s/ ID /|/ s/: type /|/ s/. size /|/ s/ bytes, file name /|/ }' } ``` Cette commande prend un fichier vidéo en argument et écrit sur la sortie un truc du genre ``` Track|0|video|V_MPEG4/ISO/AVC Track|1|audio|A_VORBIS Track|2|audio|A_VORBIS Track|3|audio|A_VORBIS Track|4|subtitles|S_VOBSUB Track|5|subtitles|S_VOBSUB Attachment|1|image/jpeg|93405|+POSTER Attachment|2|text/plain|54|+INDEX ``` Bien-sûr dans le vrai script, le script `sed` est dans un fichier auxiliaire pour, pour le test et la lisibilité. Voici comment traiter de façon polymorphe cette sortie: ```sh cinema_polymorph_example() { local field_type rest while IFS='|' read field_type rest; do IFS='|' set -- $rest case "$field_type" in Track) process_track "$@";; Attachment) process_attachment "$@";; *)>&2 printf 'Unknown field type %s\n' "$field_type"; exit;; esac done } ``` Dans cet exemple chaque ligne commençant par `Track` est traitée par `process_track` et chaque ligne commençant par `Attachment` est traitée par `process_attachment`. ## Liste des commandes travaillant avec des données tabulaires Voici une liste pêle-mêle de fonctions travaillant sur des données tabulaires. ``` awk(1) - pattern-directed scanning and processing language sed(1) - stream editor paste(1) - merge corresponding or subsequent lines of files join(1) - relational database operator comm(1) - select or reject lines common to two files cut(1) - cut out selected portions of each line of a file lam(1) - laminate files sort(1) - sort or merge records (lines) of text and binary files uniq(1) - report or filter out repeated lines in a file ``` Il faut bien noter que toutes ces fonctions n'utilisent pas forcément les mêmes options pour définir le délimiteur. # Introspection Oui, vous lisez bien, le shell permet de faire l'introspection, de façon limitée. Par exemple, voyons comment définir une mini base de données de *repositories* git à sauvegarder: ``` project1_repo='/var/git/project1.git' project1_refs='master v1.0 v2.0' project2_repo='/var/git/project2.git' project2_refs='master v1.0 v2.0' gitdb_list() { set | sed -n -e 's/_repo$//p' } gitdb_tabular() { local repo dir refs gitdb_list | while read repo; do eval printf '%s|%s|%s\n' "$repo" "\$${repo}_dir" "\$${repo}_refs" done } gitdb_backup() { local repo dir refs gitdb_list | while read repo dir refs; do git bundle create "/backup/$repo" "$repo_dir" HEAD $refs done } ``` Dans ce cas particulier, je préfèrerais utiliser directement une base de données tabulaires, mais si pour une raison ou une autre on ne devait utiliser que des variables shell pour transmettre l'information alors cette technique est utilisable.