[PowerShell] ネイティブコマンドをPowerShell風に扱うためのモジュール PowerShell Crescendo (Preview) が発表されました

2020.12.13

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

この記事はPowerShell Advent Calendar 2020の13日目です。

私個人としてはもうアドベントカレンダーに飽きているのですが *1、ちょうど良いタイミングで記事を書けたのでしれっと参加してみました。

ここから本題に入ります。

先日PowerShell Teamから新たにPowerShell Crescendoというモジュールをプレビューリリースしたというブログが公開されました。

普段からPowerShellを使っていない方にとっては少しわかりにくいモジュールだと思いますので、本記事では上記ブログの内容をベースに解説していきたいと思います。

PowerShell Crescendo

PowerShellは.NETのオブジェクトを入出力するシェルであるため、他のシェルの様に外部コマンド(以後ネイティブコマンドと表記)同士の連携ではなくコマンドレット(CmdLet)と呼ばれる独自の内部コマンドを使いオブジェクトを連携させるのが基本となっています。

そして、コマンドレットが持つ(もしくは強制される)以下の規約によりPowerShellは独自の統一されたUXを持つ形となっています。

  • コマンドレット名は [動詞]-[名詞] の形式とし、明瞭な名称とする
    • ここで言う明瞭は「初めてコマンドレットを見る人がその内容をただちに理解できる」という文脈
    • いわゆる開発者やGeekと呼ばれる人たちのとってのわかりやすさではないので注意
  • パラメーター名はハイフン付きの省略しない名称とする
    • GNUスタイルやDOS由来のスラッシュ付きでないPowerShell独自のスタイル
  • コマンドおよびパラメーターに対する自動補完
  • .NETオブジェクトの入出力
  • オブジェクトパイプラインの使用
  • コメントベースのヘルプ

当然PowerShellからネイティブコマンドを扱うことも可能ですが、ネイティブコマンドはそれぞれ独自のネーミングを持ちパラメーターの扱い方もツールごとで様々です。

そこでネイティブコマンドに対するラッパーとなるコマンドレットをもつモジュール自動生成し、"PowerShellとして" より統一されたUXを得られる様にしようというのがPowerShell Crescendoの趣旨となります。

Microsoft.PowerShell.Crescendo モジュール

PowerShell Crescendoはその実体はMicrosoft.PowerShell.Crescendoというモジュールです。
本日時点では、

  • モジュールの自動生成 : PowerShell 7.0以降をサポート
  • 自動生成されたモジュールの利用 : Windows PowerShell 5.1、PowerShell 7.0以降をサポート

といった状況となっています。

モジュールの自動生成のためにコマンドレットの定義をJSONで行います。
例えば先述のPowerShell Team Blogで紹介されている例を出すと、aptコマンドをラップするために以下の様なJSONを定義しています。

{
    "$schema": "./Microsoft.PowerShell.Crescendo.Schema.json", // <-- スキーマ定義ファイルであることの明示
    "Verb": "Get", // <-- Cmdletの「動詞」
    "Noun":"InstalledPackage", // <-- Cmdletの「名詞」
    "OriginalName": "apt", // <-- 元になるネイティブコマンド
    "OriginalCommandElements": ["-q","list","--installed"],
    "OutputHandlers": [ // <-- aptコマンドの結果を.NETのオブジェクトに変換し出力
        {
            "ParameterSetName":"Default",
            "Handler": "$args[0]| select-object -skip 1 | %{$n,$v,$p,$s = "$_" -split ' '; [pscustomobject]@{ Name = $n -replace '/now'; Version = $v; Architecture = $p; State = $s.Trim('[]') -split ',' } }"
        }
    ]
}

この様な定義をすることでGet-InstalledPackageコマンドレット(その実体はaptコマンド)を生成でき、実行すると以下の様な結果を出力することができます。

PS> Get-InstalledPackage | Where-Object { $_.name -match "libc"}

Name        Version            Architecture State
----        -------            ------------ -----
libc-bin    2.31-0ubuntu9.1    amd64        {installed, local}
libc6       2.31-0ubuntu9.1    amd64        {installed, local}
libcap-ng0  0.7.9-2.1build1    amd64        {installed, local}
libcom-err2 1.45.5-2ubuntu1    amd64        {installed, local}
libcrypt1   1:4.4.10-10ubuntu4 amd64        {installed, local}

PS> Get-InstalledPackage | Group-Object -Property Architecture

Count Name   Group
----- ----   -----
   10 all    {@{Name=adduser; Version=3.118ubuntu2; Architecture=all; State=System.String[}, @{Name=debconf; V…
   82 amd64  {@{Name=apt; Version=2.0.2ubuntu0.1; Architecture=amd64; State=System.String[]}, @{Name=base-files…

開発はGitHub上で行われており随時フィードバックを受け付けています。

試してみた

検証環境

検証環境は手元の開発機で試しています。

  • モジュール生成 : 日本語版 64bit版 Windows 10 Pro (Ver.20H2)
    • PowerShell 7.1.0
  • 動作確認 : 英語版 Windows Server 2019 EC2 (手っ取り早く用意できる英語環境がEC2だったので...)
    • PowerShell 7.1.0

モジュールのインストール

Crescendoはモジュールとしての提供なのでInstall-ModuleするだけでOKです。

Install-Module Microsoft.PowerShell.Crescendo -Scope CurrentUser -Force

現時点での最新バージョンはVer.0.4.1、モジュールで利用可能なコマンドレットは以下の様になってました。

C:\> Get-Command -Module Microsoft.PowerShell.Crescendo

CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Function        Export-CrescendoModule                             0.4.1      Microsoft.PowerShell.Crescendo
Function        Export-Schema                                      0.4.1      Microsoft.PowerShell.Crescendo
Function        Import-CommandConfiguration                        0.4.1      Microsoft.PowerShell.Crescendo
Function        New-CrescendoCommand                               0.4.1      Microsoft.PowerShell.Crescendo
Function        New-ExampleInfo                                    0.4.1      Microsoft.PowerShell.Crescendo
Function        New-ParameterInfo                                  0.4.1      Microsoft.PowerShell.Crescendo
Function        New-UsageInfo                                      0.4.1      Microsoft.PowerShell.Crescendo

新しいコマンドレットを定義する

今回はPowerShell Team Blogの例をそのまま拝借し、以下のJSONファイルを適当なフォルダに保存します。
ファイル名に制約はありませんが、「*.crescendo.json」という命名規約にしたい様です。

細かい解説はしませんがGet-IpConfigコマンドレット(その実体はipconfigコマンド)を生成する定義です。
この定義は英語版Windows OSで動作する前提(結果の文字列解析が英語OS前提)ですので実際に試す際はご注意ください。

{
    "$schema" : "./Microsoft.PowerShell.Crescendo.Schema.json",
    "Verb": "Get",
    "Noun": "IpConfig",
    "OriginalName":"c:/windows/system32/ipconfig.exe",
    "Description": "This will display the current IP configuration information on Windows",

    "Parameters": [ // <-- Parameter definition
        {
            "Name":"All", // <-- Name of parameter that appears in cmdlet
            "OriginalName": "/all", // <-- Name of original parameter that appears in native cmd
            "ParameterType": "switch",
            "Description": "This switch provides all ip configuration details" // <-- Help parameter
        },
        {
            "Name":"AllCompartments",
            "OriginalName": "/allcompartments",
            "ParameterType": "switch",
            "Description": "This switch provides compartment configuration details"
        }
    ],

    "OutputHandlers": [
        {
        "ParameterSetName": "Default",
        "Handler":"param ( $lines )
        $post = $false;
        foreach($line in $lines | ?{$_.trim()}) {
            $LineToCheck = $line | select-string '^[a-z]';
            if ( $LineToCheck ) {
                if ( $post ) { [pscustomobject]$ht |add-member -pass -typename $oName }
                $oName = ($LineToCheck -match 'Configuration') { 'IpConfiguration' } else {'EthernetAdapter'}
                $ht = @{};
                $post = $true
            }
            else {
                if ( $line -match '^   [a-z]' ) {
                    $prop,$value = $line -split ' :',2;
                    $pName = $prop -replace '[ .-]';
                    $ht[$pName] = $value.Trim()
                }
                else {
                    $ht[$pName] = .{$ht[$pName];$line.trim()}
                }
            }
        }
        [pscustomobject]$ht | add-member -pass -typename $oName"
        }
    ]
}

モジュールの自動生成

Export-CrescendoModuleを使うとJSONファイルからモジュールを自動生成してくれます。
-ConfigurationFileパラメーターにJSONファイルを、-ModuleNameパラメーターに生成したいモジュールファイル名を指定します。

Export-CrescendoModule -ConfigurationFile C:\temp\ipconfig.crescendo.json -ModuleName C:\temp\Ipconfig.psm1

エラー無く終了すればファイルが自動生成されます。

生成されたIpconfig.psm1の中身はこんな感じになります。

# Module created by Microsoft.PowerShell.Crescendo
Function Get-IpConfig
{
[CmdletBinding()]

param(
[Parameter()]
[switch]$All,
[Parameter()]
[switch]$AllCompartments
    )

BEGIN {
    $__PARAMETERMAP = @{
        All = @{ OriginalName = '/all'; OriginalPosition = '0'; Position = '2147483647'; ParameterType = [switch]; NoGap = $False }
        AllCompartments = @{ OriginalName = '/allcompartments'; OriginalPosition = '0'; Position = '2147483647'; ParameterType = [switch]; NoGap = $False }
    }

    $__outputHandlers = @{
        Default = @{ StreamOutput = $False; Handler = { param ( $lines )
        $post = $false;
        foreach($line in $lines | ?{$_.trim()}) {
            $LineToCheck = $line | select-string '^[a-z]';
            if ( $LineToCheck ) {
                if ( $post ) { [pscustomobject]$ht |add-member -pass -typename $oName }
                $oName = ($LineToCheck -match 'Configuration') { 'IpConfiguration' } else {'EthernetAdapter'}
                $ht = @{};
                $post = $true
            }
            else {
                if ( $line -match '^   [a-z]' ) {
                    $prop,$value = $line -split ' :',2;
                    $pName = $prop -replace '[ .-]';
                    $ht[$pName] = $value.Trim()
                }
                else {
                    $ht[$pName] = .{$ht[$pName];$line.trim()}
                }
            }
        }
        [pscustomobject]$ht | add-member -pass -typename $oName } }
    }
}
PROCESS {
    $__commandArgs = @()
    $__boundparms = $PSBoundParameters
    $MyInvocation.MyCommand.Parameters.Values.Where({$_.SwitchParameter -and $_.Name -notmatch "Debug|Whatif|Confirm|Verbose" -and ! $PSBoundParameters[$_.Name]}).ForEach({$PSBoundParameters[$_.Name] = [switch]::new($false)})
    if ($PSBoundParameters["Debug"]){wait-debugger}
    foreach ($paramName in $PSBoundParameters.Keys|Sort-Object {$__PARAMETERMAP[$_].OriginalPosition}) {
        $value = $PSBoundParameters[$paramName]
        $param = $__PARAMETERMAP[$paramName]
        if ($param) {
            if ( $value -is [switch] ) { $__commandArgs += if ( $value.IsPresent ) { $param.OriginalName } else { $param.DefaultMissingValue } }
            elseif ( $param.NoGap ) { $__commandArgs += "{0}""{1}""" -f $param.OriginalName, $value }
            else { $__commandArgs += $param.OriginalName; $__commandArgs += $value |Foreach-Object {$_}}
        }
    }
    $__commandArgs = $__commandArgs|Where-Object {$_}
    if ($PSBoundParameters["Debug"]){wait-debugger}
    if ( $PSBoundParameters["Verbose"]) {
         Write-Verbose -Verbose -Message c:/windows/system32/ipconfig.exe
         $__commandArgs | Write-Verbose -Verbose
    }
    $__handlerInfo = $__outputHandlers[$PSCmdlet.ParameterSetName]
    if (! $__handlerInfo ) {
        $__handlerInfo = $__outputHandlers["Default"] # Guaranteed to be present
    }
    $__handler = $__handlerInfo.Handler
    if ( $PSCmdlet.ShouldProcess("c:/windows/system32/ipconfig.exe")) {
        if ( $__handlerInfo.StreamOutput ) {
            & "c:/windows/system32/ipconfig.exe" $__commandArgs | & $__handler
        }
        else {
            $result = & "c:/windows/system32/ipconfig.exe" $__commandArgs
            & $__handler $result
        }
    }
  } # end PROCESS

<#


.DESCRIPTION
This will display the current IP configuration information on Windows

.PARAMETER All
This switch provides all ip configuration details


.PARAMETER AllCompartments
This switch provides compartment configuration details



#>
}

Export-ModuleMember -Function Get-IpConfig

モジュールの実行

あとはこのモジュールをインポートして使うだけです。
最初に述べた通り英語版Windows Server 2019で試しています。

Import-Module C:\temp\Ipconfig.psm1

と、思いきやこんなエラーが出てしまいました。

モジュールファイルの当該箇所(26行目)をよくみると、

                $oName = ($LineToCheck -match 'Configuration') { 'IpConfiguration' } else {'EthernetAdapter'}

if文のifが漏れていることが分かります。
どうやらPowerShell 5.1で動作させるための対応(三項演算子の除去)の中でデグってしまっている様です。
(JSONの記述の段階で間違ってますね...)

これくらいなら手作業で直してしまいましょうw

                $oName = if ($LineToCheck -match 'Configuration') { 'IpConfiguration' } else {'EthernetAdapter'}

改めてモジュールをインポートし直すと無事Get-IpConfigコマンドレットが使えました。

最後に

以上となります。

試みとしては面白いと思いますが、現時点でPowerShell Team BlogのコメントやGitHubで議論されている様に

  • 何故今更JSONを使うのか?
  • プラットフォーム別の定義は出来ないのか?
  • 自動生成されるコードのスタイルが統一されていない

といったフィードバックがあり私も同じ気持ちを持っています。

単純にネイティブコマンドのラッパーが必要なだけであれば自分で関数を書けば十分ですし、この様なモジュールの用途としてはプラットフォーム毎の差異の吸収とコマンドの更新に対する自動追従にあるのではないかと私個人としては見ています。
私自身はこのモジュールが必須というわけでもないのでコミュニティの動きを静観しようと思います。

もしこのモジュールに興味を持たれてた方がいらっしゃいましたら是非フィードバックしてみてください。

脚注

  1. 流石に5年くらい続けてると...それに別に12月だけでなく毎月ブログ書こうぜ!というお気持ちが強いです。