tech.guitarrapc.cóm

Technical updates

PowerShell の -PipelineVariable を使おう

PowerShell v5 で追加された PipelineVariable に関して、本では説明していたのですがブログに書いていませんでした。 いい感じの例があったので、説明しておこうと思います

目次

TL;DR

  • PipelineVariable を使うと、パイプラインの中で一度 $x = $_ と書いて変数を保持していた処理が不要になります
  • Sort-Object のような集計系のCmdlet を使うとパイプラインの後続にわたる値の挙動が変わるので注意しましょう
  • Az モジュール使いにくい

対象のPowerShellコード

いい感じの Pipeline Variable の例があります。

この例を通して PipelineVariable を見てみましょう。

PipelineVariable とは

このコードの | % -pv vnet { $_ } は Pipeline Variable を使っています。

Pipeline Variable というのは、パイプラインからオブジェクトを次のパイプラインに送出するときに、そのオブジェクトを指定した変数に保持する機能です。

公式ではこう言っています。

新しい共通パラメーター PipelineVariable が追加されました。 PipelineVariable を使用すると、パイプされたコマンド (またはパイプされたコマンドの一部) の結果を変数として保存し、パイプラインの残りの部分に引き渡すことができます。

docs.microsoft.com

そのパイプライン時点のオブジェクトを変数に保持して何が嬉しいかというと、パイプラインがつながっている限り、直後ではない後続のパイプラインでもそのオブジェクトを変数経由で利用できます。 どういうことでしょうか?

PowerShellでは、通常パイプライン直後のスクリプトブロックでは流れてきたオブジェクトを 自動変数$_ 経由で読めます。

PS > ps | %{$_} | Get-Member


   TypeName: System.Diagnostics.Process

しかし、その次のパイプラインのスクリプトブロックでは、$_ の中身は前のパイプラインの中身に変わります。 例えば次のように、%{$_.Name} とProcess型のName プロパティ出力すると、後続のパイプライン | %{$_} では Process型ではなく String型に 変わります。

PS> ps | %{$_.Name} | %{$_} | Get-Member

   TypeName: System.String

PipelineVariable を使わないと、一連のPipelineの中であるパイプラインにおける変数を保持して後続に渡すときにハッシュテーブルや適当な入れ物に入れて渡すなど手間がかかります。

# こんなことはしたくない!
PS> ps | %{@{ps=$_;name=$_.Name}} | %{$_.ps} | Get-Member

PipelineVariables を使う例

↑の例以外にも、パイプラインの中でさらにパイプラインを書く時に、一番初めにパイプラインに入った$_ を思うように取れなくて一瞬引っ掛かったという人は多いのではないでしょうか?

何もしない無駄にそれっぽいのを例にします。 末尾の %{$_} では Process型が欲しいのですが、当然 String が来ます。

PS> ps | %{$_ | Where Name -eq "pwsh" | %{$_.Name.Replace("sh","hs")} | %{$_}}
pwhs
pwhs

この時にPipelineVariable を使わない場合、一時変数に入れてからやることが多いと思います。

PS> ps | %{$ps=$_; $_ | Where Name -eq "pwsh" | %{$_.Name.Replace("sh","hs")} | %{$ps}}

 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
    149   404.51     462.63      54.20    6820   1 pwsh
     71    55.12      96.09       1.95   13816   1 pwsh

PipelineVarible はこういった「パイプラインの後続で今のコンテキスト ($_) を使いたい」というシーンで機能します。 PipelineVarible に置き換えてみましょう。

PS> ps -pv ps | %{$_ | Where Name -eq "pwsh" | %{$_.Name.Replace("sh","hs")} | %{$ps}}

 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
    149   404.51     462.63      54.20    6820   1 pwsh
     71    55.12      96.09       1.95   13816   1 pwsh

%{$ps=$_;} と書いていた処理を、パイプラインを開始する前の ps コマンド時点に -pv ps と持ってきました。 このように、「パイプラインの中で $_ をいちいち変数に受け取っていた」という人は結構楽に書けるようになるはずです。

Aggregateする処理ではPipelineVariables の利用を気を付ける

Sort-Object のようにパイプラインをせき止める Aggregation 系の処理では、後段のパイプラインの結果は前段と変わります。 例えば先ほどのコードに意図的に Sort-Object を入れるとわかります。

PS> ps -pv ps | sort | %{$_ | Where Name -eq "pwsh" | %{$_.Name.Replace("sh","hs")} | %{$ps}}

 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
     31    29.86       9.57       0.42    9476   1 YourPhone
     31    29.86       9.57       0.42    9476   1 YourPhone

pwsh を拾っているはずなのに、YourPhone というプロセスに変わってしまいました。 YourPhone Sort-Object から渡った最後のオブジェクトに相当します。

PS> ps | sort | select -Last 1

 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
     31    29.86       9.57       0.42    9476   1 YourPhone

では、Sort-Object のようなパイプラインを一度せき止めるCmdletを挟みたい場合、どうすればいいのでしょうか? 簡単です、Sort-Object で PipelineVariable に割り当ててください。

PS> ps | sort -pv ps | %{$_ | Where Name -eq "pwsh" | %{$_.Name.Replace("sh","hs")} | %{$ps}}

 NPM(K)    PM(M)      WS(M)     CPU(s)      Id  SI ProcessName
 ------    -----      -----     ------      --  -- -----------
    148   403.27     461.28      59.45    6820   1 pwsh
     71    55.39      96.36       2.00   13816   1 pwsh

元コードからみる PipelineVariable

さぁ、PipelineVariable については概ね理解できたと思います。 元のコードを再度提示してみてみましょう。

AzVirtualNetwork | % -pv vnet { $_ } | % { $_.subnets } | select @{l="vnet";e={$vnet.name}},@{l="snet";e={$_.name}},addressprefix | sort vnet, snet

このコードの PipelineVariable は、先ほどの ps と違って一度 %{$_} を介しているように見えます。 どういうことか見てみましょう。

-pv vnet は早められる

もし、PipelineVariables を使うときに自分が % -pv vnet {$_} のように、ただ後続に $_ を流すだけの処理で PipelineVariablesを使うように書いていたら、その-pv、前段のコマンド時点に持っていくことができます。

AzVirtualNetwork -pv vnet | % { $_.subnets } | select @{l="vnet";e={$vnet.name}},@{l="snet";e={$_.name}},addressprefix | sort vnet, snet

元コードでは %{$_} でパイプラインを通るたびに新しく $vnet$_ を割り当てているのですが、それは AzVirtualNetwork | 時点でやっています。 ps の例で見せたように一番初めの Cmdletを実行した結果はパイプラインを通るのですが、その時点で PipelineVariable としてキャプチャできます。

単純な % {$_} をやるようなパイプラインが減るのは可読性、速度面の両面から嬉しいので検討するといいでしょう。

Select @{l={};v={}} をやめる

PipelineVariable とは関係ありませんが、Select @{l={};v={}} を使って PSObjectを生成している部分があります。

PSObject の生成方法としては、Select @{l={};v={}} 以外にも [PSCustomObject]@{} があります。1

Select-Object を使ったPSObject生成のメリットは、「プロパティの合成ができる」ことです。プロパティの合成は、ハッシュテーブルの後に書かれている addressprefix がそれにあたります。

select @{l="vnet";e={$vnet.name}},@{l="snet";e={$_.name}},addressprefix

一方で、[PSCustomObject]@{} を使うとハッシュテーブルから直接PSObjectに型変換します。 このやり方ではプロパティの合成はできず、自分で全プロパティに関してハッシュテーブルを定義しないといけません。 とはいえ、le のようなマジックキーに比べるとシンプルで速度も速く、可読性は高いでしょう。

% {[PSCustomObject]@{vnet=$vnet.name;snet=$_.name;addressprefix=$_.addressprefix}}

初めのコードをPSCustomObject に切り替えるとこうなります。

AzVirtualNetwork | % -pv vnet { $_ } | % { $_.subnets } | % {[PSCustomObject]@{vnet=$vnet.name;snet=$_.name;addr=$_.addressprefix}} | sort vnet, snet

まとめる

「-pv を早める」、「PSCustomObject に切り替える」の2つを組み合わせてみましょう。

AzVirtualNetwork -pv vnet | % { $_.subnets } | % {[PSCustomObject]@{vnet=$vnet.name;snet=$_.name;addr=$_.addressprefix}} | sort vnet, snet

幾分ワンライナー長い!読みたくない!感は減りました。

Azure環境の事前準備

もしコードを試す場合、Azureに VNet と Subnet が必要です。 せっかくなので、Az モジュールでサクッと組んでみましょう。

VNet作るだけなら料金かからないですしね。

AzureRm をアンインストールする

おわこん!それなのに Visual Studio で勝手に入る。

foreach ($module in (Get-Module -ListAvailable AzureRM*).Name |Get-Unique) {
   write-host "Removing Module $module"
   Uninstall-module $module
}

docs.microsoft.com

Az のインストール

代わりに Az モジュールを入れます。

Install-Module -Name Az -AllowClobber -Scope CurrentUser

docs.microsoft.com

vnet とかの準備

あとは Azure環境に ReousrceGroup、VirtualNetwork、Subnetを作ります。

$location = 'Japan East'
$rg = New-AzResourceGroup -Name test -Location 'Japan East'
$vnet = New-AzVirtualNetwork -Name test -ResourceGroupName $rg.ResourceGroupName -Location 'Japan East'  -AddressPrefix 10.0.0.0/16
$subnet = Add-AzVirtualNetworkSubnetConfig -Name a -VirtualNetwork $vnet -AddressPrefix 10.0.0.0/24
$vnet | Set-AzVirtualNetwork

これでコードを試せます。

AzVirtualNetwork | % -pv vnet { $_ } | % { $_.subnets } | select @{l="vnet";e={$vnet.name}},@{l="snet";e={$_.name}},addressprefix | sort vnet, snet

後片付け

リソースグループごとさくっと消せば全部消えます。

Remove-AzVirtualNetwork -Name $rg.Name

蛇足

Add-AzVirtualNetworkSubnetConfig で$vnet のプロパティを変更して (out相当の処理!?)、$vnet | Set-AzVirtualNetwork でVirtualNetwork に変更を適用しているの、とても書きにくいやり方ですね。

PowerShell っぽくないというか Azure に見られる特有に感じるのですが... 気のせいでしょうか。 Az モジュール、コマンドも探しにくく、Cmdletから使い方が予想できない使い方になってて、PowerShell の書く経験としては最悪に感じます。すごい、悲しい。

PowerShell 的には Addを使うと対象のオブジェクトに追加されることが多いので、それを期待している人は多いでしょう。Add-Content とか。

今回の場合、Add-AzVirtualNetworkSubnetConfig というCmdlet実行時点で $vnet に割り当てがされるのを期待するような気がほんわりします。(しない感じもある)

Add-AzVirtualNetworkSubnetConfig -Name a -VirtualNetwork $vnet -AddressPrefix 10.0.0.0/24

あるいは、VirtualNetworkSubnetConfig を作って、VirtualNetworkにAddするとか。(こっちのほうが納得感とCmdletからの予測ができそう)

$subnet = New-AzVirtualNetworkSubnetConfig  -Name a -VirtualNetwork $vnet -AddressPrefix 10.0.0.0/24 #実際には-VirttualNetwork パラメーターはない
Set-AzVirutalNetwork -SubnetConfig $subnet # こんなCmdlet もない

リソースを逐次分離したくてこうなったと思うのですが使い勝手が悪いのは Az Module でも改善されてないのでした。 az cli のほうが使いやすいので、私はもっぱら az cli です。

なお、AWS の Cmdlet 設計は秀逸で、どのCmdlet も aws cli と比較してもわかりやすい印象があります。 ただ、やはり型の扱いは若干めんどうさが表に出ていますが。


  1. ほかにもAdd-Member などいくつか方法がありますが本題ではないので省略します。