PowerShell v2 - My Best Practices
After working more than a year on different projects using PowerShell (v1 and v2), I would like to share with you some Best practices that I could identify.
I decided to write this article when I realized that I was always using the same tips/tricks and asking people working with PowerShell to use them as well.
Some of the tips that I’ll give may seem stupid and/or quite common (not to say mandatory), in the development world... But scripting is not really part of the “development world” and I realized that it’s much easier to write dirty codes. Sorry for the people working with PowerShell v1 for 2 reasons: first, they (sadly) still work with v1 :-) and second they won't be able to apply all the tips from this article...
Last point: as development tool, I use PowerGui.
So let's start!
Always use a logger on your functions
Feed back:
A few weeks ago, I wrote a short "one shot script"... For the first time I decided not to include any logging function (I was, maybe, a bit too confident…). The punishment came straight away: after the first deployment I was not able to know what didn't work properly and it took me awhile to understand why.
The point:
So even if you know it, force yourself to do it. Logging is not an option.
Example:
The first time that I wrote a logger in PowerShell, it took me a long time. But since then I never stopped to improve it and even added options. I copy hereafter, a light version of it (using the log4net library), just to give you an overview.
function New-Logger
{
<#
.SYNOPSIS
This function creates a log4net logger instance already configured
.OUTPUTS
The log4net logger instance ready to be used
#>
[CmdletBinding()]
Param
(
[string]
# Path of the configuration file of log4net
$Configuration,
[Alias("Dll")]
[string]
# Log4net dll path
$log4netDllPath
)
Write-Verbose "[New-Logger] Logger initialization"
$log4netDllPath = Resolve-Path $log4netDllPath -ErrorAction SilentlyContinue -ErrorVariable Err
if ($Err)
{
throw "Log4net library cannot be found on the path $log4netDllPath"
}
else
{
Write-Verbose "[New-Logger] Log4net dll path is : '$log4netDllPath'"
[void][Reflection.Assembly]::LoadFrom($log4netDllPath) | Out-Null
# Log4net configuration loading
$log4netConfigFilePath = Resolve-Path $Configuration -ErrorAction SilentlyContinue -ErrorVariable Err
if ($Err)
{
throw "Log4Net configuration file $Configuration cannot be found"
}
else
{
Write-Verbose "[New-Logger] Log4net configuration file is '$log4netConfigFilePath' "
$FileInfo = New-Object System.IO.FileInfo($log4netConfigFilePath)
[log4net.Config.XmlConfigurator]::Configure($FileInfo)
$script:MyCommonLogger = [log4net.LogManager]::GetLogger("root")
Write-Verbose "[New-Logger] Logger is configured"
return $MyCommonLogger
}
}
}
And to use it:
$log = New-Logger -Configuration ./config/log4net.config -Dll ./lib/log4net.dll
$log.DebugFormat("Logger configuration file is : '{0}'", (Resolve-Path "./config/log4net.config"))
Limits
Ok ok… If you really DON’T want to log… But follow what’s going on during your script execution you can use the Write-Host / Write-Verbose… The cmdlet Write-Verbose "something" will write on the console when the switch parameter -Verbose is passed as argument at your script execution¹, or if you set $VerbosePreference to "Continue". For more details
Implement the -WhatIf switch parameter
Feed back:
How can I run a script, for testing purpose, without impacts? -> With the WhatIf parameter WhatIf option simulates the behavior and writes it to the console, but without doing anything. You can do dry run test using this parameter.
The point:
Definitely the first testing step... Trying it, means adopting it.
Example:
A basic one: to apply it on a cmdlet
New-Item -Path "C:\Program Files\example.txt" -ItemType File –WhatIf
To apply it into your script:
function Test-WhatIf
{
[CmdletBinding()]
Param
(
[switch]
$WhatIf
)
New-Item -Path "C:\Program Files\example.txt" -ItemType File -WhatIf:$WhatIf
}
Limit:
Sometimes you have to make a complex system just to enable it
Use Modules to share your functions…
Feed back:
On the PowerShell v1, the only way to use common functions was to use the Cmdlet:
. .\myFunctions.ps1
Definitely not the best, but I did run into limitations when I wanted to know what was imported or not. On top of that, it was executing the script and importing it. Ok it works but it's not its primary role.
The point:
PowerShell v2 came with some new features: one of them was the module feature. A module is a script file containing functions and it provides you with a way to share your functions. The module file extension in PowerShell is “.psm1” file. Once imported, it can be managed with standard cmdlet (Get-Module, Remove-Module, Import-Module, New-Module), and Force the reimport.
Example:
Import-Module $MyNewModule.psm1 –Force
I use to add at the beginning of a module file: Write-Host "importing MyOwnModule" This helps to check if the module has been imported several times (as it shouldn't be) or just once...
Cf. http://www.simple-talk.com/sysadmin/powershell/an-introduction-to-powershell-modules/
... And to share your paths
Feed back:
I don't know how do you manage your path, but it took me time to figure out how to manage them in my scripts. In fact I started to reach some limits when I started to interact a lot between scripts: they use the same paths but declared inside ps1 scripts... To avoid this issue, I decided to start to export them in a psm1 file and set this as a Best practice. On top of that it helped me during my tests (just copy/paste a new file to set testing locations).
The point:
Set a scope to your variable: at least the "script" scope but most of the time I use the "global" scope.
Example:
2 way of setting variable scope
Set-Variable -Name MY_FOLDER_PATH -Value ".\MyFolder" -Scope Global
Or
$global:MY_FOLDER_PATH = ".\MyFolder"
I definitely prefer the first way of writing it... But it's really a personal point of view :)
Limits:
It's important to be aware of variable naming convention (another best practice), so as not to get variable value overwritten problems caused by global scope. Another point: bad scope could be the source of unexpected exceptions: you add a new path (as a variable) and forget to add a scope to it… Believe me, it was dreadful to understand this the first time…
Always keep your script independent from the place you run it
Feed back:
PowerShell sets your current execution folder to the one from which the script is called. If you always run your script manually from the same folder: you don't care. If you run it through a GUI (like PowerGui), you have to always set your default folder in your script location, from RunOnce or a BAT you have to set the execution folder, etc.
Once you know it, it's easy to deal with. But that's not a convenient way to use relative path. It would be much better if your script was independent from where you run it, wouldn't it?
The point
I advise you to follow this rule: add at the beginning of the script a variable set by "$myInvocation.MyCommand.Path".
Example:
Set-Variable -Name SCRIPT_PATH -Value (Split-Path (Resolve-Path $myInvocation.MyCommand.Path)) -Scope local -ErrorAction SilentlyContinue
Then, instead of
Set-Variable -Name MY_OTHER_FOLDER_PATH -Value ".\..\..\MyOtherFolder" -Scope Global
use:
Set-Variable -Name MY_OTHER_FOLDER_PATH -Value "$SCRIPT_PATH\..\..\MyOtherFolder" -Scope Global
Prefer Exception
Feed back:
I started to work with PowerShell v1, when the error management was not easy. Options were to use :
trap: but I’ve never been able to make it work properly (have a look here to see how it was crapy before)
test after a cmdlet execution its error variable (which was giving you the chance to "catch" an error and throw it with a nice log message)
Resolve-Path -Path "./Test" -ErrorAction SilentlyContinue -ErrorVariable Err if ($Err) { Write-Host "The path was wrong :(" }
Hopefully since PowerShell v2, the block try{}catch{}finally{} appears.
The point:
Always catch exceptions. No need to go as far as in a program and catch specific exceptions but it’s useful to catch them to be logged, and then apply a rollback if needed. You can add the function name in the exception message to help you when you read the message to determine where it comes from.
Lately I have discovered that the $_.InvocationInfo.PositionMessage property indicates where the error originates from.
Example:
try
{
. ".\myScripts.ps1" # this script contains an error!!!
}
catch [Exception]
{
Write-Host "$($_.Exception.ToString()). $($_.InvocationInfo.PositionMessage)"
}
finally
{
Write-Host "End of this block"
}
Be aware of the advanced help content: "Get-Help about_*"
Feed Back:
To discover this official manual pages, I needed to read a book² on PowerShell... And I have to admit it was pretty useful and interesting. Theses detailed pages describe how to write or use some cmdlets.
The point:
You can find a lot of information on internet about PowerShell (… too often it's about PowerShell v1). So when you don't know (or even worst: when you don't have or do have just a limited access to the web): you can use this local help.
Example:
Get-Help about_try_catch_finally
Ps: That's how I've just learned that you could catch several exception type:
catch [System.Net.WebException],[System.IO.IOException]
{
}
Limits:
It can take some time to find help about a specific subject....
Write help on functions
Feed Back:
I do not write help description on each function (yes I know I should...), and I never had to use the Get-Help on a function that I did...
The point:
But writing comments is part of our job (for other people and even ourselves sometimes), and if we have to write them, it's better to do it by the book To get more help on this: about_Comment_Based_Help
Example:
<#
.SYNOPSIS
About your script
.OUTPUTS (with a S at the end....)
output returned by your script or your function
.PARAMETER MyParam
MyParam description
#>
Function Test-MyFunction
{
[CmdletBinding()]
param($MyParam)
Write-Output $MyParam
}
Then you can interrogate your script with
Get-Help Test-MyFunction -Detailed
Limits:
If you make a spelling mistake writing your help description, the Get-Help cmdlet won't work on it... For example: if you forgot the 's' at '.OUTPUTS' the Get-Help function won't show anything.
Keep the format Verb-Noun to name your functions
Feed back:
Function naming conventions are not that important... Until the moment when you need to fix a bug and you have to reread your whole script... Trust me on this one ;-)
The point:
Use the Get-Verb cmdlet to get common verb to use During a module import, function names will be checked: you can deactivate warnings with the switch parameter named "-DisableNameChecking"
Example:
At the beginning I started to name my logger function:
- ConfigLogger (I'm coming from C# world),
- ConfigureLogger,
- Configure-Logger (I started to get the Verb-Noun rule),
- Get-Logger (after discovering the PowerShell v2 module extension .psm1 and its warnings during the function name checking -> it didn't want to accept Configure)
- And finally New-Logger: which represents what it does: create a new instance of a log4net logger object.
By the way, I think the various naming conventions I used represents my own evolution in PowerShell....)
Adopt a high way attitude… to write your variable
Feed back:
Almost as stupid to say as "follow the pattern Verb-Noun", I know... But that helps when you read a script to be able to know what variables are...
The point:
It is not important to follow the CamelCase pattern or any other: the point is to FOLLOW and KEEP the same pattern to help yourself (and the others who would have to read your code)
Example:
For instance, I have used to write:
- A constant like : MY_NEW_CONSTANT
- Parameter like : Myparameter or MyParameter
- A variable like : myvariable or myVariable
Conclusion
In this post, I have tried to give you best practices that I could identify during my last projects. I hope they will be useful (maybe even used). If you find some other, feel free to share them here as well.
¹ To use -verbose on your script (or/and your functions) you have to add [CmdletBinding ()] in your script (see about_Functions_CmdletBindingAttribute: when you write functions, you can add the CmdletBinding attribute so that Windows PowerShell will bind the parameters of the function in the same way that it binds the parameters of compiled cmdlets)
² "Windows PowerShell 2.0 - Best Practices" from Ed Wilson