2015/10/18

前回の続きです。今回は、前回作ったGet-JpWord関数を使って取得した、読み仮名を利用する話になります。

文章の読み仮名を表示する

単に指定文章の読み仮名を表示するという用途には、前回作製したGet-JpWord関数はそのままだと使いにくいので、さらにラップした関数を用意しました。

function Get-JpYomi
{
    param(
        [parameter(ValueFromPipeline=$true)]
        [PSObject[]]
        $InputObject,

        [string]
        $Separator = "",

        [ValidateSet("YomiOnly", "Furigana", "RubyTag")]
        [string]
        $Format = "YomiOnly"
    )
    process
    {
        foreach($o in $InputObject)
        {
            $phenemes = if($o -is [Windows.Globalization.JapanesePhoneme])
            {
                @($o)
            }
            else
            {
                $o.ToString()  | Get-JpWord -MonoRuby:$($Format -eq "RubyTag")
            }

            ($phenemes | foreach {
                if($Format -eq "YomiOnly")
                {
                    $_.YomiText
                }
                else
                {
                    if($_.DisplayText -match "\p{IsCJKUnifiedIdeographs}")
                    {
                        if($Format -eq "RubyTag")
                        {
                            "<ruby><rb>$($_.DisplayText)</rb><rt>$($_.YomiText)</rt></ruby>"
                        }
                        elseif($Format -eq "Furigana")
                        {
                            "$($_.DisplayText)($($_.YomiText))"
                        }
                    }
                    else
                    {
                        $_.DisplayText
                    }
                }
            }) -join $Separator
        }
    }
}

Get-JpYomi "読み仮名を表示したい文章をここに書きます。"

のようにすると、以下のような結果が表示されます。

よみがなをひょうじしたいぶんしょうをここにかきます。

また、Separatorパラメータを指定すると、読み仮名に区切り文字を挿入することもできます。例えば、

Get-JpYomi "読み仮名を表示したい文章をここに書きます。"  -Separator " "

とすると結果は、

よ み がな を ひょうじ し たい ぶんしょう を ここ に か き ます 。

のようになります。

単に読み仮名を知りたい時に便利かと思います。漢字の読み方を知りたい時にさくっと使うのもいいかも。(読み方が複数あっても1つしか表示されませんが)

文章に振り仮名を付けたい時には、以下のようにします。

Get-JpYomi "読み仮名を表示したい文章をここに書きます。" -Format Furigana

結果はこうなります。

読(よ)み仮名(がな)を表示(ひょうじ)したい文章(ぶんしょう)をここに書(か)きます。

ここでポイントとなるのは、漢字部分は振り仮名を付けるが、それ以外には必要ないので付けないという処理です。それには正規表現"\p{IsCJKUnifiedIdeographs}"を用いています。\p{}は正規表現の「名前付き文字クラス」と呼ばれるもので、IsCJKUnifiedIdeographsというのはUnicodeにおける漢字を表すブロック名になります。ちなみにIsHiraganaでひらがな、IsKatakanaでカタカナにマッチさせることもできます。

Formatパラメータは、デフォルトのYomiOnly(読み仮名のみ出力)、Furigana(入力に振り仮名を付けて出力)の他に、RubyTag(HTMLのrubyタグを生成して出力)というのを用意しています。これを使うと、

<ruby><rb>読</rb><rt>よ</rt></ruby>み<ruby><rb>仮</rb><rt>が</rt></ruby><ruby><rb>名</rb><rt>な</rt></ruby>を<ruby><rb>表</rb><rt>ひょう</rt></ruby><ruby><rb>示</rb><rt>じ</rt></ruby>したい<ruby><rb>文</rb><rt>ぶん</rt></ruby><ruby><rb>章</rb><rt>しょう</rt></ruby>をここに<ruby><rb>書</rb><rt>か</rt></ruby>きます。

という出力が得られます。実際にHTMLとして表示させると以下のようになります。

ひょうしたいぶんしょうをここにきます。

50音順ソート&グループ化

Get-JpYomi関数の応用例的なものになります。漢字の読みが分かるということは、50音順にソートしたり、頭文字でグループ化できるようになるということですね。やってみましょう。

ディレクトリ名を50音順でソートするには、

dir -Directory  | sort {$_.Name | Get-JpYomi}

のようにします。例えば私のミュージックフォルダ(アーティスト名ごとにフォルダが作られている)に対して実行すると以下のようになります。

image

うーん、アニソンばっかりだな…。それはともかく、ちゃんと50音順ソートされていることがお分かりいただけるかと思います。中には読み方間違ってるやつもありますが。

頭文字でグループ化して表示するというのもやってみましょう。

dir -Directory |
    select Name,
        @{Name = "NameYomi"; Expression = {$_.Name | Get-JpYomi}},
        @{Name = "FolderCount"; Expression = {@(dir $_.FullName -Directory).Length}} |
    group {$_.NameYomi.Substring(0,1)}|
    sort Name |
    foreach {
        Write-Host "【$($_.Name)】($($_.Count))"
        $_.Group|%{
            Write-Host "$($_.Name)($($_.NameYomi)) $($_.FolderCount)"
        }
        Write-Host
    }

まずファイル名、読み、サブフォルダの数(当方環境ではアルバム数に対応)をそれぞれプロパティとして抽出し、読みの頭文字(SubString(0,1)で取得)でグループ化し、頭文字グループを50音順ソートし、結果を表示しています。

結果はこんな感じです。

image

まあ頑張って読んでいる方だと思います。むらかわなしぎぬ…。

また長くなったので次回に続きます。次回は「分かち書き」の話。

2015/09/07

Twitterでこんな問題を出してみました。

以下、解答になります。

@ &{}
結果

何も出力されません。

解説

空のスクリプトブロック{}を実行演算子&で実行しています。空なので何も出力はありません。

A &{process}
結果

「'process' の後にステートメント ブロックがありません。」というパーサーのエラーになります。

解説

PowerShellのスクリプトブロックは、beginブロック、processブロック、endブロックを内包します。スクリプトブロック直下にparamブロック、DynamicParamブロック、beginブロック、processブロック、endブロック(他にもあったかも)以外のステートメントを記述すると、Endブロック内に記述されたものと暗黙的に解釈されます。

この場合、スクリプトブロック直下にprocess…と書き始めたので、パーサーはprocessブロックが開始されたと判断しますが、続くステートメントブロック{}(≠スクリプトブロック)の記述がないため、構文エラーとなります。

B &{process{}}
結果

何も出力されません。

解説

パーサーはAのように解釈しますが、今回はステートメントブロック{}がきちんと記述されているので、エラーなく解釈されます。

processブロックは、パイプライン入力がない場合でも1回実行されますが、この場合、中身は空なので、@と同様、何も出力はありません。

C &{process{process}}
結果

Get-Processコマンドレットが実行され、プロセス一覧が表示されます。

解説

・パーサーの挙動

Bまでの解説の通り、&{process{…}}とすると、…の部分が1回実行されます。今回はprocessブロック内に「process」と記述しているので、Aのようなパーサーエラーは発生せず、「process」がステートメントとして実行されます。

さて、PowerShellのステートメント(文)には「For」とか「If」とかと並列して、「パイプライン」が存在します。「パイプライン」には1つの「式」もしくは複数の「コマンド」が含まれます。

たとえば、「Get-ChildItem | Select-Object Name」というパイプラインには「Get-ChildItem」と「Select-Object Name」という2つのコマンドが含まれます。

(ちなみに、「式」とは「$x+1」とかの、値を返すもののことです。PowerShellではパイプラインの最初の要素にのみ、「コマンド」ではなく「式」を記述することができます。)

今回のお題では、「process」はprocessブロック下に記述されており、ForやIf等のステートメントではないのでパイプラインとして扱われます。このパイプラインには1つの要素のみ含まれていますが、式ではないので、コマンドとして解釈されます。

・コマンド探索の挙動

PowerShellの「コマンド」は、関数、コマンドレット、ワークフロー、Configuration、ファイル(実行ファイル、スクリプトファイルを含む)、&演算子で実行するスクリプトブロック等が挙げられます。

コマンドの探索は、まずコマンドへのエイリアスを探します。ない場合は、関数名orコマンドレット名を探します。それでもない場合は、実行ファイルやスクリプトファイルの拡張子(.exe、.ps1等)を付与してパスの通ったディレクトリを探します(ちなみにカレントディレクトリにあったとしても、相対パスor絶対パス表記でない場合は実行しません)。

さて、ここからが「本当は怖い」ところなんですが、ここまで探索してコマンドがなかった場合、与えられたコマンド名に"Get-"を付与してもう一度探索します。

今回のお題では、processという名前のコマンドを探して、もしパスが通ったフォルダにprocess.exeとかがあればそれが実行されますが、ない場合はGet-Processというコマンド名を探します。

もちろん、Get-Processというコマンドレットは標準で存在するので、それが実行されてしまう、というわけでした。

(ちなみにPowerShell 3.0以降なら、Get-付与で見つからない場合、さらにCmdlet Auto Discoveryにより未ロードのモジュールを探します。)

コマンド探索の詳細な挙動は、Trace-Command -Expression {コマンド}  -Name CommandDiscovery -PSHost とすると調べられるので、見てみるのもいいかもしれません。

D &{process{process{}}}
結果

「Get-Process : パラメーター 'Name' を評価できません。その引数がスクリプト ブロックとして指定され、入力が存在しないためです。スクリプト ブロックは、入力を使用せずに評価できません。」というParameterBindingExceptionが発生します。

解説

・パーサーの挙動

Get-Processが実行され(ようとす)る理由についてはCまでの理解でOKでしょう。

さて、Get-Processコマンドレットには-Nameという、プロセス名を指定する位置パラメータが存在します。位置パラメータは、パラメータ名を指定せずパラメータ値のみを指定しても、指定順にパラメータにバインドしてくれる機能を持ちます。

たとえば、Get-Process powershell とすると、「Get-Process -Name powershell」が実行されます。

今回のお題「process{}」は、パーサーによってまず、コマンド名「process」と、パラメータ値「{}」(空のスクリプトブロック)に分割されます。

(ちなみにコマンド名に「{}」を含めることができないわけではなく、そういうコマンドを実行したい場合は、`でエスケープするか、&"command{}name"のように&演算子を用いれば可能です。)

今回の場合、パラメータ名の指定はありませんが、位置パラメータ-Nameに空のスクリプトブロックがバインドされることになるわけです。

・コマンドパラメータバインドの挙動

さて、-Nameパラメータの型は、System.String[]であり、scriptblockではありません。もちろんscriptblockからSystem.String[]への暗黙の型変換はありません。でもエラーメッセージ的には、スクリプトブロックを与えたこと自体は咎めていないように思えますね。

実はこれ、スクリプトブロックパラメータと呼ばれてる機能です。詳しくはスクリプトブロックパラメータのススメを見ていただくとして、要はコマンドへのパイプライン入力を、指定のスクリプトブロックで処理し、その出力結果をパラメータ値としてバインドする機能ですね。

今回エラーになった理由は、スクリプトブロックパラメータとして解釈しようとしたが、そもそも入力がなかったから、ということになります。

あまり意味はないですが、以下のように入力を与えてやれば、スクリプトブロックパラメータとして動作します。

"powershell" | Get-Process -Name {$_}

この場合パイプライン入力が追加されるので、-Nameパラメータの指定位置がずれることになるので、パラメータ名が必要になります。また、スクリプトブロックが空だと、「パラメーター 'Name' を評価できません。その引数の入力によって出力が作成されなかったためです。」というエラーをご丁寧に出してくれます。Trace-CommandでParameterBindingソースをトレースしてみるのも一興でしょう。

ちなみにあまり関係ない余談ですが、-NameパラメータにはValueFromPipelineByPropertyName属性が付いているので、実は以下のような指定もできます。

[PSCustomObject]@{Name="PowerShell"} | Get-Process

まとめ

PowerShellパーサーと飲むとき、話の肴にどうですかね。

See also: 本当は怖いPowerShell その1

2014/05/02

また続くかどうか不明の新シリーズ。今日書いたワンライナーを記録していきます。

今回は、配列に含まれる要素のうち、もっとも出現頻度の多いものを調べる方法です。たとえば、0, 0, 0, 1, 0, 3, 0, 2, 0, 2という配列がある場合、「0」は6個含まれており最も要素数が多いので、この場合「0」を出力します。

ワンライナーは以下のようになります。(配列変数の宣言を合わせると2ラインですけど)

$array = 0, 0, 0, 1, 0, 3, 0, 2, 0, 2
$array | group | sort Count -Descending | select -First 1 | select -Expand Name 

今回のケースのように、「配列内に同一の要素が含まれており、要素ごとに纏める」という手順が必要なときはGroup-Objectコマンドレット(エイリアス:group)の出番です。

$array | group とすると、

Count Name                      Group
----- ----                      -----
    6 0                         {0, 0, 0, 0...}
    1 1                         {1}
    1 3                         {3}
    2 2                         {2, 2}

のような出力が得られます。Count=出現回数、Name=要素、Group=該当する要素のリスト です。なのでこの出力だけでお題の解答である「0」が確認できます。あとはこの出力から求める値を抽出するだけです。

Group-Objectコマンドレットの出力はGroupInfoというオブジェクトです。そう、これもオブジェクトなので、他のオブジェクト同様、プロパティ情報を保ったまま後続パイプラインに渡すことができます。

まずSort-Objectコマンドレット(エイリアス:sort)を用いてGroupInfoのCountプロパティの値で降順ソート(-Descendingパラメータ使用)をかけます。するとCountプロパティが一番大きいGroupInfoオブジェクトが一番最初の要素となります。(今回の場合はたまたまソート前後で最初の要素が同じでしたが)

次にSelect-Objectコマンドレット(エイリアス:select)に-First 1と指定することで一番最初のGroupInfoオブジェクトのみ取得します。最後に、GroupInfoオブジェクトのNameプロパティの値だけを取りだすのにSelect-Objectコマンドレットに -ExpandPropertyパラメータを指定します。

ところでGroupInfoオブジェクトのGroupプロパティ、どうせNameと同じものがCount分列挙されるだけじゃないか何の意味が?と思われるかも知れないので補足です。Group-Objectコマンドレットには-Propertyパラメータ(位置パラメータなのでパラメータ名は省略可能)が定義されており、指定すると任意のプロパティ値に基づいてグループ化してくれます。

たとえば

dir | group Extension

とすると、カレントディレクトリに含まれるファイルが拡張子別にグループ化され、以下のような出力が得られます。

Count Name                      Group
----- ----                      -----
    1 .gadget                   {twitterpost.gadget}
   59 .vbs                      {7zip_fix_archive.vbs, 7zip_store_each.vbs...}
   72 .ps1                      {cddrive.ps1, clipboard.ps1, cmdlets.ps1...}

このようにGroupプロパティの中身は、指定プロパティ値を持つ要素のグループとなっていることが分かると思います。

-Propertyパラメータには集計プロパティを指定することもできるので、

1..10 | group {if($_ % 2 -eq 0){"偶数"}else{"奇数"}}
Count Name                      Group
----- ----                      -----
    5 奇数                      {1, 3, 5, 7...}
    5 偶数                      {2, 4, 6, 8...}

みたいなこともできたりします。

ちなみにGroup情報が不要であるときは、Group-Objectコマンドレットに -NoElementパラメータを付与すると出力を抑制できます。(この場合、出力はGroupInfoオブジェクトではなく、GroupInfoNoElementオブジェクトとなる)

なんか後半はGroup-Objectコマンドレット特集みたくなってしまいました。ではまた次回。

2014/04/30

はじめに

IE6〜11まで、要するに現行のIEすべてを対象とするやばげなゼロデイ脆弱性がみつかり、パッチ公開までIE以外のブラウザを使いましょうという通達が出たり出なかったりしている昨今のようです。

その文脈で、IE以外のWebブラウザをダウンロードするのにIEを使うしかない!もう死ぬしか…みたいな(本気なのか冗談なのか判断が付きかねる)反応を散見します。

実際のところはWebブラウザを探してダウンロードする位はIE使えばいいと思いますが、IE使わないでいかにWebブラウザをダウンロードするか、を考えるのが、Twitter等で一種の大喜利のようになっています。

ftp.exeを使う、wgetなどのコマンドラインツールを使う、ペイントのファイルダイアログを使う(?)等々いろいろな案がありますが、皆なぜPowerShellを使わないんだ。ということで、書きます。

なお、WebブラウザのダウンロードURLはご自分でお調べください。こちらの方の記事が参考になるかと。

Invoke-WebRequestコマンドレットを使う

一番普通のやり方としては、Invoke-WebRequestコマンドレットを使う方法ですね。

Invoke-WebRequest https://アドレス/setup.exe -OutFile setup.exe

これを実行するとファイルがダウンロードされて、カレントディレクトリにsetup.exeというファイルが生成されます。フルパス指定でももちろんOKです。

なお、Invoke-WebRequestコマンドレットはHTMLのDOMパース時のみIEコンポーネントを用いるのですが、-OutFileパラメータ使用時はパースしないのでおそらくIEとは無関係で実行できると思います。(注:IEコンポーネントによるDOMパースを抑制するには、-UseBasicParsingパラメータを付加します)

Invoke-WebRequestコマンドレットはPowerShell 3.0から追加されたコマンドレットなので、3.0が同梱されているWindows 8、4.0が同梱されているWindows 8.1では特に何もせずに利用可能です。

ちなみにInvoke-WebRequestコマンドレットはデフォルトエイリアスとしてiwrが定義されているので、iwrでも呼び出せます。PowerShell 4.0ではそれに加えてwget、curlもエイリアス定義されていたりします。(このエイリアス定義は賛否両論ですけどね)

Windows 7の場合はPowerShellのデフォルトのバージョンは2.0なので、3.0以上を追加で入れる必要があります。Vistaは2.0までしか入らないので残念でした。

Start-BitsTransferコマンドレットを使う

Windows 7以上であればBITS(バックグラウンド インテリジェント転送サービス)の機能がPowerShellコマンドレットから利用できます。BITSはその名の通り、ネットワーク帯域の空き部分を有効活用してファイルを転送するかしこいサービスで、Windows Update等で用いられています。ファイル転送のプロトコルとしてはSMBとHTTP(S)をサポートしてるので、Webサイトからファイルをダウンロードするという用途で使うことができます。

ダウンロードを開始するには、Start-BitsTransferコマンドレットを使います。

Import-Module BitsTransfer
Start-BitsTransfer https://アドレス/setup.exe setup.exe

Windows 7標準のPowerShell 2.0だと、Cmdlet Auto Discoveryの機能が働かないため、上記のようにImport-Moduleコマンドレットによる明示的なモジュールロードが必要ですが、PowerShell 3.0以降では不要です。

BitsTransferモジュールには他にもコマンドレットがあり、非同期転送等できたりするので興味のある方はヘルプをみてください。こちらの記事も参考になるかと:PowerShell: ◆Bits転送2

WebClientオブジェクトを使う

今回の大喜利(?)でPowerShellを用いてファイルダウンロードする方法というのもいくつか見かけたのですが、WebClientを使う方法が殆んどだったように思います。まあ今回の記事を書いた動機としては、今はもうWebClient使わなくても標準のコマンドレットでできるよ!ということなんで敢えてここでは書きません。次のツイートを参照して下さい:Twitter / tanakh: IEを使わずにFirefoxをダウンロードする方法、Powe ...

おまけ:chocolateyを使う

あまりPowerShellとは関係ないのですが、そもそもWindowsにはapt-getみたいなパッケージ管理システムはないんかい、という意見をみかけたので紹介します。ChocolateyというNuGetベースのWindows用アプリケーションのパッケージ管理ツールとリポジトリです。

このツール自体のインストールはコマンドプロンプトに、サイトトップに書いてあるコマンドをコピーペーストして実行するだけです。(このコマンド内でPowerShellを呼び出しているので関係あるっちゃある…)

あとはコマンドプロンプトで cinst GoogleChrome とかしてやるとダウンロードとインストールをしてくれると思います。

ところでchocolateyは現在のところコマンドプロンプトベースであり外部ツールですが、次期バージョンのPowerShell 5.0ではOneGetと呼ばれるパッケージ管理システムが追加され、コマンドレットでchocolateyをはじめとする様々なリポジトリからファイルを取得してインストールできるようになる予定なのでご期待ください。

2014/04/26

小ネタですが。

まず、ダイナミックパラメータについてはぎたぱそ先生の記事を参照してください。要は、その名の通り動的に定義されるパラメータのことです。

ダイナミックパラメータは他の(静的な)パラメータの指定状態によってリアルタイムに定義されます。ValidateSet(値の候補リスト)だけではなく、パラメータの有無、パラメータ値の型やパラメータ名ですら変わり得ます。

一方、Get-Commandコマンドレットを使うとコマンドのパラメータや構文等の情報を取得する事ができます。しかし、ダイナミックパラメータは前述のような特性があるため、パラメータの有無、パラメータ名、パラメータ値の型が一意に定まりません。

この問題を解決するため、Get-Commandコマンドレットには-ArgumentListパラメータが用意されています。指定コマンドに与えるパラメータ値を-ArgumentListパラメータに指定すると、指定コマンド実行時にそのパラメータ値を指定した場合に定義されるダイナミックパラメータに関する情報が、出力結果に含まれるようになります。

例を挙げましょう。Get-Contentコマンドレットの-Pathパラメータは「位置パラメータ」、つまりパラメータ名を省略できるパラメータであり、パラメータ名なしで指定された一つめの値がバインドされます。つまり、 Get-Content C:\ とするとC:\(FileSystemプロバイダのパス)が-Pathパラメータにバインドされるわけです。(Get-Content -Path C:\ と同じ意味となる)

ところでGet-Contentコマンドレットは、FileSystemプロバイダでのみ有効となる-Encodingというダイナミックパラメータを持っています。「FileSystemプロバイダでのみ」というのはつまり、「カレントディレクトリがFileSystemプロバイダのパスであるか、-PathパラメータにFileSystemプロバイダのパスが指定されたとき」ということになります。

すなわち、 カレントディレクトリがC:\であったり、Get-Content C:\ と入力した瞬間、-Encodingダイナミックパラメータが定義されて利用できるようになります。

ではGet-CommandコマンドレットでGet-Contentコマンドレットの-Encodingダイナミックパラメータの情報を得るにはどうすればよいか。答えは、このダイナミックパラメータが定義されるトリガーとなるパラメータ値である「C:\」を-ArgumentListパラメータに指定してやればいいわけです。つまり 例えば

Get-Command -Name Get-Content -ArgumentList C:\ -Syntax

としてやると、Get-Contentコマンドレットの構文が

Get-Content [-Path] <string[]> [-ReadCount <long>] [-TotalCount <long>] [-Tail <int>] [-Filter <string>] [-Include <string[]>] [-Exclude <string[]>] [-Force] [-Credential <pscredential>] [-UseTransaction] [-Delimiter <string>] [-Wait] [-Raw] [-Encoding <FileSystemCmdletProviderEncoding>] [-Stream <string>] [<CommonParameters>]

のように表示され(※注:一部抜粋)、Get-Content C:\を実行するときに定義される-Encodingダイナミックパラメータも含まれていることがわかります。仮にHKLM:\等のFileSystemプロバイダ以外のパスを-ArgumentListに指定すると、

Get-Content [-Path] <string[]>[-ReadCount <long>] [-TotalCount <long>] [-Tail <int>] [-Filter <string>] [-Include <string[]>] [-Exclude <string[]>] [-Force] [-Credential <pscredential>] [-UseTransaction] [<commonparameters>]

のように表示され、ダイナミックパラメータが表示されないことが分かるかと思います。

さて、-ArgumentListは配列指定もでき、複数パラメータ値を指定した時の構文を調べることもできます。しかし位置パラメータ以外の場合、つまりダイナミックパラメータ定義のトリガーとなるパラメータにパラメータ名を指定する必要があるコマンドの場合はどうやら無理のようです。

以下はおまけです。ダイナミックパラメータのリストを表示します。-ArgumentListは指定してないので、カレントディレクトリのプロバイダの種類でのみ結果が変わります。

$cmds = Get-Command -CommandType Cmdlet,Function
foreach($cmd in $cmds)
{
    $params = $cmd.Parameters
    $dynamicParamNames = @()
    if($null -ne $params)
    {
        $dynamicParamNames = @($params.Values|?{$_.IsDynamic}|%{$_.Name})
    }
    
    if($dynamicParamNames.Length -ne 0)
    {
        foreach($dynamicParamName in $dynamicParamNames)
        {
            [pscustomobject]@{
                Cmdlet=$cmd
                Module=$cmd.ModuleName
                Parameter=$dynamicParamName
            }
        }
    }
}

まあ本題とあんまり関係なくなってしまったんですが、せっかく作ったのでということで。こういうのを見ると、PowerShellはコマンドもオブジェクトなんだ、ということが分かると思います。

2013/12/05

はじめに

この記事はPowerShell Advent Calendar 2013の5日目の記事です。

突然ですが、PowerShellにはJobが完了するまで待機するWait-Jobコマンドレットというのがあります。これはその名の通り、パイプラインから入力したJobオブジェクトがすべて(あるいはどれか一つが)完了状態になるまでスクリプトの実行を待機する効果があります。

当然ながらWait-JobはJobオブジェクトにしか利用できませんが、任意の入力オブジェクトに対して待機条件を指定してやれば、その条件を満たすまで実行を停止するコマンドがあると便利なんじゃないかな?と常々思っていたので書いてみました。

Wait-State関数
function Wait-State
{
    [CmdletBinding(DefaultParameterSetName="ByProperty")]
    param(
        [Parameter(ValueFromPipeline=$true)]
        [PSObject]$InputObject,
        [Parameter(Position=1,Mandatory=$true,ParameterSetName="ByProperty")]
        [string]$Property,
        [Parameter(Position=2,ParameterSetName="ByProperty")]
        [object]$Value,
        [Parameter(Position=1,Mandatory=$true,ParameterSetName="ScriptBlock")]
        [Alias("Script")]
        [ScriptBlock]$FilterScript,
        [Parameter()]
        [switch]
        $Any,
        [Parameter()]
        [switch]
        $IgnoreImmutable,
        [Parameter()]
        [switch]
        $PassThru,
        [Parameter()]
        [switch]
        $AllOutput,
        [Parameter()]
        [int]
        $IntervalSec=1,
        [Parameter()]
        [int]
        $TimeoutSec=60
    )

    begin
    {
        $objects = @()
        $watch = New-Object System.Diagnostics.StopWatch
        $watch.Start()
        $firstChecked = $false
    }

    process
    {
        foreach($o in $InputObject)
        {
            $objects += $o
        }
    }

    end
    {
        while($true)
        {
            $remains = @()
            foreach($o in $objects)
            {
                if($firstChecked)
                {
                    if($o.Refresh)
                    {
                        $o.Refresh()
                    }
                }

                if($null -ne $FilterScript)
                {
                    if($o|&{process{&$FilterScript}})
                    {
                        if($PassThru)
                        {
                            if((!$IgnoreImmutable -or ($IgnoreImmutable -and $firstChecked)))
                            {
                                $o
                            }
                        }
                    }
                    else
                    {
                        $remains += $o
                    }
                }
                else
                {
                    if($Value -eq $o.$Property  -and (!$IgnoreImmutable -or ($IgnoreImmutable -and $firstChecked)))
                    {
                        if($PassThru)
                        {
                            if((!$IgnoreImmutable -or ($IgnoreImmutable -and $firstChecked)))
                            {
                                $o
                            }
                        }
                    }
                    else
                    {
                        $remains += $o
                    }
                }
            }

            if($remains.Length -eq 0)
            {
                break
            }
            elseif($Any -and $remains.Length -lt $objects.Length)
            {
                if($AllOutput -and $PassThru)
                {
                    $remains
                }
                break
            }
            elseif($watch.Elapsed.TotalSeconds -ge $TimeoutSec)
            {
                if($AllOutput -and $PassThru)
                {
                    $remains
                }
                break
            }
            
            $objects = @($remains)
            $remains = @()
            
            $firstChecked = $true

            Start-Sleep -Seconds $IntervalSec
        }
    }
}
コマンド構文
Wait-State [-Property] <string> [[-Value] <Object>] [-InputObject <psobject>] [-Any] [-IgnoreImmutable] [-PassThru] [-AllOutput] [-IntervalSec <int>] [-TimeoutSec <int>]  [<CommonParameters>]

Wait-State [-FilterScript] <scriptblock> [-InputObject <psobject>] [-Any] [-IgnoreImmutable] [-PassThru] [-AllOutput] [-IntervalSec <int>] [-TimeoutSec <int>]  [<CommonParameters>]
パラメータ

-InputObject:入力オブジェクト。パイプライン入力可。
-Property:変更を確認するプロパティ名。
-Value:-Propertyで指定のプロパティ値が、このパラメータに指定する値になるまで待機する。
-FilterScript:プロパティを指定する代わりに待機条件をスクリプトブロックで指定する。
-Any:入力のどれか一つが条件を満たすまで待機するようにする。(省略時は入力が全部条件を満たすまで待機)
-PassThru:入力オブジェクトが待機条件を満たした時点で、そのオブジェクトを出力する。省略時は出力なし。
-IgnoreImmutable:最初から条件を満たしている場合は出力しない。-PassThruと併用。
-AllOutput:タイムアウトした場合や-Any指定時に一部のオブジェクトしか出力していない場合でも、最終的に未出力のすべてのオブジェクトを出力してから終了する。-PassThruと併用。
-IntervalSec:プロパティ値のチェック、もしくは待機条件スクリプトの実行の間隔秒数を指定。デフォルト1秒。
-TimeoutSec:最大待機秒数。デフォルト60秒。この時間を過ぎると条件を満たしていなくても待機を終了する。

使用例
# 停止しているサービスがすべて開始するまで待機する。
Get-Service |? Status -eq Stopped | Wait-State -Property Status -Value Running

# 上記と同じだが、開始したサービスを逐次表示する。
Get-Service |? Status -eq Stopped | Wait-State -Property Status -Value Running -PassThru

# 停止しているサービスが少なくとも1つ開始するまで待機する。
Get-Service |? Status -eq Stopped | Wait-State -Property Status -Value Running -Any

# プロセスのワーキングセットが100MBを超えた段階で逐次表示する。
Get-Process | Wait-State {$_.WorkingSet -ge 100MB} -PassThru

# 上記と同じだが、最初から100MBを超えてるものは出力しない。
Get-Process | Wait-State {$_.WorkingSet -ge 100MB} -PassThru -IgnoreImmutable

# ディレクトリ内のファイル容量がすべて50KBを超えるまで待機し、出力のFileInfo配列を変数に代入。
$files = Get-ChildItem | Wait-State {$_.Length -ge 50KB} -PassThru -AllOutput -TimeoutSec 3600
問題点

プロパティ値を取得するときにリアルタイムに値が反映されないオブジェクト(要するにGetした時点のプロパティ値がずっと固定されてるもの)に対しては正しく動作しません。というか、PowerShellで扱うオブジェクトはほとんどそうなんじゃないかと思います(汗

ServiceControllerオブジェクト、Processオブジェクト、FileInfoオブジェクト、DirectoryInfoオブジェクトについては、Refreshメソッドを実行すると、プロパティ値を現在の値に更新してくれるので、それを利用してプロパティ値を監視できるようにはしています。

それ以外についても監視できるようにするには、たぶんそれぞれのオブジェクトに応じた監視方法を地道に調査して実装していくしかないんじゃないかなあと思います。

INotifyPropertyChangedインターフェースを実装したクラスについては、PropertyChangedイベントをSubscribeしてプロパティ値の変更を追跡できるようにしてみようとちょっと思ったんですが、PowerShellで扱うオブジェクトにINotifyPropertyChangedを実装したクラスのものってそんなにあるんだろうか?と疑問を覚えたのでやめました。

WMIオブジェクトについては何か共通の方法でプロパティ値変更を監視できないかなあと思ったんですが、結局IntervalSec間隔でクエリを発行する方法になってしまい、低コストで行う方法がちょっと思いつきませんでした。

ただ、-FilterScriptパラメータをサポートしているので、ここに書くことでいかようにも待機条件をカスタマイズできるので、極端な話、条件スクリプトブロックに{(Get-Hoge -Name $_.Name).Property -eq “ほげ”}みたいなコードを書いてゴリ押しすることもできるかと思います。

感想

というわけで、なんだか微妙な成果になって恐縮ですが、なんで無いんだろうと思っていた関数を実際に書いてみると、無い理由が分かったりするものなんだなあ、と思ったりした次第です。

スクリプトの解説を何もしてないですが、あえて解説する程のものでもないこともないですが、まあ長くなるのでやめときます。

ただ、入力オブジェクトを一旦全部取得してから、後続パイプラインに流し込む例としていくらか参考になるかもしれません。(beginで入れ物を用意して、processで詰めて、endでメインの処理を書くだけですけど)

あとはフィルタースクリプトブロックの実装方法の一例としても参考になるかも? スクリプトブロックを二重にして$_に対象オブジェクトがきちんと格納されるようにする方法、若干トリッキーな気もしますが正式にはどう書くのが良いのか不明なのでこうしてみました。

2013/03/29

はじめに

Twitterブログ: 日本の皆さんにも「全ツイート履歴」が使えるようになりました の記事のとおり、自分の全ツイートデータをダウンロードする機能がTwitterで利用可能になっています。

ダウンロードされるzipファイルには、ツイートを表示するためのHTML、JavaScriptファイルのほか、CSV形式のデータ(tweets.csv)も含まれています。CSVファイルの処理といえばPowerShellが得意とするところです。このファイルを読み込んで、PowerShellで自分のツイートを分析してみましょう。

準備

具体的にダウンロードする方法は上記記事を参考にしていただいて、まずはダウンロードしたzipファイルからtweets.csvを解凍し、PowerShellのカレントディレクトリをtweets.csvのあるフォルダに移動させておいてください。

毎回CSVを読み込むと時間がかかるので、まず以下のようにしてImport-CsvコマンドレットによりCSVファイルを読み込み、変数にオブジェクトとして入れておきます。

$tweets = Import-Csv tweets.csv

なお私の総ツイート数は4万ほどで、tweets.csvは10MB程です。これくらいの容量だとそのままでもまずまずまともな速度で分析が可能ですが、何十万ツイートもしていらっしゃるTwitter廃人マニアの方は、適宜ファイルを分割するなどして対処願います。

CSVファイルのヘッダ行は

"tweet_id","in_reply_to_status_id","in_reply_to_user_id","retweeted_status_id","retweeted_status_user_id","timestamp","source","text","expanded_urls"

となっています。Import-Csvコマンドレットはデフォルトでは1行目を出力オブジェクトのプロパティ名とするので、データ行の1行がtweet_idプロパティ等を持つオブジェクトとして読み込まれ、$tweets変数にはそのオブジェクトの配列が格納されることになります。

ツイート抽出/検索
一番最初のツイートを表示
PS> $tweets | select -Last 1

tweet_id                 : 948090786
in_reply_to_status_id    : 
in_reply_to_user_id      : 
retweeted_status_id      : 
retweeted_status_user_id : 
timestamp                : 2008-10-06 10:54:10 +0000
source                   : web
text                     : はぐれメタルがあらわれた!
expanded_urls            : 

Select-Objectコマンドレット(エイリアスselect)はオブジェクトの絞り込みに使います。このCSVファイルではツイートの並び順がタイムスタンプの降順なので、最初のツイートは一番最後の行となります。

直近5ツイート表示
PS> $tweets | select -First 5 | fl timestamp,text

timestamp : 2013-03-21 17:02:23 +0000
text      : Need for Speedがなんか懐かしい。初めて買ったPCに体験版がバンドルさ
            れてた記憶がある。

timestamp : 2013-03-21 17:01:23 +0000
text      : そいえばEAのシムシティ不具合お詫び無料DL特典、何選ぼうかなあ。シム
            シティ4あるけど英語版という噂だし2013やった後につらいもんがありそう
            。

timestamp : 2013-03-21 16:45:09 +0000
text      : というわけでシムシティ大好きなんで、私の街を返してください…

...

Format-Listコマンドレット(エイリアスfl)を使うと必要なプロパティ値のみ抽出してリスト形式で表示できます。

文字列で検索
PS> $tweets | where {$_.text -match "眠い"} | fl timestamp,text

timestamp : 2013-03-05 10:46:39 +0000
text      : 眠いのってもしかしてアレルギールの副作用かも。蕁麻疹がひどいときし
            か飲んでないんだけどねえ

timestamp : 2013-03-05 05:42:18 +0000
text      : なんでこんなに眠いのかな

timestamp : 2013-03-04 07:44:18 +0000
text      : 眠いなあ

...

Where-Objectコマンドレット(エイリアスwhere)を使うとオブジェクト配列のうち特定条件のもののみ抽出できます。ここではツイート本文(textプロパティ)に"眠い"という文字列が含まれているものを抽出しています。どんだけ眠いんですか私は…

2009年のツイートのみ表示
PS> $tweets | select @{L = "timestamp"; E = {Get-Date $_.timestamp}},text | 
    where {$_.timestamp.Year -eq 2009} | sort timestamp |
    fl timestamp,text

timestamp : 2009/01/01 0:01:08
text      : あけおめ!

timestamp : 2009/01/01 0:16:31
text      : 2chとついったー強いなーmixiしんでた

timestamp : 2009/01/01 13:37:50
text      : 家族でおせちをたべた。おいしかった

...

もちろん本文に含まれる文字列以外にも、timestamp(ツイート時刻)で抽出するなどもできます。ここではtimestampがGMTで分かりづらく、かつ文字列のため扱いづらいので、Select-Objectに集計プロパティを指定してDateTime型に変換しています。Format-ListやSelect-Objectに指定する集計プロパティの書式は、@{L="ラベル";E={値を返すスクリプトブロック}}のように連想配列で指定します。LはLabel、EはExpressionのように省略せずに指定してもOKです。

集計プロパティはあんまり解説を見かけないですけども、オブジェクトを処理するコマンドレットの多くで利用可能できわめて重要なので覚えておくと良いと思います。

よるほ成功ツイート
PS> $tweets | where {(Get-Date  $_.timestamp).ToString("HH:mm:ss") -eq "00:00:00"} | 
    fl @{L = "timestamp"; E = {Get-Date $_.timestamp}},text

応用でこんなんもできます。0:00:00ちょうどのツイートを抽出します。私はかつてよるほ成功したことがないので結果は何も返ってきませんけど。

ツイート中のURLリストを作る
PS> $tweets | where {$_.expanded_urls} | select -expand expanded_urls
http://ja.wikipedia.org/wiki/%E5%B2%A1%E7%B4%A0%E4%B8%96
http://htn.to/4oxXDN
http://guitarrapc.wordpress.com
...

whereによる抽出を応用するとこういうこともできます。なお、expanded_urls列は本文中のURLが複数含まれているとそれらは,で区切られるため、可変長の行となります。Import-Csvコマンドレットはこのような可変長なCSVに対応していないので、複数URLがあっても最初の1つのみ取得します。それとexpanded_urlsが追加されたのはt.coによるURL短縮が始まってからなので、昔のツイートにこの値は含まれていません。

ツイート数統計
月別ツイート数表示
PS> $tweets | group @{E = {(Get-Date $_.timestamp).ToString("yyyy/MM")}} -NoElement

Count Name
----- ----
  432 2013/03
  413 2013/02
  248 2013/01
  741 2012/12
  497 2012/11
  791 2012/10
  659 2012/09
...

ツイート分析と言えばやはりツイート数統計を取ることから始まるでしょう。統計を取るにはGroup-Objectコマンドレット(エイリアスgroup)が使えます。ここでもグループ化キーとして集計プロパティを指定してやります。ツイートの「年/月」を文字列化し、それが同じツイートでグループ化することで、月別ツイート数の統計が表示できるわけです。

時間帯別ツイート数表示
PS> $tweets | group @{E = {Get-Date $_.timestamp | 
    select -expand Hour}} -NoElement |
    sort @{E = {[int]$_.Name}}

Count Name
----- ----
 2369 0
 1630 1
 1137 2
 ...
 2270 23

やり方としては先ほどのとほぼ同じです。Select-Object -ExpandPropertyはパイプライン入力でオブジェクトのプロパティ値を取得できるのでよく使います。ちなみにPowerShell 3.0だと「$obj|foreach プロパティ名」でも取れますね。

Sort-Objectコマンドレット(エイリアスsort)でもソートキーとして集計プロパティを指定できます。ここではNameプロパティ(グループ化キーの値)をintに変換したものをキーにソートしています。

曜日別ツイート数表示
PS> $tweets | group @{E = {Get-Date $_.timestamp | 
    select -expand DayOfWeek}} -NoElement |
    sort @{E = {[DayOfWeek]$_.Name}}

Count Name
----- ----
 4939 Sunday
 5164 Monday
 5463 Tuesday
 5164 Wednesday
 5563 Thursday
 5992 Friday
 6331 Saturday

これもやり方としてはほぼ同じ。ソートキーはDayOfWeek列挙体にキャストしてちゃんと曜日順に並ぶようにしてます。

ツイート数計測
総ツイート数
PS> $tweets | measure

Count    : 38616
Average  :
Sum      :
Maximum  :
Minimum  :
Property :

ここからはツイート数の計測をしていきます。単純にツイート総数を取るだけならMeasure-Objectコマンドレット(エイリアスmeasure)を使うだけでOKです。Averageなどは対応するスイッチパラメータ(-Averageなど)を指定すると計測されますが、この場合は元オブジェクトが数値ではないのでエラーになります。

ツイート文字数分析
PS> Add-Type -AssemblyName System.Web
PS> $tweets | where {!$_.retweeted_status_id} | 
    select @{L = "TextLength"; E = { 
        [System.Web.HttpUtility]::HtmlDecode($_.text).Length}} | 
    measure -Sum -Maximum -Minimum -Average -Property TextLength

Count    : 37718
Average  : 48.7322233416406
Sum      : 1838082
Maximum  : 140
Minimum  : 1
Property : TextLength

ツイート文字数を計測するとき、元のオブジェクトにはツイート文字数を返すプロパティはないので、Select-ObjectコマンドレットにTextLengthという集計プロパティを指定して新たに作ってしまいます。

Measure-Objectコマンドレットは-Propertyパラメータにより対象オブジェクトのどのプロパティ値を計測するか指定できます。そしてスイッチパラメータを全部有効にすることで、平均、合計、最大、最小値を計測しています。私の総ツイート文字数は183万です。

なお、リツイートの場合はretweeted_status_idにリツイート元のツイートIDが入るので、このIDがあるものはWhere-Objectで除外してます。またツイート本文の<や&などはHTMLエンコードされたものがtext列に格納されているので、HttpUtilityを使ってデコードしてから文字数をカウントしています。

通常ツイートとRTの比率
PS> $tweets | foreach {
  $TweetCount = 0;
  $RTCount = 0
} {
    if($_.retweeted_status_id){
        $RTCount++
    }else{
        $TweetCount++
    }
} {
    New-Object psobject @{
        AllCount = $tweets.Length;
        TweetCount = $TweetCount;
        RTCount = $RTCount;
        RTRatio = $RTCount/$tweets.Length
    }
}


Name                           Value
----                           -----
RTCount                        898
TweetCount                     37718
AllCount                       38616
RTRatio                        0.023254609488295

Measure-Objectコマンドレットは計測方法を指定することはできないので、独自の計測を行う場合はこんな感じでコードめいたものを書く必要が出てくるかと思います。RT率たったの2%か…ゴミめ…

ForEach-Object(エイリアスforeach)は1個のスクリプトブロックをパラメータに指定するとprocessブロック相当の列挙部分を実行しますが、このように3個指定すると、それぞれbegin(初期化処理)、process、end(終了処理)ブロックに割り振られます。

ここではbeginブロックで変数初期化、processブロックで通常ツイートとリツイートを加算、endブロックで計測値をPSObjectに格納して出力してます。ちなみにPowerShell 3.0ではカスタムオブジェクトを作る場合は「[pscustomobject]@{連想配列}」で書くほうが楽です。

お前は今まで寒いと言った回数を覚えているのか
PS> $tweets | foreach {$count = 0} {
    $count += ($_.text -split "寒い").Length - 1} {$count}
137

覚えてないから数えます。137回か。

数値だけを出力するならこんな感じでシンプルに書けますね。

ランキング
クライアントランキング
PS> $tweets | group @{E = {$_.source -replace "<.+?>"}} -NoElement | 
    sort Count -Descending

Count Name
----- ----
12927 Janetter
11333 web
 5230 Azurea for Windows
 3060 TweetDeck
 1694 Hatena
  866 twicca
  667 twigadge
...

ここからはいろんなランキングを取得してみます。まずはツイートに使ったTwitterクライアントのランキング。ここでもGroup-Objectを使っています。クライアント名はクライアント配布URLがaタグで含まれているのでそれを-replace演算子で削ったものをグループ化キーとしています。ランキングなので最後はCountで降順ソート。

リプライしたユーザーランキング
PS> $tweets | where {$_.in_reply_to_user_id} |
    select @{L = "user"; E = {if($_.text -match "^(@[a-zA-Z0-9_]+)"){$matches[1]}}} |
    group user -NoElement | sort Count -Descending

Count Name
----- ----
  807 @xxxxxxxxxx
  417 @xxxxxxxxxx
  333 @xxxxxxxxxx
...

ランキング系はどれもgroup→sort Countのパターンになるかと思います。リプライツイートはin_reply_to_user_id列にリプライしたユーザーIDが含まれるのでまずはそれでフィルタし、ユーザー名はツイート本文から取ります。ユーザー名は-match演算子を使って正規表現で抽出します。$matches自動変数は連想配列で、[0]にマッチ全体が、[1],[2],...にはサブ式のキャプチャが入ります。ちなみにサブ式に名前を付けてるとキー名が数値ではなくサブ式名となります。

ハッシュタグランキング
PS> $tweets | foreach {[regex]::Matches($_.text, "(#\S+)") | 
    % {$_.Captures} |% {$_.Value}} | 
    group -NoElement | where Count -gt 1 | sort Count -Descending

Count Name
----- ----
  199 #zanmai
   75 #nowplaying
   68 #nhk
   63 #techedj2009
...

ハッシュタグも同様のアプローチで取れますが、ハッシュタグは1ツイートに複数あることがあり、-match演算子だと複数のマッチは取れないので[regex]を使って取得しています。

おわりに

PowerShellのオブジェクト処理用コマンドレットを用いると、CSVデータの分析ができます。普通はログファイル等を解析するのに使うわけですが、こういう身近なデータを扱ってみるのも面白いんじゃないでしょうか。きっとPowerShellの勉強にもなると思います。

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さんの番です。よろしくお願いします。

2011/12/02

はじめに

このたび、技術系アドベントカレンダーイベントの1つとして、PowerShell Advent Calendar 2011を企画しました。この記事はその2日目の記事となります。アドベントカレンダーについてはリンク先を参照してください。

今日のテーマはPowerShellのバックグラウンドジョブ機能の使い方についてのまとめです。

バックグラウンドジョブとは

バックグラウンドジョブ機能はその名の通り、ジョブ(具体的にはスクリプト)をバックグラウンドで非同期に実行するものです。PowerShell v2で追加された機能の一つです。インタラクティブシェルでStart-Jobコマンドレットを使用してバックグラウンドジョブ(以下、単に「ジョブ」と表記)を実行すると、新しくpowershell.exeのプロセスが起動しそのままシェルに制御が戻りユーザーは後続の処理を行うことができます。もちろんスクリプトからジョブを実行することも可能です。時間のかかる処理をバックグラウンドで走らせたり、数多くの処理を並列で実行したりするのに重宝します。

起動されたジョブは操作中のpowershell.exeとは別のジョブ用のプロセスで実行され、処理が完了すると呼び出し元でその結果をReceive-Jobコマンドレットを使って受け取ることができます。ジョブは並列して何個も同時に実行できます。なおPowerShellのジョブは1ジョブ=1プロセスです。スレッドではないので注意。

PowerShellのジョブシステムはリモート処理インフラストラクチャの上に構築されているので、たとえローカルPCでもジョブ実行するにはローカルPCをリモート用構成にしておく必要があります。詳しくはabout_Remote_Requirementsを参照のこと。

ジョブはローカルでもリモートでも走らせることができます。以下に具体的な方法を述べていきます。

ローカルコンピュータでのジョブ実行

ローカルコンピュータ上に新しくジョブを作成して開始するにはStart-Jobコマンドレットを用います。

Start-Job {ジョブとして実行したいコマンド、スクリプト}

とするとジョブを実行します。

$job=Start-Job {..}

のようにするとJobオブジェクト(System.Management.Automation.PSRemotingJob)を変数に格納してあとで利用できます。変数で受けない場合はJobオブジェクトの内容が表示されます。

存在するジョブを取得するにはGet-Jobコマンドレットを用います。

Get-Job

で現在実行中のジョブ一覧を表示します。以下に出力例を示します。

Id              Name            State      HasMoreData     Location             Command
--              ----            -----      -----------     --------             -------
1               Job1            Completed  True            localhost            "test"
3               Job3            Running    True            localhost            start-sleep -sec 120;"...

以下の表は各項目の意味です。

Id ジョブID番号
Name ジョブの名前
State

Running=実行中のジョブ

Stopped=停止したジョブ

Complete=完了したジョブ

Failed=エラーが出たジョブ

HasMoreData 返却されたデータがあるかどうか
Location ジョブが実行されているコンピュータ名
Command ジョブで実行されているコマンド、スクリプト

ジョブの終了を待つにはWait-Jobコマンドレットを用います。

Get-Job|Wait-Job

とすると実行中のジョブすべてが完了するまで待ちます。-timeoutパラメータを使うと最大待ち時間(秒)を指定できます。

Get-Job|Wait-Job -any

とすると実行中のいずれかのジョブが完了するまで待ちます。正確には「対象のジョブが一つ以上完了するまで待つ」という効果なので、完了済みのジョブが1つ以上ある場合に新たにジョブを追加した場合などは想定の動作になりません。あらかじめRemove-Jobで完了済みのジョブを削除するか、Where-ObjectコマンドレットでRunningのみ対象にするようフィルタをかけるかしてください。

ジョブを中止するにはStop-Jobコマンドレットを用います。

Get-Job -id 1|Stop-Job

とするとジョブIDが1のジョブを中止します。

$jobにJobオブジェクトが格納されている場合は

$job|Stop-Job

でもOKです。

ジョブを削除するにはRemove-Jobコマンドレットを用います。

Get-Job|where {$_.state -eq "Completed" -or $_.state -eq "Stopped"}|Remove-Job

とすると完了済みと中止したジョブを削除します。実行中のジョブは削除できませんが-forceパラメータを使って強制削除することは可能です。

ジョブの実行結果データを取得するにはReceive-Jobコマンドレットを用います。

Get-Job|Receive-Job

とすると完了済みのジョブのうち、結果を返却しているもの(HasMoreDataがTrueのジョブ)があればその結果を表示します。-keepパラメータをつければ結果データを保持しますが付けてない場合は参照後破棄します。

*-Job系のコマンドレットの多くはJobオブジェクトを返却するので、パイプラインでどんどん繋げていけます。

Get-Job|Wait-Job -timeout 10|Receive-Job

のように。

ジョブの基本的な使い方に関して詳しくはabout_jobsを参照してください。

イベントサブスクライブ

PowerShell 2.0では.NET Frameworkのオブジェクトのイベントをサブスクライブすることができます。すなわちイベントハンドラを記述することができます。このイベントサブスクライブ機能もジョブ機能を元に構築されています。

たとえばTimerオブジェクトのElapsedイベントをサブスクライブし、タイマーの実行間隔(ここでは1秒)ごとにtest.txtファイルに乱数を追記していくサンプルは次のようになります。

$timer=new-object System.Timers.Timer
$timer.Interval=1000
Register-ObjectEvent -EventName Elapsed -SourceIdentifier test -Action {get-random|add-content c:\users\daisuke\test.txt} -InputObject $timer
$timer.Enabled=$true

Register-ObjectEventの結果、新しくジョブが生成しそのJobオブジェクトが返却されます。このジョブは-EventNameパラメータで指定したイベントが発生するたび、-Actionパラメータで指定したスクリプトブロックを実行します。

なお、イベントサブスクライブを解除するには

Unregister-Event test

のように-SourceIdentifierパラメータで指定した値を指定してUnregister-Eventコマンドレットを実行することで可能です。サブスクライブを解除してもジョブ自体は削除されない(StateがStoppedになるだけ)ので、必要であればRemove-Jobで削除します。

なお.NETオブジェクトの他にPowerShellスクリプトのカスタムイベント(Register-EngineEvent)、WMIオブジェクトのイベント(Register-WmiEvent)をサブスクライブすることもできます。これらのコマンドレットも同様にイベント発生時の処理をジョブとして登録します。詳しくは各コマンドレットのヘルプを参照してください。

リモートコンピュータでのジョブ実行

最初に述べたとおりPowerShellのジョブ機能はリモートインフラストラクチャの上に構築されています。よってローカルのみならずリモートコンピュータに対してジョブを実行することができます。もちろんリモートコンピュータにもリモート構成されていることが条件です。

基本はInvoke-Commandコマンドレットを用い、

$job=Invoke-Command -ComputerName リモートコンピュータ名 {リモートで実行するコマンド、スクリプト} -asjob

となります。これで{}内の処理がリモートコンピュータ上のPowerShellインスタンスで実行されます。-asJobパラメータをつけることでジョブとして(ローカルPCから見て)非同期に処理できますが、-asJobパラメータを省略すると同期的に実行されます。この場合ジョブは作成されず、リモートでの処理が終了するまでローカル側は待機することになります。

リモートコンピュータに接続するための資格情報を別途入力する必要がある場合は-credentialパラメータを使用します。

Invoke-Command -ComputerName リモートコンピュータ名 {リモートで実行するコマンド、スクリプト} -asjob -credential ユーザー名

とするとパスワードを入力するダイアログが表示されます。なお、スクリプトで動かすときなどあらかじめ入力したパスワードを指定したい場合の方法は以前書きました

同じコマンドを複数のリモートPCで同時実行することも可能で、その場合は-computerNameパラメータにリモートコンピュータ名の配列を指定します(「,」区切り)。この場合ローカルPCで見えるジョブとしては1つですが、そのジョブにリモートコンピュータの数だけ子ジョブ(ChildJobs)が作成されています。

このように子ジョブが複数ある場合にReceive-Jobするときは

$job|Receive-Job -location リモートコンピュータ名

あるいは

$job.ChildJobs

として表示される子ジョブの名前(Name)を調べ、

Receive-Job -name 子ジョブの名前

とすることでリモートコンピュータごとに結果を取得できます。

すべての結果をまとめて取得するなら

Receive-Job $job

とします。

$job|Receive-Jobはなぜか駄目なようです。

固定セッションを用いたリモーティング

同じリモートPCに対して何度もコマンドを実行させたい場合、毎回リモートコンピュータ名を指定してセッションを張るのは非効率的なので、リモートセッションを確立したあとその固定セッションを何度も使用する方法が用意されています。新しく固定セッションを確立するにはNew-PSSessionコマンドレットを用い、

$session=New-PSSession リモートコンピュータ名

とすると固定セッションが確立され、$session変数にそのセッションオブジェクトが格納されます。あとは

Invoke-Command $session {リモートで実行するコマンド、スクリプト} -asjob

とすればそのたびにそのセッションを用いてリモートでコマンドを実行できるようになります。

ここまでの説明はリモートコンピュータでしてきましたが、ローカルコンピュータに対して固定セッションを張ることも可能です。

さらに、Enter-PSSessionコマンドレットを用いると作成したセッションに入ってリモートコンピュータ上のPowerShellを対話実行することも可能です。

Enter-PSSession $session

とすると、プロンプトが

PS カレントディレクトリ>

から

[リモートコンピュータ名]: PS カレントディレクトリ> 

に変化し、以降リモートのPowerShellをローカルPCから対話実行できます。

なおこの状態から抜けるにはexitもしくはExit-PSSessionと入力して実行します。

ジョブ実行できるそのほかのコマンドレット

これまで述べたコマンドレット以外にも、いくつかのコマンドレットはジョブ実行(ローカルorリモート)することができます。ジョブ実行するには-asJobパラメータを使用します。以下にv2の段階で-asJobパラメータが定義されているそのほかのコマンドレットを示します。

これらのコマンドレットはコマンドレット自体にジョブ実行機能がついているので、単独で実行するだけならStart-JobやInvoke-Commandを用いる必要がありません。v2ではWMIを扱うコマンドレットにのみ-asJobパラメータが存在するようです(ここに挙げたコマンドレットはすべてWMIの機能を呼び出すもの)。なお、-asJobパラメータが使用できるコマンドレットの一覧を取得するのに、fsugiyamaさんの1日目の記事の問15のスクリプトを使用させていただきました。

おわりに

PowerShell Advent Calendar 2011二日目は、PowerShellのバックグラウンドジョブ機能概要についてまとめてみました。実はバックグランドジョブ機能のTipsを書こうと思ってその前ふりとして書き始めたのですが、これだけでかなりの量になってしまったので概要だけ一記事としてまとめることにしました。おそらくPSアドベントカレンダーに私はあと何回か登場することになりそうですので、Tips編はその際に書こうと思います。

さて、明日三日目は@jsakamotoさんのご登場ですね。よろしくお願いします!

そして参加者はまだまだ募集中ですよ!→PowerShell Advent Calendar 2011

2011/09/30

いきなりですが、PowerShellで「カレントディレクトリに含まれる.txtファイルの拡張子をすべて.logに変更する」方法がぱっと思いつくでしょうか?

コマンドプロンプトなら

ren *.txt *.log

で一発なのですが、PowerShellでrenコマンドに対応するコマンドレットであるRename-Itemコマンドレットを使って

Rename-Item -path *.txt -newName *.log

と書くことはできません。Rename-Itemコマンドレットの-pathパラメータと-newNameパラメータはワイルドカード文字を受け付けないからです。

ではどう書くのか。Get-ChildItemコマンドレットの-pathパラメータはワイルドカード文字を使うことができます(Get-Help Get-ChildItem -fullを調べるとpathパラメータの「ワイルドカード文字を許可する」はfalseになってますが、実際はワイルドカードが使えます)。よってGet-ChildItemでワイルドカードを用いてファイル一覧を取得し、それをRename-Itemコマンドレットにパイプで渡すとよさそうです。Rename-Itemの-pathパラメータは「パイプライン入力を許可する true (ByValue, ByPropertyName)」なので、パイプ経由でオブジェクトを渡すとこのパラメータに値が渡ります。なお、ByValueなどの意味は以前書いたエントリを参考にしてください。では書いてみましょう。

Get-ChildItem *.txt | Rename-Item -newItem *.log

あれ、新しい名前のほうのワイルドカードはどうすればいいんだ?というわけでこれでは駄目で、まだ一工夫が必要です。

素直に考えると、Get-ChildItemの結果(FileInfoオブジェクトの配列)をForEach-Objectで列挙して、その各要素でNameプロパティを元にRename-Itemコマンドレットを実行するというのが思いつきます。

Get-ChildItem *.txt | %{Rename-Item -path $_.Name -newName ($_.Name -replace "\.txt`$",".log")}

注: -replace演算子の右辺配列の最初の要素は正規表現を指定します。なので正規表現における特殊文字「.」は「\」でエスケープする必要があります。さらに拡張子以外の文字が置き換わらないように文字列の末端を表す「$」を使用します。「$」はPowerShellにおいて特殊文字なので「`」でエスケープします。

しかしこれはなんかNameプロパティの値を2回も参照してて冗長ですしあまりやりたくないですね。そもそもせっかくRename-Itemコマンドレットの-pathパラメータにパイプライン経由で直接オブジェクトを流し込める利点を生かせていません。

そこで登場するのが、このエントリのタイトルにもある「スクリプトブロックパラメータ」です。実はPowerShellには任意のコマンドレットパラメータにスクリプトブロックを指定する機能があるのです。コマンドレットパラメータは型が指定されていますが、これが<scriptblock>である必要はなく、<string>でも<int>でも何でもOKです。したがって、冒頭の問題の回答は次のように記述することができます。

Get-ChildItem *.txt | Rename-Item -newName {$_.Name -replace "\.txt`$",".log"}

このように、-newNameパラメータの型は<string>であるにも関わらず、スクリプトブロックを指定することができるのです。このスクリプトブロック内の$_は、パイプラインに渡されたオブジェクト配列の一要素です。つまりここではFileInfoオブジェクトになります。

注:この例だとファイルはカレントディレクトリにあるものが対象になるので、カレントディレクトリ以外で実行する場合はNameプロパティの代わりにFullNameプロパティを使ってフルパスを指定してください。

この機能、マイナーだと思いますが知っているとずいぶん楽になるケースが多いと思うので、ぜひ覚えておくことをお勧めします。しかし実はこの例題、Rename-Itemコマンドレットのヘルプの例4そのままだったりします。私はそこの解説を読んでもいまいち仕組みが分かりませんでした。Flexible pipelining with ScriptBlock Parameters - Windows PowerShell Blog - Site Home - MSDN Blogsという記事を読んでようやくこれがPowerShellの機能だと認識した次第です。

まあ、それでもrenコマンドのお手軽さには負けますけども、柔軟性に関してはもちろんPowerShellのほうが圧倒的に優れているのでそこは我慢するしかないのかなあ、と思います。どうしても簡単に書きたい場合は

cmd /c ren *.txt *.log

とかしてくださいませ。

ちなみにこの機能はユーザーが定義した関数では原則使用できないようです。ただ例外があって、次のような関数定義をしておくと大丈夫でした。

function test
{
    param([parameter(ValueFromPipeline=$true)][string]$str)
    process
    {
        $str
    }
}

ポイントはパラメータにparameter属性を指定して、ValueFromPipelineもしくはValueFromPipelineByPropertyNameを$trueにすることと、型名を指定すること(ここでは<string>)です。こうしておけば

dir|test -str {$_.fullname}

のようにして、コマンドレットの場合と同様にスクリプトブロックパラメータを使うことができます。属性と型指定どちらかが欠けているとスクリプトブロックが展開されずそのまま-strパラメータに渡ってしまうようです。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2011/09/30/203768.aspx

次のページへ

Copyright © 2005-2016 Daisuke Mutaguchi All rights reserved

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

Awards

Books

Twitter