Reputation: 10060
I'm trying to add some callback capabilities to a PowerShell function but I want to be able to define the behaviour of the callbacks outside of the function definition, so basically a very simple Inversion of Control for PowerShell.
I'm trying to be as backward compatible as is practical, and this is what I've come up with so far (error handling removed for brevity):
$ErrorActionPreference = "Stop";
Set-StrictMode -Version "Latest";
function Invoke-DoSomethingMethod
{
param
(
[hashtable] $Callbacks
)
$CallBacks["OnStarted"].Invoke();
for( $i = 1; $i -le 10; $i++ )
{
# ... do something here ...
$CallBacks["OnProgress"].Invoke($i * 10);
}
$CallBacks["OnFinished"].Invoke();
}
$myHostCallbacks = @{
"OnStarted" = { write-host "started" }
"OnProgress" = { write-host "processing $($args[0])%" }
"OnFinished" = { write-host "finished" }
}
$myFileCallbacks = @{
"OnStarted" = { Add-Content "C:\temp\log.txt" "started" }
"OnProgress" = { Add-Content "C:\temp\log.txt" "processing $($args[0])%" }
"OnFinished" = { Add-Content "C:\temp\log.txt" "finished" }
}
$myWebCallbacks = @{
"OnStarted" = { Invoke-WebRequest -Uri "http://example.org/OnStarted" }
"OnProgress" = { Invoke-WebRequest -Uri "http://example.org/OnProgress/$($args[0])" }
"OnFinished" = { Invoke-WebRequest -Uri "http://example.org/OnFinish" }
}
Invoke-DoSomethingMethod -Callbacks $myHostCallbacks;
When run, this outputs the following:
c:\temp>powershell .\callbacks.ps1
started
processing 10%
processing 20%
processing 30%
processing 40%
processing 50%
processing 60%
processing 70%
processing 80%
processing 90%
processing 100%
finished
Without using external modules or C# code, is there a more idomatic way of doing this with PowerShell that doesn't rely on matching up the magic strings for callback names and the order of $args[] parameters? (Preferrably compatible with version 2.0).
Cheers,
Mike
Update
For some context, my Invoke-DoSomething function is part of a CI build framework (https://github.com/ASOS/OctopusStepTemplateCi) which is currently hard-coded to send TeamCity service messages using Write-Host during the build process.
We're interested in supporting other build server products so I'm investigating a way to abstract out the various interactions with the build server into user-definable callbacks like "OnBuildStarted", "OnBuildStepFailed", etc, like you might do with an IoC framework in C#.
This would make it easier to ship support for new build servers from within the project, and also for users to write callbacks for build servers which don't come out-of-the-box.
This might help give a flavour of what I'm trying to do:
function Invoke-BuildScript
{
param( [hashtable] $Callbacks )
$CallBacks["OnBuildStarted"].Invoke();
... build script here ...
$CallBacks["OnBuildFinished"].Invoke();
}
$teamCityCallbacks = @{
"OnBuildStarted" = { write-host "started" }
"OnBuildFinished" = { write-host "finished" }
"OnBuildError" = { write-host "error '$($args[0])'" }
}
$jenkinsCallbacks = @{
"OnBuildStarted" = { ... }
"OnBuildFinished" = { ... }
"OnBuildError" = { ... }
}
Invoke-BuildScript -Callbacks $teamCityCallbacks;
Upvotes: 4
Views: 8595
Reputation: 10060
Not so much an answer to my original question about IoC in PowerShell, but I managed to at least hide the magic strings and callback parameters behind some wrapper cmdlets so the callback structure becomes an opaque type:
# knowledge of magic strings and parameter orders confined to here
function Invoke-OnStartedCallback
{
param
(
[hashtable] $Callbacks
)
Invoke-Callback -Callbacks $CallBacks -Name "OnStarted";
}
function Invoke-OnProgressCallback
{
param
(
[hashtable] $Callbacks,
[int] $PercentComplete
)
Invoke-Callback -Callbacks $CallBacks -Name "OnProgress" -Parameters @( $PercentComplete );
}
function Invoke-OnFinishedCallback
{
param
(
[hashtable] $Callbacks
)
Invoke-Callback -Callbacks $CallBacks -Name "OnFinished";
}
function New-DoSomethingCallbacks
{
param
(
[scriptblock] $OnStarted,
[scriptblock] $OnProgress,
[scriptblock] $OnFinished
)
return @{
"OnStarted" = $OnStarted
"OnProgress" = $OnProgress
"OnFinished" = $OnFinished
};
}
The rest of the code can then be written as:
# no knowledge of magic strings below here
function Invoke-DoSomethingMethod
{
param
(
[hashtable] $Callbacks
)
Invoke-OnStartedCallback $CallBacks;
for( $i = 1; $i -le 10; $i++ )
{
# ... do something ...
Invoke-OnProgressCallback $CallBacks -PercentComplete ($i * 10);
}
Invoke-OnFinishedCallback $CallBacks;
}
$myHostCallbacks = New-DoSomethingCallbacks -OnStarted { write-host "started" } `
-OnProgress { write-host "processing $($args[0])%" } `
-OnFinished { write-host "finished" };
$myFileCallbacks = New-DoSomethingCallbacks -OnStarted { Add-Content "C:\temp\log.txt" "started" } `
-OnProgress { Add-Content "C:\temp\log.txt" "processing $($args[0])%" } `
-OnFinished { Add-Content "C:\temp\log.txt" "finished" };
$myWebCallbacks = New-DoSomethingCallbacks -OnStarted { Invoke-WebRequest -Uri "http://example.org/OnStarted" } `
-OnProgress { Invoke-WebRequest -Uri "http://example.org/OnProgress/$($args[0])" } `
-OnFinished { Invoke-WebRequest -Uri "http://example.org/OnFinish" };
Invoke-DoSomethingMethod -Callbacks $myHostCallbacks;
with a small helper cmdlet:
function Invoke-Callback
{
param
(
[hashtable] $Callbacks,
[string] $Name,
[object[]] $Parameters
)
if( -not $Callbacks.ContainsKey($Name) )
{
return;
}
$callback = $Callbacks[$Name];
if( $null -eq $callback )
{
return;
}
if( $null -eq $Parameters )
{
$callback.Invoke();
}
else
{
$callback.Invoke($Parameters);
}
}
Upvotes: 2
Reputation: 539
Not sure if the following is what you are looking for. Perhaps I also misunderstood the question.
function Invoke-DoSomethingMethod ($OnStarted, $OnProgress, $OnFinished) {
Invoke-Command -ScriptBlock $OnStarted
for ( $i = 1; $i -le 10; $i++ ) {
Invoke-Command -ScriptBlock $OnProgress
}
Invoke-Command -ScriptBlock $OnFinished
}
$OnStartedSb = [ScriptBlock]::Create('write-host "started"')
$OnProgressSb = [ScriptBlock]::Create('write-host "processing $($i * 10)%"')
$OnFinishedSb = [ScriptBlock]::Create('write-host "finished"')
Invoke-DoSomethingMethod -OnStarted $OnStartedSb -OnProgress $OnProgressSb -OnFinished $OnFinishedSb
Upvotes: 0
Reputation: 47832
I'm not fully clear on what you want to do, but OnStarted
, OnProgress
, OnFinished
kinda-sorta seems to align with the PowerShell pipeline concepts of Begin
, Process
, End
.
If you have a multi-part pipeline, they may all implement 1 or more of these blocks.
In a single pipeline, each element's Begin
block gets executed first, then each element's Process
block gets executed once for each item in the pipeline (later elements can be skipped if the previous element doesn't send any output to the pipeline), and finally each element's End
block gets executed.
The easiest way to see this without writing your own function is with ForEach-Object
which, while most often used with only a Process
block, can also run Begin
and End
blocks.
Demonstration (with a single user-defined function):
function Invoke-MyPipeline {
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline=$true)]
$Value
)
Begin {
Write-Verbose -Message 'Begin Invoke-MyPipeline' -Verbose
}
Process {
$Value * 2
}
End {
Write-Verbose -Message 'End Invoke-MyPipeline' -Verbose
}
}
1..10 |
Invoke-MyPipeline |
ForEach-Object -Begin {
Write-Verbose -Message 'ForEach (1) Begin' -Verbose
} -Process {
"~$_~"
} -End {
Write-Verbose -Message 'ForEach (1) End' -Verbose
} |
ForEach-Object -Begin {
Write-Verbose -Message 'ForEach (2) Begin' -Verbose
} -Process {
"@$_@"
Start-Sleep -Milliseconds 500 # watch this effect on the whole pipeline
} -End {
Write-Verbose -Message 'ForEach (2) End' -Verbose
}
So what does this have to do with you?
Instead of naming the script blocks, you can just take them as an array. Then you can interpret them to same way that ForEach-Object
does (if there's 1 scriptblock, it's Process
, if there are 2, the first is Begin
, the second is Process
, if there are 3, it's Begin
, Process
, End
).
ForEach-Object
also accepts additional scriptblocks. It manages them a bit strangely, I think. If there's 1 extra, it (the 4th) will be treated as a second End
block. If there's 2+ extra, then the N-1 extras are treated as extra Process
and End
blocks, and the last extra is treated as an extra End
block only.:
1,2,3|%{write-verbose 'A' -Verbose}{$_}{write-verbose 'B' -Verbose}{Write-Verbose 'Extra1' -Verbose}{Write-Verbose 'Extra2' -Verbose}{Write-Verbose 'Extra3' -Verbose}{Write-Verbose 'Extra4' -Verbose}
Because instead of reimplementing these rules (or your own), you can just let ForEach-Object
do it for you:
function Invoke-DoSomethingMethod {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[hashtable[]]
$ScriptBlock # renaming to follow PowerShell conventions
)
1..10 |
ForEach-Object -Process { $_*10 } | # this replaces your foreach
ForEach-Object @ScriptBlock # Callbacks invoked here
}
$myHostCallbacks = @(
{ write-host "started" }
,{ write-host "processing $_%" } # bonus $_ usage
,{ write-host "finished" }
)
Invoke-DoSomethingMethod -ScriptBlock $myHostCallbacks
You get to use $_
, which is a common and well-understood pattern in PowerShell, without doing anything extra.
You can still accept it as a hashtable and the same code will work if the hashtable keys match the parameters of ForEach-Object
, which may be less flexible in this case.
Upvotes: 2