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
}

How to uninstall Azure PowerShell modules - Microsoft Learn

Az のインストール

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

Install-Module -Name Az -AllowClobber -Scope CurrentUser

Azure PowerShell をインストールする方法 - Microsoft Learn

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などいくつか方法がありますが本題ではないので省略します。