PowerShell und Callback-Funktionen

Die meisten Administratoren schreiben, wie ich auch, seriell ablaufende Skripte. Es gibt einen Startpunkt und ein Ende. Modularisierung erreichen wir mit Funktionen und Modulen und mit Schleifen lassen wir Skripte auch länger laufen aber unterm Strich warten wir auf Ergebnisse um diese dann weiter zu bearbeiten. Diverse Module und Klassen erlauben aber kein "Polling" oder das Warten auf Ergebnisse. Für den Fall gibt es Callback-Funktionen, die von dem Modul in unserem Code aufgerufen werden.

Callback und Event

Die meisten Programmierer schreiben "sequentiellen Code". Das Programm läuft nacheinander die vorgesehenen Schritte ab. Natürlich gibt es IF-Verzweigungen, CASE-Abfragen und eine gewisse Modularisierung über Funktionen und Objekte ist auch noch einfach zu verstehen. Wie gehen Sie aber damit um, wenn Sie einen Aufruf starten und auf die Rückantwort nicht warten wollen, weil Sie parallel noch andere Aufgaben zu bearbeiten haben? Dann schlägt die Stunde der "eventgesteuerten" Programme oder sogar paralleler Threads und Prozesse.

Events sind aus meiner Sicht erst mal einfacher. Sie starten eine Routine oder Abgrade im Hintergrung aber warten nicht, bis dieser Code zurück kehrt. Der Code ruft seinerseits am ende z.B. einen Code auf oder hinterlegt die Information inn einer Eventqueue. Beide Optionen beschreibe ich auf dieser Seite.

  • Callback
    Hierbei hinterlege ich einen Code, der bei nächster Gelegenheit von der Klasse aufgefunden wird. Stellen Sie sich das einfach so vor, dass ihr aktuelles Skript nach Abschluss des aktuellen Befehls dazwischen eingeschoben wird.
  • Events
    Wenn Sie keinen Code hinterlegen, dann wird die Meldung in eine EventQueue des aktuellen Prozesses abgelegt. Dann müssen Sie aber selbst immer mal wieder in ihrem Programm nachschauen, ob nicht zwischenzeitlich was passiert ist. Sie können ihr Programm mit "Wait-Event" auch solange anhalten, bis etwas passiert.

Beide Optionen erlauben also etwas wie eine Parallelausführung, was aber nicht ganz die richtige Beschreibung ist. Genauer eröffnet sich so die Möglichkeit, dass an anderes Modul Informationen an ihre Skript übergibt und sie nicht darauf warten müssen. Diese Funktion ist also insbesondere dann nütztlich, wenn Sie auf Rückmeldungen warten, deren Eintreffen nicht vorhersehbar ist.

Register-WMIEvent/Register-ObjectEvent

Dreh und Angelpunkt sind diese beiden Commandlets, mit denen Sie in der aktuellen PowerShell-Session eine Verbindung zur Vorgängen herstellen können.

Die Events können Sie dann im laufenden PowerShell Script entweder abfragen oder direkt Code damit verbinden, der nach Ende des aktuell laufenden Commandlets ausgeführt wird.

Using PowerShell to Monitor Events in Windows 7
https://www.youtube.com/watch?v=G-if7A3wfss

Beispiel: Timer

Ein sehr einfaches Beispiel zum Einstieg ist der .NET "Timer". Sie können diesen per PowerShell einfach instanzieren und lassen ihn einfach regelmäßig auslösen.

# Timer instanzieren und parametrisieren
$timer = new-object timers.timer
$timer.interval = 1000    # default sind 100ms
$timer.AutoReset=$true   # immer wieder auslösen
$timer.Start()

Der läuft dann und generiert jede Sekunde einen Event. Das Problem ist aktuell, dass er aber noch nichts macht, weil noch kein Code auf den Event reagiert. Den kann ich nun aber ganz schnell einbinden. Ich definiere eine Aktion mit einem Code-Segment:

# Aktion hinterlegen
$timeraction = {write-host "Timer abgelaufen um: $(get-date -Format 'HH:mm:ss')"}
Register-ObjectEvent `
   -InputObject $timer `
   -EventName "Elapsed" `
   -SourceIdentifier  end2endTimer `
   -Action $timeraction

Der Wer für "EventName" hängt von dem Objekt ab, auf das sie lauschen wollen. Bei einem "Timer" wird ein "Elapsed" oder "Stopped" unterstützt. Ich habe auch schon "Triggered" und "EventArrived" gesehen. Letztlich müssen Sie das auslösende Objekte dazu befragen:

Und schon erscheint auf der Console jede Sekunde eine Ausgabe:

Sie können natürlich weiter tippen. Lassen Sie sich nicht durch die Ausgaben zwischendurch irritieren. Dem Spuck können Sie ein Ende machen, indem Sie entweder den Timer anhalten, das Property "AutoReset" auf $false setzen oder den Event deregistrieren.

# Timer beenden
$timer.stop()

# Timerneustart verhindern
$timer.autoreset = $false

# Events deregistieren
Get-EventSubscriber | Unregister-Event

Ehe sie nun diese Übung als trivial abtun, sollten Sie an vergangene Herausforderungen denken. Stellen Sie sich vor ihr Skript läuft so vor sich hin und regelmäßig sollte etwas "passieren". Bislang haben Sie dies umständlich so gelöst, dass Sie sich die Zeit gemerkt haben und immer mal wieder über eine IF-Abfrage geprüft haben, ob das Intervall schon abgelaufen ist und nun ein Stück Code aktiv werden muss. Sie wissen aber ja nicht, wo in der Zwischenzeit ihr Skript unterwegs ist und wie weit es an einer Stelle länger braucht. Mit einem Timer können Sie zumindest zum nächsten Befehl etwas dazwischenschieben.

Beispiel: FileSystem

Ein anderes klassisches Beispiel sind Änderungen im Dateisystem. Oft muss ein Programm ein Verzeichnis überwachen um bei Änderungen aktiv zu werden. Üblich sind Reaktionen auf neu angelegte Dateien, auf Änderungen oder Löschen von Dateien. Gerade automatisierte Prozesse, die so umgehend starten wollen, ersparen sich damit das Polling auf Verzeichnisse oder Dateien. Das ist mit PowerShell relativ einfach und schnell umgesetzt.

$dirwatcher = new-object System.IO.FileSystemWatcher
$dirwatcher.Path = "C:\temp\"
$dirwatcher.Filter="*.*"
$dirwatcher.IncludeSubdirectories = $false
$dirwatcher.EnableRaisingEvents = $true

$actioncode = {
   Write-host "Action Triggered"
   #$Event | out-host
   $file=$Event.SourceEventArgs.FullPath
   $trigger = $Event.SourceEventArgs.ChangeType
   write-host " Action: $($trigger) File: $($file)" 
}

Register-ObjectEvent $dirwatcher "Created" -Action $actioncode
Register-ObjectEvent $dirwatcher "Renamed" -Action $actioncode
Register-ObjectEvent $dirwatcher "Changed" -Action $actioncode
Register-ObjectEvent $dirwatcher "Deleted" -Action $actioncode
Register-ObjectEvent $dirwatcher "Error" -Action $actioncode

Write-host " Waitig for events"

Sobald in dem Verzeichnis eine Datei angelegt, gelöscht, umbenannt oder geändert wird, wird der Code in der Variable "$actioncode" ausgeführt. Sie können Hier also direkt eine Verarbeitung anstoßen. Über die gleiche API überwacht z.B. auch Exchange das SMTP-Verzeichnis "DROP.

Register-ObjectEvent und Wait-Event

Im Umfeld um die Events gibt es auch das Commandlet "Get-Event" und "Wait-Event". Wer damit aber bei den bisherigen Beispielen auf Events wartet, wartet sehr. lange Wenn mit Register-Object-Event auch eine Action verbunden wird, dann startet bei einem Event zuerst diese Action und der eigentliche Event wird dann nicht mehr weiter gegeben. Will man die Verarbeitung aber direkt im Code machen, dann darf keine Action angegeben werden. Folgendes funktioniert dann besser:

# Timer instanzieren und parametrisieren
$timer = new-object timers.timer
$timer.interval = 1000    # default sind 100ms
$timer.AutoReset=$true   # immer wieder auslösen
$timer.Start()

# Aktion hinterlegen
Register-ObjectEvent `
   -InputObject $timer `
   -EventName "Elapsed" `
   -SourceIdentifier  end2endTimer

while ($true) {
   $event= wait-event
   if ($event) {
      write-host "Event gefunden"
      write-host "Timer abgelaufen um: $(get-date -Format 'HH:mm:ss')"
      remove-event -EventIdentifier $event.EventIdentifier
   }
}

Wait-Event hält das Script an, bis der entsprechende Event aufgetreten ist. Ein "Start-Sleep" würde das Script auch aufhalten, aber könnte nicht sofort auf die Events reagieren und der Code müsste prüfen, ob ein Event aufgetreten ist.

Beispiel HTTP und Async

Auf der Seite PowerShell als HTTP-Client habe ich mehrere Wege aufgezeigt, wie man per HTTP verschiedene Abfragen starten kann. Die Herausforderung dabei ist natürlich, dass der Request mein Skript auf die Antworten warten lässt. In der Regel ist das nicht nur der einfachste sondern auch sicherste Weg. Es ist aber nervig, wenn sie viele URLs abrufen und eine davon "stockt". Versuchen Sie mal einen HTTP-Request auf eine URL, die es nicht gibt, d.h. auch kein Host ein "reject" sendet.

PS C:\> measure-command {Invoke-WebRequest http:\\192.168.180.135} | select totalseconds
Invoke-WebRequest: Ein Verbindungsversuch ist fehlgeschlagen, da die Gegenstelle nach einer 
bestimmten Zeitspanne nicht richtig reagiert hat, oder die hergestellte Verbindung war 
fehlerhaft, da der verbundene Host nicht reagiert hat.

TotalSeconds : 21,0730431

Dann bleibt ihr Skript nämlich stehen und wenn sie z.B. eine ganze Menge an Hosts schnell abprüfen wollen, dann wäre etwas "Hintergrund"-Funktion nicht schlecht. Das geht per PSJob oder PSRunspace und einige Commandlets erlauben sogar ein "-AsJob" als Parameter. Diverse .NET-Klassen erlauben aber auch eine Verarbeitung im Hintergrund. Man muss ihnen aber eine Funktion übergeben, die dann die Ergebnisse enthält und sie müssen bei Gelegenheit dann mal Aufträge abarbeiten. Die Klasse System.Net.HttpWebRequest kennt aber eine Methode "BeginGetRequestStream". Zuerst einmal klassisch.

$url = "http://192.168.180.135"
$httprequest=[System.Net.HttpWebRequest]::Create($url);
$data = $httprequest.getresponse();
$stat = $data.statuscode;
$data.Close();

So ist es noch ein synchroner Aufruf und die Zeile "$data = $httprequest.getresponse();" wartet wieder 21 Sekunden.

$url = "http://192.168.180.135"
$httprequest=[System.Net.HttpWebRequest]::Create($url);
$data = $httprequest.BeginGetResponse($callbacfunk,$);
$stat = $data.statuscode;
$data.Close();

Aktuell habe ich noch keine funktionierendes Beispiel hierfür mit PowerShell. Ich verfolge den Weg aber erst einmal nicht weiter sondern nutze für einfache Ausgaben die PowerShell Jobs (PS Job) und wenn es zeitkritisch wird quäle ich mich mit Runspaces

WMI und andere Events

Der "Timer" ist natürlich eine ganz einfach Anwendung. Die Callback-Funktionen gibt es aber an vielen anderen Stellen. Quasi immer da, wo ihr Skript eine Aktion auslöst aber nicht weiß, wann die Antwort kommt, macht es wenig Sinn das Skript "warten" zu lassen. Es kann ja etwas anderes machen und wenn die Antwort kommt, dann können Sie immer noch darauf reagieren. Das ist insbesondere nützlich, wenn Sie mehrere Abfragen abgesetzt haben und diese nicht sequentiell beendet werden. Verwechseln Sie Callback aber nicht mit Multithreading oder Jobs (Siehe PS Job), bei denen die Skripte wirklich parallel laufen.

Sie können aber z.B. auf WMI-Events reagieren, z.B. wenn ein Performance Counter über ein Limit geht, wenn ein Dienst startet, wenn ein Gerät eingesteckt wird oder ein Eventlog passiert. Es wäre denkbar ungünstig hier per Polling immer wieder nachzufragen oder mit einem blockenden Aufruf zu warten.

Wenn Sie ihre Skripte CPU-schonend pausieren und dennoch schnell auf Event reagieren wollen, dann sollten Sie statt "start-sleep" ein "Wait-Event" nutzen. Das Commandlet "Wait-Event" hält das aktuelle Skript an, bis ein Event auftritt.

Auch andere 3rd Party-Klassen (Siehe z.B. Powershell und MQTT) nutzen Callback. Bei MQTT wird z.B. eine Verbindung aufgebaut und gehalten. Wenn aber eine eingehende Kommunikation ansteht, dann muss das Skript reagieren. Genauso würde es sich z.B. mit Skype for Business verhalten. Sie können nicht wirklich ein "Warte auf neue Conversation" codieren, wenn sie mehrere aktive Konversationen betreiben. Selbst ein "Warte x Sekunden, ansonsten gehe weiter" ist keine Lösung. Callback-Funktionen erlauben hier eine schnellere direkte Reaktion und wenn es gut gemacht ist, dass kann die Funktion ihre Arbeit machen aber dennoch mit dem Hauptprogramm kommunizieren.

Alle Events landen in einer EventQueue und sie können auch selbst mit "New-Event" neue Einträge anlegen. Die Queue ist zwar auf die aktuelle Umgebung beschränkt aber so lässt sich dennoch eine Kommunikation zwischen mehreren Programmteilen herstellen.

Parallelität und Scope

Damit stellt sich natürlich die Frage, wie parallel diese Callback-Funktionen laufen und vor allem in welchem Prozessraum diese unterwegs sind. Um es kurz zu machen

  • Da ist nichts parallel
    Die Callback-Funktion wartet zwar im Hintergrund auf ihren Auftritt aber das Hauptprogramm oder andere Events müssen dies natürlich zulassen. Wenn Sie das Hauptprogramm mit einem "Start-Sleep" in den Schlafmodus setzen, dann kann auch keine Callback-Funktion in der Zeit wach werden. Umgekehrt ist es genau so. Wenn die Callback-Funktion aktiv ist, dann steht das eigentliche Programm. Sie sollten also bei beiden effektiv und "nicht blockend" programmieren, so dass es immer wieder eine Möglichkeit gibt, einen Event zu bearbeiten aber auch das Hauptprogramm weiter laufen zu lassen.
  • Mehrere Events
    Alle Events landen in einer Eventqueue und der Name verrät schon, dass die Abarbeitung sequentiell erfolgt. Wenn Sie also z.B. mehrere Events haben, während beim Hauptprogramm ein Commandlet längere Zeit beansprucht, dann werden die Events in einer Queue gespeichert und nach dem Ende des aktuellen Commandlets eingeschoben.
  • Scope von Variablen
    Auch nutzt die Callback-Funktion die gleichen Variablen wie das Hauptprogamm. Sie können aus der Callback-Funktion solche vorhandenen Variablen also lesen aber auch schreiben. Ein Timer kann also eine Variable im Hauptprogramm immer wieder hochzählen, was für Zeitmessungen oder "Budget-Verwaltungen" zwecks Throttling effektiv nutzbar ist.

Ich merke mir das einfach so, dass dieser Code in der Aktion im Moment der Aktivierung einfach an der Stelle des Skripts eingefügt und ausgeführt wird, an dem das ablaufende Skript gerade unterbrochen wurde. Da aber keine parallelen Codeteile laufen, kann es auch keine Konflikte beim Zugriff auf Variablen geben. Hier ein Beispiel zur Verdeutlichung. Ein Timer-Event tritt alle 5 Sekunden auf und gibt etwas aus. das Hauptprogramm hingegen ist in einer Endlosschleife gefangen, die immer 10 Sekunden pausiert und dann etwas ausgibt:

# Timer instanzieren und parametrisieren
$timer = new-object timers.timer
$timer.interval = 5000    # default sind 100ms
$timer.AutoReset=$true   # immer wieder auslösen
$timer.Start()


$timeraction = {write-host "Timer abgelaufen um: $(get-date -Format 'HH:mm:ss')"}
Register-ObjectEvent `
   -InputObject $timer `
   -EventName elapsed `
   -SourceIdentifier  end2endTimer `
   -Action $timeraction

while ($true) {
   write-host "a";
   start-sleep -seconds 8;
   write-host "b";
   start-sleep -seconds 4
}

Die Ausgabe zeigt gut, dass der Event nicht aktiv wird, solange "Start-Sleep" blockiert:

Dennoch sollten Sie natürlich zusehen, dass alle Variablen und Befehle, die Sie in einer Callback-Aktion nutzen, vor dem ersten Aufruf definiert wurden. Aber das gehört ja eh zu einer sauberen Programmierung dazu.

Callback in Polaris

Mitte 2019 bin ich bei der Suche nach einer anderen Lösung auf das Micro Framework Polaris gestoßen, welches einen Webserver startet und je nach URL verschiedene Skripte aufruft. Dabei ist mir aufgefallen, dass die Shell nach der Start des Server nicht gewartet hat, sondern weiter Eingaben angenommen hat. Da hat also nichts auf ein "Wait-Event" gewartet. Der Code liegt auf GitHub und läuft sogar mit PowerShell 6 und höher.

Über die Funktion "New-ScriptBlickCallback" in der Datei "New-ScriptBlockCallback.ps1" kann ich PowerShell-Code hinterlegen, der dann durch eine CallBack-Funktion aufgerufen wird. Der gesamte Webserver ist eine Klasse, die per Commandlet übergebe Pfade und Skripte in einem Dictionary speichert und über einen HTTPListener dann aktiviert wird. Das ganze System wird als PowerShell Klasse (ab PS5) umgesetzt und aus dem Code kann man gut etwas lernen, z.B. wie man eine instanzierte Klasse vor dem Beenden aufräumt.

$ExecutionContext.SessionState.Module.OnRemove =
{
    Stop-Polaris -ErrorAction SilentlyContinue
    Clear-Polaris
}.GetNewClosure()

Weitere Links