PS Pipeline $INPUT

Diese widmet sich der Variable "$INPUT", die in PowerShell eine alternative Verarbeitung von Eingaben der Pipeline in einem Skript erlaubt. $INPUT ist nicht nur eine Variable sondern eine Enumeration und und sollten die Fallstricke bei der Verwendung kennen.

Pipeline Basics

Eine Pipeline erlaubt die Verkettung von Commandlets und Skripten in Powershell. Ein Skript kann Informationen ermitteln und dann an ein weiteres Skript zur Weiterverarbeitung übergeben. So können ganz lange Ketten gebildet werden, z.B.

Import-CSV daten.csv `
| select Name,Strasse,Ort `
| where {$_.feld1 -eq "Paderborn"} `
| sort-Object Name `
| Out-GridView

Das Beispiel liest eine CSV-Datei ein und extrahiert die Felder Name, Strasse, Ort um dann nur die Einträge mit "Ort=Paderborn" an die Sortierung nach dem Namen zu übergeben und dann in einer GUI anzuzeigen. Schon bei dem Lesen der Quelle wissen wir, dass die Daten zeilenweise in die Pipeline geschrieben werden. Das nächste Programm wartet hier nicht, bis alle Daten der Quelle erst in der Pipeline stecken, ehe es sich an die Weiterverarbeitung macht.

Wenn ich nun einen eigenen Code hier einbauen will, dann muss ich mein PowerShell-Skript z.B. über "Begin, Process, End" pipelinetauglich machen. Das ist auf der Seite PowerShell Pipeline beschrieben.

Aber dann gibt es noch die Variable "$INPUT", die ich ganz ohne "Begin, Process, End" verwenden kann.

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. Einfach und schnell aber nicht schön.

# pipe1.ps1-Skript
Write-host "Pipe START"
$INPUT
Write-host "Pipe End"

Der folgende Aufruf mit zwei Elementen liefert dann:

PS C:\temp> ("a","b") | .\pipe.ps1
Pipe START
a
b
Pipe End

Soweit nicht überraschend.

$INPUT mit Verzögerung

Nun habe ich mir überlegt, wie eine verzögerte Lieferung von Daten sich auswirkt. Überlappen die beiden Ausgaben oder wartet das nachfolgende Skript, bis der erste Code abgeschlossen ist? Ich habe analog zu PowerShell Pipeline die Ausgabe einfach etwas verzögert.

(1,2,3) `
| %{ `
      Write-Host "A$($_)";`
      $_;`
      start-sleep -Seconds 2`
   } `
| .\pipe.ps1

Wäre der mittlere Codeteil und das Skript richtig parallel gelaufen, dann müsste nach der Ausgabe von "A1" dann die "1" und weiter "A2",2,"A3",3 kommen.

Stattdessen wurde zuerst A1, A2, A3 mit jeweils 2 Sekunden Verzögerung auf den Bildschirm (und die Pipeline) ausgegeben aber das Skript pipe1.ps1 wurde wohl gestartet aber hat bei $INPUT" angehalten, bis die Pipeline geschlossen wurde. Die Ausgabe von $INPUT" wird nur mit einem B vorangestellt.

$INPUT mit Verzögerung und Loop

Ich habe dann im pipe1.ps1-Code nicht $INPUT ausgegeben, sondern über eine Schleife laufen lassen:

Die Ausgabe hat sich dann schon unterschieden.

Aber auch hier hat das pipe1.ps1-Skript gewartet, bis die Pipeline beendet wurde um dann aber die einzelnen Werte auszugeben.

Doppelter Zugriff

Wenn Sie nun denken, dass "$INPUT" auch nur eine Variable ist, dann betrachten Sie sich das Ergebnis, wenn ich zweimal den Inhalt ausgeben will:

Die Ausgabe zeigt deutlich, dass die Variable nach dem ersten Auslesen leer ist.

Wir dürfen also "$INPUT" nicht wie eine reguläre Variable behandeln.

Diese Besonderheit hat Microsoft auch noch einmal in Verbindung mit "Begin, Process, End" beschrieben.

Sie können die $INPUT Variable nicht sowohl innerhalb des process Blocks als auch des end Blocks in derselben Funktion oder in einem Skriptblock verwenden.
Quelle: https://learn.microsoft.com/de-de/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.3#input

$INPUT.Count

Auf der Suche nach der Ursache habe ich dann gelernt, was die "Besonderheit" eines Enumerators ist. Ich wollte nämlich einfach die Anzahl der Elemente in "Input$" ermitteln. und habe dazu folgenden Code genutzt

Auch hier ist die Ausgabe anders ausgefallen.

Sie sehen, dass die Ausgabe von "$INPUT.count" nichts liefert, obwohl ich eigentlich "3" erwartet hätte. Vielleicht hätte ich mir noch 1,2,3 vorstellen können aber "1,1,1" hat mich schon überrascht. Hier ist wohl "Write-Host" mit Schuld, dass es drei Elemente mit jeweils einem Inhalt ausgegeben hat.

Enumerator

Das ist aber kein Problem des Code sondern dass $INPUT ein "Enumerator" ist. Es ist also kein Array mit den Eigenschaften. Das hat Microsoft auch beschrieben:

Die $INPUT, $foreach und $switch Variablen sind alle Enumeratoren, die zum Durchlaufen der Werte verwendet werden, die von ihrem enthaltenden Codeblock verarbeitet werden.
Ein Enumerator enthält Eigenschaften und Methoden, die Sie verwenden können, um iterations- oder iterationswerte zurückzusetzen oder abzurufen.
Direktes Bearbeiten von Enumeratoren wird nicht als bewährte Methode betrachtet.
https://learn.microsoft.com/de-de/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.3#using-enumerators

Das ist auch der Grund ,warum Sie $INPUT wohl nur einmal lesen können. Schauen Sie sich folgenden einfachen Code an:

# pipe.ps1
Write-host "Step1"
$INPUT
Write-host "Step2"
$pipeline =@(); foreach ($a in $INPUT) {$pipeline+=$a}
$pipeline
Write-host "Pipe Count $($pipeline.count)"
Write-host "Step3"

Ich habe den Code zweimal aufgerufen aber beim zweiten Aufruf die Zeile 3 mit der Ausgabe von "$INPUT" auskommentiert.

Ergebnis mit "$INPUT" Ergebnis mit "#$INPUT"

Beim ersten Aufruf wird "$INPUT" ausgegeben aber damit auch der Pointer an das Ende der Auflistung gesetzt. Die nachfolgende ForEach-Schleife hat dann nichts mehr zu lesen. Wenn $INPUT auskommentiert ist, dann kann die ForEach-Schleife die Informationen auslesen.

$input und Begin, Process, End

Nun gibt es ja die Aussage, dass $INPUT bei der Verwendung von "Begin, Process, End" sich unterschiedlich verhält. Im Bereich "begin" soll es gar nicht definiert sein, der "Process"-Teil würde je Element einmal aufgerufen und in "end" wäre das Objekt komplett enthalten. Also habe ich einen ersten einfachen Test gemacht. Ich habe mit "Get-Item" aus dem aktuellen Ordner die Dateien ermittelt und an das Skript übergeben

Get-Item *.* | .\pipe.ps1
Ort Begin Process End
Code
Begin{
     Write-host "Begin1"
     $input
     Write-host "Begin2"
}
process {
     Write-host "Process1"
     $input
     Write-host "Process2"
}
end {
     Write-host "Step1"
     $input
     Write-host "Step2"
}
Ausgabe
PS C:\> Get-Item *.* | .\pipe2.ps1
Begin1
Begin2
Process1
  Directory: C:\
Mode   LastWriteTime   Length Name
----   -------------   ------ ----
-a---  27.10.2023      23:58   954 file1.txt
Process2
Process1
-a---  30.10.2023      12:47   208 pipe.ps1
Process2
Process1
-a---  31.10.2023      18:35   150 pipe2.ps1
Process2
End1
  Directory: C:\group\Technik\Skripte\Compare-PSObject
Mode   LastWriteTime   Length Name
----   -------------   ------ ----
-a---  27.10.2023     23:58    954 file1.txt
-a---  30.10.2023     12:47    208 pipe.ps1
-a---  31.10.2023     18:36     65 pipe2.ps1
End2
Bewertung

Hier ist "$INPUT" wohl wirklich leer

Wie erwartet wird jedes Elemente der Pipeline getrennt im Process-Teil verarbeitet

Im END-Teil habe ich natürlich nur einmal das Element.

Denken Sie aber auch hier wieder an die Besonderheiten der Enumeration. Zur Demonstration habe ich das immer mit ".count" gestartet

Ort Begin Process End
Code
Begin{
     Write-host "Begin1"
     $input.count
     Write-host "Begin2"
}
process {
     Write-host "Process1"
     $input.count
     Write-host "Process2"
}
end {
     Write-host "End1"
     $input.count
     Write-host "End2"
}
Ausgabe
PS C:\> Get-Item *.* | .\pipe2.ps1
Begin1
Begin2
Process1
1
Process2
Process1
1
Process2
Process1
1
Process2
End1
1
1
1
End2
Bewertung

Begin hat immer noch nichts

Für jedes Element wird "process" einmal gestartet. Hier stimmt dann "count=1"

Im "End"-Teil sehen wir wieder, dass ".count" nicht sinnvoll genutzt werden kann.

Denken Sie aber daran, dass die "$INPUT" immer nur einmal lesen können. Wenn sie $INPUT" wohl im "process"- als auch im "end"-Bereich nutzen, dann kommt im "end"-Bereich nichts mehr an, da in "process" schon alles gelesen wurde.

PS C:\group\Technik\Skripte\Compare-PSObject> Get-Item *.* | .\pipe2.ps1
Process1
1
Process2
Process1
1
Process2
Process1
1
Process2
Process1
1
Process2
End1
End2

Eigentlich macht es hier nur Sinn, die Variable "$INPUT" im "Process"-Teil des Skripts zu verwenden und dort jedes Element einzeln zu verarbeiten. Wenn entgegen der Intention einer Pipeline aber ein wahlfreier Zugriff auf die Elemente erforderlich ist, müssen Sie die Inhalte in eine andere geeignete Variable übertragen.

ForEach statt ByRef

Wenn Sie nun auf den Inhalt von "$input" mehrfach zugreifen wollen, dann könnten Sie ja einfach folgendes machen:

Beschreibung PS1-Datei Ausgabe

Sie könnten ja auf den Gedanken kommen, und $input in eine andere Variable zu kopieren. Das klappt aber so einfach nicht, denn es ist ja ein Enumerator und kopiert wird die Referenz.

# Sample Script
process {
   $object = $input
   Write-host "Call1: $($object)"
   Write-host "Call2: $($object)"
}

Bei einem Enumerator finden Sie manchmal Hinweise auf ein Property ".current" oder die Methode "MoveNext". Beides funktioniert nicht mit $input

# Sample Script
process {
   $object = $input.current
   Write-host "Call1: $($object)"
   Write-host "Call2: $($object)"
}

Erst wenn den Inhalt von "$input" z.B.: mit @(), dem Konstruktor für ein Array kopiere, dann habe ich eine echte Kopie und kann wiederholt damit arbeiten.

# Sample Script
process {
   $object = @($input)
   Write-host "Call1: $($object)"
   Write-host "Call2: $($object)"
}

Das funktioniert natürlich auch mit einer "Foreach"-Loop.

# Sample Script
process {
   $object = $input | %{$_}
   Write-host "Call1: $($object)"
   Write-host "Call2: $($object)"
}

Wenn ich in meinem Code das Ennumerator-Objekt $input mehrfach referenzieren möchte, muss ich den Inhalt kopieren. Allerdings könnte es dann schon sein, dass in der Variable nicht nur ein Element sondern mehrere Elemente liegen. Daher nutze ich von vorneherein lieber die "Foreach"-Schleife:

# Sample Script
process {
   foreach ($object in $input) {
      Write-host "Call1: $($object)"
      Write-host "Call2: $($object)"
   }
}

Auch wenn es etwas aufwändiger aussieht. Aber der Tatsche geschuldet, dass viele Variablenzuweisungen von einfachen Typen (Int, String, Chat, Byte) eine Kopie erstellen, während komplexe Objekte als "byRef" übertragen werden. Das war aber auch schon bei VBScript ein Thema. Bei PowerShell können Sie mit "[ref]" bei einer Zuweisung erzwingen, dass keine Kopie sondern eine Referenz erstellt wird. Es gibt aber kein einfaches "[val] oder "[ByVal]" um das Gegenteil zu erreichen.

  • About_Ref
    https://learn.microsoft.com/de-de/powershell/module/microsoft.powershell.core/about/about_ref?view=powershell-7.3
  • VBScript Falle "ByVAL"

CmdletBinding und ValueFromPipeline=$true

Eigentlich wollte ich wieder nur mal ein Skript schreiben, welches Werte sowohl als Parameter als auch über die Pipeline akzeptiert. Dazu wollte ich prüfen, ob der Wert des Parameters "-eq $null" ist und dann die Wert aus $INPUT auslesen. Leider hat das nicht auf Anhieb geklappt und mittlerweile habe ich die Option "ValueFromPipeline=$true)" als Teil der Parametrisierung entdeckt, mit dem aber die Daten aus der Pipeline dann in ein die Variablen geschrieben werden.

Achtung: Wenn Sie mit "[CmdletBinding()]" arbeiten, dann müssen sie einen Parameter mit ValueFromPipeline=$true) angeben. Ansonsten bekommen Sie beim Aufruf folgenden Fehler:

The input object cannot be bound to any parameters for the command either because the command does not take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.

Daher sieht mein Beispielskript wie folgt aus:

[CmdletBinding()]
param (
    [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
    $pipe1
)

Write-Host "Pipe1"
$pipe1
Write-Host "Pipe2"
Write-Host "Input1"
$input
Write-Host "Input2"

Die Ausgabe überascht dann auch erst einmal:

Pipe1
Directory: C:\group\Technik\Skripte\Compare-PSObject
Mode LastWriteTime Length Name
---- ------------- ------ ----
-a--- 31.10.2023 18:56 201 pipe2.ps1
Pipe2
Input1
-a--- 27.10.2023 23:58 954 file1.txt
-a--- 30.10.2023 12:47 208 pipe.ps1
-a--- 31.10.2023 18:56 201 pipe2.ps1
Input2

Das erste Element der Pipeline landet in der Variable, während in $input alle Elemente enthalten sind. Ich habe das Skript darauf etwas angepasst, dass ich zweimal "$pipe" ausgegebe:

[CmdletBinding()]
param (
    [Parameter(Mandatory=$true,ValueFromPipeline=$true)]
    $pipe1
)

Write-Host "Pipe1"
$pipe1
$pipe1
Write-Host "Pipe2"
Write-Host "Input1"
$input
Write-Host "Input2"

Dann sieht die Ausgabe etwas anders aus:

Pipe1
  Directory: C:\
Mode      LastWriteTime   Length Name
----      -------------   ------ ----
-a---    31.10.2023  18:58    209 pipe2.ps1
-a---    31.10.2023  18:58    209 pipe2.ps1
Pipe2
Input1
-a---    27.10.2023  23:58    954 file1.txt
-a---    30.10.2023  12:47    208 pipe.ps1
-a---    31.10.2023  18:58    209 pipe2.ps1
Input2

Die Variable "$pipe" funktioniert hier nicht wie ein Enumerator sondern ist einfach das erste Element.

Einschätzung

Die Nutzung der Pipeline mit PowerShell ist ein genialer Ansatz, um Ergebnisse eines Skripts oder Commandlets immer weiter zu verarbeiten und dabei aufgrund der quasi parallelen Verarbeitung sogar Speicher zu sparen und die Laufzeit effektiv zu gestalten. Aber das Programmieren eines Skripts, Module oder Funktion kann die ein oder andere Überraschung bereithalten, weil die Übergabe ein Enumerator ist und ein einfacher Zugriff auf die Variable $INPUT nicht alle Daten liefert. Sie müssen schon entweder mittels ForEach durch die Objekte laufen oder mit Begin/Process/End die übergebenen Elemente verarbeiten.

Prüfen Sie daher, ob ihr Skript wirklich in allen Fällen wie erwartet funktioniert.

Weitere Links