Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
279 views
in Technique[技术] by (71.8m points)

c# - Why does this Powershell Cmdlet return a multidimensional array when parameter comes from pipeline?

I've written a Powershell cmdlet in C# which returns details about one or more employees' direct manager(s) using a home-grown API. The cmdlet is supposed to return a collection of one or more objects of type Associate. The problem I'm having is that the output type of the Cmdlet is not consistent.

I designed the Cmdlet such that if you already have a collection of Associate objects, you can pass that in via the pipeline. Otherwise, you need to pass in one or more userIds under the -Identity parameter.

Here is the behavior I'm seeing, though in terms of the Cmdlet output:

  • If I pass in one or more userIds with the -Identity parameter, I get the expected collection of Associate:
    > $test1 = Get-Manager -Identity 'user1','user2'
    > $test1.GetType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     List`1                                   System.Object


    PS H:> $test1 | select displayName

    displayName
    -----------
    John Doe
    Jane Lee
  • If I pass in one or more Associate objects by explicitly using the -Assoc parameter, I also get the expected collection
    > $folks = Get-Associate 'brunomik','abcdef2'
    > $test2 = Get-Manager -Assoc $folks
    > $test2.getType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     List`1                                   System.Object


    PS H:> $test2 | Select displayName

    displayName
    -----------
    John Doe
    Jane Lee
  • However, if I pass in a collection of Associate objects using the pipeline, I seem to get back a multidimensional array!:
    > $test3 = $folks | Get-Manager
    > $test3.GetType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     Object[]                                 System.Array


    > $test3 | select displayName

    displayName
    -----------


    ># Select-Object can't find a property called displayName
    ># But if I run GetType() on the first element of the collection:
    > $test3[0].GetType()

    IsPublic IsSerial Name                                     BaseType
    -------- -------- ----                                     --------
    True     True     List`1                                   System.Object

    ># It appears to be yet another collection!
    ># Now, if I run Select-Object on that first element of $test3, I do see the data:
    > $test3[0] | Select displayName

    displayName
    -----------
    John Doe
    Jane Lee

Here is the source code for the Cmdlet:

    [Cmdlet(VerbsCommon.Get, "Manager", DefaultParameterSetName = @"DefaultParamSet")]
    [OutputType(typeof(Associate))]
    public class GetManager : Cmdlet
    {
        private Associate[] assoc = null;
        private string[] identity = null;

        private bool assocSet = false;
        private bool identitySet = false;


        //The Assoc parameter supports the pipeline and accepts one or more objects of type Associate
        [Parameter(ParameterSetName = @"DefaultParamSet",
                   ValueFromPipeline = true,
                   HelpMessage = "An Associate object as returned by the "Get-Associate" cmdlet. Cannot be used with the "Identity" parameter")]
        public Associate[] Assoc
        {
            get
            {
                return assoc;
            }
            set
            {
                assoc = value;
                assocSet = true;
            }
        }

        //The Identity parameter accepts one or more string expressions (user IDs)
        [Parameter(HelpMessage = "An Associate user Id. Not to be used with the "Assoc" parameter")]
        public string[] Identity
        {
            get
            {
                return identity;
            }
            set
            {
                identitySet = true;
                identity = value;
            }
        }

        //This will contain the output of the Cmdlet
        private List<Associate> Result = new List<Associate>();

        protected override void BeginProcessing()
        {
            base.BeginProcessing();
        }

        protected override void ProcessRecord()
        {
            base.ProcessRecord();
            BuildOutputObject();
            WriteObject(Result);
        }

        //Builds the Cmdlet Output object
        private void BuildOutputObject()
        {
            List<Associate> Subordinates = new List<Associate>();

            //Only the Assoc or Identity parameter may be set; not both.
            if (!(assocSet ^ identitySet))
            {
                throw new ApplicationException($"Either the {nameof(Assoc).InQuotes()} or the {nameof(Identity).InQuotes()} parameter must be set, but not both.");
            }

            //If Assoc is set, we already have an array of Associate objects, so we'll simply define Subordinates by calling Assoc.ToList()
            if (assocSet)
            {
                Subordinates = Assoc.ToList();
            }

            //Otherwise, we'll need to create an associate object from each userID passed in with the "Identity" parameter.  The MyApi.GetAssociates() method returns a list of Associate objects.
            else
            {
                Subordinates = MyApi.GetAssociates(Identity);
                if (!MyApi.ValidResponse)
                {
                    throw new ApplicationException($"No associate under the identifiers {string.Join(",",Identity).InQuotes()} could be found.");
                }
            }

            //Now, to build the output object:
            Subordinates.ForEach(p => Result.Add(p.GetManager()));
        }
    }

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

ProcessRecord is executed once per input argument.

As a result, when you call Get-Manager -Identity A,B, PowerShell:

  • Resolves the appropriate parameter set (if necessary)
  • Invokes BeginProcessing()
  • Binds value A,B to Identity
  • Invokes ProcessRecord()
  • Invokes EndProcessing()

When you pipe an equivalent array to it (eg. "A","B" |Get-Manager), PowerShell enumerates the input and binds the items to the appropriate parameter one-by-one instead - that is, PowerShell:

  • Resolves the appropriate parameter set (if necessary)
  • Invokes BeginProcessing()
  • Binds value A to Identity
  • Invokes ProcessRecord()
  • Binds value B to Identity
  • Invokes ProcessRecord()
  • Invokes EndProcessing()

... resulting in 2 List<Associate>'s, instead of one.

The "solution" is to either:

  1. not return concrete collections types as output objects, or
  2. "collect" partial output in ProcessRecord, then output once, in EndProcessing.

1. No wrapping in IEnumerable types

This approach closely resembles an iterator method in C# - think of WriteObject(obj); as PowerShell's version of yield return obj;:

protected override void ProcessRecord()
{
    base.ProcessRecord();
    BuildOutputObject();
    foreach(var obj in Result)
      WriteObject(obj);
}

WriteObject() also has an overload that enumerates the object for you, so the simplest fix is actually just:

protected override void ProcessRecord()
{
    base.ProcessRecord();
    BuildOutputObject();
    WriteObject(Result, true);
}

This first option is by far the most preferable, as it allows us to take optimal advantage of performance characteristics of PowerShell's pipeline processor.

2. Accumulate output, WriteObject() in EndProcessing():

private List<Associate> finalResult = new List<Associate>();

protected override void ProcessRecord()
{
    base.ProcessRecord();
    BuildOutputObject();
    # Accumulate output
    finalResult.AddRange(Result)
}

protected override void EndProcessing()
{
    WriteObject(finalResult);
}

Omitting the second argument from WriteObject and only calling it once, will preserve the type of finalResult, but you will be blocking any downstream cmdlets from executing until this one is done processing all input


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...