PowerShellパイプラインの理解と関数の作成方法

PowerShellのパイプラインは、PowerShellシェルとスクリプト言語の中でも最も重要で便利な機能の一つです。その動作原理と可能性を理解すれば、自分自身の関数でそのパワーを活用することができます。このチュートリアルでは、まさにそれを行います!

PowerShellのパイプラインを使用すると、コマンドを連鎖させて単一の「パイプライン」を構築することができます。これによりコードを簡素化し、並列処理などを可能にします。パイプラインを学び、自分自身の関数を作成してパイプラインを活用する準備ができたら、始めましょう!

前提条件

この投稿はチュートリアルであり、すべて実演を行います。一緒に進める場合は、PowerShell v3以上が必要です。このチュートリアルでは、Windows PowerShell v5.1を使用します。

PowerShellパイプラインの理解

ほとんどのPowerShellコマンドは、パラメーターを介して入力を受け取ります。コマンドは入力としてオブジェクトを受け取り、内部で何かを行います。そして、オプションで出力としてオブジェクトを返します。

パイプライン内のコマンドは、リレー競技の人間ランナーのように振る舞います。最初と最後以外のすべてのランナーは、前のランナーからバトン(オブジェクト)を受け取り、次のランナーに渡します。

例えば、Stop-Serviceコマンドレットには、InputObjectというパラメータがあります。このパラメータを使用すると、Stop-Serviceに渡す特定のタイプのオブジェクトを指定できます。

InputObjectパラメータを使用するには、Get-Serviceを介してサービスオブジェクトを取得し、次にオブジェクトを以下に示すようにInputObjectパラメータに渡すことができます。この方法では、InputObjectパラメータを介してStop-Serviceコマンドレットに入力を提供することができます。

$service = Get-Service -Name 'wuauserv'
Stop-Service -InputObject $service

このStop-Serviceコマンドへの入力の渡し方は、2つの異なるステップが必要です。PowerShellはまずGet-Serviceを実行し、出力を変数に保存し、その値をInputObjectパラメータを介してStop-Serviceに渡す必要があります。

さて、上記のスニペットを下記のスニペットと比較してみましょう。同じことを行いますが、はるかに簡単です。 $services変数を作成する必要も、InputObjectパラメータを使用する必要もありません。代わりに、PowerShellはInputObjectパラメータを使用する意図を「知っています」。これは、パラメータバインディングと呼ばれる概念によって実現されます。

これで、|演算子を使用してコマンドを「チェイン」しました。 パイプラインを作成しました。

Get-Service -Name 'wuauserv' | Stop-Service

ただし、パイプラインを作成するために2つのコマンドだけを使用する必要はありません。コマンドのパラメータがサポートしている場合、チェインするコマンドの数は任意です。例えば、以下のコードスニペット:

  1. Get-Service cmdletが返すすべてのオブジェクトをWhere-Object cmdletに渡します。
  2. Where-Object cmdletは、それぞれのオブジェクトのStatusプロパティを確認し、値がRunningのオブジェクトのみを返します。
  3. その後、それらのオブジェクトはSelect-Objectに送られ、オブジェクトのNameおよびDisplayNameプロパティのみを返します。
  4. Select-Objectの出力を受け入れる他のcmdletが存在しないため、コマンドはオブジェクトを直接コンソールに返します。

Where-ObjectおよびSelect-Object cmdletは、パイプライン入力を処理する方法をパラメータバインディングという概念で理解しています。次のセクションで説明します。

Get-Service | Where-Object Status -eq Running | Select-Object Name, DisplayName

パイプラインに関する詳細な情報については、Get-Help about_pipelinesコマンドを実行してください。

パイプラインパラメータバインディング

一見すると、パイプラインは些細なものに見えるかもしれません。結局、それは単にコマンドから別のコマンドにオブジェクトを渡しているだけです。しかし、実際にはパイプラインはもっと複雑です。コマンドはパラメータを介してのみ入力を受け付けます。パイプラインは、明示的に定義しなくてもどのパラメータを使用するかを何らかの方法で判断する必要があります。

コマンドがパイプライン経由で入力を受け取る際に、どのパラメータを使用するかを判断するタスクは、パラメータバインディングとして知られています。パイプラインから入ってくるオブジェクトをパラメータに正しくバインドするためには、受信コマンドのパラメータがサポートしている必要があります。コマンドパラメータは、ByValueおよび/またはByPropertyNameのいずれかの方法でパイプラインのパラメータバインディングをサポートします。

ByValue

Get-ChildItemコマンドレットには、Pathというパラメータがあり、文字列オブジェクト型とByValueを介したパイプライン入力を受け入れます。そのため、'C:\Windows' | Get-ChildItemのように実行すると、C:\Windowsは文字列であるため、C:\Windowsディレクトリ内のすべてのファイルが返されます。

ByPropertyName

コマンドパラメータは、オブジェクト全体ではなく、そのオブジェクトの単一のプロパティを受け入れます。これはオブジェクトの型ではなく、プロパティ名を見て行います。

Get-Processコマンドレットには、パイプライン入力ByPropertyNameを受け入れるように設定されたNameパラメータがあります。したがって、[pscustomobject]@{Name='firefox'} | Get-ProcessのようにNameプロパティを持つオブジェクトをGet-Processコマンドレットに渡すと、PowerShellは入力オブジェクトのNameプロパティをNameパラメータとマッチングまたはバインドし、その値を使用します。

パイプライン入力をサポートするコマンドパラメータの発見

前述したように、すべてのコマンドがパイプライン入力をサポートしているわけではありません。コマンドの作成者は開発時にその機能を作成する必要があります。コマンドには少なくとも1つのパイプラインをサポートするパラメータが必要であり、ByValueまたはByPropertyNameを指定する必要があります。

どのコマンドとそのパラメータがパイプライン入力をサポートしているかを知る方法はありますか?試行錯誤するだけでもできますが、Get-Helpコマンドを使用したPowerShellヘルプシステムを使用するともっと良い方法があります。

Get-Help <COMMAND> -Parameter <PARAMETER>

例えば、以下のGet-ChildItemコマンドレットのPathパラメータを見てみましょう。両方のタイプのパイプライン入力をサポートしていることがわかります。

PowerShell pipeline input is allowed

コマンドのパラメータがパイプライン入力をサポートしているかどうかを知ったら、以下のようにその機能を活用することができます。

# パイプラインなしの呼び出し
Get-ChildItem -Path 'C:\\Program Files', 'C:\\Windows'

# パイプラインの呼び出し
'C:\\Program Files', 'C:\\Windows' | Get-ChildItem

しかし、一方で、Get-ServiceDisplayNameパラメータはパイプライン入力をサポートしていません。

PowerShell pipeline input is not allowed

独自のパイプライン関数の作成

標準のPowerShellのcmdletがパイプライン入力をサポートしているということは、その機能を活用することができないわけではありません。幸いにも、パイプライン入力を受け入れる関数を作成することができます。

デモンストレーションのために、既存の関数Get-ConnectionStatusを使用してみましょう。

  • この関数には、パイプライン入力を受け入れない単一のパラメータComputerNameがあり、それに対して1つ以上の文字列を渡すことができます。ComputerNameパラメータがパイプライン入力を受け入れないことは、パラメータ属性([Parameter()])として定義されていないことからわかります。
  • その後、関数はそれぞれの文字列を読み取り、Test-Connection cmdletを実行します。
  • 各文字列のコンピュータ名が渡された場合、ComputerNameおよびStatusプロパティを持つオブジェクトが返されます。
function Get-ConnectionStatus
{
    [CmdletBinding()]
    param
    (
        [Parameter()] ## パイプライン入力なし
        [string[]]$ComputerName
    )

    foreach($c in $ComputerName)
    {
        if(Test-Connection -ComputerName $c -Quiet -Count 1)
        {
            $status = 'Ok'
        }
        else
        {
            $status = 'No Connection'
        }

        [pscustomobject]@{
            ComputerName = $c
            Status = $status
        }
    }
}

次に、以下のようにComputerNameパラメーターに1つ以上のホスト名またはIPアドレスを渡して、Get-ConnectionStatusを呼び出す必要があります。

Get-ConnectionStatus -ComputerName '127.0.0.1', '192.168.1.100'

ステップ1:パイプライン入力の許可

この関数がパイプライン入力を受け入れるようにするには、まず適切なパラメータ属性を定義する必要があります。この属性は、パイプライン入力ByValueを受け入れるためのValueFromPipelineであるか、パイプライン入力ByPropertyNameを受け入れるためのValueFromPipelineByPropertyNameであるかのいずれかです。

この例では、以下に示すように[Parameter()]のブラケット内にValueFromPipelineパラメータ属性を追加します。

 [Parameter(ValueFromPipeline)]
 [string[]]$ComputerName

この時点では、技術的にはこれで十分です。Get-ConnectionStatus関数は、渡された任意の文字列オブジェクトをComputerNameパラメーターにバインドします。ただし、パラメーターバインディングが行われているとしても、関数がそれに意味のある処理を行うわけではありません。

ステップ2:プロセスブロックの追加

パイプラインから入力されるすべてのオブジェクトを処理するために、次にProcessブロックを追加する必要があります。このブロックは、PowerShellにパイプラインから受け取った各オブジェクトを処理するように指示します。

Processブロックがない場合、PowerShellはパイプラインから最初のオブジェクトのみを処理します。Processブロックは、PowerShellにオブジェクトの処理を続けるように指示します。

以下のように、Process ブロックを追加して、関数の機能をすべて囲んでください。

function Get-ConnectionStatus
{
    [CmdletBinding()]
    param
    (
        [Parameter(ValueFromPipeline)]
        [string[]]$ComputerName
    )

    Process ## 新しい Process ブロック
    {
        foreach($c in $ComputerName)
        {
            if(Test-Connection -ComputerName $c -Quiet -Count 1)
            {
                $status = 'Ok'
            }
            else
            {
                $status = 'No Connection'
            }

            [pscustomobject]@{
                ComputerName = $c
                Status = $status
            }
        }
    } ## end Process ブロック
}

Process ブロック内から常に出力を送信してください。 Process ブロックからの出力は、オブジェクトをパイプラインにストリームすることで、他のコマンドがパイプラインからそれらのオブジェクトを受け入れることができるようにします。

PowerShell パイプラインにオブジェクトを渡す

上記の関数で Process ブロックが定義されたら、次のようにパイプラインを介して ComputerName パラメータに値を渡して関数を呼び出すことができます。

Get-ConnectionStatus -ComputerName '127.0.0.1', '192.168.1.100'
## または
'127.0.0.1', '192.168.1.100' | Get-ConnectionStatus

この段階では、パイプラインの真のパワーを活用し、さらに多くのコマンドを組み合わせることができます。たとえば、おそらくテキストファイル C:\Test\computers.txt に、以下のように改行で区切られた IP アドレスの行があるとします。

127.0.0.1
192.168.1.100

次に、Get-Content コマンドレットを使用して、テキストファイル内の各 IP アドレスを読み取り、それらを直接 Get-ConnectionStatus 関数に渡すことができます。

Get-Content -Path C:\Test\computers.txt | Get-ConnectionStatus 

さらに、このセットアップをさらに進めて、Get-ConnectionStatus が返すオブジェクトを直接 ForEach-Object コマンドレットにパイプで渡すこともできます。

以下のコード:

  • テキストファイル内のすべてのコンピュータ名を読み込み、それらをGet-ConnectionStatus関数に渡します。
  • Get-ConnectionStatusは、各コンピュータ名を処理し、ComputerNameStatusのプロパティを持つオブジェクトを返します。
  • Get-ConnectionStatusは、それぞれのオブジェクトをForEach-Objectコマンドレットに渡し、シアンで色付けされた単一の文字列として人間が読めるステータスを返します。
Get-Content -Path C:\Test\computers.txt |
Get-ConnectionStatus |
ForEach-Object { Write-Host "$($_.ComputerName) connection status is: $($_.Status)" -ForegroundColor Cyan }

ComputerNameパラメーターでパイプライン入力が有効にされていない場合、またはGet-ConnectionStatusProcessブロック内でオブジェクトを返さなかった場合、PowerShellはすべてのオブジェクト(IPアドレス)が処理されるまで、コンソールにステータスを返しません。

プロパティ名によるパイプラインバインディング

今まで、Get-ConnectionStatusコマンドレットは、'127.0.0.1'、'192.168.1.100'のような文字列の配列を受け入れることでパイプライン入力を受け入れるように設定されています。この関数は、IPアドレスのテキストファイルではなく、CSVファイルから入力が受け取られた場合にも正常に動作しますか?

おそらく、C:\Test\pc-list.csvに以下のようなCSVファイルがあるとします。

ComputerName,Location
127.0.0.1,London
192.168.1.100,Paris

CSVファイルのComputerNameフィールドは、Get-ConnnectionStatusComputerNameパラメーターと同じ名前です。

CSVをインポートしてパイプラインを介してGet-ConnectionStatusに渡す場合、関数はComputerName列で予期しない結果を返します。

Import-Csv -Path 'C:\Test\pc-list.csv' | Get-ConnectionStatus

ComputerName                                  Status       
------------                                  ------       
@{ComputerName=127.0.0.1; Location=London}    No Connection
@{ComputerName=192.168.1.100; Location=Paris} No Connection

何が間違っているか推測できますか?パラメータ名は一致しているので、なぜPowerShellのパイプラインがImport-CSVの出力をGet-ConnectionStatusComputerNameパラメータにバインドしなかったのでしょうか?それは、パラメータ属性のValueFromPipelineByPropertyNameが必要だからです。

現時点では、関数のComputerNameパラメータは[Parameter(ValueFromPipeline)]というパラメータ定義を持っています。したがって、入力ByPropertyNameをサポートするためにValueFromPipelineByPropertyNameを追加する必要があります。以下に示します。

    [CmdletBinding()]
    param
    (
        [Parameter(ValueFromPipeline,ValueFromPipelineByPropertyName)]
        [string[]]$ComputerName
    )

パイプラインがByPropertyNameをサポートするようになったら、PowerShellにオブジェクトのプロパティ名とオブジェクトの型を調べるように指示します。この変更を行った後、予想される出力が表示されるはずです。

PS> Import-Csv -Path 'C:\Test\pc-list.csv' | Get-ConnectionStatus

ComputerName  Status       
------------  ------       
127.0.0.1     Ok           
192.168.1.100 No Connection

要約

このチュートリアルでは、PowerShellのパイプラインの動作方法、パラメータのバインド方法、さらにはパイプラインをサポートする独自の関数の作成方法について学びました。

関数はパイプラインなしでも動作しますが、オブジェクトをコマンドからコマンドに「ストリーム化」し、コードを簡素化することはできません。

パイプラインに対応させることで恩恵を受けることができる、作成したり作成しようとしている関数を思い浮かべることはできますか?

Source:
https://adamtheautomator.com/ppowershell-pipeline/