mclayton
mclayton

Reputation: 10060

Invoking callbacks from PowerShell functions

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

Answers (3)

mclayton
mclayton

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

t1meless
t1meless

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

briantist
briantist

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
    }

Try it online! (but paste it into ISE to see the effect of the timing delays)

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}

"Ok but, how is this useful?"

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

Bonus

You get to use $_, which is a common and well-understood pattern in PowerShell, without doing anything extra.

Note

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

Related Questions