20/09/2024

Les boucles ForEach

I. Présentation

Après avoir étudié les boucles For dans le précédent chapitre, nous allons nous intéresser à un second type : les boucles "ForEach" en PowerShell. Elles sont indispensables et c'est de loin celles que j'utilise le plus souvent dans mes scripts, ou directement dans la console. Nous allons étudier des exemples pour bien comprendre le fonctionnement des boucles "ForEach", mais avant évoquons le fonctionnement général et la différence vis-à-vis des boucles For.

II. Le principe des boucles ForEach

Une boucle "ForEach" va permettre de manipuler une collection de données dans son intégralité. Ceci est avantageux car la boucle "ForEach" va automatiquement traiter tous les éléments de notre collection, donc il n'y a pas besoin de connaître à l'avance le nombre qu'il y en a, comme c'est le cas avec une boucle "For".

Une boucle "For" est généralement utilisée lorsque vous savez combien de fois vous voulez que la boucle s'exécute. Par exemple, si vous voulez que quelque chose se répète exactement 10 fois, vous pouvez utiliser une boucle "For". Le comportement natif d'une boucle "ForEach", c'est de parcourir chaque élément d'une collection ou d'un tableau, donc il n'y a pas besoin de compter le nombre d'éléments ou la longueur du tableau (comme c'est le cas avec une boucle "For").

Pratique et simple d'apparence, elle peut s'utiliser de différentes façons avec, à chaque fois, les spécificités qui vont avec. L'utilisation est possible directement dans la ligne de commande au sein d'une console PowerShell, mais aussi dans un script avec une syntaxe adaptée. Nous verrons qu'il y a deux noms différents : "ForEach" et "ForEach-Object".

Ce chapitre est disponible au format vidéo :

III. Prise en main des boucles ForEach

A. Syntaxe d'une boucle ForEach

Dans un script, une boucle "Foreach" se déclare de façon relativement simple et classique pour du PowerShell. La syntaxe d'une boucle "ForEach" est la suivante :

Foreach (<élément> in <collection>

   # bloc d'instructions / traitement
}

La collection correspond à notre ensemble de valeurs, représenté par une variable. Il peut s'agir du contenu d'un fichier CSV, de la liste des processus en cours d'exécution sur votre PC, de la liste des utilisateurs locaux, de la liste des services, du résultat d'une recherche d'utilisateurs dans l'Active Directory, etc... ou d'un tableau déclaré et alimenté par vos soins. Tout ce qui permet de récupérer un ensemble de valeurs. Autrement dit, la collection peut être le résultat de la commande "Get-Service", tout simplement.

L'élément correspond à une variable qui va prendre pour valeur chaque élément à traiter de la collection de données. Par exemple, au premier tour de boucle, l'élément sera notre première ligne du fichier CSV, au second tour de boucle, ce sera la deuxième, etc... jusqu'à arriver à la fin du fichier. Cette variable est valide uniquement au sein de la boucle ForEach pour le traitement interne de la boucle.

Le bloc d'instructions correspond aux actions à réaliser à chaque tour de boucle sur l'objet en cours de traitement (variable de l'élément). Par exemple, si nous utilisons un fichier CSV qui contient une liste de nom et prénom, nous pouvons imaginer que nous allons vouloir créer un utilisateur dans l'Active Directory pour chaque ligne du CSV.

B. Boucle ForEach : premier exemple

Prenons un exemple : nous allons récupérer la liste des services sur notre PC (Get-Service) que l'on va stocker dans la variable "$collection", et afficher l'état du service, à savoir s'il est démarré ou arrêté, avec une phrase personnalisée.

$collection = Get-Service

Foreach ($element in $collection) 
{ 
   "$($element.Name) dans l'état : $($element.Status) ($($element.StartType))" 
}

De "façon naturelle", la boucle "ForEach" va parcourir l'ensemble de la collection, qu'elle soit constituée de 10 services, 50 services ou 200 services. Très pratique.

Bien entendu, ici, j'ai spécifié les noms "$element" et "$collection" pour les variables, mais vous pouvez utiliser d'autres noms... Le résultat contiendra le nom du service, son état actuel et son type de démarrage (manuel ou automatique).

Nous obtenons le résultat suivant :

PowerShell - Exemple boucle ForEach

Nous aurions pu faire la même chose avec cette syntaxe :

Foreach ($element in Get-Service) 
{ 
   "$($element.Name) dans l'état : $($element.Status) ($($element.StartType))" 
}

À chaque itération de la boucle "ForEach", la variable "$element" va prendre un nouvel élément de notre collection "$collection", à savoir un nouveau service. À chaque fois, il est possible d'accéder aux propriétés de l'objet et à ses méthodes. Ainsi, nous avons facilement pu récupérer le nom du service via la lecture de "$element.Name", ainsi que son état avec "$element.Status" et son type de démarrage avec "$element.StartType".

Cette syntaxe est très intéressante et performante puisqu'elle permet de réaliser un ensemble d'actions sur une collection complète de données chargée au préalable dans une variable. Attention à la consommation de mémoire sur votre PC puisque l'on va charger l'intégralité de la collection en mémoire avant traitement.

C. Boucle ForEach : second exemple

Nous allons évoquer un second exemple pouvant répondre à un besoin concret sur un ordinateur sous Windows. Nous allons utiliser une boucle "ForEach" pour effectuer le nettoyage d'un dossier contenant des fichiers temporaires. Notre objectif sera de supprimer tous les fichiers non modifiés depuis plus de 7 jours présents dans le répertoire "C:\TEMP" de la machine. Ceci va nous obliger à coupler l'utilisation de "ForEach" avec les structures conditionnelles.

Voici un code PowerShell permettant d'atteindre cet objectif :

# Chemin du dossier au sein duquel effectuer le nettoyage
$DossierCible = "C:\TEMP"

# Obtenir la liste de tous les fichiers présents dans le dossier cible
$ListeDesFichiers = Get-ChildItem -Path $DossierCible

# Parcourir la liste de fichiers avec une boucle ForEach
ForEach ($Fichier in $ListeDesFichiers) {

    # Si le fichier a plus de 7 jours, nous le supprimons, sinon, il sera conservé
    if ($Fichier.LastWriteTime -lt (Get-Date).AddDays(-7)) {

        # Supprimer le fichier (sans confirmation)
        Remove-Item -Path $Fichier.FullName -Force
    }
}

Ce script utilise une boucle "Foreach" pour analyser chaque fichier présent dans le dossier spécifié (variable "$DossierCible"). Pour chaque fichier, le script vérifie si la date de dernière modification est antérieure à 7 jours avant la date actuelle. Si c'est le cas, il supprime le fichier. Sinon, aucune action ne sera effectuée.

Notez que ce script supprime réellement les fichiers sans demander de confirmation ! Donc, si vous décidez de le tester, soyez prudent !

Remarque : la propriété "LastWriteTime" retournée par la commande Get-ChildItem permet d'obtenir la date de dernière modification d'un fichier. Pour s'appuyer plutôt sur la date de création, utilisez la propriété "CreationTime" à la place.

IV. Découverte de ForEach-Object

Une autre façon d'utiliser une boucle "ForEach", c'est de passer directement la collection d'éléments à traiter dans la boucle au travers d'un pipeline. Dans ce cas, la boucle ne s'appelle plus "ForEach" mais "Foreach-Object" bien que le mot clé "ForEach" fonctionne toujours, la différence est à signaler. Il est à noter également que "%" est un alias de "ForEach-Object".

Pour que ce soit plus simple pour vous de comprendre la différence au niveau de la syntaxe, reprenons le premier exemple évoqué précédemment. Si nous utilisons une boucle "Foreach-Object", et que nous voulons reproduire exactement le même résultat que précédemment, la syntaxe sera la suivante :

Get-Service | Foreach-Object { "$($_.Name) dans l'état : $($_.Status) ($($_.StartType))" }

Cette syntaxe comme elle tient sur une seule ligne et parfaitement adaptée à la console PowerShell. La commande "Get-Service" va envoyer toute sa collection d'objets à la boucle "Foreach-Object" qui va traiter chaque objet.

Avec cette syntaxe, nous laissons tomber notre variable $element puisque l'on peut récupérer directement l'élément en cours avec l'appel de la variable automatique : $_.

Bon à savoir : au niveau de la consommation mémoire, l'impact est plus faible qu'avec la méthode précédente car on traite les objets au fur et à mesure, on ne stocke pas toute la collection d'objets dans une variable avant de la traiter. Néanmoins, cette méthode est moins efficace d'un point de vue des performances. Parfois, il peut s'avérer utile de stocker la collection en mémoire dans une variable notamment si nous envisageons d'utiliser les données à plusieurs reprises.

Enfin, il faut savoir qu'à l'instar de la commande "Where-Object", la commande "Foreach-Object" propose, elle aussi, une syntaxe simplifiée. Si nous souhaitons afficher seulement une propriété (Name, par exemple), nous devons écrire ceci :

Get-Service | Foreach-Object { $_.Name }

Avec la syntaxe simplifiée (qui limite les possibilités), voici deux exemples sans et avec l'alias :

Get-Service | Foreach Name
Get-Service | % Name

Vous avez désormais connaissance des deux syntaxes possibles pour une boucle "ForEach" en PowerShell, avec les avantages et inconvénients de chaque méthode.

V. ForEach-Object et le paramètre Parallel

À l'occasion de la sortie de PowerShell 7 au début de l'année 2020, Microsoft a introduit une nouvelle fonctionnalité aux boucles "ForEach" utilisée via "ForEach-Object". Il s'agit d'une fonctionnalité qui sert à paralléliser le traitement des éléments reçus du pipeline grâce à plusieurs jobs exécutés en simultanés. Cette nouveauté est accessible par l'intermédiaire du paramètre "-Parallel".

Dans son comportement classique, une boucle ForEach-Object traite tous les éléments reçus par le pipeline de manière séquentielle, c'est-à-dire un par un, dans l'ordre. Avec le paramètre "-Parallel", la boucle ForEach-Object est capable de traiter plusieurs éléments en parallèle ! L'objectif étant de réduire le temps de traitement et d'exécution de vos scripts, mais attention, cette fonctionnalité ne doit pas être utilisée systématique car cela pourrait bien créer l'effet inverse. Explications.

Note : cette option fonctionne seulement pour la boucle de type ForEach-Object lorsqu'elle est utilisée à la suite du pipeline. Elle n'existe pas à ce jour pour une boucle de type ForEach(){} où l'on détermine la collection d'objets en amont, par exemple, sous cette forme : ForEach($element in $collection){ # instructions }.

A. Utilisation du paramètre -Parallel

Pour commencer, nous allons voir comment s'utilise ce paramètre au travers d'un exemple tout simple. Cela nous donnera l'occasion de comparer le comportement avec ou sans ce paramètre avec un cas de figure où je suis certain qu'il sera bénéfique.

L'exemple est le suivant : nous avons une collection qui contient les valeurs de 1 à 5 (que l'on peut obtenir via "1..5") et pour chaque valeur, nous allons écrire dans la console "Numéro <valeur>" et marquer une pause d'une seconde entre chaque itération.

Avec l'écriture classique d'une boucle "ForEach-Object", cela donne :

1..5 | ForEach-Object { 
         "Numéro $_"
         Start-Sleep -Seconds 1 
     }

Dans la console, nous obtenons le résultat attendu, à savoir :

Numéro 1
Numéro 2
Numéro 3
Numéro 4
Numéro 5

Grâce à la commande "Measure-Command", nous allons calculer le temps d'exécution de ce bloc afin d'avoir un temps de référence. Cette action s'effectue simple via :

(Measure-Command { 1..5 | ForEach-Object { 
                            "Numéro $_"
                            Start-Sleep -Seconds 1 
                        }
                 }).Seconds

Le résultat est : 5, c'est-à-dire 5 secondes. C'est cohérent puisque nous avons 5 valeurs et qu'il y a une pause d'une seconde à chaque fois.

Maintenant, nous allons ajouter une dose de parallélisation pour exécuter ce même bloc. Nous allons utiliser deux paramètres : "-Parallel" et "-ThrottleLimit". Le premier paramètre sert à activer la parallélisation sur la boucle "ForEach-Object" alors que le second indique le nombre de script blocs à exécuter en même temps. Par défaut, ThrottleLimit = 5.

La syntaxe est la suivante :

1..5 | ForEach-Object -Parallel { 
         "Numéro $_"
         Start-Sleep -Seconds 1 
     } -ThrottleLimit 5

Le résultat retourné par cette commande est le même qu'avec la méthode séquentielle. En revanche, il est intéressant de calculer le temps d'exécution de cette commande afin de voir si la parallélisation est bénéfique.

(Measure-Command { 1..5 | ForEach-Object -Parallel { 
                            "Numéro $_"
                            Start-Sleep -Seconds 1 
                        } -ThrottleLimit 5
                 }).Seconds

Miracle ! Le temps d'exécution est passé à 1 seconde seulement ! C'est logique, car avec la parallélisation, nous avons autorisé l'exécution de 5 scripts blocs en même temps (ThrottleLimit) donc la pause de 1 seconde incluse au traitement de chaque objet, n'affecte pas l'élément suivant puisque tout s'est fait en parallèle.

B. Les cas d'usage du paramètre -Parallel

Une boucle "ForEach-Object" exécutée avec le paramètre "-Parallel" s'appuie sur le principe des espaces de travail PowerShell appelés "Runspace" pour faire tourner plusieurs tâches en parallèle.

Il faut prioriser l'utilisation de cette option sur les machines équipées d'un processeur avec plusieurs cœurs afin d'optimiser les performances et ne pas risquer de saturer l'hôte. Pour utiliser cette option, il faut également déterminer si cela a un intérêt en fonction de l'action réalisée par votre boucle "ForEach-Object". Cela s'applique principalement dans les deux cas suivants :

  • Une boucle qui attend après quelque chose : si pour le traitement de chaque élément, vous attendez la fin d'une opération ou vous devez ajouter une temporisation, ce temps perdu peut-être limité grâce à la parallélisation. C'est sur un exemple de ce type, très simplifié, que portait le premier exemple de cet article.
  • Traitement d'une quantité importante de données : si pour chaque élément vous devez exécuter plusieurs traitements dans le bloc d'instructions et que ce sont des opérations longues, vous pouvez envisager d'utiliser la parallélisation pour en lancer plusieurs en même temps. Exemples : traitement sur des lots de fichiers, des fichiers journaux ou exécution d'actions sur des hôtes distants.

Dans certains cas, la parallélisation n'a pas d'intérêt et peut même allonger le temps d'exécution de votre script. En fait, le temps de création d'un nouvel espace de travail pour chaque instance demande de la ressource et du temps, donc cela peut alourdir votre traitement plus de l'optimiser.

Pour optimiser les performances et le temps de traitement, vous devez également ajuster le paramètre "-ThrottleLimit" pour autoriser plus ou moins d'espace de travail à se créer en parallèle sur votre machine.

C. L'isolation d'un espace de travail

Lorsque l'on s'appuie sur l'option Parallel, cela va utiliser le principe des espaces de travail : pour chaque traitement lancé, un espace de travail est créé et utilisé pour réaliser le traitement de l'objet. Ce contexte d'exécution crée une isolation de chaque espace de travail, ce qui n'est pas neutre : le runspace n'accède pas aux variables de votre programme principal, car il est isolé.

Prenons un exemple, à partir de celui vu précédemment. Nous allons définir une variable "$data" avec une valeur toute simple en dehors de la boucle "ForEach-Object"... Et nous allons appeler cette variable pour l'afficher dans la console. Ce qui donne :

$data = "IT-Connect"
1..5 | ForEach-Object -Parallel {
            Write-Output "$data - Numéro $_"
            Start-Sleep -Seconds 1 
       } -ThrottleLimit 5

Si nous exécutons ce code, nous obtenons la sortie ci-dessous. Ce qui prouve que l'espace de travail n'a pas accès à notre variable.

- Numéro 1
- Numéro 2
- Numéro 3
- Numéro 4
- Numéro 5

Pour qu'une variable soit accessible à l'intérieur de l'espace de travail, nous devons utiliser le mot clé "$using:" en préfixe. Par exemple pour la variable "$data" l'appel sera le suivant : "$using:data".

Si l'on applique cette méthode à notre exemple précédent, cela donne :

$data = "IT-Connect"
1..5 | ForEach-Object -Parallel {
            Write-Output "$using:data - Numéro $_"
            Start-Sleep -Seconds 1 
       } -ThrottleLimit 5

Cette fois-ci, le retour visible dans la console correspond à notre attente.

Néanmoins, il est important de préciser que cette méthode est contraire au principe d'isolation des runspaces et crée une violation de l'isolation. En effet, la variable se retrouve partagée entre les espaces de travail : soyez donc vigilant à l'usage que vous faites de cette variable.

En conclusion, je dirais que cette fonctionnalité de parallélisation peut s'avérer intéressante dans de nombreux cas, tout dépend du contenu de votre bloc d'instructions pour déterminer si cela est pertinent ou non de l'utiliser. Des tests seront à réaliser pour trouver le scénario le plus intéressant, que ce soit en termes d'utilisation des ressources, que du temps d'exécution. Pour cela, la commande "Measure-Command" que nous venons d'explorer rapidement sera votre alliée.

VI. Conclusion

Il est impératif de maîtriser les boucles "ForEach" (et l'alternative "ForEach-Object") en PowerShell car elles sont indispensables. Ceux qui seront plus à l'aise pourront explorer le paramètre "-Parallel" évoqué dans ce chapitre, mais ceci ne doit pas être votre priorité.


livre pour apprendre PowerShell
author avatar
Florian BURNEL Co-founder of IT-Connect
Ingénieur système et réseau, cofondateur d'IT-Connect et Microsoft MVP "Cloud and Datacenter Management". Je souhaite partager mon expérience et mes découvertes au travers de mes articles. Généraliste avec une attirance particulière pour les solutions Microsoft et le scripting. Bonne lecture.
Partagez cet article Partager sur Twitter Partager sur Facebook Partager sur Linkedin Envoyer par mail

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.