PowerShell Pipeline

Eine große Stärke von PowerShell ist die Möglichkeit, Skripte zu verketten. Ausgaben von einem Skript oder Commandlet können an das nächste Commandlet übergeben werden. Das gab es natürlich auch schon bei MS-DOS und schon immer bei UNIX. Allerdings werden hier zwischen den Prozessen dann nur "Strings" übergeben.

Bei MS-Dos war es sogar so, dass erst ein Skript komplett durchgelaufen war, ehe die Daten aus der Pipeline dann dem zweiten Skript übergeben wurde. MS-Dos war nun mal kein "Multi Tasking System" mit mehreren Threads. PowerShell hingegen kann auf der Windows Basis auch problemlos mehrere Skripte ausführen und damit die Verarbeitung von Daten aus der Pipeline direkt fortsetzen. Allerdings ist die Pipeline damit nicht schneller, wie ich auf PS Parallel beschrieben habe.

Der große Unterschied zwischen PowerShell und anderen Diensten ist aber das Format der Pipeline. Mit PowerShell können komplette Objekte übergeben werden und nicht nur "Texte". Daher ist die PowerShell Pipeline ein sehr interessantes Thema auch für die Eigenentwicklung von Funktionen.

In dem Zuge möchte ich auf einen englischen Artikel verweisen, den ich später gefunden habe und der sehr lesenswert is:
Tips on Implementing Pipeline Support http://learn-powershell.net/2013/05/07/tips-on-implementing-pipeline-support/ 

Eingaben über INPUT$

Der einfachste Weg eine Information aus der Pipeline in einem eigenen Skript weiter zu verwenden ist die Systemvariable "INPUT$. Die Variable ist automatisch vordefiniert, wenn Daten aus der Pipeline übergeben werden.

function Teil1 {
   # langsame Ausgabe von Startwerten
   foreach ($count1 in (1..4)) {
      Write-host "Teil1: Sende $Count1"
      $count1
      start-sleep -seconds 2 
   }
}

function Teil2 {
   foreach ($count2 in $input){
      write-host "Teil2: $count2"
   }
}

Teil1 | Teil2

Damit werden die Daten aber seriell abgearbeitet, d.h. erst wenn der vorherige Prozess beendet ist, kann der folgende Prozess weiter machen.

 

Einfach und schnell aber nicht schön.

Eingaben über Begin, Process, End

Powershell kennt über die Bezeichner Begin, Process, End auch die Möglichkeit, das Skript aufzuteilen. So kann man als Entwickler z.B. globale Variablen und Objekte instanziieren und effektiv nutzen. In Verbindung mit der Pipeline hingegen eröffnet dies neue Wege, da nun die Prozesse überlappt ablaufen.

function Teil1 {
   # langsame Ausgabe von Startwerten
   foreach ($count1 in (1..4)) {
      Write-host "Teil1: Sende $Count1"
      $count1
      start-sleep -seconds 2 
   }
}

function Teil2 {

   Begin { write-Host "Teil2:Start"}
   Process {
      foreach ($count2 in $input){
         write-host "Teil2: $count2"
      }
   }
   End { write-Host "Teil2:End"}
}

Teil1 | Teil2

In der Powershell ist gut zu sehen, dass nun beide Funktionen miteinander verzahnt laufen und der Teil2 sogar noch vor dem Teil 1 eine Ausgabe erzeugt.

Hier nutzt ich die Variable "$INPUT" weiterhin, obwohl sie nun nicht mehr die komplette Pipeline sondern nur das eine Element enthält. Alternativ können Sie natürlich auch "$_" verwenden.

Wenn Sie mit Begin, Process, End arbeiten, darf außer eines Param-Blocks kein Code außerhalb stehen. sonst wird dieser ausgeführt und ein Fehler bezüglich "Begin" generiert.

Der Scope von Variablen in dem Begin, Process und End-Block ist Skriptlokal, d.h. eine Variable, die Sie als Vorarbeit im BEGIN-Block definieren und füllen, können Sie im Process-Block problemlos weiter verwenden.

Pipeline und Objekte

Wie ich anfangs geschrieben habe, ist die Verwendung der Pipeline natürlich nicht nur auf Zahlen und Strings beschränkt. Das Objekt in der Pipeline kann auch ein volles PowerShell-Objekt sein. Hier ein anderes Beispiel mit "$_" und einem Objekt

begin { 
    Write-Host 'Testskript: ============== started ============== '
}
process {
    Write-Host "Testskript: ------ Pipelinedata --------"
    $pipedata = $_
    $fields = ($_ | gm -MemberType NoteProperty) 
    foreach($item in $fields) {
        Write-Host "Name:" $item.name " Daten: " $pipedata.($item.name)
    }
}
end {
    Write-Host "Testskript: ==============  ended ============== "
}

Ab PowerShell 3 können Sie statt "$_" auch "PSItem" schreiben.

Interessanter ist bei der Konstruktion die Arbeitsweise. Wer damit mehrere Prozesse miteinander verkettet, wird erkennen, dass alle Prozesse parallel ablaufen, d.h. es ist eine permanente Abarbeitung möglich.

Hier noch ein Beispiel in getrennten Skripten. Kopieren sie die beiden Skripte in ein Verzeichnis.

script1.ps1.txt
script2.ps1.txt

Starten Sie diese dann mit folgendem Aufruf:

script1.ps1 | script2.ps1

Und sie werden sehen, dass Skript 1 startet aber dann auch gleich Skript 2 und jede Ausgabe, die Skript 1 produziert von von Skript 2 gleich weiter verarbeitet wird.

Wenn Sie in solch einer Konstruktion noch eigene Funktionen addieren wollen, dann sind diese sinnvoll nur innerhalb des "begin"-Blocks zu definieren. Wenn die sie Lücke weg lassen, dann setzt die PowerShell das Skript automatisch als "END"-Block.

Pipeline und $_
Ich habe mir angewöhnt, das Pipelineobjekt in eine Variable namens $pipeline zu kopieren und dann damit zu arbeiten. Zu oft habe ich innerhalb des "process"-Blocks wieder mit FOR-Schleifen gearbeitet und mir so die Variable überschrieben.

Es ist meines Wissens nicht möglich, die Pipeline bidirektional zu nutzen, d.h. ein direkter "Rückweg" zum aufrufenden Prozess ist verbaut. Denkbar wäre aber z.B. der Umweg über eine Datei (Prozess2 schreibt) und Prozess1 prüft.

Pipeline Parameter

Es gibt noch einen weiteren interessanten Weg mit der Pipeline zu arbeiten. Bei der Definition von Parametern können Sie auch diese als Pipeline-Parameter definieren.

function Teil1 {
   # langsame Ausgabe von Startwerten
   foreach ($count1 in (1..4)) {
      Write-host "Teil1: Sende $Count1"
      $count1
      start-sleep -seconds 2 
   }
}

function Teil2 {
   param (
      [Parameter(ValueFromPipeline=$true)]
      $variable1
   )
   Begin { write-Host "Teil2:Start"}
   Process {
      foreach ($count2 in $variable1){
         write-host "Teil2: $count2"
      }
   }
   End { write-Host "Teil2:End"}
}

Teil1 | Teil2

Durch die Angabe von "ValueFromPipeline" wird die Variable durch die Pipeline gefüllt. Es muss auch nicht immer die erste Variable sein. Die Varaiblen können durch den Property-Name bestimmt werden, wenn Sie die aktivieren. Die beiden folgenden Schlüsselworte sind hier relevant:

PowerShell und "zwei Pipelines"

Es kann eigentlich nur eine aktive Pipeline geben. Und so stoßen Sie bei Exchange 2010 z.B. bei folgendem Befehl auf einen Fehler:

get-mailbox | $userlist | %{ set-mailbox -Identity $_.identity -SimpleDisplayName $_.displayname}

ERROR: Pipeline not executed because a pipeline is already executing. Pipelines cannot be executed concurrently.

Trennen Sie aber die Zeilen dann funktioniert es:

$userlist = get-mailbox
$userlist | %{ set-mailbox -Identity $_.identity -SimpleDisplayName $_.displayname}

Eine andere Lösung ist der Verzicht auf die Pipeline mit einer For-Schleife:

foreach ($mb in get-mailbox) {
    set-mailbox -Identity $mb.identity -SimpleDisplayName $mb.displayname}
}

Ein einfaches get-mailbox | set-mailbox hingegen funktioniert. Das liegt an der Kombination der Pipeline mit einem FOR-Operator, der dann in der Befehlsgruppe wieder ein eigenständiges Commandlet startet. Dieses versucht nun auch auf die gleiche Pipeline zuzugreifen bzw. diese zu erstellen und das geht nicht. Das ist aber nur ein "Sonderfall" beim Einsatz mit RBAC.

PowerShell und Output Pipeline

Genauso kann es natürlich interessant sein, Ausgaben in eine Pipeline zu schreiben. Wie wäre es, wenn ein Skript z.B.: die Mitglieder einer Verteilerliste extrahiert und nicht stumpf in eine Datei schreibt, sondern Sie in eine PIPE übergibt, so dass Sie selbst mit Export-CSV oder Export-CliXML oder andere Tools darauf zugreifen können ? Auch das geht per PowerShell.

Achtung
Ich habe schon Effekte gesehen, wenn die übergebenen Werte keine neuen Objekte oder einfache Variablen waren.
Beispiel: Sie instanziieren eine Ausgabevariable mit "new-object PSObject" und füllen die gleiche Variable immer mit neuen Werten und geben diese aus, dann bekommt der nächte Prozess eventuell überschriebene Werte.

Man muss einfach nur ein PowerShell Objekt erstellen, mit Properties und Werten versehen und "ausgeben.

Write-Host "Testskript: ------ Pipelinedata --------"
$pso = New-Object PSObject
$pso | Add-Member NoteProperty "ColumnA" "Data1"
$pso | Add-Member NoteProperty "ColumnB" "Data2"
$pso | Add-Member NoteProperty "ColumnC" "Data3"
Write-Output $pso

Wen die Schreibweise mit der PIPE nach Add-Member nicht gefällt, kann die Werte auch anders zuweisen:

$pso = New-Object PSObject
Add-Member -InputObject $pso -MemberType NoteProperty -Name Feld1 -Value Wert1
Add-Member -InputObject $pso -MemberType NoteProperty -Name Feld2 -Value Wert2


# Oder Kürzer
Add-Member -InputObject $pso noteproperty feld1 wert2
Add-Member -InputObject $pso noteproperty feld2 wert2

# Bestehende Felder können einfach dann zugewiesen werden
$pso.feld1 = "wert1neu"

#und ausgegeben werden
write-host $pso.feld1

Siehe auch http://technet.microsoft.com/en-us/magazine/cc510337.aspx

Es geht sogar noch einfacher durch einen kleinen Kniff. Man gibt einfach einen Leerstring an "select" aus und wählt die Felder. PowerShell erstellt so ein leeres Objekt mit den Feldern. Das Beispiel erstellt nicht nur ein Objekt sonder addiert es auch an eine Tabelle. So können also sehr einfach viele Ausgaben schnell aufgestellt und später ausgegeben werden.

$ergebnistabelle = @()
$ergebnis = "" | select Feld1,Feld2,Feld3
$ergebnis.Feld1 = "Inhalt1"
$ergebnis.Feld2 = "Inhalt2"
$ergebnis.Feld2 = "Inhalt3"
$ergebnistabelle += $ergebnis

Übrigens können Sie die Ausgabe auch an CScript übergeben, welches dann über "wscript.stdin.readall" darauf zugreifen kann. Allerdings sollten Sie nicht zu viel erwarten. Mehr als die Bildschirmausgabe des vorherigen PowerShell Skripts kommt in VBScript nicht an. Also ist dieser Weg nicht wirklich sinnvoll zu nutzen.

Pipeline mit Dateien

Ich schreibe in vielen Skripten verschiedene Protokolldateien, teils direkt oder über ein "Start-Transcript". Wer viele Logs schreibt, sollte sich aber auch um das Aufräumen kümmern, d.h. alte Dateien nach Ablauf einer Zeit wieder entfernen. Niemand will tausende von Logfiles vorhalten und die Festplatte füllen. Ein einfacher "Einzeiler könnte wie folgt aussehen

get-item -path "C:\Tasks\scriptname\logs\dynquota*.log" | where {$_.lastwritetime -lt ((get-date).adddays(-30))} | remove-item

Das funktioniert in der Regel ganz gut aber manchmal eben auch nicht die Fehlermeldung ist dabei

Get-Item : Could not find item C:\Tasks\skriptname\logs\skriptname.2016-03-15-20-00-05-493.log.
At C:\Tasks\skriptname\skriptname.ps1:56 char:9
+ get-item <<<<  -path "C:\Tasks\skriptname\logs\skriptname*.log" | where {$_.lastwritetime -lt ((get-date).adddays(-30))} | remove-item
    + CategoryInfo          : ObjectNotFound: (C:\Tasks\skriptname...0-00-05-493.log:String) [Get-Item], IOException
    + FullyQualifiedErrorId : ItemNotFound,Microsoft.PowerShell.Commands.GetItemCommand

Auf den ersten Blick könnte man meinen, dass jemand anderes die Datei schon gelöscht hat. Das Skript wird aber nur einmal gestartet. Ich vermute mal, dass hier die "Parallelisierung" oder eine Verwirrung bei Filehandles die Ursache ist. Schließlich löscht das "Remove-Item" eine Datei, während vorne noch das "Get-Item" die Liste aufbaut. Hier hilft es dann die beiden Prozesse zu entzerren

$purgefilelist = get-item -path "C:\Tasks\scriptname\logs\dynquota*.log" | where {$_.lastwritetime -lt ((get-date).adddays(-30))} 
$purgefilelist| remove-item

Damit habe ich zumindest die Probleme deutlich reduzieren können.

STDPOUT-Bug

Etwas nervig war in der Anfangszeit der Powershell ein Bug in der Behandlung von Ausgaben nach STDOUT. In der Powershell1 und 2 konnte die Powershell einfach eine Ausgabe an STDOUT senden und damit in die Pipeline zur weiteren Verarbeitung übergeben. Aber auch die Ausgabe von "Write-host" will man vielleicht in eine Datei umleiten, wenn ein Skript "unbewacht" läuft. Natürlich gibt es in der Powershell auch die Befehle "Start-Transcript" und "Stop-Transcript"

Und selbst beim Aufruf der Powershell z.B. aus einer CMD-Datei kann mit " > datei.txt" eine Umleitung erfolgen. Das passiert gerne, wenn andere Job-Scheduler wie z.B. Control-M (http://www.bmcsoftware.de/it-solutions/control-m.html) die Jobs starten und die Ausgaben für die Protokollierung abgreifen.

Die Fehlermeldung in der Powershell war dann ziemlich nichtssagend und eher erschreckend

Write-Host : The OS handle’s position is not what FileStream expected. Do not use a handle 
simultaneously in one FileStream and in Win32 code or another FileStream. 
This may cause data loss.

Ursächlich ist aber wohl die Tatsache, dass Powershell nicht direkt auf STDOUT schreibt, sondern ein Helper-Objekt verwendet, welches zwischen der Powershell und STDOUT vermittelt. Wenn nun ein anderes Programm ebenfalls nach STDOUT schreibt, dann verweist der Powershell Helper auf die falsche Position. STDOUT ist ja schon weiter gerückt.

Es gibt wohl einen Fix, der auch bei meinen Skripten temporär geholfen hat aber mit einer neueren Powershell nicht mehr funktionieren muss oder sogar stören kann:

#Fix für STDOUT Bug in PS.10 und 2.0
$bindingFlags = [Reflection.BindingFlags] "Instance,NonPublic,GetField"
$objectRef = $host.GetType().GetField( "externalHostRef", $bindingFlags ).GetValue( $host )
$bindingFlags = [Reflection.BindingFlags] "Instance,NonPublic,GetProperty"
$consoleHost = $objectRef.GetType().GetProperty( "Value", $bindingFlags ).GetValue( $objectRef, @() )
[void] $consoleHost.GetType().GetProperty( "IsStandardOutputRedirected", $bindingFlags ).GetValue( $consoleHost, @() )
$bindingFlags = [Reflection.BindingFlags] "Instance,NonPublic,GetField"
$field = $consoleHost.GetType().GetField( "standardOutputWriter", $bindingFlags )
$field.SetValue( $consoleHost, [Console]::Out )
$field2 = $consoleHost.GetType().GetField( "standardErrorWriter", $bindingFlags )
$field2.SetValue( $consoleHost, [Console]::Out )

Ab der Powershell 3.0 soll das Problem durch einen Fix richtig gelöst worden sein. Aber zumindest Exchange 2010 nutzt ja noch die Powershell 2.0.

Weitere Links