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はコマンドもオブジェクトなんだ、ということが分かると思います。

2014/04/20

Select-Object、Format-Table、Sort-Objectなど、-Propertyパラメータを持つコマンドレットには、プロパティ名を指定する以外にも「集計プロパティ」という連想配列を指定することができます。

集計プロパティを利用すると、オブジェクトが持っていないプロパティを動的に作成し、その値を表示することができるようになります。

たとえば、dir (Get-ChildItem)の出力するファイルリストに、テキストファイルとして開いた場合の1行目の記述(Textプロパティとする)も合わせて表示したい場合は以下のようにします。

dir | select Name, Extension, @{Name="Text"; Expression={$_|gc|select -First 1}}

出力は以下のようになります。

Name            Extension            Text                              
----            ---------            ----         
test1.ps1       .ps1                 param([Parameter(ValueFro...
test2.ps1       .ps1                 $x=1231                           
test3.ps1       .ps1                 Start-Transcript                  
test4.ps1       .ps1                 Get-Content .\gacha_log.t...

ここで、@{Name="Text"; Expression={$_|gc|select -First 1}}と指定している部分が集計プロパティです。連想配列のキーとしてプロパティ名を表すNameと、スクリプトブロックを指定するExpressionを含めます。なお、キー名のNameはN、ExpressionはEと省略表記できるので、

dir | select Name, Extension, @{N="Text"; E={$_|gc|select -First 1}}

と書くこともできます。

ここまではヘルプにも載っていることなのでご存知の方も多いと思います。ただ、連想配列を指定してごにょごにょ、というのは(特にインタラクティブ実行時には)正直だるいです。そこでもっと楽な書き方はないものかと思って何気なく、

dir | select Name, Extension, {$_|gc|select -First 1}

とプロパティとしてスクリプトブロックをそのまま書いたら、普通に通りました。これ、知ってました? 私は知らなかったです。しかしv2環境で実行しても動いたので前からあったんですね。なぜヘルプに載ってないのか…。それともどこかに載ってるんでしょうか…。

ただしこの方法だと、集計プロパティでプロパティ名(Nameキー)を指定しない場合と同様、以下のようにプロパティ名がスクリプトブロックの定義そのままになります。

Name          Extension          $_|gc|select -First 1                              
----          ---------          ---------------------         
test1.ps1     .ps1               param([Parameter(ValueFro...

そのため、コマンドの出力を変数に入れて利用する場合などはこの方法は不適です。ですがインタラクティブ実行時で、結果表示の見た目よりも効率を重視する場合なら、この方法はお勧めです。

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/25

本記事はPowerShell Advent Calendar 2012、最終日の記事です。

前回はAdd-Typeコマンドレットを使って独自のクラスを作成し、そのクラスを入力あるいは出力型に取る関数をどのように記述すれば良いのか、というお話でした。

今回は前回に残した課題である、ユーザー定義の独自型の出力にちゃんとした書式を設定する方法について説明していきます。

型データと書式設定データ

PowerShellは.NETオブジェクト(に限らず、型アダプタが存在するCOMやXMLなども、ですが…)をラッピングした型システムを有しているわけですが、このラッピング時にオブジェクトに対してPowerShell独自のデータを付与することができます。それが型データと書式設定データと呼ばれるものです。

型データはクラスにPowerShellエンジンが付加する独自のメンバ(プロパティ、メソッド)です。代表的なものにNoteProperty(静的な値をもつプロパティ), ScriptProperty(スクリプトで記述されたgetterとsetterをもつプロパティ), AliasProperty(既存プロパティのエイリアス), CodeProperty(.NETのスタティックプロパティ), CodeMethod(.NETのスタティックメソッド), ScriptMethod(スクリプトで記述されたメソッド)があります。

型データは .types.ps1xmlファイルにその定義を記述し、モジュールならモジュールマニフェスト(.psd1)のTypesToProcessプロパティに型データファイルパスを指定することで、インポート時に型データを反映させることができます。

Update-TypeDataコマンドレットで後から型データファイルを読み込んで反映することもできます。PowerShell 3.0ではUpdate-TypeDataコマンドレットで.types.ps1xmlファイルを読むのではなく、直接任意のメンバを任意の型に追加することも可能になっています。

また、

$obj | Get-Member -View Extended

とすることで$objに追加されたメンバがどれなのかが分かります。(ちなみに型アダプタによって追加されたメンバはAdapted指定で分かります)

今回の記事では型データについてはこれくらいにして(またいつか改めて取り上げたいですが)、以下、本題の書式設定データの話をしていきます。

書式設定データとは

書式設定データも型データと同様、クラスに付加するデータなのですが、これはオブジェクトを出力する際のデフォルトの表示フォーマットを定義するものとなります。

たとえばGet-Processコマンドレットを実行すると

Handles  NPM(K)    PM(K)      WS(K) VM(M)   CPU(s)     Id ProcessName
-------  ------    -----      ----- -----   ------     -- -----------
    138      13    18456       7104    62            2052 aaHMSvc
     88       8     2168       1268    55            2112 AdminService
...

のようにProcessオブジェクトが表形式で表示されます。

ここで表に含まれるプロパティ、IdとProcessNameは.NETのProcessクラスが持つオリジナルのプロパティで、HandlesはHandleCountプロパティのAliasPropertyです。NPMやWSなども対応するAliasPropertyやScriptPropertyが定義されているのですが、たとえばNPMというAliasPropertyはあってもNPM(K)というメンバはありません。これを定義しているのが書式設定データになるわけです。そもそもこの表に含まれるプロパティの種類であるとか、もっというとProcessオブジェクトは特に指定がない場合は表形式で表示する、といった定義も書式設定データに含まれます。

書式設定データも型データと同様にXMLファイルに定義されるのですが、その拡張子は.format.ps1xmlです。モジュールならマニフェストのFormatsToProcessプロパティに.format.ps1xmlファイルのパスを指定することで表示に反映されますし、Update-FormatDataコマンドレットによって後から反映させることも可能です。

この書式設定ファイルはユーザー定義型に関しても定義を記述できます。つまり、ユーザー定義型に対応する.format.ps1xmlファイルを記述し、それを読み込むことで、自分がAdd-Typeで作った型に対しても書式を設定できるわけです。次の節でそのやり方を見ていきましょう。

書式設定データの作り方

書式設定データ.format.ps1xmlの書式についてはMSDNにリファレンスがあるので、これを読めば自分で一から作成することは可能です。ですがそれはちょっと面倒くさいので、既存の書式設定データをベースに、独自型用に修正していくのがお勧めです。

書式設定データはGet-FormatDataコマンドレットで取得でき、Export-FormatDataコマンドレットでファイルとして出力できます。なお、Export-FormatDataコマンドレットの出力XMLファイルは改行コードが入っていなくて見づらいので、XmlDocumentとして再度読み込んでSave()するという小細工を施すのがお勧めです。先ほどのProcessクラス(System.Diagnostics.Process)の書式設定データをファイル化するには以下のようなスクリプトを実行します。

$ps1xml="process.format.ps1xml"
Get-FormatData System.Diagnostics.Process | Export-FormatData -Path $ps1xml -IncludeScriptBlock
([xml](Get-Content $ps1xml)).Save((Join-Path (Get-Location) $ps1xml))

このスクリプトを実行すると、Processクラスの書式設定データをprocess.format.ps1xmlファイルとして出力できます。

出力した.format.ps1xmlはPowerShell ISE(ただしv3の)で開くのがお勧めです。ちゃんとXMLノードを折りたたみできるので。

さて、出力した.format.ps1xmlファイルをつらつらと眺めると、実際の出力書式の定義はビュー(View)という単位で行われていることがわかります。View要素の下にはName要素(ビューの名前)、ViewSelectedBy要素(ビューを反映する対象の型)、TableControl要素(表の書式)があります。

TableControl要素の下にはTableHeaders要素とTableRowEntries要素が含まれており、前者は表のヘッダーに記載するラベルやその幅などをTableColumnHeader要素に一つ一つ定義し、後者は表の本体に表示するオブジェクトのプロパティ値をTableColumnItem要素に一つ一つ定義しています。

TableColumnItem要素は単純にプロパティ値を表示させるならPropertyName要素にプロパティ名を書くだけでOKです。スクリプトの結果を表示させるならScriptBlock要素内にスクリプトを書きます。ScriptBlock要素内で自動変数$_に1オブジェクトが格納されています。

結局のところ、表に表示したいプロパティの分だけ、TableColumnHeader要素(プロパティ名のラベル)とTableColumnItem要素(プロパティ値)を1:1で定義していけばOKです。

以上を踏まえてprocess.format.ps1xmlを改変して、前回作成したWinscript.Driveクラスの書式設定ファイルdrive.format.ps1xmlを作成してみました。

<?xml version="1.0" encoding="utf-8"?>
<Configuration>
  <ViewDefinitions>
    <View>
      <Name>drive</Name>
      <ViewSelectedBy>
        <TypeName>Winscript.Drive</TypeName>
      </ViewSelectedBy>
      <TableControl>
        <TableHeaders>
          <TableColumnHeader>
            <Label>Name</Label>
            <Width>4</Width>
          </TableColumnHeader>
          <TableColumnHeader>
            <Label>VolumeName</Label>
            <Width>15</Width>
          </TableColumnHeader>
          <TableColumnHeader>
            <Label>Type</Label>
            <Width>15</Width>
          </TableColumnHeader>
          <TableColumnHeader>
            <Label>RootPath</Label>
          </TableColumnHeader>
          <TableColumnHeader>
            <Label>Size(GB)</Label>
            <Width>25</Width>
            <Alignment>Right</Alignment>
          </TableColumnHeader>
          <TableColumnHeader>
            <Label>Used(%)</Label>
            <Width>7</Width>
            <Alignment>Right</Alignment>
          </TableColumnHeader>
        </TableHeaders>
        <TableRowEntries>
          <TableRowEntry>
            <TableColumnItems>
              <TableColumnItem>
                <PropertyName>Name</PropertyName>
              </TableColumnItem>
              <TableColumnItem>
                <PropertyName>VolumeName</PropertyName>
              </TableColumnItem>
              <TableColumnItem>
                <PropertyName>Type</PropertyName>
              </TableColumnItem>
              <TableColumnItem>
                <PropertyName>RootPath</PropertyName>
              </TableColumnItem>
              <TableColumnItem>
                <ScriptBlock>[int]($_.Size/1GB)</ScriptBlock>
                <FormatString>{0:#,#}</FormatString>
              </TableColumnItem>
              <TableColumnItem>
                <ScriptBlock>[int]($_.UsedSpace*100/$_.Size)</ScriptBlock>
              </TableColumnItem>
            </TableColumnItems>
          </TableRowEntry>
        </TableRowEntries>
      </TableControl>
    </View>
  </ViewDefinitions>
</Configuration>

前回作成したスクリプトを実行後、このdrive.format.ps1xmlファイルを

Update-FormatData -AppendPath .\drive.format.ps1xml

のようにして現在のセッションに読み込んでやることで、以降は定義した関数を実行すると、

PS> Get-Drive
Name VolumeName      Type            RootPath                  Size(GB) Used(%)
---- ----------      ----            --------                  -------- -------
C:                   LocalDisk       C:\                            112      88
D:                   LocalDisk       D:\                            466      63
Q:                   CompactDisc     Q:\                                       
V:                   NetworkDrive    \\server\D                   1,397      64

このように定義した型のオブジェクトに対しても、綺麗な書式で出力することができるようになるわけです。

まとめ

ここまで全三回にわたって、「関数の定義」「型の定義」「出力書式の定義」の基本のきについて説明してきました。基本とはいえ、PowerShellでがっつりとちゃんとした関数を書く上で真っ先に押さえておかないといけないことばかりですし、逆にここまで必要最小限に絞った記事もあまりないかなと思い、まとめてみました。参考にしていただければ幸いです。

さて、PSアドベントカレンダー2012もこれで終わりです。皆様、よいクリスマス…はもう終わりなので、よいお年を!

※終わりと言っておきながら実は明日以降、ロスタイムとしてもうひとかたご登場の予定です。ご期待ください。

2012/12/14

本記事はPowerShell Advent Calendar 2012の14日目の記事になります。

前回(アドベントカレンダー1日目)は「PowerShellらしい関数の書き方」と題して、パイプライン内でうまく他のコマンドと連携させるための関数をどう書けばいいのか、ということについて書きました。前回の関数の例では入力型と出力型がstringだったのですが、実際は自分で定義した型を入力、出力値に取るように書くのが普通かと思います。今回は、それをするためにどうやって型を定義するのか、そしてその型を関数にどう指定するのか、という話をします。

PowerShellにはクラス定義構文がない

そもそもの話になるんですが、型を定義する、つまりはクラスを記述するためのPowerShellのステートメントやコマンドレットが無いため、PowerShell単独ではできません。なので無理です以上おしまい。…というわけにはいかないので、実際はどうするのがいいのかという話をしていきます。

方法としては大きく分けて二つあると思います。

1.C#など他の.NET言語を用いてクラスを記述する

2.ユーザー定義オブジェクトを作成する

今回は1の方法を説明します。

C#を用いてクラスを記述する

つまりはPowerShellでクラスを定義できないなら、C#を使えばいいじゃない。ということです。幸いPowerShell 2.0からはAdd-Typeというコマンドレットを用いると、C#やVBなど.NET言語のソースをその場でコンパイルしてアセンブリとして現在のセッションに読み込むことが可能です。

たとえば、論理ドライブを表すDriveというクラスを定義してみます。

Add-Type -TypeDefinition @"
    namespace Winscript
    {
        public enum DriveType
        {
            Unknown, NoRootDirectory, RemovableDisk, LocalDisk, NetworkDrive, CompactDisc, RAMDisk
        }

        public class Drive
        {
            public string Name {get;set;}
            public string VolumeName {get;set;}
            public DriveType Type {get;set;}
            public long Size  {get;set;}
            public long FreeSpace  {get;set;}
            public long UsedSpace  {get;set;}
            public string RootPath {get;set;}
        }
    }
"@ -Language CSharpVersion3

このようにC#のコードを文字列として-TypeDefinitionパラメータに与えると、コンパイルされて指定のクラス(ここではWinscript.Drive)がロードされます。

ここで-Language CSharpVersion3というパラメータは指定コードをC# 3.0としてコンパイルすることを指定するため、今回使用している自動実装プロパティなどC# 3.0の構文が利用できます。なおこのパラメータはPowerShell 3.0では不要です。ただし明示しておくとPowerShell 2.0でも正しく動作します。というのも-Languageパラメータ省略時はPowerShell 2.0ではC# 2.0でコンパイルされるのですが、PowerShell 3.0ではC# 3.0でコンパイルするためです(逆にPSv3でC#2.0でコンパイルするには”CSharpVersion2”という新しく追加されたパラメータ値を指定します)

なお、ここでは-TypeDefinitionパラメータを用いてクラス全体を記述しましたが、この例のように列挙体も定義してそれをプロパティの型にするなどせず、すべて基本型のプロパティで完結するのならば、-MemberDefinitionパラメータを使ってメンバ定義だけを行う方が記述が短くなります。以下はWinscript.Manというクラスを定義する例です。

Add-Type -Namespace Winscript -Name Man -MemberDefinition @"
    public int Age {get;set;}
    public string Name {get;set;}
"@ -Language CSharpVersion3

例のようにC#のコード内には特にロジックを記述せず、単にデータの入れ物となるクラスにとどめておくのが良いかと思います。別にロジックを書いてもいいのですが、ISEで記述する限りはC#の編集に関してはただのテキストエディタレベルの恩恵しか受けないですし、それなら最初からVisual Studio使ってC#で全部コマンドレットとして書けばいいのに、ともなりかねないので。PowerShellでは実現困難な処理などがあればそれをメソッドとして書く程度ならいいかもしれません。ただしメソッドを記述してもそれをユーザーに直接使わせるというよりも、関数でラップして使わせる形が望ましいでしょう。

さて、次はこのクラスのオブジェクトを扱う関数を記述していきます。

定義した型のオブジェクトを扱う関数の記述

ここでは3つの関数を定義しています。Get-Drive関数はシステムに含まれるすべての論理ドライブを取得、Show-Drive関数は指定のDriveオブジェクトをエクスプローラで開く、Set-Drive関数は指定のDriveオブジェクトのボリューム名(VolumeNameプロパティ)を変更するものです。

ちなみに関数の動詞部分(ここではGet, Show, Set)は、Get-Verb関数で取得できるリスト以外のものは基本的に使わないようにします。モジュールに組み込んだ場合、インポートのたびに警告が出てしまうので。

関数の基本については前回に書いているので、今回のコードはそれを踏まえて読んでみてください。

function Get-Drive
{
    [OutputType([Winscript.Drive])]
    param(
        [string[]]$Name,
        [Winscript.DriveType]$Type
    )

    Get-WmiObject -Class Win32_LogicalDisk | ForEach-Object {
        if($null -ne $Name -and $Name -notcontains $_.Name)
        {
        }
        elseif($Type -ne $null -and $_.DriveType -ne $Type)
        {   
        }
        else
        {
            New-Object Winscript.Drive -Property @{
                Name = $_.Name
                VolumeName = $_.VolumeName
                Type = [enum]::Parse([Winscript.DriveType],$_.DriveType)
                RootPath = if($_.ProviderName -ne $null){$_.ProviderName}else{$_.Name + "\"}
                Size = $_.Size
                FreeSpace = $_.FreeSpace
                UsedSpace = $_.Size - $_.FreeSpace
            }
        }
    }
}

(↑10:33 foreachステートメントではなくForEach-Objectコマンドレットを使うように修正。Get-*な関数のようにパイプラインの先頭で実行する関数でも、内部でPowerShellのコマンドレットや関数の出力を利用する場合は、配列化してforeachするよりも、ForEach-Objectで出力を逐次処理した方が良いですね。内部関数の出力がすべて完了してから一気に出力するのではなく、内部関数が1個オブジェクトを出力するたびに出力するようにできるので。)

function Show-Drive
{
    [OutputType([Winscript.Drive])]
    param(
        [Parameter(ValueFromPipeline=$true,Mandatory=$true)]
        [Winscript.Drive[]]
        $Drive,
        
        [switch]
        $PassThru
    )

    process
    {
        foreach($d in $Drive)
        {
            Start-Process $d.Name
            if($PassThru)
            {
                $d
            }
        }
    }
}
function Set-Drive
{
      param(
        [Parameter(ValueFromPipeline=$true,Mandatory=$true)]
        [Winscript.Drive]
        $Drive,
        
        [Parameter(Mandatory=$true)] 
        [string]
        $VolumeName
    )

    process
    {
        Get-WmiObject Win32_LogicalDisk -Filter "DeviceID='$($Drive.Name)'" |
            Set-WmiInstance -Arguments @{VolumeName=$VolumeName} | Out-Null
    }
}

細かい説明は省きますが、前回説明した関数の基本フォーマットに、自分で定義した型を適用してロジックを書くとこうなる、という参考例としてとらえてください。

一つだけ前回に説明し忘れてたことがあります。それは[OutputType]属性です。これは文字通り、関数の出力型を指定するものです。この属性を指定しておくと何が嬉しいかというと、関数の出力を変数に代入したりWhere-Objectコマンドレットでフィルタをかけるコードを記述する際、関数の実行「前」にもプロパティ名をちゃんとタブ補完してくれるようになります。残念ながらこの静的解析機能はPowerShell 3.0からのものなので2.0だとできませんが、OutputType属性自体は2.0でも定義可能なので、定義しておくことを推奨します。

さて、型の定義と関数の定義をしたので実際に関数を実行してみます。

PS> Get-Drive # 全ドライブ取得

Name       : C:
VolumeName :
Type       : LocalDisk
Size       : 119926681600
FreeSpace  : 12262494208
UsedSpace  : 107664187392
RootPath   : C:\

Name       : D:
VolumeName :
Type       : LocalDisk
Size       : 500086886400
FreeSpace  : 198589583360
UsedSpace  : 301497303040
RootPath   : D:\

Name       : Q:
VolumeName :
Type       : CompactDisc
Size       : 0
FreeSpace  : 0
UsedSpace  : 0
RootPath   : Q:\

Name       : V:
VolumeName : 
Type       : NetworkDrive
Size       : 1500299390976
FreeSpace  : 571001868288
UsedSpace  : 929297522688
RootPath   : \\server\D

PS> Get-Drive | where {$_.Size -gt 1TB} # Where-Objectでフィルタ

Name       : V:
VolumeName : 
Type       : NetworkDrive
Size       : 1500299390976
FreeSpace  : 571001868288
UsedSpace  : 929297522688
RootPath   : \\server\D

PS> Get-Drive -Type NetworkDrive | Show-Drive -PassThru | ConvertTo-Csv #ネットワークドライブのみエクスプローラーで開く。取得結果はCSVとして出力。
#TYPE Winscript.Drive
"Name","VolumeName","Type","Size","FreeSpace","UsedSpace","RootPath"
"V:","","NetworkDrive","1500299390976","571001868288","929297522688","\\server\D"
PS> Get-Drive -Name D: | Set-Drive -VolumeName 新しいドライブ # D:ドライブのボリューム名を指定。(管理者権限で)

関数をきちんとPowerShellの流儀に従って記述したおかげで、このようにPowerShellの他の標準コマンドレットと同様の呼び出し方ができ、自作関数やそれ以外のコマンド同士をうまくパイプラインで繋げて実行することができています。

さて、おそらく一つ気になる点があるとすれば、ドライブの容量表示が見づらいということでしょう。容量であればGBとかの単位で表示してほしいですし、大きい数字は,で桁を区切ってほしいですよね。じゃあそういう値を文字列で返すプロパティを定義してやる必要があるというかと言えばそんなことはなく、PowerShellには型に応じた表示フォーマットを指定する方法が用意されています。次回はそのあたりを解説しようと思います。

また、C#とかめんどくさいしもうちょっと楽な方法はないのか?ということで、最初の方でちょっと触れた、ユーザー定義オブジェクトを利用する方法も、余裕があれば次回に。

さて、PSアドベントカレンダー、明日はsunnyoneさんです。よろしくお願いします!

2012/12/02

わんくま同盟大阪勉強会#52(2012/10/27)のLTで発表したときのスライドです。PowerShell 3.0で新しく追加されたコマンドレットのうち、Invoke-WebRequestとInvoke-RestMethodの2つをピックアップして紹介しました。

これらは主にWeb APIを利用するためのコマンドレットですが、取得したHTML、XML、JSONはそれぞれ自動でパースしてオブジェクトに変換してくれる優れものです。Windowsでは長らくwgetに相当するコマンドがありませんでしたし、PowerShellでもWebClientなど.NETのオブジェクトを使ったりして面倒でしたが、これからはコレで決まりです。

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/08/01

シェル操作課題 (cut, sort, uniq などで集計を行う) 設問編 - Yamashiro0217の日記

ログファイルをコマンド使って解析するというこの課題。

え。Windows?まさか???cygwin入れたり、vmでやってみたり、開発サーバーで作業すればいいんじゃないっすか(ホジホジ)

いやいやWindowsでシェルと言えばPowerShellでしょう!というわけでやってみました。出力形式に関しては本筋とは関係ないと思うので、PowerShellのデフォルトのままです。

準備
$logs = Import-Csv hoge.log -Header ServerName,UnixTime,UserId,AccessUrl
問1 このファイルを表示しろ
$logs
問2 このファイルからサーバー名とアクセス先だけ表示しろ
$logs|select ServerName,AccessUrl
問3 このファイルからserver4の行だけ表示しろ
$logs|where {$_.ServerName -eq "server4"}
問4 このファイルの行数を表示しろ
$logs|measure
問5 このファイルをサーバー名、ユーザーIDの昇順で5行だけ表示しろ
$logs|sort ServerName,{[int]$_.UserId}|select -First 5
問6 このファイルには重複行がある。重複行はまとめて数え行数を表示しろ
$logs|sort * -Unique|measure
問7 このログのUU(ユニークユーザー)数を表示しろ
$logs|sort UserId -Unique|measure
問8 このログのアクセス先ごとにアクセス数を数え上位1つを表示しろ
$logs|group AccessUrl -NoElement|sort Count -Descending|select -First 1
問9 このログのserverという文字列をxxxという文字列に変え、サーバー毎のアクセス数を表示しろ
$logs|group {$_.ServerName -replace "server","xxx"} -NoElement|sort Count -Descending
問10 このログのユーザーIDが10以上の人のユニークなユーザーIDをユーザーIDでソートして表示しろ
$logs|where {[int]$_.UserId -ge 10}|select -Expand UserId|sort -Unique

PowerShellはテキスト情報をテキストのまま扱うのではなく、オブジェクトとして扱うところが特徴で、CSVファイルもImport-Csvコマンドレットを用いることで、ヘッダ文字列をプロパティ名として各行をオブジェクトとして読み込んでくれます。

あとは各種オブジェクト処理のコマンドレットに、パイプライン経由でオブジェクトを渡していくだけです。コマンドが何をやっているのかも、列番号ではなくプロパティ名を指定してやるので分かりやすいと思います。この課題に寄せられた回答の中では、シェル操作課題 SQLによる解答例が近いかと思います。

今回はCSVから情報を読み込んでいますが、ソースは別になんであってもいいわけです。たとえばGet-EventLogコマンドレットだとイベントログから持ってくることもできますし、XMLなら$xml=[xml](Get-Content file.xml)みたいな感じです。一旦オブジェクトとして取得できてしまえば、あとの処理方法は基本的に同じなので悩む必要がないのが良いところです。

そしてPowerShellの良いところはやはり、外部処理コマンドを使わずとも、シェル言語と組み込みコマンド(コマンドレット)のみで処理が完結するところです。たとえばbashだけでやるとtsekine's miscellaneous thoughts: シェル操作課題への回答のような大変なことになるようです。sortなど基本的なコマンドは使用するにしても、複雑なフィルタ処理などはawkを組みあわせる等しないと、特に後半の処理は辛いでしょう。PowerShellなら異なる処理系を持ち出す必要がなく、PowerShellのみですべて行えます。

もちろん問題点も色々あります。まず第一に、今回の課題のように、ヘッダ文字列がないCSVの場合は例のようにヘッダー文字列(=プロパティ名)を自分で定義してやる必要が出てきます。そのため可読性はともかく、若干、冗長なところがでてきてます。まあ例のように一旦オブジェクトを作って変数に入れてしまえばあとは使いまわせますが、そこはちょっと厳しい点かもしれません。

あとImport-CsvコマンドレットはCSVの各エントリをすべて文字列型のプロパティとしてオブジェクトに格納するところがちょっと微妙な気がしました。数値は数値型で入れてほしいですよね。おかげで何か所か、[int]にキャストせざるを得ない部分が出てきました。

そして処理速度の問題。おそらくログファイルが巨大だとかなりリソースを食って時間もかかると思います。処理を分ける、バックグラウンドジョブやワークフローで動かす、そもそもログファイルを分割保存するようにしておく、等、実運用上では工夫が必要かと思います。

(5:14追記)
既出でした>< [Power Shell] シェル操作課題への回答 - Pastebin.com(by @usamin5885さん)

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を活用し、楽しんでくださいませ。

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


古い記事のページへ | 新しい記事のページへ


Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー

Twitter

Books