PowerShell Modules Exporting classes

Module PowerShell exportant ses classes

Ne serait-il pas formidable de pouvoir exporter des classes à partir d’un module PowerShell ? Vous savez, lorsque vous importez un module, ses fonctions publiques sont disponibles dans votre session. Ne pourrait-on pas faire de même pour les classes ?

N’hésitez pas à suivre ce post avec le code de l’exemples ici : ModuleExportClasses. Ce contenu provient de la partie avancée du cours PowerShell Fundamentals, et nous l’utilisons dans Maitriser PowerShell Universal.

Mais, c’est pas possible !?

Si vous avez suivi l’évolution de PowerShell, vous savez probablement qu’il n’y a aucun moyen d’exporter une classe à partir d’un module. Rien dans du manifeste du module, ni dans la fonction Export-ModuleMember non plus…

Screenshot of a PowerShell terminal displaying the command parameters for the function 'Export-ModuleMember', highlighting the lac of class.

L’instruction using

Un script PowerShell doit commencer par l’instruction using module pour utiliser les types définis dans un module. Spécifiez le nom du module ou son chemin d’accès pour identifier l’endroit où les types sont définis.

Soit un module qui définit la classe suivante et sa méthode statique DoStuff() :

# .\MyModule\MyModule.psm1
class MyModuleClass
{
    static [string] DoStuff()
    {
        return 'I''m busy!'
    }
}

Pour utiliser cette méthode statique dans un script ou un autre module, nous devons alors écrire l’instruction using comme ci-dessous :

# ./myScript.ps1
using module .\MyModule

# do stuff
[MyModuleClass]::DoStuff()

C’est un peu lourd et parfois pas tout à fait intuitif.
Essayez de faire les déclarations using dans le shell pour les modules ./MyModule et ./OtherModule

PowerShell command line interface showing the usage of modules and methods from defined classes, including a failed attempt to access a class method with an error message.

Mais si vous exécutez les instructions « using » dans le même bloc, cela fonctionne :

PowerShell console displaying the use of the 'using module' statement with two modules, showing successful execution of commands and an error message regarding type not found.

D’accord, cela fait l’affaire. Cependant, ce serait beaucoup mieux si nous pouvions simplement exporter les classes que nous avons sélectionnées dans notre module PowerShell. De cette façon, nos utilisateurs peuvent les utiliser directement lorsqu’ils importent le module !

Présentation des accélérateurs de type

Les TypeAccelerator sont des alias pour les types .NET, généralement utilisés pour raccourcir le nom d’un long type, sans utiliser l’instruction using namespace…
Vous pouvez trouver les accélérateurs de type enregistrés en utilisant la méthode statique de la classe TypeAccelerators :

# Get the internal TypeAccelerators class to use its static methods.
$typeAcceleratorsClass = [psobject].Assembly.GetType(
    'System.Management.Automation.TypeAccelerators'
)

$typeAcceleratorsClass::Get
# ...

$typeAcceleratorsClass::Get.GetEnumerator() | Select -First 3

Key                  Value
---                  -----
Alias                System.Management.Automation.AliasAttribute
AllowEmptyCollection System.Management.Automation.AllowEmptyCollectionAttribute
AllowEmptyString     System.Management.Automation.AllowEmptyStringAttribute

Si vous sélectionnez les 3 premiers, vous devriez voir que [Alias] est en fait un accélérateur de type pour [System.Management.Automation.AliasAttribute].

Et si nous enregistrions des accélérateurs de type pour nos classes de modules lors de l’import ?

Exporter des accélérateurs de type pour les classes de modules

Nous ajoutons maintenant l’extrait suivant au bas du PSM1. En effet, nous devons l’ajouter après que les classes ont été déclarées.

# At the bottom of ./TheModule/TheModule.psm1
# inspired from https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.5

$typesToExport = @(
    'TheModuleClass'
)

# Get the internal TypeAccelerators class to use its static methods.
$typeAcceleratorsClass = [psobject].Assembly.GetType(
    'System.Management.Automation.TypeAccelerators'
)
$existingTypeAccelerators = $typeAcceleratorsClass::Get

foreach ($typeToExport in  $typesToExport)
{
    # validate the type exists
    $type = $TypeToExport -as [System.Type]
    if (-not $type)
    {
        Write-Warning -Message (
            'Unable to export {0}. Type not found.' -f $typeToExport
        )
    }
    else
    {
        $null = $TypeAcceleratorsClass::Add($TypeToExport, $Type)
    }
}

Et nous testons dans le Shell:

Screenshot of PowerShell output showing the import of a module and the usage of a class method from that module, through an exported class which is a TypeAccelerator.

Sans utiliser l’instruction using module ./TheModule, nous pouvons maintenant utiliser la classe du module une fois que celui-ci est importé. Nous pouvons même choisir les classes qui seront disponibles lors de l’importation du module.

Vérifions maintenant dans la classe TypeAccelerators ce qui est disponible :

$typeAcceleratorsClass = [psobject].Assembly.GetType(
    'System.Management.Automation.TypeAccelerators'
)
$existingTypeAccelerators = $typeAcceleratorsClass::Get
$existingTypeAccelerators.GetEnumerator() | Select -last 1

Nous disposons également du dernier accélérateur de type ajouté :

PowerShell console output showing existing type accelerators with their keys and values, specifically 'TheModuleClass' mapped to 'TheModuleClass'.

Utilisation de types exportés dans d’autres modules

Maintenant, si je commence à partir d’un shell propre, et que j’importe le module nommé UsingTheModule qui a une fonction comme celle-ci :

function Invoke-TheModuleClassMethod
{
    [CmdletBinding()]
    param
    (
        # No param required
    )

    [TheModuleClass]::DoStuff()
}

L’importation du module fonctionnera parfaitement. En effet, l’utilisation de [TheModuleClass] se fait dans la définition du script (scriptblock). Cependant, l’invocation de la commande échouera avec InvalidOperation : Impossible de trouver le type [TheModuleClass]

Les types sont exportés (l’accélérateur de type correspondant est enregistré) uniquement lorsque le module est importé. En effet, c’est à ce moment-là que le code de TheModule.psm1 est exécuté.

Importer le module et exécuter à nouveau le même code :

Pour utiliser le type (accélérateur) avec notre module sans étape manuelle supplémentaire, il faut d’abord importer le module. Sinon, l’analyseur PowerShell se plaindra que le type n’existe pas.

Dans un module, ceci est réalisé avec le paramètre RequiredModules dans le manifeste du module. Dans le manifeste du module UsingTheModule.psd1, nous pouvons ajouter TheModule à la liste des RequiredModules.

Repartons d’un terminal neuf :

PowerShell terminal showing an error message when trying to import a module named 'TheModule', which

Encore une erreur !
Mais il sait qu’il doit charger le module requis TheModule, mais il ne le trouve pas ! C’est un problème de $Env:PSModulePath, ajoutons le répertoire courant.

$psmodpath = $Env:PSModulePath -split [io.path]::PathSeparator
if ($psmodpath -notcontains $PWD.Path) { $psmodpath = @($PWD.Path) + $psmodpath }
$env:PSModulePath = $psmodpath -join [io.path]::PathSeparator
$Env:PSModulePath

Ce qui se passe comme suit :

PowerShell script showing the current module path configuration, including paths split by the system IO path separator. It also shows how to prepend a custom path.

Et maintenant, nous pouvons réessayer !

A PowerShell terminal displaying a command prompt with a user executing a function 'Invoke-TheModuleClassMethod' and checking installed modules. The displayed results include module names and types underlined, illustrating module importation and available commands using the class.

Absence d’espace de noms et solution de contournement

L’un des inconvénients de l’utilisation de classes dans les modules PowerShell est qu’elles ne prennent pas en charge les namespaces. Notre classe [TheModuleClass] n’a pas de nom pleinement qualifié. Cette absence inclurait le nom du module dans lequel elle est définie. Par conséquent, la classe est difficile à découvrir et il existe un risque de conflit de nom de classe.
Rappelez-vous quand hyper-v et vmware ont tous deux proposé le nom de la cmdlet New-VM ?

Il y a des discussions à ce sujet depuis de nombreuses années maintenant. J’ai peu d’espoir de le voir mis en œuvre de sitôt. Il convient de mentionner que les types PowerShell sont des types .NET définis au moment de l’exécution et qu’ils posent des problèmes…

De plus, il s’agirait d’un changement qui ne verrai pas le jour dans PowerShell 5.1, et qui aurait donc un impact limité.

Mais avec les Type Accelerators, nous n’avons pas à nous limiter à leur nom exact, ce sont des alias de toute façon…

Rien ne nous empêche d’exporter l’alias [TheModule.TheModuleClass], et c’est ce que j’utilise souvent dorénavant. Le suffixe ci-dessous est ce que j’ajoute habituellement à mes projets, en utilisant Sampler (qui mériterait quelques articles aussi !).

#region TypeAccelerator export for classes

# If classes/types are specified here, they won't be fully qualified (careful with conflicts)
$typesToExportAsIs = @(
    # 'APIEndpoint'
)

# The type accelerators created will be ModuleName.ClassName (to avoid conflicts with other modules until you use 'using moduleName'
$typesToExportWithNamespace = @(
    'TheModuleClass'
)

# inspired from https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_classes?view=powershell-7.5

# Always clobber an existing type accelerator, but
# warn if a type accelerator with the same name exists.
function Get-CurrentModule
{
    <#
      .SYNOPSIS
      This is a Private function to always be able to retrieve the module info even outside
      of a function (i.e. PSM1 during module loading)

      .DESCRIPTION
      This function is only meant to be used from the psm1, hence not exported.

      .EXAMPLE
      $m = Get-CurrentModule

      #>
    [OutputType([System.Management.Automation.PSModuleInfo])]
    param
    ()

    # Get the current module
    $MyInvocation.MyCommand.ScriptBlock.Module
}

# Get the internal TypeAccelerators class to use its static methods.
$typeAcceleratorsClass = [psobject].Assembly.GetType(
    'System.Management.Automation.TypeAccelerators'
)
$moduleName = (Get-CurrentModule).Name
$existingTypeAccelerators = $typeAcceleratorsClass::Get

foreach ($typeToExport in  @($typesToExportWithNamespace + $typesToExportAsIs))
{
    if ($typeToExport -in $typesToExportAsIs)
    {
        $fullTypeToExport = $TypeToExport
    }
    else
    {
        $fullTypeToExport = '{0}.{1}' -f $moduleName,$TypeToExport
    }

    $type = $TypeToExport -as [System.Type]
    if (-not $type)
    {
        $Message = @(
            "Unable to register type accelerator '$fullTypeToExport' for '$typeToExport'"
            "Type '$typeToExport' not found."
        ) -join ' - '

        throw [System.Management.Automation.ErrorRecord]::new(
                [System.InvalidOperationException]::new($Message),
                'TypeAcceleratorTypeNotFound',
                [System.Management.Automation.ErrorCategory]::InvalidOperation,
                $fullTypeToExport
            )
    }
    else
    {
        if ($fullTypeToExport -in $existingTypeAccelerators.Keys)
        {
            $Message = @(
                "Overriding type accelerator '$($fullTypeToExport)' with '$($Type.FullName)'"
                'Accelerator already exists.'
            ) -join ' - '

            Write-Warning -Message $Message
        }
        else
        {
            Write-Verbose -Message "Added type accelerator '$($fullTypeToExport)' for '$($Type.FullName)'."
        }

        $null = $TypeAcceleratorsClass::Add($fullTypeToExport, $Type)
    }
}

# Remove type accelerators when the module is removed.
$MyInvocation.MyCommand.ScriptBlock.Module.OnRemove = {
    foreach ($TypeName in $typesToExportWithNamespace)
    {
        $fullTypeToExport = '{0}.{1}' -f $moduleName,$TypeName
        $null = $TypeAcceleratorsClass::Remove($fullTypeToExport)
    }
}.GetNewClosure()

#endregion

TypeAccelerator comme classes exportées pour les modules

J’utilise cela depuis un certain temps maintenant, et j’aime cette approche. J’ai tendance à exporter la plupart des types dont j’ai besoin avec le nom du module ([ModuleName.ClassName]), et parfois (rarement) j’ajoute un thème ou une catégorie si j’ai beaucoup de classes de ce module ([ModuleName.Category.ClassName]).
Évidemment, ce n’est pas parfait. Nous n’avons pas d’intellisense ou d’autocomplétion lorsque nous écrivons du code avec ce nom complet. Mais c’est un compromis que je suis heureux de faire pour un code plus clair et une utilisation plus facile des classes.

Le code est assez générique. Il suffit de remplir une liste avec les noms des types que l’on souhaite exporter. Je l’ajouterai bientôt à Sampler en tant qu’option…

Ce billet étant terminé, j’ouvre la porte au prochain ! Nous l’utiliserons avec un attribut personnalisé pour les endpoints d’API PowerShell Universal.


Discover more from SynEdgy

Subscribe to get the latest posts sent to your email.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.

Discover more from SynEdgy

Subscribe now to keep reading and get access to the full archive.

Continue reading

Discover more from SynEdgy

Subscribe now to keep reading and get access to the full archive.

Continue reading