Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How do I pass a class object in a argument list to a another computer and call a function on it?

I am attempting to create a class object and use Invoke-Command to call a function on the class on a remote machine. When I use Invoke-Command with no computer name this works fine but when I attempt to do this on a remote computer I get an error saying the that the type does not contain my method. Here is the script I am using for testing this.

$ComputerName = "<computer name>"

[TestClass]$obj = [TestClass]::new("1", "2")

Get-Member -InputObject $obj

$credentials = Get-Credential

Invoke-Command -ComputerName $ComputerName -Credential $credentials -Authentication Credssp -ArgumentList ([TestClass]$obj) -ScriptBlock {
    $obj = $args[0]

    Get-Member -InputObject $obj

    $obj.DoWork()
    $obj.String3
}

class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3

    [void]DoWork(){
        $this.String3 = $this.String1 + $this.String2
    }

    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
}

Here is the output I get.

PS > .\Test-Command.ps1

cmdlet Get-Credential at command pipeline position 1
Supply values for the following parameters:
User: <my user>
Password for user <my user>: *

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}


   TypeName: Deserialized.TestClass

Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatProvider)
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}
Method invocation failed because [Deserialized.TestClass] does not contain a method named 'DoWork'.
    + CategoryInfo          : InvalidOperation: (DoWork:String) [], RuntimeException
    + FullyQualifiedErrorId : MethodNotFound
    + PSComputerName        : <computer name>

I can see that the type changes from TestClass to Deserialized.TestClass and I am wondering if there is a way around this? My goal is to be able to ship the functions I need to each of the machines I am running a script on so that I don't have to rewrite the functions in the context of the Invoke-Command script block.

like image 448
Max Young Avatar asked Aug 31 '25 03:08

Max Young


2 Answers

In short: The XML-based serialization / deserialization that PowerShell employs behind the scenes during remoting and in background jobs only handles a handful of known types with type fidelity.

Instances of custom classes such as yours are emulated with method-less "property bags" in the form of [pscustomobject] instances, which is why the emulated instances of your class instances have no methods on the remote machine.

For a more detailed overview of PowerShell's serialization/deserialization, see the bottom section of this answer.


As Mike Twc suggests, you can work around the problem by passing your class definition along to your remote command as well, allowing you to redefine the class there and then recreate instances of your custom class in the remote session.

While you cannot dynamically obtain a custom class definition's source code directly, you can work around this by placing it inside a helper script block, which allows you to:

  • Define the class locally by dot-sourcing the helper script block (. { ... })

  • Recreate it remotely, via the script block's source code, using Invoke-Expression

    • A script block's verbatim source code (excluding the { and }) is obtained simply by stringifying it, i.e., by calling .ToString() on it. In fact, in the context of PowerShell remoting a script block implicitly becomes its string representation - surprisingly so; see GitHub issue #11698.

    • Note that while Invoke-Expression should generally be avoided, its use is safe here, given that the string being evaluated as PowerShell code is fully under your control.

A simplified example, which uses the $using: scope rather than parameters (via -ArgumentList) to include values from the caller's scope.

# Define your custom class in a helper script block
# and dot-source the script block to define the class locally.
# The script block's string representation is then the class definition.
. (
  $classDef = {
    class TestClass {
      [string] $String1
      [string] $String2
      [string] $String3

      DoWork() {
        $this.String3 = $this.String1 + $this.String2
      }

      TestClass([string] $string1, [string] $string2) {
        $this.String1 = $string1
        $this.String2 = $string2
      }

      # IMPORTANT:
      # Also define a parameter-less constructor, for convenient
      # construction by a hashtable of properties.
      TestClass() {}

    }
  }
)

# Construct an instance locally.
$obj = [TestClass]::new("1", "2")

# Invoke a command remotely, passing both the class definition and the input object. 
Invoke-Command -ComputerName . -ScriptBlock {
  # Define the class in the remote session too, via its source code.
  # NOTE: This particular use of Invoke-Expression is safe, because you control the input,
  #       but it should generally be avoided.
  #       See https://blogs.msdn.microsoft.com/powershell/2011/06/03/invoke-expression-considered-harmful/
  Invoke-Expression $using:classDef
  # Now you can cast the emulated original object to the recreated class.
  $recreatedObject = [TestClass] $using:obj
  # Now you can call the method...
  $recreatedObject.DoWork()
  # ... and output the modified property
  $recreatedObject.String3
}

See also:

  • For an analogous approach to using locally defined functions remotely, see this answer.
like image 123
mklement0 Avatar answered Sep 02 '25 22:09

mklement0


It's an older question, but it was relevant to me. I found another way for my purposes:

  1. To make TestClass known in the remote environment, it can be included in the abstract syntax tree (AST) of the script before processing this. The same is also very useful for using statements (which must be declared on top of the file only) or functions (which can be used local and in a remote script without double declaration). The Edit-RemoteScript function is used for this purpose. (The solution was inspired by this answer in another forum. This very useful tool can help exploring the AST.)
  2. In order to get an object of the self-defined class as a 'living' object remotely or after it has been returned from the remote environment, it can be casted from Deserialized.TestClass to TestClass. The new constructor, which accepts a PSObject, serves this purpose. Alternatively, an op_Implicit or op_Explicit operator also accepting a PSObject can do the same. Inside this operator a class constructor must be invoked. Both operators seem to work identically in PowerShell.

This sample code illustrates the functionality:

using namespace Microsoft.PowerShell.Commands
using namespace System.Collections
using namespace System.Diagnostics.CodeAnalysis
using namespace System.Management.Automation
using namespace System.Management.Automation.Language

Set-StrictMode -Version ([Version]::new(3, 0))

class TestClass {
    [string]$String1
    [string]$String2
    [string]$String3

    [void]DoWork() {
        $this.String3 = $this.String1 + $this.String2
    }

    TestClass([string]$string1, [string]$string2) {
        $this.String1 = $string1
        $this.String2 = $string2
    }
    TestClass([PSObject]$ClassObject) {
        $this.String1 = $ClassObject.String1
        $this.String2 = $ClassObject.String2
        $this.String3 = $ClassObject.String3
    }
}

<#
    .DESCRIPTION
        This function adds using statements, functions, filters and types to ScriptBlocks to be used for remote access.

    .PARAMETER ScriptBlock
        The ScriptBlock to be processed. Mandatory.

    .PARAMETER Namespace
        The list of namespaces to add. 'default' adds any namespaces listed in the root script's using statements. Alternatively or additionally, 
        any other namespaces can be added. These have to be fully qualified. The statement 'using namespace' must not be prefixed. 
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.

    .PARAMETER Module
        The list of PowerShell modules to add. 'default' adds any module listed in the root script's using statements. Alternatively or additionally, 
        any other module can be added. The value of the argument can be a module name, a full module specification, or a path to a module file.
        When it is a path, the path can be fully qualified or relative. A relative path is resolved relative to the script that contains the using statement. 
        The modules referenced by path must be located identically in the file systems of the calling site and the remote site.
        The statement 'using namespace' must not be prefixed. 
        When it is a name or module specification, PowerShell searches the PSModulePath for the specified module.
        A module specification is a hashtable that has the following keys:
            - ModuleName - Required, specifies the module name.
            - GUID - Optional, specifies the GUID of the module.
            - It's also required to specify at least one of the three below keys.
            - ModuleVersion - Specifies a minimum acceptable version of the module.
            - MaximumVersion - Specifies the maximum acceptable version of the module.
            - RequiredVersion - Specifies an exact, required version of the module. This can't be used with the other Version keys.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.
    
    .PARAMETER Assembly
        The list of .NET assemblies to add. 'default' adds any assembly listed in the root script's using statements. Alternatively or additionally, 
        any other assembly can be added. The value can be a fully qualified or relative path. A relative path is resolved relative to the script that 
        contains the using statement. The assemblies referenced must be located identically in the file systems of the calling site and the remote site.
        The using statements are inserted in the processed ScriptBlock at the position where it contains the #ImportedUsings comment.
        Defaut is an empty list.

    .PARAMETER Type
        The list of names from types defined by the root script to add to the processed script.  
        The type definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedTypes comment.
        Defaut is an empty list.

    .PARAMETER Function
        The list of names from functions or filters defined by the root script to add to the processed script.  
        The function definitions are inserted in the processed ScriptBlock at the position where it contains the #ImportedFunctions comment.
        Defaut is an empty list.

    .PARAMETER SearchNestedScriptBlocks
        If this parameter is set, ScriptBlocks contained in the root script are also searched for functions, filters and types, otherwise only the root 
        script itself.

    .EXAMPLE
        In this example the namespaces used by the root script and two additional using namespace statements are added to $myScriptBlock.
        One type and two functions, defined by the root script, are also added:

        $myScriptBlock | Edit-RemoteScript `
            -Namespace 'default', 'System.Collections', 'System.Collections.Generic' `
            -Type 'MyType' `
            -Function 'ConvertTo-MyType', 'ConvertFrom-MyType'

    .NOTES
        Because the using statements must come before any other statement in a module and no uncommented statement can precede them, including parameters, 
        one cannot define any using statement in a nested ScriptBlock. Therefore, the only alternative to post-inserting the using statements into a 
        previously defined ScriptBlock, as is done in this function, is to define $myScript as a string and create the ScriptBlock using [ScriptBlock]::Create($myScript). 
        But then you lose syntax highlighting and other functionality of the IDE used.

        An alternative way of including functions, filters and types that are used in both, the root script and the remote script, in the latter is shown in 
        the links below. An alternative to post-insertion would be to redefine these functions, filters, and types in the remote script. However, the downside 
        is that changes to the code have to be kept in sync in different places, which reduces its maintainability. 

    .LINK 
        this function:
        https://stackoverflow.com/a/76695304/2883733
    
    .LINK 
        alternative for types:
        https://stackoverflow.com/a/59923349/2883733

    .LINK 
        alternative for functions:
        https://stackoverflow.com/a/71272589/2883733
#>
function Edit-RemoteScript {

    [CmdletBinding()]
    [OutputType([ScriptBlock])]
    [SuppressMessage('PSUseDeclaredVarsMoreThanAssignments', 'functionText', Justification = "Doesn't apply")]

    param(
        [Parameter(Mandatory, ValueFromPipeline)] [ScriptBlock]$ScriptBlock,
        [Parameter()] [AllowEmptyCollection()] [String[]]$Namespace = @(),
        [Parameter()] [AllowEmptyCollection()] [ModuleSpecification[]]$Module = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Assembly = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Type = @(),
        [Parameter()] [AllowEmptyCollection()] [String[]]$Function = @(),
        [Parameter()] [Switch]$SearchNestedScriptBlocks
    )

    begin {
        [Ast]$cmdletAst = $MyInvocation.MyCommand.ScriptBlock.Ast
        do {
            [Ast]$tempAst = $cmdletAst.Parent
        } while ($null -ne $tempAst -and ($cmdletAst = $tempAst))
        [String[]]$remoteUsings = @()
        [String[]]$remoteTypes = @()
        [String[]]$remoteFunctions = @()
    } process {
        if (($Namespace -or $Module -or $Assembly) -and -not $remoteUsings) {
            if ('default' -iin $Namespace -or
                'default' -iin $Assembly -or (
                    $Module | Where-Object -Property 'Name' -EQ -Value 'default' | Select-Object -First 1
                )
            ) {
                [UsingStatementAst[]]$allUsings = @($cmdletAst.FindAll({ $args[0] -is [UsingStatementAst] }, $false))
            }
            $remoteUsings = @(
                @(
                    @{
                        Kind  = [UsingStatementKind]::Namespace
                        Names = $Namespace
                    },
                    @{
                        Kind  = [UsingStatementKind]::Module
                        Names = $Module
                    },
                    @{
                        Kind  = [UsingStatementKind]::Assembly
                        Names = $Assembly
                    }
                ) | ForEach-Object -Process { 
                    [UsingStatementKind]$kind = $_.Kind
                    $_.Names | ForEach-Object -Process {
                        if (($kind -eq [UsingStatementKind]::Module -and $_.Name -ieq 'default') -or ($kind -ne [UsingStatementKind]::Module -and $_ -ieq 'default')) {
                            @($allUsings | Where-Object -Property 'UsingStatementKind' -EQ -Value $kind | ForEach-Object -Process { $_.ToString() })
                        } else {
                            if ($kind -eq [UsingStatementKind]::Assembly) {
                                "using $( $kind.ToString().ToLowerInvariant() ) '$_'"
                            } else {
                                "using $( $kind.ToString().ToLowerInvariant() ) $_"
                            }
                        }
                    }
                }
            )
        }
        if ($Type -and -not $remoteTypes) {
            $remoteTypes = @(
                $cmdletAst.FindAll({ $args[0] -is [TypeDefinitionAst] }, $SearchNestedScriptBlocks) | 
                    Where-Object -Property 'Name' -In $Type | 
                    ForEach-Object -Process { $_.ToString() }
            )
        }
        if ($Function -and -not $remoteFunctions) {
            $remoteFunctions = @(
                if ($SearchNestedScriptBlocks) {
                    # this is slower
                    $cmdletAst.FindAll({
                            param(
                                [Parameter()] [Ast]$Ast
                            )
                            <#
                                Class methods have a FunctionDefinitionAst under them as well, but we don't want them.
                                from: https://stackoverflow.com/a/45929412/2883733
                            #>
                            $Ast -is [FunctionDefinitionAst] -and $Ast.Parent -isnot [FunctionMemberAst]
                        },
                        $true) |
                        Where-Object -FilterScript {
                            $_.Name -iin $Function
                        } |
                        ForEach-Object -Process { $_.ToString() }
                } else {
                    # this is faster
                    Get-ChildItem -Path 'Function:' |
                        Where-Object -Property 'Name' -In $Function |
                        ForEach-Object -Process {
                            if ($_.CommandType -eq [CommandTypes]::Filter) {
                                "filter $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            } else {
                                "function $( $_.Name ) {$( $_.ScriptBlock.ToString() )}" 
                            }
                        }
                }
            )
        }
        [ScriptBlock]::Create($ScriptBlock.ToString(). `
                Replace('#ImportedUsings', $remoteUsings -join "`n"). `
                Replace('#ImportedTypes', $remoteTypes -join "`n"). `
                Replace('#ImportedFunctions', $remoteFunctions -join "`n"))
    } end {
    }
}

function TestFunction {
    42
}

$ComputerName = 'Server1'
[TestClass]$obj = [TestClass]::new('1', '2')
[ScriptBlock]$testScript = {
    #ImportedUsings # the imported using statements will be inserted here

    Set-StrictMode -Version ([Version]::new(3, 0))

    #ImportedTypes # the imported types will be inserted here
    #ImportedFunctions # the imported functions will be inserted here

    $obj = $args[0]
    [ArrayList]$results = @() # using statements are working remotely
    [TestClass]$castedObj = [TestClass]$obj # the type is known remotely

    [void]$results.Add('')
    [void]$results.Add('* * * remote * * *')
    [void]$results.Add((TestFunction)) # the function is known remotely
    $castedObj.DoWork() # the type has his functionality remotely
    [void]$results.Add($castedObj.String3)
    [void]$results.Add((Get-Member -InputObject $obj))
    [void]$results.Add((Get-Member -InputObject $castedObj))
    [void]$results.Add('')
    [void]$results.Add($castedObj)
    [void]$results.Add([TestClass]::new('3', '4'))
    $results
}
$testScript = $testScript | Edit-RemoteScript -Namespace 'default' -Type 'TestClass' -Function 'TestFunction'
$credentials = Get-Credential

'* * * local * * *'
TestFunction
Get-Member -InputObject $obj

$results = Invoke-Command -ComputerName $ComputerName -Credential $credentials -ArgumentList ([TestClass]$obj) -ScriptBlock $testScript
foreach ($ctr in 0..6) {
    $results[$ctr]
}
[TestClass]$resultObj = $results[7] # also returned objects can be casted back to the original type
"this is the original instance, DoWork() is already done, String3 = '$( $resultObj.String3 )'"
$resultObj = $results[8]
"this is a new instance, DoWork() isn't done yet, String3 = '$( $resultObj.String3 )'"
$resultObj.DoWork()
"... but now, String3 = '$( $resultObj.String3 )'"

Output:

* * * local * * *
42

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}

* * * remote * * *
42
12

   TypeName: Deserialized.TestClass

Name     MemberType Definition
----     ---------- ----------
GetType  Method     type GetType()
ToString Method     string ToString(), string ToString(string format, System.IFormatProvider formatProvider), string IFormattable.ToString(string format, System.IFormatProvider formatPro… 
String1  Property   System.String {get;set;}
String2  Property   System.String {get;set;}
String3  Property    {get;set;}

   TypeName: TestClass

Name        MemberType Definition
----        ---------- ----------
DoWork      Method     void DoWork()
Equals      Method     bool Equals(System.Object obj)
GetHashCode Method     int GetHashCode()
GetType     Method     type GetType()
ToString    Method     string ToString()
String1     Property   string String1 {get;set;}
String2     Property   string String2 {get;set;}
String3     Property   string String3 {get;set;}

this is the original instance, DoWork() is already done, String3 = '12'
this is a new instance, DoWork() isn't done yet, String3 = ''
... but now, String3 = '34'

In this case it is certainly a big overhead and it would actually be easier to re-define TestClass. In larger projects with complex classes, however, the procedure may worthwhile. Another advantage: there is no longer any need to synchronize functions and classes that have been declared multiple times when changes are made.

If you are working with a PSSession in which several remote calls are passed one after the other, it may even be worthwhile to have a script executed remotely first that is used exclusively for the declarations. Then a specific typed parameter type TestClass can be used instead of Object or PSObject because type TestClass is already known when the script is invoked. A casting of the parameter can be ommitted in this case:

[ScriptBlock]$TestScript = {
    param([Parameter()] [TestClass]$Obj)
    ....
    $Obj.DoWork() # the type has his functionality remotely
    [void]$results.Add($Obj.String3)
    ...
}

Edit 1: a small correction of the function code and inserted usefull links

Edit 2: suggested by @mklement0 's answer: making the function more universal; a comment-based help has also been added

Edit 3: clarification and small correction regarding casting operators

like image 20
Olli Avatar answered Sep 02 '25 20:09

Olli