2016/01/03

PowerShellのパイプライン処理

まず、PowerShellのパイプライン処理について軽くおさらいします。

例えば、@、A、Bをそれぞれ何らかのコマンドとしたとき、

@|A|B

というパイプラインがあったら、処理の流れは、

@begin→Abegin→Bbegin→(@process→Aprocess→Bprocess→)×n→@end→Aend→Bend

の順に実行されます。(processブロックで「1入力に対し1出力する」場合以外は必ずしもこうならないですが)

さて、AかBのprocessブロック実行中に、何らかの条件を満たした時、パイプラインのprocessの後続処理を打ち切りたい場合はどうすれば良いでしょうか。

まずはbreakを使った駄目な例

ネットでよく見かける以下のようなコード、すなわち「パイプラインはbreakで処理を打ち切ることができる」というのは実は正しくないのです。

function Select-WhileTest
{
    [CmdletBinding()]             
    param (             
        [parameter(ValueFromPipeline=$true)] 
        [psobject[]]$InputObject,
        [parameter(Position=0)]           
        [scriptblock]$Predicate
    )

    process
    {
        if(!(&$Predicate))
        {
            break
        }
        $InputObject
    }
}

このコードはv2までではそもそも正しく動作しませんが、v3以降では条件によっては正しく動作しているように見えるのが、誤解の元なのかと思います。(というか私も誤解してました。)

例えば、

$result = "初期値"
$result = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 3}
Write-Host "`$resultは $result です。"

のようにすると、

$resultは 1 2 です。

のように、想定した通りの結果が得られます。このように、上流のスクリプトブロックのendブロック内にforeachなどループブロックが存在し、そのループブロック内で下流に値を出力している場合はうまくいきます。(ちなみに、スクリプトブロック直下に記述するのとendブロック内に記述するのは等価。)

しかし、上流にループブロックがない場合、例えば

$result = "初期値"
$result = 1..5 | Select-WhileTest {$_ -lt 3}
Write-Host "`$resultは $result です。"

とすると、コンソールに1と2が改行区切りで表示されますが、ホストに表示されるだけで$resultには値は格納されません。そしてスクリプト化して実行した場合は、Write-Hostが実行されることすらなく、スクリプトが終了してしまいます。

breakだとなぜうまくいかないのか

結局どういうことかというと、パイプライン下流のbreakは、パイプラインを打ち切る処理をするのではなく、単に一つ上流のブロックをbreakする処理に過ぎないのです。

パイプライン上流にループブロックがある場合は、そのループブロックをbreakしますが、それ以外の場合はスクリプトのbegin, process, endのいずれかのブロックがbreakされてしまい、結果としてスクリプトが終了してしまうわけですね。

そして、このSelect-WhileTest関数だと大丈夫ですが、processブロックの中にループブロックを記述し、その中でbreakを書くのは当然ダメです。単にそのループを抜けるだけなので、上流の出力は止まってくれません。

breakではなくcontinueを使う場合も基本は同じ結果です。しかもcontinueは所詮、その名の通り継続処理なので、上流に以下のような無限リストがあると無限ループに陥ってしまいます。

&{begin{$i = 0} process{while($true){$i++; $i}}}|Select-WhileTest {$_ -lt 3}

breakの代わりに、

throw (New-Object System.Management.Automation.PipelineStoppedException)

を実行する方法も見かけますが、これはループブロックがあっても強制的にスクリプトが終了するので余計ダメです。try...catchでエラートラップすればスクリプトの終了は回避できますが、「パイプラインが正常終了せずエラーを出した」扱いであることには代わりないので、やはり出力を変数に格納することができません。

ダミーループを用いる、取りあえずの解決策

前述のbreakを使った方法の問題点のうち、上流にループブロックがないとスクリプトが終了してしまい、出力を変数に代入することもできない問題は、とりあえず解決する方法があります。

以下のように、呼び出す時にパイプライン全体をダミーのループブロックでラップすれば良いのです。

$result = "初期値"
$result = do{
    1..5 | Select-WhileTest {$_ -lt 3}
}until($true)
Write-Host "`$resultは $result です。"

このようにしておけば、breakはパイプラインの外側のdo...untilを抜ける効果になるので、スクリプトが終了する心配も、値を出力しない問題も起こりません。

元々、パイプライン上流にループブロックが存在する場合でも、単にdoループ内の処理が1回走るだけなので、特に問題は起きません。1回だけ処理を実行するダミーループなら、for($i=0; $i -lt 1; $i++){}とかでも良いです。

ただ…この記述を美しいと思う人は多分いないですね。事情を知らないと意味不明ですし。そして、breakを記述する側の関数には、前述の通りのループブロック内では値を出力できないという制限は残ったままになります。

やはりbreakでパイプラインを打ち切るのは、本来想定された動作かと言われるとかなり微妙なところだと思います。(v3で一応動くようになったとはいえ)

この方法についての参考記事:Cancelling a Pipeline - Dreaming in PowerShell - PowerShell.com ? PowerShell Scripts, Tips, Forums, and Resources (ただしv2準拠の内容であることに注意)

ところで、Select-Object -Firstは…

さて、話は変わって、PowerShell 3.0からはSelect-Object -First の処理が変わったことについては、ご存知の方も多いかと思います。

具体的には、v2までは単にパイプライン処理をすべて終了してから、最初のn件を抽出する処理であったn件のパイプライン出力がされた後は、入力をすべてフィルタし出力に流さなくなる動作であったところが、v3からはn件のパイプライン出力がされた時点で、パイプラインの処理を打ち切るようになりました。(1/5修正)

つまり、

$result = 1..5| &{process{Write-Host "Process:$_"; $_}}| Select-Object -First 2
Write-Host "`$resultは $result です。"

というスクリプトは、v2までは

Process:1
Process:2
Process:3
Process:4
Process:5
$resultは 1 2 です。

のようにパイプライン出力は指示通り2件であるものの、上流の処理は結局、全部実行されてしまっています。

一方v3以降では、

Process:1
Process:2
$resultは 1 2 です。

のように、きちんと上流の処理を打ち切ってくれています。

つまり、ここまで述べてきたパイプライン処理の打ち切りは、実はv3以降のSelect-Object -Firstでは実現できているということです。これと同じことを我々も自作関数の中でやりたいわけです。

ではSelect-Object -Firstは具体的にどういう処理をしているかというと、StopUpstreamCommandsExceptionという例外をthrowすることでパイプライン処理の打ち切りを実現しています。この例外はまさに名前の通り、パイプライン上流の処理を中止するための例外です。この例外を自作関数でthrowしてやればうまくいきそうです。

しかし、この例外は非publicな例外クラスであることから、New-Objectコマンドレットなどでインスタンス化することはできません。リフレクションを駆使する必要がでてきます。
参考:PowerShell 3.0からはじめるTakeWhile - めらんこーど地階

(1/5追記)参考2:パイプラインの処理を途中で打ち切る方法のPowerShell版 - tech.guitarrapc.cóm(Add-TypeでC#経由でリフレクションしてます。)

頑張ればできなくはないですが、もっと楽な方法はないものか…と思ってあきらめかけたところ、いい方法を思いついたので紹介します。

Select-Object -Firstを利用する方法

Select-Object -Firstでできることが我々には(簡単には)できない。ならばどうするか。Select-Object -Firstを使えばいいじゃない。という発想です。

function Select-While
{
    [CmdletBinding()]             
    param (             
        [parameter(ValueFromPipeline=$true)] 
        [psobject[]]$InputObject,
        [parameter(Position=0)]           
        [scriptblock]$Predicate
    )

    begin
    {
        $steppablePipeline = {Select-Object -First 1}.GetSteppablePipeline()
        $steppablePipeline.Begin($true)
    }

    process
    {
        if(!(&$Predicate))
        {
            $steppablePipeline.Process($InputObject)
        }
        $InputObject
    }

    end
    {
        $steppablePipeline.End()
    }
}

scriptblockにはGetSteppablePipelineというメソッドが存在し、このメソッドによりSteppablePipelineオブジェクトが取得できます。これは何かというと、要は「スクリプトブロック内のbegin, process, endを個別に実行する」ための機能です。
参考:PowerShell: ◆パイプライン入力・パラメータ入力対応のGridView出力関数を作る(私自身も以前この記事で知りました。)

{Select-Object -First 1}というスクリプトブロックは、1回目に実行するprocessブロック内でStopUpstreamCommandsExceptionを出してくれます。

よって、自作関数のprocessブロック内のパイプライン処理を打ち切りたい箇所で、SteppablePipelineオブジェクトのProcessメソッドを使うことで、{Select-Object -First 1}のprocessブロックの処理を呼び出してやればいいわけです。

このようにして作成したSelect-While関数を以下のように実行してみると、

# 上流にループあり
$result1 = &{end{foreach($i in (1..5)){$i}}} | Select-WhileTest {$_ -lt 2}
Write-Host "`$result1は $result1 です。"

# 上流にループなし
$result2 =  1..5 | Select-While {$_ -lt 3}
Write-Host "`$result2は $result2 です。"

# 上流に無限リスト
$result3 = &{begin{$i = 0} process{while($true){$i++; $i}}} | Select-While {$_ -lt 4}
Write-Host "`$result3は $result3 です。"

結果は

$result1は 1 です。
$result2は 1 2 です。
$result3は 1 2 3 です。

となり、少なくとも今まで述べてきた諸問題はすべて解消していることが分かると思います。

このSelect-While関数は、スクリプトブロックで指定した条件を満たさなくなったときに、パイプライン処理を打ち切ってくれるものですが、この「Steppable Select -First 方式」を使えば他の自作関数でも、割と気楽に呼べるんじゃないかなと思います。ループブロック内で呼び出すことももちろん可能です。

ただし問題点はある

これはSelect-Object -FirstというかStopUpstreamCommandsExceptionあるいはPowerShellのパイプライン機構の仕様に由来する問題であると思われるので、どうにもならないことではあるんですが、一点だけ注意事項があります。

$result = 1..5| &{
    begin
    {
        Write-Host "Begin"
    }
    process
    {
        Write-Host "Process:$_"
        $_
    }
    end
    {
        Write-Host "End"
    }
}| Select-While {$_ -lt 2}

Write-Host "`$resultは $result です。"

これの結果は

Begin
Process:1
Process:2
$resultは 1 です。

となり、なんとendブロックが実行されていません。Select-While {$_ -lt 2} の代わりに Select-Object -Firstを使っても、同様にendは実行されません。

つまり、StopUpstreamCommandsExceptionというのはパイプライン処理を打ち切って、そこまでの出力値を正しくパイプライン出力として出してくれますが、やってくれるのはそこまでで、最後のendブロック処理はしてくれません。

これは十分注意が必要な点で、自作関数内でbeginブロックで確保したリソースをprocessブロックで利用して、endブロックで解放する…という、いかにも書いてしまいそうなパターンは、実はNGなんですね。何も上のようにマニアックなことをしなくても、単に下流でSelect-Object -Firstを使うだけでアウトです。

じゃあ、リソースの取り回しはどうするのが良いの、って話もありますが、それはまたの機会にしましょう。

(1/5追記)あえとすさんの記事が参考になります。:パイプライン処理の後始末をしよう - 鷲ノ巣 ただ、この方法ではパイプライン下流でthrowされた場合はトラップできないぽいですね。コマンドレットクラスの場合はIDisposable実装で良さそうです。

ここからは私見ですが、StopUpstreamCommandsExceptionが後付けかつ非パブリックなところとか、パイプラインを合法的に脱出するステートメントが今に至るまで用意されていないところとか、パイプラインを何とかして途中で打ち切っても、endは実行されないところとかを見ていると、そもそもPowerShellではパイプライン処理の中断というのは、あまり想定してない操作なのかなぁ、という気がしてきています。

上記のような裏技を使って回避するのも一案ではあるとは思いますが、そもそも「パイプライン処理の中断はイレギュラー」と考えて、そういう処理は避けて、必要に応じて別のアプローチを取ることも考えた方がいいのかもしれません。

2013/09/04

ぎたぱそ氏がPowerShell で TCP/IP 接続監視をしたい | guitarrapc.wordpress.comというエントリを上げておられます。

ループを回して定期的に出力するとTableが一つにまとまらない、とのことですが、パイプラインの先頭で無限リストを出力するようにすればOKです。

あとデータの再利用を考えるなら、最初からCSVで出力しておくのが無難かと思います。

画面出力と同じものをファイル出力するだけで良いなら、Tee-Objectコマンドレットでもいいかと。

ついでに本体の関数も何となく短くしてみました。

function Get-NetTCPConnectionCheck
{
    $result = [ordered]@{Date = Get-Date}
    echo Listen, Established, TimeWait, CloseWait, LastAck |
        %{$result[$_] = 0}
    Get-NetTCPConnection |
        group state -NoElement |
        ?{$result.Contains($_.Name)} |
        %{$result[$_.Name] = $_.Count}
    [PSCustomObject]$result
}

&{process
    {
        while($true)
        {
            Get-NetTCPConnectionCheck
            sleep -Seconds 1
        }
    }
} |% {
    $_ | Export-Csv -Append C:\Users\daisuke\Documents\test.csv
    $_
} | Format-Table 

以上。

2013/03/16

PowerShellのforループで2変数を初期化するにはどうすれば良いのかと、昨日とある方に質問されました。C#やJavaScriptなんかでは

for (int i = 0, j = 10; i < 10; i++, j--){}

のように初期化子を,で区切って複数指定できますが、PowerShellでは

for ($i = 0, $j = 10; $i -lt 10; $i++, $j--){}

という書き方ができません。初期化子部分には一つの変数しか書けないのです。

そこで考えたのが部分式を利用する方法です。部分式は一つの式に複数の文を埋め込むための書式で、

Write-Host "今日は $($d = Get-Date; $d.ToString("M/dd")) です。"

こんな感じで$()の中に複数の文を入れて変数のように実行結果の値を参照できます。なお、部分式の内部は、スクリプトブロックとは違い、外側と同じスコープとなります。

この部分式をforループの初期化子に利用してみます。

for ($($i = 0; $j = 3); $i -lt 3; $i++, $j--)
{
    "$i $j"
}

実行結果は

0 3
1 2
2 1

となりちゃんと動いてますね。

この書き方が果たして正式なものなのか、分かりませんが、forループで複数値の初期化をしたいときに使えるテクなんじゃないでしょうか。

ちなみにPowerShellのforループの初期化子宣言はループ内のスコープにおける変数とはならず、ループの外側でも参照や代入ができてしまいますので注意です。forループの中は別スコープとはならないのですね。それは部分式を使っても同じです。(それだったらforループの前で変数初期化しても同じことじゃん、となるかもしれませんが…)

また例をみていただければ分かりますが、反復式の部分は,で複数指定できるようです。ただしこの,はforステートメントの構文の一部ではなく、配列連結演算子の,だと思います。

追記。エントリ読み返して気づいたんですが、反復式を配列として記述できるのだから、初期化も配列でやっちゃえば良かったですね。このように。

for ($i, $j = 0, 3; $i -lt 3; $i++, $j--)
{
    "$i $j"
}

こっちのほうがいいですね。

2012/12/01

今日から、PowerShell Advent Calendar 2012が始まりました。初日は私が担当させていただきます。お題は旬の話題、PowerShell 3.0の新機能!…ではなく、初心に返って、PowerShellの「関数」ってどう書くのがいいのかというお話をします。PowerShell 3.0どころか、大部分はPowerShell 1.0から変わっていない基本の話です。

これは今までずっと書きたかったネタですがなかなか書く暇がなくて放置してたものです。3.0の話はきっと他の皆さんが書いて下さるはず!私もまた順番が回ってきたら書こうと思います。

PowerShellの関数は従来言語とだいぶ違う

PowerShellを使いこなすようになってくると、他の言語を使う時と同じで、定型処理は関数として一つにまとめたくなってきます。ところが他の言語と同じような感覚で関数を書くと、どうもうまくいかないのです。

たとえば引数にフォルダパスとフォルダ名を指定すると、指定フォルダが存在すればFalseを返し、存在しなければ作成してTrueを返す関数を書いてみました。

function MakeDir($path,$name)
{
    $newDirPath = Join-Path $path $name
    if((Test-Path $newDirPath))
    {
        return $false
    }
    else
    {
        New-Item -ItemType Directory -Path $newDirPath 
        return $true
    }
}

実行は

MakeDir("C:\test","NewFolder")

と、メソッド風に呼び出すことはできないので、コマンドレット風に

MakeDir C:\test NewFolder

と呼び出せばいいんですが(まあ最初はここもつまづきポイントではありますが)、この実行結果は以下のようになります。

    ディレクトリ: C:\test

Mode                LastWriteTime     Length Name 
----                -------------     ------ ----
d----        2012/12/01      7:51            NewFolder
True

フォルダが作成されてTrueが返却されることを想定していたのに、なんか余計な出力が混じってしまっています。なんでしょうこれは?

実はPowerShell関数内で値が出力されると、returnキーワードがついてなくてもすべて呼び出し元に出力されるという仕様なのです。そしてPowerShellにおけるreturnキーワードの効果は「後続処理を打ち切って呼び出し元に戻る。ただしreturnの後に値が指定してあればそれを最後の値として戻す」となります。そのため、呼び出し元に返したくない出力が関数内にある場合は、すべて[void]にキャストしたり|Out-Nullとしてリダイレクトするなどして出力を破棄する必要があるのです。このMakeDir関数の場合はNew-Itemコマンドレットが作成したフォルダのFolderInfoオブジェクトを出力するので、これをNew-Item -ItemType Directory -Path $newDirPath | Out-Null のように破棄してやる必要があるわけです。

パイプラインの動作

先ほどの例を見ると、「いやいやなんでそんな訳のわからない仕様なんだよ、returnあるときだけ値返せよ」とお思いかと思います。しかしこれはPowerShellの特長の一つである、コマンドのパイプラインによる連携を行うための仕様なんです。

ここでコマンドを繋ぐパイプラインがどういう動作をしてるか、おさらいします。

Get-Process | where {$_.Handles -ge 500} | foreach {$_.Path}

これはハンドル数が500以上のプロセスのメインモジュールファイルのパスを取得するというコマンドで、別に何の変哲もありません。ところが、このコマンドがやっている処理を、次のように誤解してませんでしょうか?

@ 稼働中のすべてのプロセスの一覧を配列として取得する。
A @で取得した配列を走査して、Handlesプロパティの値を調べる。Handlesが500以上のオブジェクトだけ抽出した配列を生成する。
B Aで生成した配列を列挙して、{}内のスクリプトをそれぞれ実行する。

しかし、これは間違いです。

正しくは

@ 稼働中の1つのプロセスオブジェクトを取得して次のコマンドへ送る。
A そのプロセスのハンドル数が500以上なら、次のコマンドへ送る。そうでないなら@に戻る。
B そのプロセスに対して{}内のスクリプトを実行する。まだ未取得のプロセスが残っていれば@に戻る。

という動きをしています。つまり、パイプラインの手前で一旦すべての処理を終えてから、出力オブジェクトがまとめて配列という形で次のコマンドに送られるのではなく、オブジェクトがパイプラインの先頭から末尾に向けて1つずつ通過していき、それが先頭コマンドの出力オブジェクト数だけ繰り返される、という動作をしているのです。

これがPowerShellのパイプライン処理が、従来の処理系での関数と決定的に違うところで、パイプラインによって複数のコマンドが、あたかももとからあった単一のコマンドのように密に連携するわけです。

(この処理、.NETのLINQにちょっと似てると思う方もいらっしゃると思います。しかしLINQとは全然違うものです。なんせPowerShellはLINQより先に世に出てますし! しかし類似点も多いのでいずれ比較なんかを書きたいと思ってます)

パイプラインで連携可能な関数の書き方

さて、先ほどのパイプラインの話ではコマンドレットを連携させていました。しかしPowerShellにおいてはコマンドレットも関数も、それが.NETのクラスかPowerShellのスクリプトなのかの違いがあるだけで、基本は同じ「コマンド」です。なので、関数もコマンドレットと同様、適切な記述をおこなえば、パイプラインでコマンド同士を連携させることが可能です。

以下に、Get-Repeatという関数の例を挙げます。この関数は-Textパラメータに文字列を指定し、-Countパラメータに回数を指定すると、指定文字列を指定回数分連結した文字列を出力する、という何の変哲もない関数です。しかしパイプラインからの入力を受け付け、次のパイプラインへ出力することを想定した作りになっています。

function Get-Repeat
{
    param(
        [Parameter(ValueFromPipeline=$true,Mandatory=$true)]
        [string[]]
        $Text,
        
        [int]
        $Count=2
    )

    begin
    {
    }

    process
    {
        foreach($s in $Text)
        {
            $s * $count
        }
    }

    end
    {
    }
}

以下は実行例です。

PS> Get-Repeat -Text ab -Count 2
abab
PS> "ab" | Get-Repeat -Count 2
abab
PS> Get-Repeat -Text ab,cd -Count 2
abab
cdcd
PS> "ab","cd" | Get-Repeat -Count 2
abab
cdcd

このように、パラメータに値を指定してもパイプラインから入力しても、スカラー値(配列ではない単一のオブジェクト)でも配列でも、正しく処理されています。

この関数をポイントごとに見ていきましょう。

PowerShellの正式な関数はparam節、beginブロック、processブロック、endブロックに分かれます。param節にはパラメータを指定します。beginブロックにはパイプラインで連携した際、最初の1回だけ実行される初期化処理、endブロックには最後の1回だけ実行される後始末処理を記述します。beginとendは今回の例では内容を省略しています。processブロックには、パイプラインから入力された1つのオブジェクトに対してその都度実行される処理を記述します。

ちなみに、

コマンド@|コマンドA|コマンドB

とある場合、各コマンドにおけるbegin,process,endブロックは次のような順番で呼び出されます。

コマンド@begin→コマンドAbegin→コマンドBbegin→{コマンド@process→コマンドAprocess→コマンドBprocess→コマンド@process…}→コマンド@end→コマンドAend→コマンドBend

processブロックでの処理は、通常はパイプラインだけではなくパラメータからも値を入力できるようにしておきます。そのためにはparam節に記述するパラメータに「このパラメータはパイプラインから値を入力することもできる」を意味する[Parameter(ValueFromPipeline=$true)]という属性を指定します(この属性はPowerShell 2.0から利用可)。今回のパラメータには「このパラメータは必須である」を意味するMandatory=$trueもあわせて指定しています。

先述の通り、パイプラインから入力される場合は配列ではなくオブジェクトが単体で渡されるのですが、パラメータから入力される場合はスカラー値と配列値、どちらの可能性もあるため、[string[]] のようにパラメータの型を配列型にしておくことで、どちらを指定しても処理できるようにしています。

processブロックではパラメータ経由で配列値が渡された場合に、各要素に対して処理を行うためforeachループを設けています。ちなみにスカラー値が渡された場合もforeachは問題なく処理します。

processブロック内では、returnは記述しません。returnするとその時点で関数が終了してしまうので正しくすべての出力ができなくなってしまいます。

特にこの例の関数のように入力型と出力型が同一の場合は、processブロックでは1オブジェクトの入力に対して、1オブジェクトを出力するようにしておくと、他のコマンドと連携させやすくなります。ただしWhere-Objectコマンドレットのようにフィルタ処理を行う関数の場合は、条件によっては何も出力しないようにします(空の配列とか$nullを返すのではないことに注意)。もちろん入力オブジェクトから何らかの配列値を出力する場合もありえます。

最低限、これらのポイントを押さえて関数を記述すると、他のコマンドとパイプラインで連携しやすい、PowerShellらしい関数を書くことができると思います。

まとめ

PowerShellでは従来言語と同じ感覚で関数を書くと、うまくいかないことが多いです。もっとも単に処理をひとまとめにしたいというニーズだけならばそれでも問題ないのですが、関数同士を組み合わせたいときに問題が顕在化します。

パイプラインの真の動作を理解し、パイプラインの中に組み込んで動作させることを想定した関数を記述すると、他のコマンドレットあるいは自作関数と連携しやすくなり、PowerShellの真の力を解放することができると思います。

PowerShell Advent Calendar 2012の1日目にしてはえらい固いネタかもですが、基本をおさらいするのも大事ですよね。

さて、明日は@jsakamotoさんの番です。よろしくお願いします。

2012/03/06

次期Windows Server OSであるWindows Server “8”のベータ版が3/1よりWindows 8 Consumer Preview版と同時に提供が開始されました。今回のリリースでは日本語版も提供されています。

それと同時にPowerShell 3.0 betaを含むWindows Management Framework (WMF) 3.0 betaの提供も始まりました。こちらは英語版のみの提供で、日本語環境にインストールするには英語言語パッケージをダウンロードし適用する必要があります。

まだ評価を初めて間もない段階ですが、きづいた点をいくつか書いてみます。

PowerShell ISEがDeveloper Preview/CTP版から大きく変わっており、コマンドを入力する「コマンドペイン」と結果が表示される「出力ペイン」が統合され、「コンソールペイン」となりました。image

こんな感じでコンソールペインはその名の通りコンソール版のPowerShellと見た目や使用感が似ている上にISEならではの機能(インテリセンスなど)が使用可能になっており、ISEはスクリプト編集のみならずインタラクティブシェルとしてpowershell.exeの代わりに使用する場合でもより便利になったと言えるでしょう。なおISEのキーワード色分け設定は細かく指定できたりします。

Server8ではActive Directory管理センターの大幅な機能増強が目に留まります。Active Directory管理センターには「Windows PowerShell 履歴」ペインが追加され、GUIで操作した履歴がそのまま、再実行可能なPowerShellスクリプトとして表示されるようになりました。

たとえばユーザーを作成する作業をGUIでやります。

image

するとその作業内容がこのように履歴ペインに表示されます。

image

履歴はまさにPowerShellスクリプトそのままなので、これを「コピー」してISEに貼りつけるともうスクリプトファイルが完成します。

image

あとは必要に応じてパラメータを変更したり、ループを設けて繰り返し処理をしたり、自由自在に再利用ができます。ここではユーザー名を”testuser2”に変えて実行してみました。最初にGUIで作ったユーザーと同じ設定を用いて複数のユーザーを作成することが簡単に行えます。ユーザー名を記載したCSVファイルを読み込んでそのユーザーを一括作成することなどもできますね(PowerShellにはImport-CsvというCSVファイルを扱うコマンドレットもあります)。

スクリプトファイルにして保存すれば何回でも実行可能ですし、PSScheduledJobモジュールを使ってタスクスケジューラに登録すれば定期実行も可能です。

履歴スクリプトのうち、どこからどこまでが「今やった作業」なのか分かりにくいこともあると思いますが、そういうときには履歴ペインの「タスクの開始」をクリックし、今からやりたい作業名をまずメモします。

image

そしてGUIで作業を行い、終わったら「タスクの終了」をクリックします。

image

作業単位でこれを繰り返します。すべてが終わったら履歴をすべてコピーしてISEに貼りつけてみます。

image

するとこのようにタスク名がコメント行として挿入され、スクリプトのどこからどこまでが、どの作業に対応しているかが明確になるわけです。

このように管理GUIがPowerShellモジュール(コマンドレット)のフロントエンド的存在となり、GUIでの操作によりPowerShellコマンドレットが実行され、その履歴はスクリプトとして再利用が可能というMicrosoftが提唱する新しい管理方式が、ついにWindows Serverの本丸的存在Active Directoryに採用されたということになります。2008R2のActive Directory管理センターも内部的にはそういう構造だったのですが、履歴をスクリプトとして取り出す方法がなかったのですが、Server8からは可能になったということになります。

これまではこの構造が完全に組み込まれていたのはExchange Serverなど限られた製品のみでしたが、今後はこちらの構造が主流になると思われます。GUIとCUIの「いいとこどり」ができるこの構造はなかなか理想的なシステムなんじゃないかと思います。

PowerShellは3.0になってますますWindows OS管理の中核として重要度が高まっており、それに答えるように様々な機能増強が行われています。今回紹介したのはその一面ですが、特にServer8においてPowerShellがいかに重要かはWindows Server "8" に関するテクニカル プレビューの各項目の記述内容の多くにPowerShellに関する記載があることでも分かるかと思います。

2011/12/25

はじめに

PowerShell Advent Calendar 2011の25日目最終日の記事、そしてこれが私の記事では4回目となります。今回もバックグラウンドジョブについての話題です。今回はバックグラウンドジョブを使って並列処理をやってみようという試みです。

これまでの記事は以下になります。

2日目:バックグラウンドジョブの使い方・基本編

13日目:バックグラウンドジョブとの通信

19日目:PowerShell 3.0で追加されるバックグラウンドジョブ関係の新機能

ところでつい2日前、WMF3 CTP2 Windows PowerShell Workflow.pdfというpdfファイルが公開されました。これは19日目に書いたPS workflowについての詳しい説明(英語)です。構文だけでなくPSスクリプトとの違いやWFとの関係などが詳しく書かれています。ぜひ目を通しておくことをお勧めします。23日目のAhfさんの記事と併せて読むと理解が深まると思いますよ!

並列処理スクリプト

C#をご存知の方なら、PowerShellのバックグラウンドジョブ機能はC#4.0から使えるTaskオブジェクトとちょっと似てるかなーと思われるかもしれません。ではC#4.0でコレクションに対して並列処理でループを回すParallel.For()やParallel.Invoke()みたいなことはPowerShellでできないのか、という疑問が出てくるかと思います。

前回述べたようにPowerShell 3.0ならworkflowを使えば並列処理が可能で、for -parallelステートメントやparallelブロックでParallel.For()やParallel.Invoke()みたいなことが可能になります。しかしPowerShell 3.0がリリースされるのはまだ先ですし制限事項も多いので、なんとかPowerShell 2.0で、しかもworkflowのような制限なしで、並列処理のスクリプトは書けないものかと考えてみました。

function ParallelForEach-Object
{    
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true,Position=1)][scriptblock]$process,
        [scriptblock]$begin={},
        [scriptblock]$end={},
        [Parameter(ValueFromPipeline=$true)][psobject]$inputObject
    )
    begin
    {
        &$begin
        $jobs=@()
    }
    
    process
    {    
        $jobs|Receive-Job
        while(@($jobs|?{$_.State -eq "Running"}).Length -ge 5)
        {
            $jobs|Receive-Job
            start-sleep -Milliseconds 100
        }       
        
        $jobs += Start-Job $process -argumentList $inputObject
    }

    end
    {
        while(@($jobs |?{$_.State -eq "Running"}).Length -gt 0)
        {
            $jobs|Receive-Job
            start-sleep -Milliseconds 100
        }
        $jobs|Receive-Job
        $jobs|remove-job
        &$end
    }
}




$watch=new-object System.Diagnostics.Stopwatch

"ForEach-Object 開始"

$watch.Start()
1..10|ForEach-Object {
    "start: " + $_
    Start-Sleep -sec 5
    "end: " + $_
    
}
$watch.Stop()

"ForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"

$watch.Reset()

"ParallelForEach-Object 開始"

$watch.Start()
1..10|ParallelForEach-Object {
    "start: " + $args[0]
    Start-Sleep -sec 5
    "end: " + $args[0]
}
$watch.Stop()

"ParallelForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"

ParallelForEach-Object関数はパイプラインから渡されたコレクションの各要素について、並列にスクリプトブロックを実行させるものです。同等の処理をForEach-Objectを使って同期的に逐次処理した場合とかかる時間を比較しています。10個の要素があり、各要素につき5秒かかる処理なので、逐次的に処理すると当然50秒以上かかりますが、ParallelForEach-Object関数を使って並列処理させると環境にもよりますが20秒以内に完了します。

この関数では渡されたコレクション1要素に対し1つのジョブを割り当て、同時に5ジョブまで(呼び出し元を含めて同時稼働が6プロセスまで)を並列実行するようにしています。

ただこれはあくまでなんちゃって並列処理なので、並列化することで本当に処理が高速になるかどうかは環境次第かと思います。一応、うちのCore2Duo (2コアCPU)な環境だと、足し算を3万回ほどする処理を10回行う場合、逐次処理とこの関数を使った並列処理では54秒が39秒に短縮され、有意な実行時間差が出ました。

またジョブを開始するのに新しくプロセスを起動させるので、1ループあたりの実行時間がプロセス起動にかかる時間より短ければ、この関数による並列化で処理時間の短縮は見込めません。

処理の対象が複数のリモートPCである場合などは割と有効なのかなと思います。たとえば複数サーバーから別々のファイルを同時にダウンロードするときなど。

ここではParallel.For()やParallel.ForEach()相当の関数を書きましたが、Parallel.Invoke()のような関数も書けるかと思います。スクリプトブロックの配列をStart-Jobで順に走らせ、Wait-Job, Receive-Jobする感じですね。

あとここではやりませんでしたが、Start-Jobの代わりにInvoke-Commandを使い複数のリモートPCに処理を振り分ければ、なんちゃって分散処理もできるのかなあと思いました。

おわりに

実はこのスクリプトを書いたのはPS Workflowの調査前のことで、Workflowで同様のことが可能になることを知って少々愕然としたのですが、それなりに面白いスクリプトかと思ったので公開することにしました。ともあれ、これからのマルチコア、メニーコアの時代、非同期処理や並列処理はますます重要になるかと思います。管理スクリプトにおいてもこれらの概念を意識しないわけにはいかなくなるでしょう。全4回にわたってPowerShellのバックグラウンド機能を解説してきましたが、これらがあなたの非同期&並列スクリプトライフ(?)の一助になれば幸いです。

さてさて、これでPSアドベントカレンダー2011もおしまいです。楽しんでいただけたでしょうか? 私自身も自分で記事を書いていて楽しかったですし、他の方の記事を読むのも色々な発見があり、とても有意義な25日間でした。記事を書いて参加していただいた方々、そして読者の方々に厚く御礼申し上げます。これからもぜひ、PowerShellを活用し、楽しんでくださいませ。

それでは皆様、良いクリスマスをお過ごしください!

2011/10/09

JScriptは言語単体ではSafeArrayを作ることができません。

そこでJScriptでSafeArrayが必要な場合、VBScriptを併用しVBScriptの配列(これはSafeArrayです)をJScriptに取り込む方法や、Scripting.DictionaryのItems()メソッドを使う方法などが使われているようです。

しかしこれらの方法で多次元のSafeArrayを作るサンプルをあまり見かけませんでした。Dictionaryの方法ではそもそも1次元しか無理ですしね。そんな中、この記事を発見しました→JScriptの配列とVBScriptの配列(SafeArray)を相互変換する方法(2次元編) - プログラマとSEのあいだ
この記事の二つ目の例ではExcelを使用しRegionオブジェクトが二次元配列を返す点を利用しています。これはなかなか盲点というかアイデアものではありますが、実行速度にやや難があるかな?と思いました。

注: ただしこの記事の方法は、もともとExcelで二次元配列が必要な場合があったから考案されたもののようで、その用途においてはExcelを起動するコストは考慮しなくてよいのかもしれません。

一つ目の方法ではVBScriptを併用していますが、.wsfファイルを使用してJScriptとVBScriptを混在させる形式をとっています。この方法はWSHでは問題ありませんが、複数のスクリプトエンジンを混在できないホスト環境では問題があります。

注:そんな環境ってあるのか?と聞かれそうですが、たしかにHTML/HTA/Windowsデスクトップ(サイドバー)ガジェット/WSH/classic ASPなどほとんどの環境では大丈夫そうです。ただ私が最近はまっているJScript実行環境であるところのAzureaでは無理ですね。WSHでも.jsファイルにこだわるのであれば。

そこで考えたのが、ScriptControlを使用してJScriptのコードの中でVBScriptのコードを実行させる方法です。以下のような感じになります。

function array2dToSafeArray2d(jsArray2d)
{
	var sc = new ActiveXObject("ScriptControl");
	sc.Language = "VBScript";
	var code =
'Function ConvertArray(jsArray)\n' +
'	ReDim arr(jsArray.length - 1, jsArray.[0].length - 1)\n' +
'	outerCount = 0\n' +
'	For Each outer In jsArray\n' +
'		innerCount = 0\n' +
'		For Each inner In outer\n' +
'			arr(outerCount, innerCount) = inner\n' +
'			innerCount = innerCount + 1\n' +
'		Next\n' +
'		outerCount = outerCount + 1\n' +
'	Next\n' +
'	ConvertArray = arr\n' +
'End Function\n';
	sc.AddCode(code);
	return sc.Run("ConvertArray",jsArray2d);
}

まあやっていることは本当にJScriptの配列をバラしてVBScriptの二次元配列に詰め直しているだけです。

ただいくつかポイントがあって、まずVBScriptからはJScriptのオブジェクトメンバーにドット演算子でアクセスができます。JScriptの配列はオブジェクトと同一であり、配列はオブジェクトに0,1,2...という名前のプロパティが存在することになります。しかしVBScriptで数字のメンバ名はそのままではドット演算子でアクセスできないので、[]でくくる必要があります(これ、予約語なんかもそうですね。あとVB6でもVB.NETでも同じなので覚えておくといいかも)。なので次元数2の配列の長さを調べるのにjsArray.[0]でまず内側の配列オブジェクトを取得しているわけです。

さらにポイントとして、VBScriptでJScriptの配列を含むオブジェクトメンバを列挙するのにコード例のようにFor Each Next構文が使えます。ただしFor Nextを使ってインデックスアクセスはできません。というのもjsArray.[3]とかはあくまでjsArrayオブジェクトの3プロパティの値を参照しているにすぎず、jsArray.[I]という書き方ができないからです(これだと単にIプロパティの値を見てることになる)。Eval関数を併用すれば可能ではありますが、コードの中にコードを含ませさらにその中にまたコードを含ませるのも微妙なのでここでは使ってません。

あとは紹介した記事の関数部分だけ置き換えればJScriptのVBArrayオブジェクトを用いたテストもできるかと思います。注意点はExcelオブジェクトは配列添え字が1から始まるのに対し、VBScriptの配列は0から始まる点です。LBound関数を使えばその差違は吸収できるかな、と思います。

最初は多次元配列というかn次元配列に拡張した関数を書いてやろうと企んでましたが挫折しました。ネストしたループではなく再帰呼び出しである必要がありますし、ReDimは次元数を動的に指定することができないので実行するVBScript自体を動的生成しなければいけません。興味がある方はチャレンジしてみてください。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2011/10/09/204198.aspx

2010/09/20

文字列を連結する際、+=演算子などを使うとループ回数によっては非常に時間がかかることがあります。これは+=演算子が実行されるたびに毎回string型の新しいインスタンスを生成しているからです。同じ文字列変数に対して+=演算子で文字列を追加していくループがある場合は、+=演算子の代わりにStringBuilderクラスを使うのが良いです。

たとえば、

$str=""
@("aaaa","bbbb","cccc","dddd")|
%{$str += $_}
$str

というようなコードと同様の結果を得るためには、

$sb=New-Object System.Text.StringBuilder
@("aaaa","bbbb","cccc","dddd")|
%{[void]$sb.Append($_)}
$sb.ToString()

のようにします。[void]にキャストしているのは、AppendメソッドがStringBuilderのインスタンスを返すため、それを表示させないようにするためです。結果はいずれも

aaaabbbbccccdddd

となり、文字列の連結が可能です。

このようにループ回数が少ない場合はそれほど所要時間に差はないのですが、数千の文字列を連結していく場合だと+=演算子を使うと非常に時間がかかります。どれくらい差が出るのか、スクリプトを書いて検証してみました。

function Measure-StringJoinCommand
{
    param([int]$itemCount)
    
    $randomStrs=@()
    @(1..$itemCount)|%{$randomStrs += Get-Random}
    $StringJoinTime=@()
    $StringBuilderTime=@()
    @(1..3)|
    %{
        $StringJoinTime +=
            (Measure-Command {
                $str=""
                $randomStrs|%{$str+=$_}
                $str
            }).Ticks
        $StringBuilderTime +=
            (Measure-Command {
                $sb=New-Object System.Text.StringBuilder
                $randomStrs|%{[void]$sb.Append($_)}
                $sb.ToString()
            }).Ticks
    }   
   
    $result=New-Object psobject
    $result|Add-Member -MemberType noteproperty -Name ElementCount -Value $itemCount
    $result|Add-Member -MemberType noteproperty -Name StringJoinTime -Value (($StringJoinTime|Measure-Object -Average).Average/10000)
    $result|Add-Member -MemberType noteproperty -Name StringBuilderTime -Value (($StringBuilderTime|Measure-Object -Average).Average/10000)
    $result|Add-Member -MemberType noteproperty -Name StringJoinTicksPerElement -Value (($StringJoinTime|Measure-Object -Average).Average/$itemCount)
    $result|Add-Member -MemberType noteproperty -Name StringBuilderTicksPerElement -Value (($StringBuilderTime|Measure-Object -Average).Average/$itemCount)
    $result 
}

@(100,300,500,800,1000,3000,5000,8000,10000,30000,50000,80000)|
    %{Measure-StringJoinCommand -itemCount $_}|
    Format-Table -Property `
        @{Label="Elements";Expression={$_.ElementCount.ToString("#,##0")};Width=8},
        @{Label="StringJoin(msec)";Expression={$_.StringJoinTime.ToString("#,##0")};Width=20},
        @{Label="StringBuilder(msec)";Expression={$_.StringBuilderTime.ToString("#,##0")};Width=20},
        @{Label="StringJoin(tick/element)";Expression={$_.StringJoinTicksPerElement.ToString("#,##0")};Width=30},
        @{Label="StringBuilder(tick/element)";Expression={$_.StringBuilderTicksPerElement.ToString("#,##0")};Width=30}

CPU=Intel(R) Core(TM)2 CPU 6600 @ 2.40GHz,memory=3GB,Windows 7 x86での実行結果は次の通り

Elements StringJoin(msec)     StringBuilder(msec)  StringJoin(tick/element)       StringBuilder(tick/element)   
-------- ----------------     -------------------  ------------------------       ---------------------------   
100      8                    6                    830                            646                           
300      22                   22                   719                            746                           
500      36                   34                   718                            689                           
800      60                   55                   755                            690                           
1,000    86                   72                   857                            721                           
3,000    264                  192                  880                            638                           
5,000    573                  334                  1,146                          667                           
8,000    1,534                522                  1,917                          653                           
10,000   2,562                731                  2,562                          731                           
30,000   23,386               2,204                7,795                          735                           
50,000   60,805               3,730                12,161                         746                           
80,000   184,407              6,541                23,051                         818   

この表は左から、連結する文字列要素数(ループ回数)、+=演算子を使った場合の所要時間(ミリ秒)、StringBuilderを使った場合の所要時間(ミリ秒)、+=演算子を使った場合の1要素あたりの所要時間(tick=100ナノ秒)、StringBuilderを使った場合の1要素あたりの所要時間(tick=100ナノ秒)、です。なお1要素当たりの平均文字数は9文字程度です。また、測定は3回おこない平均値を取っています。

この表によるとループ回数が5000回程度であれば所要時間にさほど差違は見られませんが、それ以降は急激に+=演算子の所要時間が増えることが分かります。また、+=演算子はループが5000回より増えるとループ回数が増えれば増えるほど1要素あたりにかかる時間も増えるのに対し、StringBuilderの場合はほぼ一定です。

というわけで1ループあたりに追加する文字数とループ回数が少ない場合は+=演算子でもそれほど問題にはなりませんが、そうでない場合はStringBuilderを使うのが良さそうです。これらの値が増加する可能性がある場合は、最初からStringBuilderを使っておけば、ある日突然処理がめちゃくちゃ重くなる、という事態も避けられるでしょう。PowerShellでStringBuilderを使っているサンプルがネットにはあまり見当たらなかったのですが、PowerShellでも積極的に使うと幸せになれると思います。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2010/09/20/193089.aspx

2010/03/06

ホワイトボードプログラミング
反転...ですかー
ループなしで、配列の順序を逆にする

よし、おれにまかせろ!

PS C:\Users\daisuke> $array=@(1,3,5,7,9,11)
PS C:\Users\daisuke> $array=$array[($array.length-1)..0]
PS C:\Users\daisuke> $array
11
9
7
5
3
1
元記事:http://blogs.wankuma.com/mutaguchi/archive/2010/03/06/186860.aspx

2007/06/10

たとえば、2階層下のフォルダまで(無限階層じゃないのがミソ)のファイル・フォルダのフルパスを取得するスクリプトを考えます。

dir|%{$_.Fullname;if ($_.PSIsContainer){dir $_|%{$_.Fullname}}}

こんな感じでしょうか。

実行例。-recurseパラメータをつけた場合は3階層目までさらいますが、上記のスクリプトは二階層目で止まります。

PS C:\s> dir -recurse |%{$_.fullname}
C:\s\t
C:\s\新規 「サクラ」用音楽テキスト.mml
C:\s\t\u
C:\s\t\新規 Microsoft Office PowerPoint プレゼンテーション.pptx
C:\s\t\u\v
C:\s\t\u\v\新しいビットマップ イメージ.bmp
PS C:\s> dir|%{$_.Fullname;if ($_.PSIsContainer){dir $_|%{$_.Fullname}}}
C:\s\t
C:\s\t\u
C:\s\t\新規 Microsoft Office PowerPoint プレゼンテーション.pptx
C:\s\新規 「サクラ」用音楽テキスト.mml
PS C:\s>

さて、このForEach-Objectコマンドレット(エイリアスは%)はネストされています。最初の$_.Fullnameは最初のdir(Get-ChildItemコマンドレット)が返すFileInfo/DirectoryInfoオブジェクトのFullnameプロパティを見ています。その次の$_.PSIsContainerもそうですね。$_がフォルダだったら次のdirを実行します。dir $_の$_も最初のオブジェクトです。さてここでふたたびForEachループを回します。ここで実行される$_.Fullnameの$_は、直前のdir $_が返したFileInfo/DirectoryInfoオブジェクトを参照しています。

このようにスクリプトブロックをネスト化した場合、$_にはもっとも内側のパイプラインを通ったオブジェクトが格納されます。

さて、では内側のスクリプトブロックから、外側のスクリプトブロックのパイプラインにわたったオブジェクトを参照するにはどうするのでしょう?この場合、たとえば最初から見て1階層下のフォルダC:\s\tのdirを内側のスクリプトブロックで回す際、外側のC:\s\tのフルパスも合わせて表示したいとき、内側から外側の$_を参照する必要がでてきます。このやり方は実は分かりません(汗 できるのかな〜?

一晩寝たら思いつきました。コロンブスの卵です。外側の$_をほかの変数に入れちゃえばOKですね。

PS C:\s> dir|%{$_.Fullname;if ($_.PSIsContainer){$outer=$_;dir $_|%{"[" + $outer
.Fullname + "]";$_.Fullname;}}}
元記事:http://blogs.wankuma.com/mutaguchi/archive/2007/06/10/80193.aspx

次のページへ

Copyright © 2005-2016 Daisuke Mutaguchi All rights reserved

mailto: mutaguchi at roy.hi-ho.ne.jp

Awards

Books

Twitter