20

Building Modules

A module groups a set of commands together, most often around a common system, service, or purpose. Modules were introduced with PowerShell 2.

A module written in PowerShell can contain functions, classes, enumerations, and DSC resources, and it may define aliases and include variables to include in the global scope.

There are several different common styles or layouts used when authoring modules. This chapter explores these different approaches, starting with the simplest.

This chapter explores the following topics:

  • Creating a module
  • Publishing a module
  • Multi-file module layout
  • Module scope
  • Initializing and removing modules

Technical requirements

This chapter uses the following modules, which can be installed from the PowerShell Gallery:

  • PowerShellGet version 2.2.5
  • ModuleBuilder version 2.0.0

The module created in this chapter is available on GitHub: https://github.com/PacktPublishing/Mastering-Windows-PowerShell-Scripting-Fourth-Edition/tree/master/Chapter20/LocalMachine.

The repository includes the different layouts discussed in this chapter.

Creating a module

A module most often consists of one or more functions. Typically, you create modules in one or more files starting with a psm1 file. The psm1 file is known as the root module.

The root module

The root module has a psm1 extension and is otherwise like any other script file in PowerShell. The root module file is named after the module.

The psm1 file can contain all the module content directly; nothing else is required to create a module.

A module requires content. Chapter 19, Classes and Enumerations, ended by creating a class-based DSC resource to manage the computer description property. You'll rebuild this content of the resource and use it as the basis for creating a module during this chapter.

Here is the first command to add to a file named LocalMachine.psm1:

function Get-ComputerDescription {
    [CmdletBinding()]
    [OutputType([string])]
    param ( )
    $getParams = @{
        Path = 'HKLM:SYSTEMCurrentControlSetServicesLanmanServerParameters'
        Name = 'srvcomment'
    }
    Get-ItemPropertyValue @getParams
}

Once saved, you can import the module file. The following command assumes the file is in the current directory in PowerShell:

Import-Module .LocalMachine.psm1

The command will be displayed in the ExportedCommands property when running Get-Module:

PS> Get-Module LocalMachine
ModuleType Version PreRelease Name         ExportedCommands
---------- ------- ---------- ----         ----------------
Script     0.0                LocalMachine Get-ComputerDescription

The ModuleType shows as Script because the module is defined by a psm1 file. If ExportedCommands is empty, there may be an error in the module file. Attempting to import the module should show any errors.

You can run the command if the module is imported. If a computer description is set, it will be displayed, otherwise an error will be displayed. You can optionally set a description by running the following command as administrator:

$params = @{
    Path  = 'HKLM:SYSTEMCurrentControlSetServicesLanmanServerParameters'
    Name  = 'srvcomment'
    Value = 'Computer description'
}
New-ItemProperty @params

As you add more functions to the psm1 file, additional commands will be displayed in ExportedCommands. By default, all functions are exported from the module, making them available to anyone using the module.

Export-ModuleMember

You can use the Export-ModuleMember command inside a psm1 file to explicitly define what is exported from a module.

At this point, the LocalMachine module only contains one command, and that command is expected to be visible to the end user.

As the module grows, some of the code will start to repeat, and functions might be added to the module to reduce repetition. Such functions might be expected to be internal to the module, not visible to a user of the module.

Functions that are exported are often referred to as public functions; functions that are not exported are known as private functions.

The following change to LocalMachine.psm1 introduces a function that will be shared inside the module and is not intended to be seen by users of the module.

The private function anticipates that the registry path and value name parameters will be used by other functions in the module later:

function GetRegistryParameter {
    [CmdletBinding()]
    [OutputType([Hashtable])]
    param ( )
    @{
        Path = 'HKLM:SYSTEMCurrentControlSetServicesLanmanServerParameters'
        Name = 'srvcomment'
    }
}

The private function GetRegistryParameter is used by the Get-ComputerDescription command, as the following shows:

function Get-ComputerDescription {
    [CmdletBinding()]
    [OutputType([string])]
    param ( )
    $getParams = GetRegistryParameter
    Get-ItemPropertyValue @getParams
}

GetRegistryParameter is also used by both Set-ComputerDescription and Remove-ComputerDescription. The short Remove-ComputerDescription function is:

function Remove-ComputerDescription {
    [CmdletBinding(SupportsShouldProcess)]
    param ( )
    $removeParams = GetRegistryParameter
    if ($PSCmdlet.ShouldProcess(
        'Removing computer description')) {
        Remove-ItemProperty @removeParams
    }
}

And finally, you can add the Set-ComputerDescription command to complete the functions for this version of the module. Note that Set-ComputerDescription requires administrator rights to execute:

function Set-ComputerDescription {
    [CmdletBinding(SupportsShouldProcess)]
    [OutputType([string])]
    param (
        [Parameter(Mandatory, Position = 1, ValueFromPipeline)]
        [string]$Description
    )
    process {
        if ($pscmdlet.ShouldProcess(
            'Removing computer description')) {
            $setParams = GetRegistryParameter
            $params = @{
                Type  = 'String'
                Value = $Description
                Force = $true
            }
            try {
                New-ItemProperty @setParams @params
            } catch {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }
    }
}

The Set-ComputerDescription command adds a few features first explored in Chapter 17, Scripts, Functions, and Script Blocks. First it has a mandatory parameter for the new description. That Description parameter accepts values from the pipeline.

Both the Remove-ComputerDescription and Set-ComputerDescription commands implement SupportsShouldProcess, enabling the use of the -WhatIf switch parameter. Implementing SupportsShouldProcess is an encouraged practice for commands that change state.

If the functions are saved to LocalMachine.psm1, and the module is imported, then the new command will show in ExportedCommands:

PS> Import-Module .LocalMachine.psm1 -Force -Verbose
VERBOSE: Loading module from path 'C:workspaceLocalMachine.psm1'.
VERBOSE: Importing function 'Get-ComputerDescription'.
VERBOSE: Importing function 'Remove-ComputerDescription'.
VERBOSE: Importing function 'Set-ComputerDescription'.
PS> Get-Module -Name LocalMachine |
>>     ForEach-Object ExportedCommands
Key                        Value
---                        -----
Get-ComputerDescription    Get-ComputerDescription
GetRegistryParameters      GetRegistryParameters
Remove-ComputerDescription Remove-ComputerDescription
Set-ComputerDescription    Set-ComputerDesciption

You used the Force parameter with Import-Module in the previous example to overwrite the previously imported version of the module. If a module is already imported, Import-Module will not, by default, reimport that module.

Naming private commands

GetRegistryParameters follows my own convention for naming private commands. Private commands always use an approved verb (see Get-Verb) but omit the hyphen.

This convention is not mandatory; the naming of private commands is a personal preference.

Adding the Export-ModuleMember command to the end of the LocalMachine.psm1 file will hide the GetRegistryParameters function from view:

Export-ModuleMember -Function @(
    'Get-ComputerDescription'
    'Remove-ComputerDescription'
    'Set-ComputerDescription'
)

Importing the module again shows the change to ExportedCommands:

PS> Import-Module .LocalMachine.psm1 -Force
PS> Get-Module -Name LocalMachine |
>>     ForEach-Object ExportedCommands
Key                        Value
---                        -----
Get-ComputerDescription    Get-ComputerDescription
Remove-ComputerDescription Remove-ComputerDescription
Set-ComputerDescription    Set-ComputerDesciption

Export-ModuleMember can be used to define which functions, cmdlets, variables, and aliases are exported from a module. You can also define these options by using a module manifest.

Module manifests

The module manifest is a PowerShell data file (psd1 file, a Hashtable stored in a file) that contains metadata for a module.

The manifest includes information such as the module description, PowerShell version and edition compatibility, version number, commands, aliases, variables that should be exported, and so on.

Help for either the New-ModuleManifest or Update-ModuleManifest commands will show the possible keys. A manifest created using New-ModuleManifest includes comments describing the purpose of each field.

The use of a manifest is mandatory if a module is published on the PowerShell Gallery or any other repository when using the Publish-Module command.

The following code creates a module manifest for the LocalMachine module:

$params = @{
    Path                 = '.LocalMachine.psd1'
    Description          = 'Local machine configuration'
    RootModule           = 'LocalMachine.psm1'
    ModuleVersion        = '1.0.0'
    FunctionsToExport    = @(
        'Get-ComputerDescription'
        'Remove-ComputerDescription'
        'Set-ComputerDescription'
    )
    PowerShellVersion    = '5.1'
    CompatiblePSEditions = @(
        'Core'
        'Desktop'
    )
}
New-ModuleManifest @params

In the preceding example, RootModule is the full name of the psm1 file. This path, and all other paths used in the manifest, are relative to the module manifest file.

RootModule is used to determine the module type displayed when running the Get-Module command:

  • If RootModule is set to a psm1 file (as in our example), the module type will be script
  • If RootModule is set to a dll file, the module type will be binary
  • If RootModule is not set or is set to a value with any other file extension, the module type will be manifest

The RootModule property is not the only value that affects the module type. A module in PowerShell can have other modules nested inside. Nested modules are loaded via a NestedModules property in the manifest:

  • If NestedModules is set, the module type will be manifest (regardless of the RootModule value)

Other than affecting the module type value, NestedModules is beyond the scope of this chapter.

The newly created module manifest will, by default, set CmdletsToExport, VariablesToExport, and AliasesToExport to *. You can set these values to @() if automatically exporting everything is not desirable.

You can use wildcards for any of the ToExport properties, including FunctionsToExport. For example, you could use the value *-* to export public functions in the LocalMachine module. Using a specific list helps the module autoloader in PowerShell import the module if one of the commands is used and the module has never been imported before.

This manifest replaces the functionality of the Export-ModuleMember command.

PowerShell and PowerShellGet include several commands for working with the content of the module manifest.

Test-ModuleManifest

The Test-ModuleManifest command attempts to read and perform basic checks of the values used in the module manifest.

The command returns an object that represents data in the manifest. Import-PowerShellDataFile can also be used to read the manifest if required.

If the PowerShellVersion property in the manifest is reduced to 5.0 in LocalMachine.psd1, Test-ModuleManifest will raise an error pointing out that the CompatiblePSEditions key is not available before PowerShell 5.1:

PS> Test-ModuleManifest .LocalMachine.psd1
Test-ModuleManifest: The module manifest 'C:WorkspaceLocalMachineLocalMachineLocalMachine.psd1' is specified with the CompatiblePSEditions key which is supported only on PowerShell version '5.1' or higher. Update the value of the PowerShellVersion key to '5.1' or higher, and try again.
ModuleType Version PreRelease Name         ExportedCommands
---------- ------- ---------- ----         ----------------
Script     1.0.0              LocalMachine Get-ComputerDescription

Test-ModuleManifest will also test paths used in the manifest. For example, if an absolute path is used for RootModule, such as C:LocalMachine.psm1, an error will be displayed. The RootModule path must be a relative path.

The New-ModuleManifest command can, but should not, be used to update the content of an existing manifest. Instead, you should use the Update-ModuleManifest command.

Update-ModuleManifest

You can use the Update-ModuleManifest command from the PowerShellGet module to update the content of an existing manifest. However, you cannot use Update-ModuleManifest to create a new manifest.

The advantage of using Update-ModuleManifest is that it correctly handles writing to the PrivateData section of the manifest. New-ModuleManifest is unable to set values in this section.

You can use Update-ModuleManifest to add a project URL and license information to the manifest, as shown here:

Update-ModuleManifest -Path .LocalMachine.psd1 -PrivateData @{
    ProjectUri = 'https://github.com/indented-automation/LocalMachine'
    LicenseUri = 'https://opensource.org/licenses/MIT'
}

Similarly, you can update the version number in the manifest; in this case, the major version is updated:

$path = '.LocalMachine.psd1'
$manifest = Test-ModuleManifest -Path $path
$newVersion = [Version]::new(
     $manifest.Version.Major + 1,
     $manifest.Version.Minor,
     $manifest.Version.Build
)
Update-ModuleManifest -Path $path -ModuleVersion $newVersion

You may want to publish the module to a PowerShell repository at this point.

Publishing a module

Use the Publish-Module command to publish a module to a PowerShell repository.

The most common repository types are a NuGet feed (like the PowerShell Gallery), or a directory (often a file share). There are several free options available for creating a dedicated private PowerShell repository, including:

Each of these allows you to create a private repository for use within an organization. The repository would typically contain internally developed content or curated content that has been approved for internal use copied from public repositories.

To test publishing the LocalMachine module, create a repository in a local directory:

New-Item ~PSLocal -ItemType Directory
Register-PSRepository -Name PSLocal -Source ~PSLocal

Repositories such as the PowerShell Gallery require an API key to authenticate the request.

Removing the PSLocal repository

When it is no longer required, the repository can be removed:

Unregister-PSRepository -Name PSLocal

You can now publish the module with the Publish-Module command:

Publish-Module -Path . -Repository PSLocal

The Path used with Publish-Module, the current working directory in the preceding example, has a constraint: the path must be a directory and it must be named after the module.

That is, if the module is LocalMachine.psd1, and the manifest's parent directory is named LocalMachine, then publishing will likely succeed, showing a directory structure like the following:

ProjectRoot
| -- LocalMachine
     | -- LocalMachine.psd1
     | -- LocalMachine.psm1

Once published, a nupkg file will be created in the PSLocal directory, like in this example:

PS> Get-ChildItem ~PSLocal
        Directory: C:UsersChrisPSLocal
Mode                LastWriteTime  Length Name
----                -------------  ------ ----
-a---        11/04/2021     12:00    3856 LocalMachine.2.0.0.nupkg

A versioned directory is also permitted, provided the parent of that is named after the module. This is part of the side-by-side versioning support introduced with PowerShell 5.0.

Publishing and side-by-side versioning

Before PowerShell 5, only one version of a module could be installed under each module path. The content of a Modules directory was as shown below, which is like the structure used in the project repository:

Modules
| -- LocalMachine
     | -- LocalMachine.psd1
     | -- LocalMachine.psm1

Side-by-side versioning stores each version of a module in a versioned directory:

Modules
| -- LocalMachine
     | -- 1.0.0
          | -- LocalMachine.psd1
          | -- LocalMachine.psm1

This allows more than one version of a module to be installed. Commands such as Import-Module include parameters used to select a version.

This affects publishing a module in that a versioned directory is also permitted as the value for the Path parameter of Publish-Module. That is, if the project were laid out as the following illustrates:

ProjectRoot
| -- LocalMachine
     | -- 1.0.0
          | -- LocalMachine.psd1
          | -- LocalMachine.psm1

Then the following command would succeed if executed from the ProjectRoot directory:

$params = @{
    Path       = '.LocalMachine1.0.0'
    Repository = 'PSLocal'
}
Publish-Module @params

Embedding a version number as shown in the previous example will satisfy the path constraint for Publish-Module, but it is an unlikely directory structure to use when writing a module.

The LocalMachine module only includes three functions and a module manifest at this point. As the module grows in complexity, attempting to keep writing the module in a single file becomes more difficult.

Multi-file module layout

Multi-file layouts typically aim to split the content of a project up into smaller and easier to maintain files. The most common strategy is to create one file for each function, class, or enumeration.

Files are categorized and grouped together to make it easier to find parts of the module. For example, the LocalMachine module might split up as follows:

ProjectRoot
| -- LocalMachine
     | -- public
     |     | -- Get-ComputerDescription.ps1
     |     | -- Set-ComputerDescription.ps1
     |
     | -- private
     |    | -- GetRegistryParameter.ps1
     |    | -- TestComputerDescriptionValue.ps1
     |
     | -- LocalMachine.psd1
     | -- LocalMachine.psm1

When split like this, LocalMachine.psm1 needs to change to load the content from those separate files.

Dot sourcing module content

Dot sourcing is used to load the content of a target file into the current scope. If used inside the psm1 file, each individual file will be loaded into the same scope as the psm1 file.

When the psm1 file runs, the $PSScriptRoot variable (introduced in PowerShell 3) will be set to the directory the psm1 file is saved in. This can be used to create an expression to find the files that make up the module content:

'public', 'private' |
    Resolve-Path -Path $PSScriptRoot -ChildPath { $_ } |
    Get-ChildItem -Recurse -File -Filter *.ps1

Note that this cannot be run outside of a script (or script module); the $PSScriptRoot variable will not exist.

Getting the module base

$PSScriptRoot is specific to the file it is used in. Therefore, if one of the dot sourced files from a sub-directory requires access to the module base directory, it is less useful.

Instead, a property of the reserved variable, $MyInvocation, can be used from any script or function within a module to get the folder for the module manifest file:

$MyInvocation.MyCommand.Module.ModuleBase

Each of the discovered files needs to be dot sourced. You can use the result of the preceding command in a loop or with ForEach-Object:

'public', 'private' |
    Resolve-Path -Path $PSScriptRoot -ChildPath { $_ } |
    Get-ChildItem -Recurse -File -Filter *.ps1 |
    ForEach-Object {
        . $_.FullName
    }

Explicitly naming the directories at the top level, that is, public and private, ensures that some control is maintained over the loading order of the parts of the module. For functions, this is unlikely to be required, but if classes are introduced, the order may become important.

This approach has a small risk in that it will load any other ps1 files present in the module directory regardless of whether they belong to the module.

An alternative, but a higher maintenance approach is to name the files to import instead of allowing any file at all to load. For example:

$private = 'GetRegistryParameter'
foreach ($item in $private) {
    . '{0}private{1}.ps1' -f $PSScriptRoot, $item
}
$public = @(
    'Get-ComputerDescription'
)
foreach ($item in $public) {
    . '{0}public{1}.ps1' -f $PSScriptRoot, $item
}
Export-ModuleMember -Function $public

With this version, module content is loaded with an explicit name. Additional files that are erroneously placed in the module directory are not processed.

Dot sourcing module content is useful when a module is being developed. It allows a developer to realize the benefits of having module content split into separate files: content is easier to find, files are easier to read, and when source control is in use, changes are easier to review.

Conversely, modules split into lots of files are a little slower to load; potentially a lot slower if code signing is used. Code signing itself is beyond the scope of this chapter as few publicly available modules are signed. The topic is explored in the about_Signing help topic.

Merging module content

Merging module content using a build process of some kind offers the best of both worlds. The module is split into files, making it easy to write and edit; the module is merged into a single file, making it quick to load and easy to sign.

The ModuleBuilder module is capable of merging module content into a single file. The ModuleBuilder module requires a build.psd1 file in the root of the module (adjacent to LocalMachine.psd1). The build.psd1 file does not need to contain more than an empty Hashtable; it can be used to customize the merge process.

The following example instructs Build-Module to place merged content in a build directory and includes a versioned directory that might be used with Publish-Module:

@{
    ModuleManifest           = 'LocalMachine.psd1'
    OutputDirectory          = '../build'
    VersionedOutputDirectory = $true
}

With the file present, you can use the following command to merge the module content:

Install-Module ModuleBuilder -Scope CurrentUser
Build-Module -SourcePath .LocalMachine

If the command is run from the same directory as build.psd1, the SourcePath argument can be omitted. For example:

Set-Location .LocalMachine
Build-Module

The resulting module file is placed in the output directory under the project directory. The output path is configurable.

ModuleBuilder will generate a new module manifest based on the existing manifest. It will update FunctionsToExport based on the content of the public directory, and it will fill AliasesToExport if any of the commands use aliases.

As a module grows in complexity, it may be desirable to perform additional tasks during the build step. For example, you might add extra metadata to the module manifest, run tests, regenerate help files, or publish the module.

ModuleBuilder and DSC resources

ModuleBuilder will, by default, merge content from a subdirectory named classes into the generated root module. However, ModuleBuilder will not update the DscResourcesToExport property in the module manifest.

The classes directory in the module contains a version of the DSC class created in Chapter 19, Classes and Enumerations. The simplified version of the class is available on GitHub: https://github.com/PacktPublishing/Mastering-Windows-PowerShell-Scripting-Fourth-Edition/blob/master/Chapter20/LocalMachine/MultiFileWithModuleBuilder/LocalMachine/classes/ComputerDescription.ps1.

It is possible to dynamically update DscResourcesToExport but finding the resources in the module requires an advanced technique. The following snippet uses the Abstract Syntax Tree or AST, which is explored in more detail in Chapter 21, Testing.

The snippet is saved in a build.ps1 file in the preceding repository in the MultiFileWithModuleBuilder directory, the project root for this module layout. The build.ps1 file runs Build-Module and then updates the missing information in the module manifest based on the search below. The Update-ModuleManifest command from PowerShellGet is used to fill in the missing values in the manifest.

The snippet can be run from the MultiFileWithModuleBuilder directory:

using namespace System.Management.Automation.Language
# Find the root module
$rootModulePath = @{
    Path      = $pwd
    ChildPath = 'build***.psm1'
}
$rootModule = Join-Path @rootModulePath | Resolve-Path
# These values do not need to be captured for this search process
$tokens = $errors = $null
$ast = [Parser]::ParseFile(
    $rootModule,
    [ref]$tokens,
    [ref]$errors
)
$dscResourcesToExport = $ast.FindAll({
    param ( $node )
    $node -is [TypeDefinitionAst] -and
    $node.IsClass -and
    $node.Attributes.TypeName.FullName -contains 'DscResource'
}, $true).Name

The preceding code reads the content of the psm1 file generated by ModuleBuilder, parses that file, and searches for every PowerShell class that has the DscResource attribute. It outputs the names of the classes, which can then be used with the Update-ModuleManifest command:

$moduleManifestPath = @{
    Path      = $pwd
    ChildPath = 'build***.psd1'
}
# Find the module manifest
$moduleManifest = Join-Path @moduleManifestPath |
    Get-Item |
    Where-Object { $_.BaseName -eq $_.Directory.Parent.Name }
$updateParams = @{
    Path                 = $moduleManifest
    DscResourcesToExport = $dscResourcesToExport
}
Update-ModuleManifest @updateParams

The technique above is complex but shows how PowerShell can be used to create a generic process that can be used to work on different kinds of modules without building a specialized process every time.

Module scope

Script modules loaded from psm1 files have a shared scope that you can access using the $Script: scope modifier.

You can create variables in the module scope, which is the same as the script scope. Functions within the module may consume those variables. You can create such variables in the root module or when a command is run.

Helper functions can be created to provide obvious access to the variable content.

This approach is illustrated in the following example. This pattern can be used for modules that interact with services that require a connection or authentication. For example, a REST web service might require an explicit authentication step to acquire a time-limited token or key.

First, a function styled like the following Connect-Service function establishes a script-scoped variable. The connection can capture an authentication token rather than representing a truly connected service (for instance, a connection to an SQL server). The implementation of the command depends on the needs of the service and the module:

function Connect-Service {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String]$Server
    )
    $Script:connection = [PSCustomObject]@{
        Server     = $Server
        PSTypeName = 'ServiceConnectionInfo'
    }
}

You can create a command to allow either another command in the module or the end user to get the stored connection object:

function Get-ServiceConnection {
    [CmdletBinding()]
    param ( )
    if ($Script:connection) {
        $Script:connection
    } else {
        throw [InvalidOperationException]::new(
            'Not connected to the service'
        )
    }
}

Finally, the stored connection can be used by another function in the module to carry out the actions it needs:

function Get-ServiceObject {
    [CmdletBinding()]
    param (
        [PSTypeName('ServiceConnectionInfo')]
        $Connection = (Get-ServiceConnection)
    )
}

Defining the connection as a parameter allows the user of the function to provide an explicit connection if more than one connection is to be used. The Connect-Service function would have to be adjusted to accommodate such a scenario.

Accessing module scope

All functions and classes within a module can access script-scoped variables by using the scope modifier.

The script scope of a module can be accessed from outside the module to aid debugging by using the call operator.

The following snippet is used to demonstrate accessing module scope. The module consists of one public function, one private function, and a module-scoped variable.

The module is created in memory using the New-Module command, which avoids the need to create a file to demonstrate the feature. Note that the newly created module is piped to Import-Module, which ensures the module is accessible after import using Get-Module:

New-Module SomeService {
    function GetServiceConnection {
        [CmdletBinding()]
        param ( )
        $Script:connection
    }
    function Connect-Service {
        [CmdletBinding()]
        param (
            [String]$Name
        )
        $Script:connection = $Name
    }
    $Script:connection = 'DefaultConnection'
    Export-ModuleMember -Function Connect-Service
} | Import-Module

By default, only the Connect-Service function is available, as shown here:

PS> Get-Command -Module SomeService
CommandType     Name              Version    Source
-----------     ----              -------    ------
Function        Connect-Service   0.0        SomeService

The value of the script-scoped connection variable may be retrieved as follows. The module used in this manner must be imported prior to use:

PS> & (Get-Module SomeService) { $connection }
DefaultConnection

This technique can be used with any module, but it likely only makes sense when used with a script module (or a manifest module that includes script modules).

If the Connect-Service command is used, the value returned by the preceding command will change. The same approach may be used to interact with functions that are not normally exported by the module. For example, the GetServiceConnection function can be called:

& (Get-Module SomeService) { GetServiceConnection }

Finally, as the command is executing in the scope of the module, this technique may be applied to list the commands within the module, as follows:

PS> & (Get-Module SomeService) { Get-Command -Module SomeService }
CommandType     Name                  Version    Source
-----------     ----                  -------    ------
Function        Connect-Service       0.0        SomeService
Function        GetServiceConnection  0.0        SomeService

This technique is useful when debugging modules that heavily utilize module scope.

Module scope is also used when creating classes and enumerations within a module.

Modules, classes, and enumerations

PowerShell classes and enumerations are not exported from a module and are not made available to an end user when Import-Module is used, meaning they cannot be used outside of the module scope by default.

Classes and enumerations are only available outside of the module scope if a using module statement is used.

You can use classes as return values from functions within a module without changing scope. Enumeration values can be supplied as arguments for function parameters via the value name without needing direct access to the enumeration type. This is shown in the following example:

New-Module ModuleWithEnum {
    enum ParameterValues {
        ValueOne
        ValueTwo
    }
    function Get-Something {
        [CmdletBinding()]
        param (
            [ParameterValues]$Parameter
        )
    }
} | Import-Module
Get-Something -Parameter ValueOne

The preceding Get-Something function offers tab completion with names from the enumeration. A string, such as ValueOne, can refer to a value in the enumeration. The enumeration itself does not have to be made available to the global (or the caller) scope.

An instance of a PowerShell class can be returned as objects from functions within a module. This is shown in the following example:

New-Module ModuleWithClass {
    class ModuleClass {
        [string] $Property = 'value'
    }
    function Get-Something {
        [ModuleClass]::new()
    }
} | Import-Module

Running the Get-Something command will show that it returns an instance of the class:

PS> Get-Something
Property
--------
value

However, an instance of the class cannot be created in the global scope by default:

PS> [ModuleClass]::new()
InvalidOperation: Unable to find type [ModuleClass].

The using module is required to directly work with a class inside a module. Unfortunately, this is not possible with a module created by New-Module. The content below should be saved into a ModuleWithClass.psm1 file so it can be imported:

@'
class ModuleClass {
    [string] $Property = 'value'
}
function Get-Something {
    [ModuleClass]::new()
}
'@ | Set-Content -Path ModuleWithClass.psm1

Once the file is created, the module can be imported with a using module statement.

using module .ModuleWithClass.psm1

The using module statement can import either a module using a path or a module by name. Once the module has been imported this way, you can use the class:

PS> [ModuleClass]::new()
Property
--------
value

It is possible to work around this limitation by moving class definitions into ScriptsToProcess. This is explored in the next section.

Initializing and removing modules

The content of the root module executes every time a module is imported. You can use a root module file to perform initialization steps, for example, initializing a cache, importing static data, or setting a default configuration.

These steps are extra code that you must add to the root module at the beginning or end of the file.

In some cases, it is desirable to execute actions before a module is imported or when a module is removed.

You can use the ScriptsToProcess property in a module manifest to execute code in the caller scope before a module is imported.

The ScriptsToProcess property

You can use the ScriptsToProcess property in the module manifest to run scripts in the caller scope before importing the module. The caller is the entity importing the module; if that is an end user in the global scope, then ScriptsToProcess is executed in the global scope. If the module is imported by another script, ScriptsToProcess is executed in that script scope.

There are no limitations on what actions can be performed in ScriptsToProcess, but it is important to remember that it affects the global scope. Using the Remove-Module command will not remove content added to the caller scope.

The ScriptsToProcess property can be used, or abused, to make classes available to a module user without requiring the use of the using module.

Since ScriptsToProcess acts in the caller scope, this is not always ideal. The following snippet sets up a module with a class in a script that is executed in the caller scope to demonstrate this limitation:

@'
class ModuleClass {
    [string] $Property = 'value'
}
'@ | Set-Content -Path 'Script.ps1'
@'
function Get-Something {
    [ModuleClass]::new()
}
'@ | Set-Content -Path Module.psm1
$manifestParams = @{
    Path             = 'Module.psd1'
    RootModule       = 'Module.psm1'
    ScriptsToProcess = 'Script.ps1'
}
New-ModuleManifest @manifestParams

You can import this module in the current scope to use the class:

PS> Import-Module -Name .Module.psd1
PS> [ModuleClass]::new()
Property
--------
value

PowerShell must be restarted to show the change in behavior when running the same command inside a function:

function Import-Something {
    Import-Module -Name .Module.psd1 -Scope Global
}

Running Import-Something will, as the command says, import the module in the global scope. However, ScriptsToProcess still executes in the caller scope, that is, the scope of the function. Therefore, the class, exposed only via ScriptsToProcess, is still not available in the global scope, as the following shows:

PS> Import-Something
PS> Get-Module Module
ModuleType Version PreRelease Name   ExportedCommands
---------- ------- ---------- ----   ----------------
Script     0.0.1              Module Get-Something
PS> [ModuleClass]::new()
InvalidOperation: Unable to find type [ModuleClass].

ScriptsToProcess can be useful in the right circumstances, but it is not a fix for every problem with module content.

Remove-Module will attempt to remove module content from the session. If explicit actions are required when a module is removed, the OnRemove event can be used.

The OnRemove event

The OnRemove event is raised when you use Remove-Module. Any script block assigned to the event as a handler will be invoked. The event handler may be used to trigger a cleanup of the artifacts created by the module (if required).

The ModuleInfo object provides access to the OnRemove event handler via the $executionContext variable when it is used in the psm1 file:

$executionContext.SessionState.Module.OnRemove

The following module creates a file named log.txt when the module is imported. An OnRemove handler is added to the module to attempt to release a lock created by the StreamWriter on the file when the module is removed:

@'
using namespace System.IO
$path = Join-Path $PSScriptRoot -ChildPath 'OnRemove.log'
$stream = [StreamWriter][File]::OpenWrite($path)
$stream.WriteLine('Initialising module')
$executionContext.SessionState.Module.OnRemove = {
    $stream.WriteLine('Closing log')
    $stream.Flush()
    $stream.Close()
}
'@ | Set-Content OnRemove.psm1

The file is created when the module is imported, and a StreamWriter is created:

PS> Import-Module -Name .OnRemove.psm1
PS> Get-ChildItem -Path OnRemove.log
        Directory: C:Workspace
Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a---        17/04/2021     10:19              0 OnRemove.log

Commands such as Get-Content cannot read the file; it is locked by the StreamWriter.

PS> Get-Content .OnRemove.log
Get-Content: The process cannot access the file 'C:WorkspaceOnRemove.log' because it is being used by another process.

Running Remove-Module triggers the OnRemove event. The OnRemove event writes one final line, flushes the buffer (StreamWriter does not immediately write to the file), and closes the stream.

Once the stream is closed, Get-Content is able to read the file content:

PS> Get-Content -Path OnRemove.log
Initialising module
Closing log

This event only runs when Remove-Module is used; it does not trigger if the PowerShell console is closed. In this case, the only difference is that the Closing log line is not written.

Summary

Modules are an important part of PowerShell as they encapsulate related functions (and other content). You can publish modules to public repositories such as the PowerShell Gallery, or any internal PowerShell repositories that may be in use.

Module layout in PowerShell is flexible beyond the creation of a psm1 file (or a dll file in the case of a binary module).

You can optionally include a psd1 file to describe information about the module, including versions, what should be made available to anyone importing the module, and other metadata such as project information.

Multi-file module layouts are common as they allow authors to manage content in separate files rather than a single monolithic root module. Some module authors choose to merge content into a single file as part of a publishing process, while others are happy to load individual files via the root module.

You can use module scope to store the information a module needs to work; for example, authentication tokens used with a remote service.

You can use PowerShell classes within a module and return instances of classes via functions to users of a module. Directly using classes within a module requires the use of the using module statement.

You can perform actions before importing a module via the ScriptsToProcess property in a module manifest and when removing a module using Remove-Module via the OnRemove event.

In the next chapter, Chapter 21, Testing, you'll explore static analysis and unit testing in PowerShell.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset