Entretien avec un débutant en bash

GNU/Linux Magazine n° 147 | mars 2012 | Etienne Dublé
Creative Commons
  • Actuellement 0 sur 5 étoiles
0
Merci d'avoir participé !
Vous avez déjà noté cette page, vous ne pouvez la noter qu'une fois !
Votre note a été changée, merci de votre participation !
L'autre jour, à la K-fête, je remarque un contenu inhabituel sur le tableau blanc. Sur plus de la moitié de la surface, il y a un script shell. Pas simplement des notes, mais tout le script, à l'instruction près. Je me rapproche pour voir à quoi il sert. Un gars, que j'ai déjà dû voir une ou deux fois dans le couloir, s'approche et me dit qu'il a un problème avec ce script. Je lui demande si c'est pour l'aider à réfléchir qu'il l'a recopié. Il me répond : « Non, c'est plutôt une technique pour repérer quelqu'un qui puisse m'aider ».

1. Contexte

Je n'étais pas franchement en manque d'activité, mais comme j'ai trouvé sa « technique de hameçonnage » plutôt originale, j'ai accepté de l'aider. On s'est donc dirigés vers mon bureau et il a commencé à m'introduire le problème.

Lui > En fait, au début du projet, comme la base de code à migrer vers IPv6 était importante, les chefs ont décidé de mettre en place un indicateur qualité à ce sujet. Un script a donc été écrit par une des équipes réseau. C'est du bash. Le script recherche dans le code source des chaînes de caractères qui correspondent à des incompatibilités IPv6.

Moi > Oui j'ai vu, il recherche des noms de fonctions de l'ancienne API, par exemple « gethostbyname »...

Lui > Oui, ou des adresses IPv4 écrites en dur... 127.0.0.1 ou 0.0.0.0 par exemple.

Moi > Je vois. Et donc, c'est quoi le problème avec ce script ?

Lui > Eh bien, il est trop lent. Au départ, il était intégré dans le système de build, ce qui fait qu'à chaque nouvelle version des paquets, il était lancé automatiquement. Et à partir du résultat, on récupérait le pourcentage de paquets qui passaient le test, ce qui alimentait la métrique qualité. Mais comme il était trop lent, ça ralentissait tout, donc ils l'ont désactivé. Il faut dire qu'il y a vraiment beaucoup de code à vérifier...

Moi > OK ben il faut y réfléchir un peu plus, mais d'après ce que j'ai vu, on peut paralléliser davantage les traitements.

Lui > Ah. Donc il faudra recoder avec un autre langage je suppose...

Moi > Comment ça ?? Bash est on-ne-peut-mieux indiqué pour la programmation parallèle !!! Toi il faut que je t'explique deux ou trois trucs...

2. Bash et la programmation parallèle

Moi > Finalement, ta remarque ne m'étonne pas trop : avec bash, la programmation parallèle est tellement intuitive qu'on ne s'en rend même pas compte, quand on en fait... Mais, dans certains cas, c'est mieux de s'en rendre compte...

2.1. L'opérateur « & »

Moi > Je ne te ferai pas l'injure de te présenter l'opérateur &, qui lance dans un sous-processus une commande, en parallèle... Le truc, c'est que la plupart du temps, il n'est utilisé qu'en ligne de commandes. Par contre, quand on commence à en mettre dans les scripts, alors c'est de la programmation parallèle...

2.2. Le pipe : « | »

Moi > Passons au pipe, qui est encore plus fascinant. Quand tu écris <commande1> | <commande2>, les commandes sont lancées dans 2 processus différents, d'où le parallélisme, et la sortie de <commande1> devient l'entrée de <commande2>. En écrivant cela, on décompose donc une tâche complexe en faisant coopérer deux commandes plus simples. Qu'est-ce que c'est sinon la parfaite expression de la philosophie UNIX ?? Le jour où j'ai réalisé ça, j'ai imprimé un « | » en taille 96 sur du papier photo et je l'ai mis au-dessus de mon lit avec une couronne de fleurs...

Lui > Moui c'est ça... tu exagères à peine.

Moi > Juste un chouïa. En tout cas, ce qui est clair, c'est que UNIX ne serait pas UNIX sans le shell ! Un autre exemple : pour appeler une commande externe dans un script, comment tu fais ?

Lui > Eh bien tu tapes la commande en question...

Moi > Exactement. Le shell est bien le seul langage que je connaisse où ça marche comme ça. Dans les autres, pour « sortir » du langage et appeler un programme externe, il faut utiliser une commande particulière, par exemple system() en C. En shell, tu es donc naturellement incité à réutiliser des commandes existantes.

Lui > Ouais c'est vrai.

Moi > Et au final, on se rend compte que bash, en lui-même, il ne fait pas grand-chose de plus que de gérer des boucles et des variables... Bref, il se borne à coordonner ces commandes externes. Il ne fait pas grand-chose, mais il le fait bien, et il participe à la tâche complexe, avec les commandes externes. Tu vois, on est en plein dans la philosophie UNIX.

2.3. L'aspect séquentiel

Lui > Ecoute, tu m'as convaincu sur le fait qu'avec bash, on introduit assez facilement du parallélisme. Et de manière très concise effectivement... Mais avoue quand même qu'on est limités au niveau des possibilités. Par exemple, je peux lancer des instructions en parallèle, mais comment je lance des séquences d'instructions en parallèle ?

Moi > Eh bien comme ça :

$ {

> echo a

> echo b

> } &

[1] 3276

$ a

b

Il suffit de créer un bloc d'instructions. On les crée avec des accolades, comme dans beaucoup de langages. Là j'ai écrit ligne par ligne, mais tu peux aussi écrire tout le bloc sur une ligne, en séparant les instructions par « ; ». Attention à la subtilité, dans ce cas, il faut aussi un « ; » avant le « } » :

$ { echo a; echo b; } &

[1] 3298

$ a

b

Si tu trouves cette construction peu lisible, tu peux aussi « nommer ton bloc », c'est-à-dire créer une fonction :

$ f()

> {

> echo a

> echo b

> }

$ f &

[1] 15393

$ a

b

Pour les constructions comme les boucles, c'est comme si on avait un bloc d'instructions implicite. En effet, on ne peut pas avoir, par exemple, le while et le do dans 2 processus différents... Donc dans ce cas, on n'a pas besoin des accolades. Voici un exemple, cette fois avec le pipe :

$ cat /var/log/syslog | while read Mot1 Mot2 Mots3etPlus

> do

> echo $Mot1 $Mot2

> done | tail -n 3

Oct 25

Oct 25

Oct 25

$

On lit les 2 premiers mots des 3 dernières lignes d'un fichier. On a 2 pipes, donc 3 processus, qui font chacun une partie du boulot. Regarde bien la boucle en while read, c'est une structure que j'ai beaucoup utilisée, surtout avant que je ne connaisse d'autres outils adaptés au traitement ligne par ligne, comme awk.

Un dernier exemple, que penses-tu de ceci :

$ i=0; cat /var/log/syslog | while read Ligne

> do i=$((i+1))

> done ; echo $i

0

$

Lui > Euh... Apparemment on veut compter le nombre de lignes du fichier... Mais ça renvoie  . Attends... Non je vois pas d'erreur... Pourquoi ça renvoie   ??

Moi > Eh bien parce que le fichier est vide ! Non je rigole, mon système n'est pas en vrac à ce point... :) Plus sérieusement, tout à l'heure je t'ai prévenu : quand on fait du parallélisme, c'est mieux de s'en rendre compte ! Car voilà l'explication : comme la boucle while est derrière le pipe, elle tourne dans un sous-processus. Or à la dernière instruction, ce qu'on consulte, c'est la variable i du processus initial, qui n'a pas bougé, elle.

Lui > C'est un peu subtil, mais je crois que j'ai compris.

Moi > Donc tu vois comment modifier pour que ça fonctionne ?

Lui > Voyons voir... Comme ça ?

$ cat /var/log/syslog | {

> i=0

> while read Ligne

> do

> i=$((i+1))

> done

> echo $i

> }

5688

$

Moi > Exactement. Grâce aux accolades, on peut grouper toutes les manipulations sur i dans le même processus, et il n'y a plus de problème.

2.4. L'opérateur « $() »

Moi > Il y a un autre opérateur plus ou moins lié au parallélisme, c'est l'opérateur $(<commandes>) ; on exécute <commandes> dans un sous-processus, et on récupère ce que celui-ci a écrit sur sa sortie standard. Par exemple :

$ echo "num_lignes=$(cat /var/log/syslog | wc -l)"

num_lignes=5688

$

Lui > C'est équivalent à l'écriture `<commandes>` ?

Moi > Oui, avec les back-quotes c'est l'écriture historique, l'inconvénient de celle-ci étant qu'on a le même symbole au début et à la fin. Du coup, on ne peut pas les imbriquer, contrairement à la notation $().

Lui > En même temps, tu n'as pas besoin de les imbriquer tous les quatre matins, si ?

Moi > Disons que chacun a ses habitudes, mais moi ça m'arrive très souvent. Pour prendre un exemple simple, j'utilise souvent ce genre de construction au début des scripts :

REPERTOIRE_DE_CE_SCRIPT=$(cd $(dirname $0); pwd)

De cette façon, j'obtiens forcément un chemin absolu quelle que soit la façon dont le script a été lancé.

Note

$0 est le chemin du script, tel qu'il a été indiqué sur la ligne de commandes quand on a lancé ce script. Par exemple, si on a tapé la ligne de commandes ../new/script.sh arg1 arg2, alors on aura $0 = ../new/script.sh.

Donc tu vois, c'est peut-être un peu moins standard, mais j'utilise le $(), et pas les back-quotes. Pour en revenir au parallélisme, là aussi on lance <commandes> dans un sous-processus. Par contre, le processus appelant attend que <commandes> se termine, donc on ne peut pas vraiment accélérer les choses de cette manière.

Au passage, tu as vu, pour compter les lignes, on peut utiliser un utilitaire dédié, wc, ce qui rend les choses quand même plus lisibles que la boucle de notre exemple précédent !

2.5. Des limites au sujet du parallélisme avec bash (?)

Moi > En fait, pour être honnête1, je pense qu'on peut quand même rencontrer quelques limitations au sujet du parallélisme avec bash. Par exemple, quand on a besoin de synchroniser finement les sous-processus. Mais là encore, on peut faire beaucoup de choses, par exemple :

- des fifos (voir mkfifo) ;

- des sémaphores (voir lockfile) ;

- des signaux (émission avec kill, réception avec trap) ;

- et sans doute d'autres choses auxquelles je ne pense pas tout de suite.

De toute façon, dans le cas où on rencontrerait des limitations, mais qu'on veut quand même utiliser bash pour diverses raisons, rien n'empêche de coder un petit exécutable dans un autre langage pour gérer uniquement la fonctionnalité manquante. Par exemple, un jour j'ai eu besoin de détecter l'appui sur une touche (sans attendre le relâchement de celle-ci), j'ai donc fait un petit programme en C pour gérer cela. Et j'ai pu réaliser le reste du code en bash, parce que j'estimais que c'était le langage le plus adapté pour ce que je voulais faire.

3. Un code moche et lent

Moi > Bon, je pense qu'on a fait à peu près le tour du parallélisme avec bash. On va pouvoir retravailler ton script. Tu en as une version électronique, ou bien il a toujours été transmis de tableau blanc en tableau blanc ?

Lui > (rires) Si... bien sûr... le voilà, il s'appelle script_moche_et_lent.sh (voir [ARTICLE] pour la version en ligne) :

#!/bin/bash

echo_space()

{

for ((a=1; a <= $1 ; a++))

do

  echo -n " "

done

}

echo_passed()

{

  echo -n "Success"

}

echo_failure()

{

  echo -n "Failed"

}

patterns="0\.0\.0\.0 127\.0\.0\.1 255\.255\.255\.255 AF_INET gethostbyaddr gethostbyname gethostbyname_ex Inet4Address inet_addr inet_aton inet_ntoa sockaddr_in"

name=$1

files=`find $name \( \

  -name \*.c -o -name \*.h -o -name \*.cpp -o \

  -name \*.hpp -o -name \*.java \)`

for pattern in $patterns

do

  occurences=''

  for f in $files

  do

    if [ `grep -c $pattern $f` -gt 0 ]; then

      occurences=`echo $occurences $f`

    fi

  done

  len=${#pattern}

  spc=$[20-$len]

  lenoc=${#occurences}

  v=( $occurences )

  if [ $lenoc -gt 0 ]; then

    echo -n $pattern; echo_space $spc; echo_failure $pattern

    echo

    i=2

    for e in ${v[@]#$name\/}

    do

      echo -n +; echo_space 19; echo $e

      (( i += 1))

    done

  else

    echo -n $pattern; echo_space $spc; echo_passed $pattern

    echo

  fi

done

(L'auteur se voit désolé de souiller un si beau magazine avec un code si peu intéressant.)

Moi > Par certains aspects, on dirait vraiment un code de débutant. Et ça ne m'étonne pas que ce soit lent ! Ils font même des calculs savants pour aligner des résultats... Alors que la commande printf existe aussi en bash, pourquoi ne pas l'utiliser ?

Lui > Moi non plus je ne savais pas que ça existait en bash...

Bon je te montre ce que ça donne à l’exécution. Je n'ai pas le vrai code du projet, mais on va prendre le code de mysql-server : ce paquet a tout ce qu'il faut pour nos tests, y compris des incompatibilités IPv6.

$ apt-get source mysql-server

[…]

$ cd mysql-5.1-5.1.58/

Lui > Et c'est parti...

$ time script_moche_et_lent.sh

0\.0\.0\.0          Failed

+                   ./storage/[...]/CommandInterpreter.cpp

+                   ./netware/mysqld_safe.c

127\.0\.0\.1        Failed

+                   ./storage/ndb/src/mgmsrv/ConfigInfo.cpp

+                   ./netware/mysql_test_run.c

[...]

255\.255\.255\.255 Success

AF_INET             Failed

+                   ./storage/ndb/test/src/CpcClient.cpp

[...]

real 1m8.371s

user 0m3.300s

sys 0m7.968s

$

(Plus d'une minute plus tard...)

Moi > Effectivement, c'est lent...

Lui > Je ne te le fais pas dire. Donc tu vois, ça recherche les motifs dans les fichiers de l'arborescence. Si on en trouve, on met « Failed » en face du motif, puisque les motifs correspondent à des incompatibilités IPv6. Ensuite, on liste les fichiers impactés, avec un « + » en début de ligne pour faire joli. Et sinon, on met « Success » en face du motif.

Moi > Je vois.

4. Optimiser la recherche de motifs

Moi > En tous cas, c'est clair que dans ton script il y a une partie que l'on va pouvoir rendre beaucoup plus rapide... Tu vois c'est où ?

Lui > Non, aucune idée.

Moi > Non ? Eh bien c'est comme dans tous les langages, en général, ce qui est le plus rentable, pour une optimisation, c'est ce qui se passe dans les boucles...

Lui > Ah ouais, c'est vrai que, pour le grep, on a même 2 boucles imbriquées... et plus loin aussi, là, pour l'affichage du résultat je suppose...

Moi > Ouais. Dans un premier temps, on va juste voir pour cette partie avec le grep. Parce que là, il y a de l'abus ! Le truc, c'est que grep est un programme externe, et non une commande interne au shell, donc il est lancé dans un nouveau processus, et il refait un parsing de ses arguments à chaque fois qu'on le lance, etc. Or ici, on le relance pour chaque motif recherché et pour chaque fichier !! On dirait qu'il a été conçu pour écrouler les serveurs, ce script...

Lui > Pourtant je ne crois pas...

Moi > Bon, le prototype de grep, c'est quelque chose comme :

grep <options> <motif_a_rechercher> <fichier1> [<fichier2> ...]

Donc déjà, boucler sur les fichiers, c'est une aberration. Il suffit de les mettre sur la ligne de commandes.

Lui > Ouais. Par contre, on ne peut mettre qu'un seul motif à rechercher...

Moi > Mouais. Mais en fait, ce motif, c'est une expression régulière. Donc, au final, on peut très bien rechercher plusieurs choses en même temps.

Lui > Ah bon ??

Moi > Oui. Regarde, par exemple, pour 3 motifs à rechercher, par exemple sockaddr_in, AF_INET et gethostbyname, on peut faire :

grep "\(sockaddr_in\)\|\(AF_INET\)\|\(gethostbyname\)" ... 

Il suffit de les mettre entre parenthèses et intercaler des OU logiques (avec le caractère « | » à nouveau, je l'aime ce caractère). Sauf qu'il faut des « \ » devant tous ces caractères spéciaux.

Mais si j'ai bonne mémoire, dans la page de manuel de grep, on a une solution encore plus simple...

(Un man grep plus tard) oui c'est ça, avec l'option -F, le motif est interprété comme une liste de motifs séparés par des '\n'. C'est exactement ce qu'il nous faut !! Voilà donc ce qu'on va écrire :

patterns="0.0.0.0 127.0.0.1 255.255.255.255 AF_INET gethostbyaddr gethostbyname gethostbyname_ex Inet4Address inet_addr inet_aton inet_ntoa sockaddr_in"

grep_patterns="$(echo $patterns | tr ' ' '\n')"

Tu vois, on avait la liste des motifs séparés par des espaces, avec le petit utilitaire de traduction tr, il est simplissime de remplacer ces espaces par des sauts de ligne.

Note

Avec l'option -F, grep n’interprète pas les motifs comme des expressions régulières, donc on n'a pas besoin de mettre un backslash devant chaque point dans les adresses IP (voir les premiers motifs de la liste), contrairement au script initial.

Lui > Sympa. Donc maintenant, on peut faire le grep -F ?

Moi > Ouais... Mais voyons si on n'a pas d'autres options à lui préciser...

Lui > OK...

Moi > L'option qui va bien pour nous, a priori, c'est -o. Cela permet de donner, en sortie, uniquement le motif reconnu et non toute la ligne, ce qui va grandement nous faciliter les traitements suivants. Il nous faut aussi le nom du fichier dans lequel on a vu le motif, donc l'option -H. Et enfin, une option que j'utilise très souvent, l'option -w. Cela permet de matcher uniquement des mots complets. Par exemple, si on voit AF_INET6 (la famille des sockets IPv6), il ne faut pas détecter AF_INET... D'ailleurs, à ce sujet, on dirait bien qu'il manque cette option -w dans ton script initial...

Lui > Effectivement... C'est un bug... De toute façon, je savais déjà que le résultat de ce script est une estimation...

Moi > Ah bon, comment ça ?

Lui > Eh bien récemment, on s'est aperçu que, quand les développeurs font la migration, souvent ils mettent l'ancien code en commentaire, ou ils indiquent en commentaire quelle était l'ancienne fonction qu'ils ont remplacée... Comme le script ne fait pas la différence entre les commentaires et le reste, ça fait des « faux-positifs »...

Moi > Je vois... Mais tu aurais dû me le dire plus tôt ! Il suffit qu'on enlève les commentaires du fichier avant de faire le test !

Lui > Tu plaisantes ?? Déjà que le script est trop lent !!

Moi > Le script initial est trop lent. Écoute, ça ne me paraît pas bien difficile à implémenter, donc ça ne coûte pas grand-chose d'essayer, et on enlèvera ce traitement si ça prend trop de temps.

Lui > OK...

5. « Décommenter » les fichiers

Moi > Bon, je vois déjà un utilitaire qui sait enlever les commentaires2 d'un fichier...

Lui > Lequel ?

Moi > gcc... C'est fait dans l'étape de précompilation. Et avec l'option -E, on peut indiquer qu'on veut effectuer uniquement cette étape. Mais j'ai déjà utilisé gcc pour faire ça dans un autre projet, et ce n'est pas particulièrement rapide, sans doute parce que gcc fait beaucoup d'autres choses dont on n'a pas besoin.

On va devoir faire un petit code maison. Restons sur du bash, ce sera le plus facile. Mais si tu veux améliorer les performances, ce serait pas mal de le recoder en C plus tard.

Lui > Je note. Le C, je maîtrise.

Moi > Bon, donne-moi 10 minutes et on voit ce que j'ai pu obtenir. En attendant, révise les pages de manuel de sed et awk, je pense en avoir besoin.

Lui > OK.

10 « minutes d'informaticien » plus tard...

Moi > Bon, voilà ce que j'ai :

comments_filter()

{

  sed   -e 's/\/\/.*$//g' \

    -e 's/\/\*/\n_COMMENT_\n/g' \

    -e 's/\*\//\n_END_COMMENT_\n/g' |

  awk   '

    { do_print=1 }

    /_COMMENT_/ , /_END_COMMENT_/   { do_print=0 }

    { if ( do_print==1 ) print $0 }

    '

}

Cette fonction est un filtre qui permet de décommenter du code C, Java, etc. On recherche donc des commentaires mono-lignes (débutant par double-slash) et multi-lignes (de slash-étoile à étoile-slash).

Pour les commentaires mono-lignes, c'est facile. Tu sais, une expression du type sed -e 's/<expression>/<remplacement>/g' permet de remplacer toutes les occurrences de <expression> par <remplacement>. Ce qui complique un peu l'écriture, c'est que dans <expression>, si on a des caractères comme * ou /, il faut mettre un backslash devant, car ce sont des caractères spéciaux ; pour comprendre la suite, on va donc faire abstraction de ces backslashs additionnels.

Si tu regardes la première expression du sed, je remplace ce qui correspond à //.*$ par... rien du tout : donc je supprime. Je ne vais pas te faire un cours sur les expressions régulières (voir [REGEXP]), mais là, la correspondance va bien du double-slash à la fin de la ligne : cette expression est donc suffisante pour supprimer les commentaires mono-lignes.

Pour les commentaires multi-lignes, c'est plus compliqué. L'idée est d'utiliser awk, qui est bien adapté aux traitements multi-lignes. Mais on peut avoir des cas relativement difficiles à gérer, par exemple :

start_server(AF_INET /* mon commentaire

difficile a

supprimer */, "127.0.0.1");

Dans ce cas, il faut filtrer une partie seulement de la 1ère et de la 3e ligne, ce qui n'est pas évident avec awk, qui est, à mon avis, surtout efficace pour travailler sur des lignes complètes. Qu'à cela ne tienne, si c'est ce qu'il veut, on va lui en fournir, des lignes complètes. En effet, grâce aux deux dernières expressions du sed, notre exemple sera transformé en :

start_server(AF_INET

_COMMENT_

mon commentaire

  difficile a

  supprimer

_END_COMMENT_

, "127.0.0.1");

Tu vois, les marqueurs de début de commentaire et de fin de commentaire sont maintenant isolés sur une ligne, grâce aux \n dans le sed. Et comme, apparemment, plus il y a de backslashs dans le code, plus tu fronces les sourcils, j'en profite pour remplacer les marqueurs /* et */ par des mots qui ne contiennent pas de caractères spéciaux.

À partir de là, le code du awk est plus facile à comprendre. L'idée, avec awk, c'est qu'on a un ensemble de couples <expression, traitements_associes>. Pour chaque ligne en entrée, on parcourt les expressions, et si la ligne correspond à l'expression, on applique les traitements.

Ici, j'ai utilisé 2 types d'expressions :

- l'absence d'expression (lignes 1 et 3 du awk) : dans ce cas, les traitements sont toujours effectués, quelle que soit la ligne en entrée.

- l'expression <regexp1>,<regexp2> (ligne 2 du awk) : si on trouve une ligne en entrée l1 qui correspond à <regexp1> et une autre l2 qui correspond à <regexp2>, alors les traitements seront effectués sur toutes les lignes intermédiaires, l1 et l2 incluses.

Donc voilà comment on pourrait traduire ce code awk en pseudo-code :

pour chaque ligne en entree

do_print = 1
 si la ligne est comprise entre _COMMENT_ et _END_COMMENT_ inclus
 do_print = 0
 fin si
 si do_print == 1
 afficher la ligne

fin si

 fin pour

Lui > Je vois.... Ce qui est étrange, c'est que le code complet du filtre, avec le sed et le awk, n'est guère plus long que ce pseudo-code, qui ne fait pourtant qu'une petite partie du travail !

Moi > Ben oui, c'est concis. (Si on compare avec ta future implémentation en C, si tu réussis à la faire tenir en 8 petites lignes, ça m'intéresse...). Tu vois, à mon avis, pour faire du beau code en bash, il faut choisir les bons outils, les faire coopérer, et s'arranger pour que chacun fasse ce qu'il sait faire le mieux. En raisonnant à l'inverse, j'aurais peut-être pu tout gérer avec awk, mais cela ne me paraît pas évident, et le code aurait peut-être été 3 fois plus long. En plus, là j'obtiens 2 processus qui vont travailler en parallèle autour d'un pipe, donc ça me semble assez efficace.

Lui > Ouais. Mais ça me paraît quand même relativement lourd d'appliquer ça sur chaque fichier. Et puis tu ne trouves pas que ça va un peu à l'encontre de notre optimisation du grep, où on s'est arrangés pour ne faire qu'une opération, qui traite tous les fichiers d'un coup ?

Moi > Si, mais j'ai une idée. Cette opération de suppression des commentaires, on ne va l'appliquer qu'à un nombre restreint de fichiers, en suivant l'algorithme suivant :

1) On fait un grep global et on en déduit une liste de fichiers « suspects ».

2) On décommente chaque fichier « suspect ».

3) On refait un grep sur les fichiers décommentés.

Un fichier « suspect » est donc un fichier qui contient au moins un des motifs recherchés, soit dans le code, soit dans un commentaire. Avec les étapes 2 et 3, on affine la recherche en ne l'appliquant qu'au code.

La liste de fichiers suspects devrait être restreinte, car normalement on n'éparpille pas la gestion du réseau dans tous les fichiers d'un projet : il n'y a donc pas de raison de trouver ces motifs dans des fichiers qui gèrent une autre fonctionnalité. Du coup, l'opération 2 sera appliquée à un petit nombre de fichiers, et l'opération 3 également.

Lui > C'est pas bête...

Moi > Voilà ce que ça donnerait :

DEC_FILES_DIR=/tmp/dec_files_$$

mkdir -p $DEC_FILES_DIR

files=$(find . \(   \

  -name \*.c -o   \

  -name \*.h -o   \

  -name \*.cpp -o \

  -name \*.hpp -o \

  -name \*.java \))

real_file_names=$(

  index=1

  grep -l -w -F "$grep_patterns" $files | while read suspect_file

  do

    echo $suspect_file

    cat $suspect_file | comments_filter > $DEC_FILES_DIR/$index

    index=$((index+1))

  done

)

grep -o -H -w -F "$grep_patterns" $DEC_FILES_DIR/* | \

  display_result $real_file_names

rm -rf $DEC_FILES_DIR

Pour le grep global, j'ai juste mis l'option -l (et non -o -H comme plus loin), car à ce stade, on veut juste le nom des fichiers (suspects).

Ensuite, on décommente chaque fichier suspect et on met les fichiers décommentés dans un répertoire DEC_FILES_DIR, en leur donnant pour nom un compteur incrémental (voir la variable index). Par la suite, pour un index donné, on sera capable de retrouver le vrai nom du fichier, grâce à la ligne echo $suspect_file qui alimente la variable real_file_names. Cette variable contiendra ainsi l'ensemble de ces noms de fichiers, dans l'ordre.

Lui > C'est quoi le $$ dans la variable DEC_FILES_DIR ?

Moi > C'est une variable spéciale qui donne le numéro du processus (PID) correspondant au script en cours d'exécution. De cette façon, j'obtiens un nom de répertoire unique. C'est une technique archi-classique qui permet d'éviter les soucis, au cas où on lancerait plusieurs instances du script simultanément.

Lui > OK. Il nous manque encore la fonction display_result.

Moi > Tout à fait, on y vient...

6. Afficher les résultats

Lui > Il faut qu'on fasse bien attention à cette partie, parce que, comme je te disais, il y a un indicateur qualité qui appelle ce script, et je n'ai pas accès au code de l'indicateur en lui-même. Donc si on veut upgrader le script sans souci, il faut qu'on fasse bien attention à respecter le formatage qu'on obtenait avec le script initial.

Moi > Je vois.

Bon, alors, en sortie du grep final, donc sur l'entrée standard de notre fonction display_result(), on obtient des lignes sous le format <chemin_du_fichier_decommente>:<motif>. Avec tr, on peut déjà remplacer le : par un espace pour séparer les infos. Ensuite, dans l'affichage final, c'est plutôt trié par motif, pas par fichier, donc on peut inverser les deux champs avec awk et refaire le tri avec sort. Donc je verrais bien quelque chose comme ça :

display_result()

tr ':' ' ' | awk '{ print $2 " " $1 }' | sort -u | {

 <traitements_additionnels>

 }

}

Note bien que, comme on a nommé les fichiers décommentés avec un compteur incrémental, <chemin_du_fichier_decommente> correspond en fait à <DEC_FILES_DIR>/<index>. On pourra donc récupérer l'index plus loin, avec la fonction basename.

Après le sort, pour ce que j'ai nommé <traitements_additionnels>, l'algorithme va être un peu subtil. Donc, prenons un exemple.

Voilà les lignes que l'on pourrait avoir à ce stade :

<motif1> <fichier_x>

<motif4> <fichier_y>

<motif4> <fichier_z>

[...]

Comme on a trié suivant les motifs, dès la 2e ligne, on sait que <motif2> et <motif3> ne seront pas présents dans les lignes suivantes. On peut donc déjà conclure qu'il n'y a pas d'incompatibilité IPv6 détectée à leur sujet.

Au final, on doit donc obtenir :

<motif1>            Failed

+                   <fichier_x>

<motif2>            Success

<motif3>            Success

<motif4>            Failed

+                   <fichier_y>

+                   <fichier_z>

<motif5>            Failed

+                   <fichier_y>

[...]

Habituellement, quand on écrit des boucles en bash, on boucle sur les lignes qui arrivent en entrée. Mais ici on voit bien que ce n'est pas bien adapté, car il nous faut aussi gérer <motif2> et <motif3>, qui ne sont pas présents.

Notre boucle principale sera donc plutôt indexée sur la liste des motifs recherchés (la variable patterns définie au début du script). Comme ça, on est sûrs de ne pas en oublier. Mais on va quand même, en parallèle, parcourir séquentiellement les lignes en entrée. Ainsi, au fur et à mesure que l'on avance dans la liste de motifs, on pourra comparer le motif en cours avec celui indiqué sur la ligne en cours, et agir en conséquence. Voilà le pseudo-code :

Lire une ligne -> enregistrer motif_vu, fichier_vu
Pour chaque motif (de la variable 'patterns')

Si motif_vu == motif

Alors

Afficher le motif et « Failed »
 Tant que motif_vu == motif

Afficher '+' et fichier_vu

 Lire une ligne -> enregistrer motif_vu, fichier_vu

Fin tant que
 Sinon

Afficher le motif et « Success »

Fin si

 Fin pour

Lui > Aïe aïe aïe, ma tête...

Moi > Ouais c'est pas super simple... Mais ce n'est plus une question de bash, c'est de l'algorithmique...

Lui > Ouais... Mais bon, ça a l'air de marcher... par contre... il faut quand même que, dans la boucle Pour, on récupère les motifs dans l'ordre alphabétique, non ? Donc il faut que ceux-ci soient correctement ordonnés dans la variable patterns au début du script ?

Moi > En effet. C'est le cas actuellement, donc il n'y a pas de souci. Mais je suis d'accord avec toi, c'est un point important, et si on ne veut pas que des bugs apparaissent dès que quelqu'un ajoute un motif dans la liste, je vois 2 solutions : soit tu ajoutes un commentaire bien visible (en majuscules par exemple) à côté de cette variable, soit tu implémentes un tri avec la commande sort au début du script. Pour ce genre de choses, un peu ingrates et sans intérêt, je te laisse gérer...

Lui > Toi tu as le beau rôle et tu en profites... Mais OK c'est noté.

Moi > Bon, si on traduit le pseudo-code en bash, on peut à présent compléter notre fonction :

display_result()

{

  tr ':' ' ' | awk '{ print $2 " " $1 }' | sort -u | {

    read pattern_found file_path

    for pattern in $patterns

    do

      if [ "$pattern_found" = "$pattern" ]

      then

        printf "%-20sFailed\n" $pattern

        while [ "$pattern_found" = "$pattern" ]

        do

          file_index=$(basename $file_path)

          file_name=$(eval "echo \${$file_index}")

          printf "%-20s%s\n" '+' $file_name

          read pattern_found file_path

        done

      else

        printf "%-20sSuccess\n" $pattern

      fi

    done

  }

}

Moi > Dans le pseudo-code, j'avais simplifié un tout petit peu, en oubliant qu'on avait cette histoire d'indice au sujet des noms de fichiers. Mais sinon, c'est calqué sur le pseudo-code...

Lui > Joli… Enfin... sauf le eval "echo \${$file_index}", qu'est-ce que c'est que cette horreur ??

Moi > Eh bien, tu sais, en bash, les paramètres d'une fonction sont stockés dans des variables accessibles via $1, $2, $3, etc. On peut aussi les écrire ${1}, ${2}, ${3}, les accolades permettant de spécifier où s'arrête le nom de la variable. En fait, à partir du 10e paramètre, on est obligés de spécifier les accolades, car sinon $10 serait interprété comme ${1}0, et non pas comme ${10}.

Je ne sais pas si tu te rappelles, mais lors de l'appel à cette fonction display_result(), on lui a passé en paramètres les noms de fichiers réels, pour pouvoir les retrouver à partir de leur index. Donc, à partir d'un index de fichier <file_index>, pour retrouver son nom, il suffit de lire le paramètre de fonction correspondant, soit ${<n>}, avec <n>=<file_index>.

La difficulté est que le nom de cette variable à lire, <n>, est dynamique. On fait donc intervenir la commande eval, qui permet justement d'interpréter du code écrit dynamiquement. Et enfin, pour ce qui est du contenu à évaluer, il est très simple... Il fallait juste penser à écrire le backslash devant le 1er dollar. On évite ainsi que celui-ci soit interprété au départ ; il le sera dans un 2e temps, au moment du eval.

Tout cela te paraît plus clair ?

Lui > Je crois oui... Bon, a priori, on a fait le tour ? On passe au test ?

Moi > Oui. Tu as tout regroupé ?

Lui > Oui, j'ai appelé notre nouvelle version script_optimise.sh (voir [ARTICLE] pour la version en ligne).

7. Le test

Lui > Tu crois vraiment qu'on sera plus rapides qu'avec la version précédente, en tenant compte de l'opération additionnelle (et relativement lourde) de suppression des commentaires ?

Moi > Je pense oui... On a quand même bien optimisé. Mais tant qu'on n'a pas testé, on ne peut pas en être sûrs. Et puis j'ai noté un défaut inhérent à notre algorithme, qui pourrait impacter les performances...

Lui > Lequel ?

Moi > Je te montrerai plus tard. Testons déjà comme ça, pour voir.

$ time script_optimise.sh

[...]

real 0m0.983s

user 0m0.204s

sys 0m0.114s

$

Lui > Une SECONDE ??!! L'autre tournait en une MINUTE !!!

Moi > Ouais... on dirait qu'on a bien assuré. Mais il faut quand même comparer les résultats avec ce que nous donnait l'ancien script. Voilà une autre tâche ingrate pour toi...

Lui > OK je reviens...

Quelques minutes plus tard...

Lui > Les résultats sont cohérents... Il y a quelques différences mais ça ne pose pas de problème, au contraire :

Premièrement, certaines choses étaient détectées à tort par l'ancien script, et avec le -w ajouté dans le grep, les fichiers en question n'apparaissent plus.

Deuxièmement, les motifs correspondant aux adresses IP n'apparaissent plus avec les backslashs. C'est également une correction de bug, cette fois-ci assez mineure.

Ensuite, les fichiers pour un motif donné ne sont pas listés dans le même ordre, mais ça n'a pas d'importance.

Et enfin, pour l'histoire des motifs dans les commentaires, je n'ai pas vu de cas de ce genre dans les sources de mySQL. Donc j'ai modifié un fichier pour faire le test, et ça marche bien !

8. Bilan (et quelques infos de dernière minute)

Moi > Donc tout va bien on dirait...

Lui > Oui, et je n'en reviens pas... C'est de la sorcellerie ton truc !! Le nouveau script est 60 fois plus rapide alors qu'il fait des choses en plus (avec la suppression des commentaires) !! Comment c'est possible ??

Moi > Écoute, il n'y a pas de miracle... On a réfléchi et optimisé en conséquence... Et puis franchement, à la lecture du script initial, on voit bien qu'il n'a rien pour être rapide, donc ce n'est pas un exploit non plus...

Tu sais, a priori, le gain vient surtout du fait qu'on a supprimé tout un tas d'appels à la commande grep (il y en avait un par motif et par fichier...). Pour le reste, si tu regardes les « | » par exemple, notre nouveau script en est truffé, alors que dans le script initial, il n'y en avait aucun. On est donc parti d'un script strictement séquentiel et on en a fait quelque chose de hautement parallèle, ce qui est bien mieux adapté aux architectures d'aujourd'hui.

Lui > Ouais... Mais je ne pensais pas qu'on gagnerait autant !

Moi > Alors je suis content pour toi.

Lui > Au fait, ton supposé « défaut inhérent à notre algorithme », de quoi s'agissait-il ?

Moi > Regarde, tu vas comprendre.

$ mount | grep /tmp

$ sudo mount -t tmpfs tmpfs /tmp

[sudo] password for etienne:

$ mount | grep /tmp

tmpfs on /tmp type tmpfs (rw)

$ time script_optimise.sh

[...]

real 0m0.979s

user 0m0.204s

sys 0m0.114s

$

Moi > Non, apparemment il n'y a pas de différence significative3... Tu vois ce que je voulais vérifier ?

Lui > Euh... Tu as testé en montant /tmp en mémoire... Tu craignais que notre gestion des fichiers temporaires ne soit pénalisante à cause des accès au disque dur ?

Moi > Oui exactement. Mais apparemment notre OS préféré est suffisamment performant en matière de caching pour qu'on ne note pas de différence.

En tout cas, d'habitude, je n'utilise jamais de fichiers temporaires. Plutôt que de faire une écriture de fichier du genre :

<traitements> > <fichier>

je préfère stocker dans une variable, en faisant :

<variable>=$(<traitements>)

Et pour récupérer le contenu, on fait un echo sur la variable au lieu d'un cat sur le fichier.

Mais là, comme notre 2e grep doit travailler sur les fichiers décommentés, il fallait bien les écrire, ces fichiers.

Lui > Je vois. Bon. On le considère comme finalisé ce script, alors ?

Moi > En fait, il y a encore quelque chose que je modifierais à ta place. Si on fait tourner notre script dans un répertoire contenant beaucoup de fichiers, la variable files contient tous ces fichiers, donc la ligne qui contient notre premier grep risque de s'allonger rapidement. Et sur des systèmes moins récents4, on obtient dans ce cas une erreur « Argument list too long ». Mais la commande xargs -0 donne une solution à ce problème, donc je te conseille de regarder ça et de faire le changement.

Lui > OK... Et en dehors de ça ?

Moi > Tout de suite, je ne vois rien de plus, sauf si tu veux continuer à optimiser...

Lui > Non, c'est largement suffisant pour mon besoin. Mais par curiosité, qu'est-ce que tu ferais pour optimiser davantage ??

Moi > Eh bien, à ce stade, je commencerais par étudier l'impact de chaque partie de notre algorithme sur le temps total, pour savoir où on peut encore optimiser. Dans le cas de la partie suppression de commentaires, tu pourrais essayer de recoder le filtre en C, comme je te disais. Dans le cas de la partie affichage, je ne serais pas étonné que la commande eval soit la plus lourde, donc il serait peut-être intéressant de la lancer une seule fois, à la fin, pour qu'elle résolve tous les noms de fichiers d'un coup. Ou bien, si tu trouves une autre solution pour nommer nos fichiers décommentés, tu pourras peut-être te passer de cette commande.

Lui > Je vois. En tout cas merci, tu m'as aidé largement au-delà de ce que j'espérais, et j'ai appris pas mal de choses. Cela dit la technique du tableau blanc m'a rarement déçu.

Moi > Moi aussi le résultat me plaît : quand on essaie d'améliorer un script, ce n'est pas toujours aussi probant. Mais, à voir le script initial que tu m'as présenté, j'imagine que les gars avaient bien peu d'expérience avec bash, donc on avait une bonne marge de progression... ou bien ils ont oublié de réfléchir en codant...

Liens

[ARTICLE] http://sites.google.com/site/etienneduble/articles/bash

[REGEXP] http://www.shellunix.com/regexp.html

Tags : bash, diff, HTML, langages