Wednesday, December 4, 2013

You want me to stick this PowerShell variable…where?

I was reading an old blog article by Jeffrey Snover and found a hidden gem.  Buried in the middle of the article is a side note with a single paragraph about PowerShell variables that are not stored in memory.  Fascinated, I have been playing and experimenting and researching, and this is what I have figured out so far.

A PowerShell variable name, such as $X, points to a spot in memory where your variable object is stored.  But here's the cool thing.  The pointer doesn't have to point to memory.  It can point to a file.  (It has the potential to point other places as well.  More on that later.)

Strings

Here is the syntax for storing a variable in a file.

${D:\Temp\CoolVariable.txt} = "very blue"

No quotes.  Just a dollar sign with the file path and name in curly braces.

And it works the same for using the variable in a sentence.

$Full = "The sky is " + ${D:\Temp\CoolVariable.txt} + " today."

Numbers and dates

It stores the value of the variable, and only the value, in a text file.  On the surface, this makes it appear that this only works for string variables.  But thanks to the magic of PowerShell's powerful dynamic type conversion, it actually means it works for any simple variable type, any variable that has a single independent value.  So it also can be used for all of the different types of numbers and dates, though you need to be aware that it will come back to you as a string which may or may not dynamically convert back to a number or date depending on the context.

For example

${D:\Temp\CoolVariable.txt} = 27.6
100.3 + ${D:\Temp\CoolVariable.txt}

Will work the way you expect.

But if you reverse the order of these two mismatched variable types, PowerShell will assume you want the result to be a string, and concatenate the two instead of adding them together.  So you need to do the conversion explicitly.

[double]${D:\Temp\CoolVariable.txt} + 100

It does NOT work with Boolean values

It does NOT work with Boolean values.  The problem is false.  That is, the problem is with converting $False to and from a string.

The PowerShell designers made some great decisions about how to convert things into [Boolean] true/false values.   Empty strings, empty arrays, the number zero, $Null, and $False all evaluate as $False when converted to [Boolean] values.  Non-empty strings, populated arrays, non-zero numbers, $True, and more or less every other object evaluate as $True.  This is very nice to have for simplifying syntax of conditionals.

If ( $X )

Is short for "If $X exists and contains a tangible thing". ("Tangible" being a relative term in a computer-modeled world. ; )  I like that functionality.  I use it a lot.

But part of what I said up there is "non-empty strings… evaluate as $True."  Even when the non-empty string is the word "False".

So if we convert $False to a [string], it becomes "False", and when we convert that back to a [Boolean], it becomes $True.  You can demonstrate that like this.

[boolean][string]$False

And of course the same thing happens when you assign $False to a file variable and then try to use it as a [Boolean].

So don't store [Boolean] values in file variables.

Arrays

It does works with arrays.

${D:\Temp\CoolVariable.txt} = Get-ChildItem D:\Stuff | ForEach { $_.FullName }

This stores a list of the full file path and names of the files found in D:\Stuff.

And they come back out as an array of strings.

ForEach ( $FilePath in ${D:\Temp\CoolVariable.txt} ) { $FilePath }

If you stick an array of numbers in there, you can explicitly convert them as you pull them out or as you use them.

ForEach ( $EmployeeNumber in [int[]]${D:\Temp\CoolVariable.txt} ) { $EmployeeNumber }

Or

ForEach ( $EmployeeNumber in ${D:\Temp\CoolVariable.txt} ) { [int]$EmployeeNumber }

Complex objects

It works nicely as a shortcut for complex object types when you only wanted the name of the object anyway.  Whenever you try to assign an object to a file variable, it uses the object's built-in .ToString() method to convert it as it saves it.

For example, if you want to save a list of services that are running,

${D:\Temp\CoolVariable.txt} = Get-Service | Where { $_.Status -eq "Running" }

This will give you a list of the service names, not the display names, because the name is what you get when you run the .ToString() method on a service object.

If you want to save the display names instead, and you are still using PowerShell 2.0, we can do that.

${D:\Temp\CoolVariable.txt} = Get-Service | Where { $_.Status -eq "Running" } | ForEach { $_.DisplayName }

If you are using PowerShell 3.0 or later, we can tighten that up a bit:

${D:\Temp\CoolVariable.txt} = ( Get-Service | Where Status -eq "Running" ).DisplayName

You may have been tempted to try using Select-Object there instead.  If you did, you noticed unexpected results.

When you use Select to choose only certain properties, the objects are converted to type PSCustom.  PSCustom objects act differently when you try to stick them in a file variable.  The object is converted to a hash table, and the hash table is converted to a string, and the string is saved to the file variable.  If you do this with an array of objects, you get an array of hash table strings.

This is almost useful.  This allows you to save all of the properties of a complexes object, or at least those property values that can be easily converted to strings.

But it's not really useful, because when you try to use the variable, what you are going to get is a single string for each object, representing the hash table that represents the custom object, and it does not automatically reconstitute itself.  To use the objects, you have to do something's like this.

${D:\Temp\CoolVariable.txt} = Get-Service | Select DisplayName, Name, Status
$Services = ${D:\Temp\CoolVariable.txt} | ForEach { New-Object PSObject -Property ( ConvertFrom-StringData $_.Substring( 2, $_.Length - 3 ).Replace( "; ", "`n" ) ) }

Which is far more trouble than it's worth.  Use Export-/Import-CSV instead.

$S = Get-Service | Select DisplayName, Name, Status
$S | Export-CSV D:\Temp\Services.csv -NoTypeInformation
$Services = Import-CSV D:\Temp\Services.csv

Variable variable names

One disadvantage is that we are now using paths and file names as a variable name, which means they can't vary.  You don't embed variables within variable names and expect PowerShell to parse them.

This does NOT work:  ${$LogShare\$LogName}

Well, we can make it work by wrapping entire lines in Invoke-Expression, but it gets messy.

Invoke-Expression "`${$LogShare\$LogName} = [array]`${$LogShare\$LogName} + `$LastResultCode"

Not worth the effort.

Shared variables

One way to use a file variable is when you need a variable that can survive a catastrophic failure of the script.  Or any other time you want the variable to persist between subsequent runs of the script. 
Or between runs of the script on different servers.

There might be an issue if two servers tried to access the variable at exactly the same time, but you would have that with any file-based system for sharing the data.  If they never ran at the same time, it would work just fine.

For example…

I have a report that needs to run every night.  A scheduled task kicks off a script to generate the report at 10 PM on server A.  For redundancy, the same script runs at 11 PM on server B.

I don't want server B to tax the database re-running the report if sever A was successful.  But my users have a tendency to move, rename, or otherwise modify the report almost as soon as it's generated, so I can't look to the report file to confirm it's done.  Coincidentally, a file variable is perfect for this.

The script ends with

${\\MainFS\TJC\ReportLastRun.txt} = Get-Date

And starts with

If ( [datetime]${\\MainFS\TJC\ReportLastRun.txt} -gt ( Get-Date ).AddHours( -2 ) ) { Exit }

That way, if the script successfully ran less than two hours ago, it immediately exits without re-running the report.

File variables vs. Get-Content and Set-Content

You may have noticed that working with file variables is similar to using Set-Content and Get-Content.  When you play around with them, you find that they work exactly the same.  This is because behind the scenes, they are the same.  They both work by calling the iContentCmdletProvider interface of the FileSystem provider.

Measuring the performance of the two leads to some interesting results.

This

$ServerList = ${D:\Temp\ServerList.txt}

is actually faster than this

$ServerList = Get-Content D:\Temp\ServerList.txt

Where else can I stick it?

I asked Jeffrey Stover that question, and rather than file a restraining order, he graciously explained that while it had never been a high enough priority for the PowerShell team to extend the functionality to other places, he did make sure that it was possible for anyone to extend it to other things, like the registry or databases.

Then he showed how to do it in MUMPS, which wasn't exactly helpful, but his enthusiasm was inspiring.

I am planning to see if I can use this to create a simpler, more intuitive way to store information in the registry.  I believe it can be accomplished by implementing the iContentCmdletProvider interface on the PowerShell registry provider.  This will be my first foray into such an extreme enhancement to PowerShell, so it will be an adventure.  I'll tell you all about it when it's done.

No comments:

Post a Comment