Question [Fonction] ConvertFrom-ConsoleProgram

Plus d'informations
il y a 15 ans 6 mois #7771 par Laurent Dardenne
Dernièrement je me demandais comment généraliser ce type de traitement , à savoir le parsing de chaînes de caractères issues d'un utilitaire, ici handle.exe .
Bien que PowerShell soit basé objet, il est parfois nécessaire de s'appuyer sur des outils spécialisés natif win32, par exemple ceux proposés par Sysinternals, Handle.exe étant l'un de ceux là.
On peut donc rédécouvirir un des principes des shells sous Unix, s'appuyer sur des programmes spécialisés qui ne font qu'une seule chose, mais vite et bien.
Paradoxalement la pratique de PowerShell nous éloigne de ces outils spécialisés puisque basés texte.

Dans ce post j'essairais de proposer une solution générique qui éviterais de créer autant de wrapper que de programme externes utilisés.
Allons-y !

Première réécriture du script cité, on utilise la construction du switch couplé à un pipeline tout en précisant l'option -Regex :
[code:1]
#Accessible sur \\live.sysinternals.com\Tools
# Dir \\live.sysinternals.com\Tools
#
# \\live.sysinternals.com\Tools\Handle.exe
#
# cd C:\temp; copy-item \\live.sysinternals.com\Tools\Handle.exe

$o=switch -RegEx (&\"C:\Tools\sysinternal\Handle\handle.exe\"«»)
{
'^(?<program>\S*)\s*pid: (?<pid>\d*)\s*(?<user>.*)$' {
$matches| %{
$id = $_.pid
$program = $_.program
$user = $_.user
}
continue
}

'^\s*(?<handle>[\da-z]*): File \((?<attr>...)\)\s*(?<file>(\\\\)|([a-z]:«»).*)' {
$matches |
select @{n=\"Pid\";e={$id}},
@{n=\"Program\";e={$program}},
@{n=\"User\";e={$user}},
@{n=\"Handle\";e={$_.handle}},
@{n=\"attr\";e={$_.attr}},
@{n=\"Path\";e={$_.file}}
}
}
[/code:1]
On fait la même chose, mais pas de la même manière. Comme il est dit dans le fichier about_switch.help.txt :
\"Une instruction switch est, dans le fond, une série d'instructions If.\"
Si on regarde la grammaire de PS, un des intérêts de l'instruction switch est qu'il est construit autour d'une structure clé-code :
[code:1]
switch -RegEx (&\"Programme_Console_Externe\"«»)
{
# \"Condition\" {Code}
PourUneRegex EstAssociéUnScriptblock
}
[/code:1]
La ligne PourUneRegex EstAssociéUnScriptblock renvoi à une construction d'une entrée de hashtable, essayons :
[code:1]
$h=new-object System.Collections.Specialized.OrderedDictionary
$h.'^(?<program>\S*)\s*pid: (?<pid>\d*)\s*(?<user>.*)$'={
$matches| %{
$id = $_.pid
$program = $_.program
$user = $_.user
}
continue
}
$h.'^\s*(?<handle>[\da-z]*): File \((?<attr>...)\)\s*(?<file>(\\\\)|([a-z]:«»).*)'={
$matches |
select @{n=\"Pid\";e={$id}},
@{n=\"Program\";e={$program}},
@{n=\"User\";e={$user}},
@{n=\"Handle\";e={$_.handle}},
@{n=\"attr\";e={$_.attr}},
@{n=\"Path\";e={$_.file}}
}
[/code:1]
On doit utiliser une classe spécialisée de hashtable afin de respecter l'ordre d'insertion qui ici à son importance.
Notez que les variables automatiques $matches et $_ sont renseignés, la dernière avec le contenu de la chaîne en cours d'analyse.
Maintenant on peut dire qu'à chaque condition de l'instruction switch correspond une entrée de la hashtable.
Chaque entrée de la hashtable permet de construire l'intégralité d'une ligne de notre switch, c'est ici qu'entre en jeux le dynamisme de PowerShell:
[code:1]
$Program=\"C:\Tools\sysinternal\Handle\handle.exe\"
$Code=@\"
switch -regex (&\"$Program\"«»)
{
$(
$h.GetEnumerator()|% { \"'$($_.Key)' {$($_.Value.ToString())}\" }
)
}
\"@
$o=Invoke-Expression $Code
$o
[/code:1]
Cette construction fonctionne. On peut également construire la clause Default :
[code:1]
$h.default={Write-host \"Ligne non gérée.`r`n$_\"}
[/code:1]
On peut reprocher à cette réécriture qu'elle ne fait qu'ajouter du code là ou un script spécialisé suffit.
C'est pas faux, comme dirait le moderne Perceval.
On construit du code à partir de données hébergées dans une hashtable, ces données représentant du code.

Le plus souvent les programmes console listent leur informations ordonnées et par groupe, dans ce cas pour quelques-uns de ces programmes, certaines des conditions de l'instruction switch n'ont plus de raison d'être à un moment donné.
Un des intérêts de cette construction est qu'elle permet de supprimer dynamiquement des conditions devenues inutiles au fur et à mesure du déroulement du traitement.
Même si celui-ci ne génére pas nombreuses lignes, prenons l'exemple du programme PsLoggedon.exe qui liste les utilisateurs loggués sur la machine.
On contruit deux listes, la première contenant les utilisateurs ayant un session active et la seconde pour ceux qui loggués via un share.
On paramètre le nom de la collection à renseigner dans les scriptblocs associés aux clés (regex) :
[code:1]
$h=new-object System.Collections.Specialized.OrderedDictionary
#Bien que la hashtable soit ordonnée, ici on n'ordonne pas
#les clés, on place en premier la regex la plus récurrente.
#
#C'est la sortie du programme qui détermine l'ordre d'exécution
#des différentes parties de code.

#Construit l'objet initiale hébergeant des collections
#On traite, dés le début du parsing, la liste Locally
$h.'^Users logged on locally:$'={
Write-Debug \"$($switch.current)\"
$UsersLoggedOn=New-Object PSObject -Property @{
Locally=New-Object System.Collections.ArrayList;
ViaResourceShares=New-Object System.Collections.ArrayList
}
$isLocally=$true
$UsersLoggedOn
continue
}
$h.'^\s*(?<LogonTimes>\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2})\s*(?<Account>.*)$'={
Write-Debug \"via resource shares\"
$matches |
Foreach {
if ($isLocally)
{$MemberName=\"Locally\"}
else
{$MemberName=\"ViaResourceShares\"}
[void]$UsersLoggedOn.$MemberName.Add((New-Object PSObject -Property @{LogonTimes=$_.LogonTimes;Account=$_.Account}))
}
continue
}
#On traite désormais la seconde liste, ViaResourceShares
$h.'^Users logged on via resource shares:$|^No one is logged on via resource shares.$'={
Write-Debug \"Locally off-Share on\"
$isLocally=$false
continue
}

$Program=\"C:\Tools\sysinternal\PsTools\PsLoggedon.exe\"
$ErrorActionPreference=\"SilentlyContinue\"
$Error.Clear()
$code=@\"
switch -regex (&\"$Program\" 2>&1)
{
$(
$h.GetEnumerator()|% { \"'$($_.Key)' {$($_.Value.ToString())}\" }
)
}
\"@
$o=iex $code
$o.Locally
$Error
[/code:1]
La redirection (2>&1) coupler au paramètrage de $ErrorActionPreference évite un affichage parasite (bug de PsLoggedon.exe ?) sur la console.
Ce n'est qu'un pis aller car les informations 'parasites' se retrouvent dans la variable $Error...

Les deux listes sont bien renseignées au fur et à mesure du parsing des données renvoyées par le programme PsLoggedon.exe.
Le scriptblock associé à la clé '^Users logged on locally:$', peut être supprimé une fois qu'il a été exécuté, dans notre cas il est exécuté une seule fois.

Le principe est le suivant, on modifie la structure du switch afin qu'il traite une chaîne de caractères et non plus l'appel du programme.

Ensuite dans le scriptblock de la clé $h.'^Users logged on locally:$' on supprime dans la hashtable $h cette même clé, puis on reconstruit le scriptblock exécutant le code du switch.
Celui-ci étant désormais utilisé avec le cmdlet Foreach :
[code:1]
$h=new-object System.Collections.Specialized.OrderedDictionary
$h.'^Users logged on locally:$'={
Write-Debug $_
$UsersLoggedOn=New-Object PSObject -Property @{
Locally=New-Object System.Collections.ArrayList;
ViaResourceShares=New-Object System.Collections.ArrayList
}
$isLocally=$true
$UsersLoggedOn
#Supprime la clé courante
$h.RemoveAt(0)
#Reconstruit le code
#l'itération suivante, dans le Foreach principal,
# utilisera le nouveau code
$sb=$Code.ExpandString().NewScriptBlock()
continue
}
$h.'^\s*(?<LogonTimes>\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2})\s*(?<Account>.*)$'={
Write-Debug \"via resource shares \" #`r`n $sb\"
$matches |
Foreach {
if ($isLocally)
{$MemberName=\"Locally\"}
else
{$MemberName=\"ViaResourceShares\"}
[void]$UsersLoggedOn.$MemberName.Add((New-Object PSObject -Property @{LogonTimes=$_.LogonTimes;Account=$_.Account}))
}
continue
}
#On traite désormais la seconde liste, ViaResourceShares
$h.'^Users logged on via resource shares:$|^No one is logged on via resource shares.$'={
Write-Debug \"Locally off-Share on\"
$isLocally=$false
continue
}

$Program=\"C:\Tools\sysinternal\PsTools\PsLoggedon.exe\"
$code=@'
switch -regex (`$_)
{
$(
$h.GetEnumerator()|% { \"'$($_.Key)' {$($_.Value.ToString())}\" }
)
}
'@
$sb=$Code.ExpandString().NewScriptBlock()
$ErrorActionPreference=\"SilentlyContinue\"
$o=&\"$Program\" 2>$null|% $sb
$o.Locally
$sb
[/code:1]
L'objet $O est bien renseigné, le scriptblock ($sb) construit à partir de la hashtable $h ne contient plus que 2 conditions et le pipeline exécute bien le nouveau code $sb reconstruit.

Dernière étape, placer cette mécanique dans une fonction dédiée.
On remplace la portion de code suivante :
[code:1]
$h.RemoveAt($RemoveIndex)
$sb=$Code.ExpandString().NewScriptBlock()
[/code:1]
par l'appel de la fonction New-sbSwitch :
[code:1]
New-sbSwitch -RemoveIndex 0
[/code:1]
Celle-ci, créée par et dans la portée de la fonction ConvertFrom-ConsoleProgram, permet au scriptblock associé à une clé de connaître la hashtable auquel il est rattaché sans avoir à préciser le nom de la variable contenant cette hashtable :
[code:1]
function New-sbSwitch([int]$RemoveIndex=-1) {
if ($RemoveIndex -ne -1 )
{ $hCopy.RemoveAt($RemoveIndex) }
#crée la variable dans la portée de l'appelant
new-variable sbSwitch ($ExecutionContext.InvokeCommand.NewScriptBlock($ExecutionContext.InvokeCommand.ExpandString($SwitchCode))) -scope 1 -Force
}
[/code:1]
Cette fonction construit le code du parsing. On l'utilisera uniquement dans le cas où l'on supprime une clé, c'est à dire une des conditions de test du switch.

La fonction ConvertFrom-ConsoleProgram déclare dans sa portée la variable $sbSwitch et la fonction New-sbSwitch.

On appel, dans la fonction ConvertFrom-ConsoleProgram, une première fois la fonction New-sbSwitch, celle-ci crée la variable $sbSwitch dans la portée parente ( celle de la fonction ConvertFrom-ConsoleProgram).

On exécute également dans la fonction ConvertFrom-ConsoleProgram les scriptblocks associés aux clés de la hashtable $hCopy, et dans ceux-ci on appel une enième fois la fonction New-sbSwitch.
Ainsi on est assuré d'adresser une seule et même instance de la variable $sbSwitch.

On ne peut donc ni accéder à $sbSwitch ni à New-sbSwitch en dehors de cette portée. L'appel suivant ne fonctionnera pas :
[code:1]
$o=ConvertFrom-ConsoleProgram $Program $h -RedirectStdErr|% {gv sbSwitch ; New-sbSwitch -RemoveIndex 0 }
[/code:1]
Le code de la fonction ConvertFrom-ConsoleProgram :
[code:1]
function ConvertFrom-ConsoleProgram{
param ([string] $Program,
[System.Collections.IDictionary] $h,
[switch] $RedirectStdErr)
#Construit le code de parsing des résultats d'un programme console
# $Program définie le path du programme console à exécuter.
# $H est une hashtable contenant la définition du code du parsing.
# $RedirectStdErr indique que l'on redirige le flux d'erreur sur la console
# standard du programme

function New-sbSwitch([int]$RemoveIndex=-1) {
#Crée dynamiquement le code du parsing
#On force la redéfinition de la variable sbSwitch
#
#$RemoveIndex indique l'index de la clé
#à supprimer dans la hashtable définissant
# le code du switch
if ($RemoveIndex -ne -1 )
{ $hCopy.RemoveAt($RemoveIndex) }
#crée la variable dans la portée de l'appelant
new-variable sbSwitch ($SwitchCode.ExpandString().NewScriptBlock()) -scope 1 -Force
}

$hType=$h.GetType()
#En cas de suppression d'une entrée,
#on ne modifie pas la hashtable d'origine
#cf. multiples exécutions
if ($hType.GetMethod('Clone'))
{ $hCopy=$h.Clone() }
else
{
#La classe de la hashtable n'implémente
#pas la méthode Clone().
#On la reconstruit via la méthode Add
$hCopy=New-Object $hType.Fullname
$h.GetEnumerator()|
Foreach { $hCopy.Add($_.Name,$_.Value) }
}

$SwitchCode=@'
#Write-Debug `\"SbSwitch : `$_`\"
switch -regex $('($_)')
{
$(
$hCopy.GetEnumerator()|% { \"'$($_.Key)' {$($_.Value.ToString())`t}`r`n\" }
)
}
'@

#On crée la variable sbSwitch
#contenant le code du switch
New-sbSwitch
#On exécute le code du switch.
#On propage dans le pipeline les objets
#créés par le scriptblock $sbSwitch
if ($RedirectStdErr)
#on redirige le flux d'erreur sur la console.
{&\"$Program\" 2>&1|Foreach $sbSwitch}
else
{ &\"$Program\"|Foreach $sbSwitch}
#Lors de l'exécution du code du scriptblock $sbSwitch,
#celui-ci peut se reconstruire dans la portée parente,
#c'est dire celle du segment de pipeline où il est exécuté.
}
[/code:1]
Ici une des difficultés est de s'y retrouver dans les portées, ce n'est qu'une question d'entraînement. Une autre est de construire le code mentalement, là où dans un langage statique on ne fait, la pluspart du temps, que lire une description figée.

Voir aussi What Scope Am I In ? .

Dans la version finale on ne déclare plus que le nom du programme à appeler et la hashtable associée :
[code:1]
$h=new-object System.Collections.Specialized.OrderedDictionary
$h.'^Users logged on locally:$'={

Write-Debug $_
$UsersLoggedOn=New-Object PSObject -Property @{
Locally=New-Object System.Collections.ArrayList;
ViaResourceShares=New-Object System.Collections.ArrayList
}
$isLocally=$true
$UsersLoggedOn
New-sbSwitch -RemoveIndex 0
continue
}

$h.'^\s*(?<LogonTimes>\d{2}/\d{2}/\d{4} \d{2}:\d{2}:\d{2})\s*(?<Account>.*)$'={
Write-Debug \"via resource shares\"
$matches |
Foreach {
if ($isLocally)
{$MemberName=\"Locally\"}
else
{$MemberName=\"ViaResourceShares\"}
[void]$UsersLoggedOn.$MemberName.Add((New-Object PSObject -Property @{LogonTimes=$_.LogonTimes;Account=$_.Account}))
}
continue
}
$h.'^Users logged on via resource shares:$|^No one is logged on via resource shares.$'={
Write-Debug \"Locally off-Share on\"
$isLocally=$false
continue
}

$Program=\"C:\Tools\sysinternal\PsTools\PsLoggedon.exe\"
$o=ConvertFrom-ConsoleProgram $Program $h -RedirectStdErr
$o
[/code:1]
Notez qu'entre la version simple basée sur le switch et celle appelant une méthode, le temps d'exécution différe entre 100 et 300 ms environ.

La pluspart des programmes émettent des données permettant de construire les objets au fur et à mesure, ce qui est le cas de Handle.exe, les objets sont tout de suite \"prêt à l'emploi\".

En revanche pour :
-psloggedon.exe l'objet final n'est \"prêt à l'emploi\" qu'une fois toutes les données émisent,
-SVN.exe -log -xml les objets sont émis au fur et à mesure, mais on ne doit émettre qu'un seul objet de type tableau afin de le convertir en un objet XML.

Ajoutons la prise en compte de ces différents cas :
[code:1]
function ConvertFrom-ConsoleProgram{
[CmdletBinding(DefaultParameterSetName=\"Stream\"«»)]
param (
[Parameter(Position=0, Mandatory=$true)]
[string] $Program,

[Parameter(Position=1, Mandatory=$true)]
[System.Collections.IDictionary] $h,

[Parameter(Position=2, Mandatory=$false)]
[string] $Parameters=[String]::Empty,

[switch] $RedirectStdErr,

[Parameter(ParameterSetName=\"NoStream\"«»)]
[switch] $NoStream,

[Parameter(ParameterSetName=\"NoStream\"«»)]
[Parameter(ParameterSetName=\"AsArray\"«»)]
[switch] $AsArray
)
#Construit le code de parsing des résultats d'un programme console
# $Program définie le path du programme console à exécuter.
#
# $H est une hashtable contenant la définition du code du parsing.
#
# $Parameters contient les paramètres assiciés au programme $Program.
#
# $RedirectStdOut indique que l'on redirige vers $null la sortie
# standard du programme.
#
# $NoStream indique l'émission des objets en une seule fois,
# par défaut ils sont émis au fur et à mesure de leur création.
#
# $AsArray indique que les données du programme sont émis dans
# un seul objet de tye tableau.
# Ce switch ne peut être utilisé qu'en présence du switch -NoStream.
#
Begin {
function New-sbSwitch([int]$RemoveIndex=-1) {
#Crée dynamiquement le code du parsing
#On force la redéfinition de la variable sbSwitch
#
#$RemoveIndex indique l'index de la clé
#à supprimer dans la hashtable définissant
# le code du switch
if ($RemoveIndex -ne -1 )
{ $hCopy.RemoveAt($RemoveIndex) }
#crée la variable dans la portée de l'appelant
new-variable sbSwitch ($ExecutionContext.InvokeCommand.ExpandString($SwitchCode).NewScriptBlock()) -scope 1 -Forc }

$hType=$h.GetType()
#En cas de suppression d'une entrée,
#on ne modifie pas la hashtable d'origine
#cf. multiples exécutions
if ($hType.GetMethod('Clone'))
{ $hCopy=$h.Clone() }
else
{
#La classe de la hashtable n'implémente
#pas la méthode Clone().
#On la reconstruit via la méthode Add
$hCopy=New-Object $hType.Fullname
$h.GetEnumerator()|
Foreach { $hCopy.Add($_.Name,$_.Value) }
}

$SwitchCode=@'
#Write-Debug `\"SbSwitch : `$_`\"
switch -regex $('($_)')
{
$(
$hCopy.GetEnumerator()|% { \"'$($_.Key)' {$($_.Value.ToString())`t}`r`n\" }
)
}
'@

#On crée la variable sbSwitch
#contenant le code du switch
New-sbSwitch
} #begin
Process {

$cmd=\"$Program $Parameters\"
if ($RedirectStdErr)
#on redirige le flux d'erreur sur la console.
{$cmd +=' 2>&1' }

#On exécute le code du switch.
#On propage dans le pipeline les objets
#créés par le scriptblock $sbSwitch
if ($NoStream)
{
write-debug $cmd
#Lors de l'exécution du code du scriptblock $sbSwitch,
#celui-ci peut se reconstruire dans la portée parente,
#c'est dire celle du segment de pipeline où il est exécuté.
$o=iex $cmd |Foreach $sbSwitch
if ($AsArray)
{,$o}
else
{$o}
}
else
{
write-debug $cmd
iex $cmd|Foreach $sbSwitch
}
} #process
} #ConvertFrom-ConsoleProgram

ConvertFrom-ConsoleProgram $Program $h -RedirectStdErr|% {Write-host $($_.Locally) -fore green}
#ras
ConvertFrom-ConsoleProgram $Program $h -RedirectStdErr -noStream|% {Write-host $($_.Locally) -fore green}
#@{Account=PCTEST\User1; LogonTimes=11/09/2010 11:56:57}
[/code:1]
Emission d'un seul objet en tant que tableau :
[code:1]
#le répertoire doit être 'versionné' via Subversion
$Projet=\"G:\ps\Add-Lib\Add-Lib\trunk\"
cd $projet
$h=@{}
$h.\".*\"={$_}
$xml=ConvertFrom-ConsoleProgram \"C:\Dev\SlikSvn\bin\SVN.exe\" $h \"log -r 1:75 --xml\" -NoStream -AsArray|
Foreach {[xml]$_}|
Microsoft.PowerShell.Utility\Select-Xml -XPath \"//LogEntry\"
$xml[0].node
$xml|select -ExpandProperty node
# revision author date msg
#

---- ---
# 1 laurent-dardenne 2009-09-16T19:24:56.594071Z Corrections des noms de fi...
# ...
[/code:1]
Pour ce dernier exemple on peut ne pas utiliser la fonction ConvertFrom-ConsoleProgram :
[code:1]
[xml]$xml=iex \"C:\Dev\SlikSvn\bin\SVN.exe log -r 1:75 --xml\"
$xml.log.logentry
[/code:1]
Une idée à creuser :P
[edit]
Message édité par: Laurent Dardenne, à: 13/09/10 15:20

Correction de l'appel à ExpandString : $ExecutionContext.InvokeCommand.ExpandString

Message édité par: Laurent Dardenne, à: 18/12/11 17:15
Correction de l'appel à NewScriptblock<br><br>Message édité par: Laurent Dardenne, à: 2/02/12 13:01

Tutoriels PowerShell

Connexion ou Créer un compte pour participer à la conversation.

Temps de génération de la page : 0.067 secondes
Propulsé par Kunena