“Vague is good” revisited: How to make usable PowerShell Functions
Before Christmas I wrote about the conclusion I was forming on PowerShell parameters: Vague is good. The Christmas season is when my parents used to get various kinds of puzzles out and most of my puzzles these day seem to be PowerShell type things rather than Jigsaws and Crosswords. My equivalent of the 1000 piece jigsaw over the holidays was to try to get my Hyper-V library for PowerShell working with PowerShell-Remoting. There isn’t an overwhelming need to do this because the library can manage a remote server using WMI, but I felt I should at least test it. It failed miserably. I showed the template for my functions back in that post it goes like this
Function Stop-VM{
Param( [parameter(Mandatory = $true, ValueFromPipeline = $true)]$VM,
$Server = ".")
Process{ if ($VM –is [String]) {$VM = GetVM –vm $vm –server $server}
if ($VM –is [array]) {[Void]$PSBoundParameters.Remove("VM")
VM | ForEach-object {Stop-Vm -VM $_ @PSBoundParameters}}
if ($VM -is [System.Management.ManagementObject]) {
#Do the work of the function
$vm.RequestStateChange(3) }
}}
My point when I wrote “vague is good” was don’t assume either that the user will pass a name , or that they will pass an object: specifying types enforce those assumption forces on user behaviour. In the same way don’t assume that the server is a single string . Accept what user wants to pass – it’s your job to sort it out, not theirs. That’s not like implementing code to be used by other programmers in other languages because they expect the constraints of types.
Remoting doesn’t like the constraints of types: the serialization process it uses to cope with different objects being available on different machines means when a Virtual Machine object comes back from a remote server it is no longer of the type [System.Management.ManagementObject]. So if I send it back to a second command it just drops through without doing anything. If I send an array of such objects it morphs into an ArrayList. After chatting with some friends in the product team I understand why this is the case but whilst I wasn’t making assumptions about types in my parameters I hadn’t got rid of them completely and I had to go back and change every instance where I’d used this template. The key bits I needed to change became if ($VM.__Count –gt 1) {[Void]$PSBoundParameters.Remove("VM") VM | ForEach-object {Stop-Vm -VM $_ @PSBoundParameters}} ``if ($vm.__CLASS -eq 'Msvm_ComputerSystem') {}
There were other side effect too . In some places I might have written $VM.getRelated(“Msvm_computerSettings”)
but the serialized object doesn’t have a getrelated()
method, so that fails. Instead I can use Get-wmiobject
with a query in the form “associators of {$vm} where resultClass=Msvm_computerSettings”.
Even that needs to change because $VM
expands to it’s wmi Path, but only when it is of the type [System.Management.ManagementObject] : when it’s been serialized it doesn’t expand in the same way so it needs to be associators of {$($vm.__Path)}
Now you might think that this cements the view that leaving types unspecified is something I’d put forward as a best practice – but it’s more complicated than that having read this post of Raymond’s recently , the advice is more along the lines of “If you are interested in the Tail, don’t specify the animal, just check it has a valid tail.” (Raymond was making a totally different point, he’s very good on those). To see why it is more complicated let’s take another example. I have a function which does things to files - in this case Expand Virtual Hard disks. My code wants the path to the file it is supposed to work on, and if the file lives on a remote server, the server name as well. But as well as typing path1.vhd, path2.vhd , the user may well want to pipe Virtual Hard disks into the command. They might
- Have a list of names in a file and do type Names.txt | Expand-vhd –size 30gb
- Do DIR D:\VHDS | expand-VHD
- Pipe In the output of he Get-VHD function I provided which uses WMI to get remote file objects for VHDs on a remote computer
- Pipe in the output of the Get-VHDInfo command I provided so they can filter the list of disks to only dynamic VHD files
- Pipe in the output of Get-VMDisk function I provided – which returns disks attached to a virtual machine.
That’s 5 different kinds of object and they use different property names for the path. Fortunately PowerShell has a warren of trained rabbits for me to pull out a hat here. So first here is the simplest way we can write the function to deal with paths, which forces the user to use strings.
param ( [parameter(ValueFromPipeline=$true)] [String[]]$VHDPaths, [String]$Server=”.” ) process{ `` Foreach ($VHDPath in $VHDPaths) { #Do Stuff } }
But here is the first change, which enables the function to get the string from a property of the object.
[parameter(Mandatory=$true, ValueFromPipelineByPropertyName =$true, ValueFromPipeline=$true)]
[String[]]$VHDPaths
That has turned the parameter declaration into “Take a string from a command-line parameter, or a string from the pipeline, OR if you have an object in the pipeline which has a property named VHDPaths , use that”. Great … except nothing uses the property name VHDpaths so the parameter can be told to use other property names by adding
[Alias("Fullname","Path","DiskPath")]
Now the function can take a string, a WMIObject or a file object: when I provide functions which output disk objects I just have to make sure they include a property with one of those names. If we go back to the animals analogy – someone might tell you a number of feet, or might pass you an animal which has “Hooves” or “Paws”, what we’re saying is “If a number, great, but if passed an animal, look at the count of Feet/Hooves/Paws” Simples.
There is one last trick – more than one parameter can be set using a property of a piped object. If someone does Get-VHD –Server MyServer | Expand-VHD –Size 30gb
it should just work. It shouldn’t force them to put a –Server
parameter into the Expand
part of the command line – so how can that be done? Like this.
[parameter(ValueFromPipelineByPropertyName =$true][Alias("__Server")] [String]$Server = "."