2016/12/15

この記事はPowerShell Advent Calendar 2016の15日目です。

前回はPowerShellのASTの概要を解説しました。今回は前回の補足というか応用的な内容になります。

前回、スクリプトブロックからどのようなASTが生成されるのか、図で書きました。そもそもあの図を作るにあたって、ASTの構造を視覚的に把握したかったので、そのためのスクリプトを書きました。

PowerShellで木構造を展開表示する方法は色々ある(※)かと思いますが、今回はJSONとして出力して、表示については他のアプリに任せることにしました。

※Format-Customのデフォルトビューは意外と使える

ただし、ASTオブジェクトをそのままConvertTo-Jsonコマンドレットに渡すわけにはいきません。というのも、AST構造を再帰的に展開するには、探索の深さ(-Depth)を大きくしなければいけませんが、そうするとASTではないオブジェクトも逐一展開してしまい、現実的な時間内で終わらなくなってしまいます。

そこで、ASTオブジェクトそのものをJSONにするのではなく、必要なプロパティのみ再帰的に取得したカスタムオブジェクトを生成し、それをJSONにする方針を取りました。その成果が以下のコードです。(using namespace節を使っているので、v5以上必須です。)

using namespace System.Management.Automation.Language

function GetAstInner
{
    param([Ast]$ast)
    end
    {
        $base = [ordered]@{
            ExtentText = $ast.Extent.Text
            AstName = $ast.GetType().Name
        }

        $children = [ordered]@{}
        $leaves = [ordered]@{}

        $ast.psobject.Properties |
        ? Name -notin Extent, Parent |
        %{
            $type = [type]($_.TypeNameOfValue)
            $propValue = $ast.($_.Name)
                
            if($type.IsSubclassOf([ast]))
            {             
                if($null -ne $propValue)
                {
                    $children[$_.name] = GetAstInner $propValue
                }
            }
            elseif($type.IsGenericType -and $null -ne ($type.GetGenericArguments() | where{$_.Name -eq "Tuple``2"}))
            {
                $asts = @()

                foreach($next in $propValue)
                {
                    if($null -ne $next)
                    {
                        $asts += [pscustomobject]@{
                            Item1 = $(
                                if($null -ne $next.Item1 -and $next.Item1 -is [ast])
                                {
                                    GetAstInner $next.Item1
                                }
                            )
                            Item2 = $(
                                if($null -ne $next.Item2 -and $next.Item2 -is [ast])
                                {
                                    GetAstInner $next.Item2
                                }
                            )
                        }
                    }
                }

                if($asts.length -ne 0)
                {
                    $children[$_.Name] = $asts
                }

            }
            elseif($type.IsGenericType -and $null -ne ($type.GetGenericArguments() | where{$_.IsSubclassOf([ast])}) )
            {
                $asts = @()

                foreach($next in $propValue)
                {
                    if($null -ne $next)
                    {
                        $asts += GetAstInner $next
                    }
                }

                if($asts.length -ne 0)
                {
                    $children[$_.Name] = $asts
                }
            }
            else
            {
                if($null -ne $propValue)
                {
                    $leaves[$_.Name] += $propValue.Tostring()
                }
            }
        }
        [pscustomobject]($base + $leaves + $children)
    }
}

function Get-Ast
{
    param([scriptblock]$ScriptBlock)
    end
    {
        GetAstInner $ScriptBlock.Ast
    }
}

本来なら、50種以上あるAstクラスに応じてきちんと場合分けすべきなのですが、コードが長くなるだけなので、動的言語の強みを生かしてダックタイピング的な方法で下位ノードを再帰的に展開しています。

途中、IfStatementAstのClausesプロパティなどで用いられている、ReadOnlyCollection<Tuple<Ast, Ast>>型であることを確認するのに苦労してますが、多分もっといい方法があると思います…。他はAstオブジェクトそのものか、ReadOnlyCollection<Ast>を返すだけなのでそんなに苦労はないです。Ast抽象クラスに含まれているExtent、Parentプロパティ以外で、Astを要素に含まないプロパティに関しては、ASTの葉として解釈しています。

次にこのスクリプトを使って、スクリプトブロックをJSONとして出力します。

$scriptBlock = {
    param([int]$x,[int]$y)
    end
    {
        $out = $x + $y
        $out | Write-Host -ForegroundColor Red
    }
}

Get-Ast $scriptBlock | 
    ConvertTo-Json -Depth 100 |
    Set-Content ast.json

サンプルとして用いるスクリプトブロックは、前回のものと同じです。これを先ほど書いたGet-Ast関数に渡して、結果をConvertTo-JsonでJSON化しています。この際、探索の深さを100としていますが、ネストが深いスクリプトブロックなどでは、もっと大きくする必要も出てくるかもしれません。

出力されたast.jsonを、JSON Viewerを使って表示してみたのが、以下のスクリーンショットになります。

スクリーンショット 2016-12-15 09.44.06

色んなスクリプトのASTを表示して、楽しんでみてください。

ASTシリーズはもう少し続きます。次回はAST Visitorと静的解析のお話です。

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の勉強にもなると思います。

2011/12/13

はじめに

この記事はPowerShell Advent Calendar 2011の13日目、そして私の2回目の記事となります。

今日のテーマは前回の続きで、PowerShellのバックグラウンドジョブの結果を読み取ったり、バックグラウンドジョブに値を与えたりして、ジョブと通信を行う方法を解説します。

ジョブから呼び出し元に値を返却する

ジョブの結果を取得するにはReceive-Jobコマンドレットを使用すれば良いと前回書きましたが、今回はジョブ側から結果を返す実際の方法を示します。

基本的にPowerShellのスクリプトやスクリプトブロックが呼び出し元に返却する値というのは、そのスクリプト(or ブロック)でパイプラインを通じて最終的にデフォルト出力に渡されたすべての値です。複数行に渡って出力されている場合は、呼び出し元にはその配列(object[])として返却されます。

ジョブにおいてもそれは同様で、基本的にStart-Jobなどで生成したスクリプトやスクリプトブロックが出力したすべての値がジョブの出力となり、呼び出し元からはReceive-Jobコマンドレットで受け取ることができます。

以下に現在の日付時刻を出力するサンプルを示します。サンプルなのでジョブなのに同期的な処理になってますがご了承を。

$job=Start-Job {
    Start-Sleep -sec 5
    Get-Date
}
Wait-Job $job|Receive-Job

複数だと以下のようになります。

$job=Start-Job {
    Start-Sleep -sec 1
    "Give me job."
    Get-Date
    1+1
}
Wait-Job $job|Receive-Job

この場合だと文字列、日付時刻、数値の3種類のオブジェクトが出力されますので、結果は長さ3のobject配列になります。そのためこれらの値を個別に取り出す場合は次のようにします。

$job=Start-Job {
    Start-Sleep -sec 1
    "Give me job."
    Get-Date
    1+1
}
$result=Wait-Job $job|Receive-Job
Write-Host $result[0]
Write-Host $result[1].ToString("yyyyMMdd")
Write-Host $result[2]

このように配列のインデックスで各値にアクセスできますが、これだと受け取り側での処理が分かりにくいと思われるかもしれませんね。

そこでお勧めなのが、このように複数値を返却するのではなく、カスタムオブジェクトを1つだけ返却するようにする方法です。

$job = Start-Job {
    Start-Sleep -sec 1
    $ret = New-Object PSObject -property @{
        String = "Give me job.";
        Date = Get-Date;
        Number = 1+1
    }
    $ret
}
$result = Wait-Job $job|Receive-Job
Write-Host $result.String
Write-Host $result.Date.ToString("yyyyMMdd")
Write-Host $result.Number

この方法ではジョブの中でNew-Objectコマンドレットでカスタムオブジェクトを作成し、それを返却しています。返却値は1つのオブジェクトでそのプロパティに値が格納されているのでドット演算子で値を参照できるようになりました。

ただしこの方法にも欠点があって、Receive-Objectで結果を参照するとき、ジョブが終了するまですべての値が参照できません。実はジョブが完了してない段階でも、Receive-Objectを実行するとジョブがそこまで出力した値を逐次取得することができるのです。よって

$job=Start-Job {
    Start-Sleep -sec 3
    "Give me job."
    Start-Sleep -sec 3
    Get-Date
    Start-Sleep -sec 3
    1+1
}

のようにしてジョブを走らせた後、適当な間隔で

$job|Receive-Job

を実行すると、それまでに出力した部分までを取得して書き出します。先程の例のように出力をカスタムオブジェクトでまとめてしまうとこの手法が使えなくなってしまいます。

どちらもメリット、デメリットがあるのでうまく使い分けると良いかと思います。具体的にはジョブの実行途中では結果を取得せず、ジョブ完了後の最終的な結果のみまとめて参照したい場合はカスタムオブジェクトで返却し、それ以外はそのまま随時値を返却するようにすればいいと思います。

さて、ジョブの結果を受け取る際にもう一点注意しなければならないことがあります。それはジョブが返すオブジェクトの型です。PowerShellのジョブ機能はリモーティング機構の上に構築されているというのは前回も書きましたが、その関係上、呼び出し元とジョブとの間でオブジェクトを受け渡しする場合は一度シリアル化され、受け取り側でデシリアライズされます。

オブジェクトのクラスもしくは構造体がシリアライズ可能(Serializable属性がついている)なら、PowerShellによりシリアル化→デシリアライズされたオブジェクトはシリアル化される前のオブジェクトと同一のものです。しかしそうではないオブジェクトの場合だと完全に元と同じオブジェクトには復元されません。

たとえば(Get-Process)[0]をジョブで実行するとSystem.Diagnostics.Processオブジェクトが得られますが、それをジョブの呼び出し元に返却するとDeserialized.System.Diagnostics.Processというカスタムオブジェクトに変換されます。このオブジェクトは各プロパティ値は(シリアル化可能なものだけ)保持しているものの、メソッド定義などは消失しているのでこのオブジェクトのメソッドを実行することはできません。

ちなみにSystem.StringクラスやSystem.Int32やSystem.DateTime構造体はSerializable属性がついているのでジョブの結果として取得しても元のオブジェクトと同一なので、メソッドなどが呼び出し可能です。

ジョブに呼び出し元の値を渡す

今度は逆の場合です。ジョブを走らせるとき、呼び出し元からジョブに値を渡す方法です。

$job = Start-Job {
    param($date,$value)
    Start-Sleep -sec 1
    "${date}の${value}日後の日付は" + $date.AddDays($value).ToString("yyyy/MM/dd") + "です。"
} -argumentList @((Get-Date),1)
Wait-Job $job|Receive-Job

このようにStart-Jobコマンドレットの-argumentListパラメータに、ジョブに渡したい値を指定すればOKです。複数ある場合はこのように配列指定も可能です。

ジョブ側ではparamキーワードで仮引数を指定しておけば、スクリプトブロック内で呼び出し元の値が格納された変数を使用できます。ここではparamを使いましたが、paramを使用しない場合は$argsに実引数が配列として格納されているので、これを利用するのでもOKです。

値を渡す場合でもシリアライズとデシリアライズが行われるので、その点だけは注意が必要です。

ジョブは呼び出し元と別インスタンスなので、呼び出し元に読み込まれた関数を参照することはできません。よってジョブでも呼び出し元で定義した関数を実行したい場合は同様に-argumentListで関数の実体であるスクリプトブロックを送ってやる必要があります。

function Get-Test
{
    "テスト!" + (1+1)
}

$job = Start-Job {
    param($sb)
    &([scriptblock]::Create($sb))
} -argumentList (Get-Item Function:\Get-Test).ScriptBlock

Wait-Job $job|Receive-Job

-argumentListでスクリプトブロックを渡すとStringにキャストされてしまうので、ジョブ内でそれをCreateメソッドでスクリプトブロックに戻してから実行演算子&で実行するという回りくどいことになってしまいました。関数にこだわらなければ呼び出し側でスクリプトブロックを作って変数に入れ、それを-argumentListに入れてやると少しだけ記述がシンプルになりますが、ジョブ内でスクリプトブロックを復元しなければならないのは同様です。

いずれにせよあんまり美しくないのでお勧めしません。こんなことをやるくらいならジョブの中あるいは -InitializationScriptパラメータの中で関数やスクリプトブロックを定義してやるか、関数を別スクリプトファイルに切り出して、そのスクリプトファイルをジョブ内で読み込むほうが良いかと思います。前者の場合だと呼び出し元とジョブ内で関数を共有することはできませんが、後者の方法だとファイルとしては分割してしまいますが可能です。

おわりに

今回はジョブと通信する方法として、ジョブから結果を出力したり、ジョブに値を渡したりする方法をまとめました。意外と落とし穴が多いので注意してください。

このシリーズはあと1回だけ続く予定です。お楽しみに。

2011/05/16

昨日の記事で取り上げたJavaScriptSerializerを用いると、連想配列から楽にJSONを作成できることが分かったのですが、この記事を書いていて思ったのは、「PowerShellの連想配列って意外に使えるな」という点でした。

現在のところ、PowerShellは独自のクラスを記述する方法がありません(Add-Typeコマンドレットを用いてC#などでクラスを書いて利用することはできますが)。Add-Memberコマンドレットを用いると、既存のオブジェクトに対し、任意のプロパティやPowerShellスクリプトで記述したメソッドを追加することはできます。素のオブジェクトであるPSObjectをNew-Objectして作ったオブジェクトでもこれは可能なので、一応ユーザー定義オブジェクトを作ることは可能です。ですが、Add-Memberコマンドレットを使うのはちょっとめんどくさいです。

Windows PowerShellインアクション」ではAdd-Memberを使いPowerShellの関数を駆使してクラス定義構文のようなものを実装した例はありますが、いささか大仰な感は否めません。

しかし連想配列をユーザー定義オブジェクト代わりに使うと、簡単にできますしそこそこ便利に使えます。

連想配列をオブジェクトの代わりにすることのメリットとデメリット

連想配列をオブジェクトの代わりにすることのメリットは以下の三点があるかと思います。

  1. 簡単な記述(連想配列のリテラル)でオブジェクトが作成できる
    PowerShellの連想配列リテラル@{}を使うことで、簡単に記述できます。またそれを配列化するのも@()を使うと容易です。
    $pcItems=
    @(
        @{
            code=25;
            name="ハードディスク2TB";
            price=7000;
        },
        @{
            code=56;
            name="メモリ8GB";
            price=8000;
        }
    )
  2. ドット演算子で値の参照、設定ができる
    PowerShellの連想配列は「連想配列[キー名]」のほかに、「連想配列.キー名」でもアクセスできる。
    Write-Host $pcItems[1].name # 値の参照
    $pcItems[1].name = "test" # 値の設定
    $pcItems[0].maker = "Seagate" # 要素の追加
    
  3. 連想配列の配列に対してWhere-Objectコマンドレットでフィルタをかけることができる
    これは2とも関係しているのですが、通常のオブジェクトと同様にWhere-Objectコマンドレットでのフィルタ、ForEach-Objectコマンドレットでの列挙が可能です。
    $pcItems|?{$_.price -gt 7000}|%{Write-Host $_.name}

このようにメリットはあるのですが、本物のオブジェクトではないのでそれに起因するデメリットがいくつかあります。

  1. 要素(プロパティ)をいくらでも自由に追加できてしまう
    これはメリットではあるのですが、デメリットでもある点です。後述するデメリットのせいで、同じキーをもつ連想配列の配列を作ったつもりでも、どれかのキー(プロパティ名)を間違えていた場合、それを検出するのが困難です。
  2. メソッドがうまく記述できない
    連想配列要素にスクリプトブロックを指定し、&演算子で実行することでメソッド的なことはできます。しかしこのスクリプトブロック内では$thisが使えず、オブジェクトのプロパティにアクセスすることができないのでいまいちです。
    $pcItem= @{
        name="ハードディスク2TB";
        price=7000;
        getPrice={Write-Host $this.price};
    }
    &$pcItem.getPrice # 何も表示されない。$thisが使えない
    # getPrice={Write-Host $pcItem.price}ならOKだが…
  3. Get-Member、Format-List、Format-Tableなどが使えない
    これらのコマンドレットはあくまで連想配列オブジェクト(Hashtable)に対して行われるので、意図した結果になりません。たとえば$pcItems|Format-Listした場合、
    Name  : name
    Value : ハードディスク2TB
    
    Name  : code
    Value : 25
    
    Name  : price
    Value : 7000
    
    Name  : name
    Value : メモリ8GB
    
    Name  : code
    Value : 56
    
    Name  : price
    Value : 8000
    こんな表示になってしまいます。
連想配列をユーザー定義オブジェクトに変換する関数ConvertTo-PSObject

このように、連想配列の記述のお手軽さは捨てがたいものの、いくつかの問題点もあるのが現実です。そこで連想配列のお手軽さを生かしつつ、ユーザー定義オブジェクトの利便性も取るにはどうすればいいか考えました。結論は、「連想配列を変換してユーザー定義オブジェクトにする関数を書く」というものでした。それが以下になります。

#requires -version 2
function ConvertTo-PSObject
{
    param(
        [Parameter(Mandatory=$true, ValueFromPipeline=$true)]
        [System.Collections.Hashtable[]]$hash,
        [switch]$recurse
    )
    process
    {
        foreach($hashElem in $hash)
        {
            $ret = New-Object PSObject
            foreach($key in $hashElem.keys)
            {
                if($hashElem[$key] -as [System.Collections.Hashtable[]] -and $recurse)
                {
                    $ret|Add-Member -MemberType "NoteProperty" -Name $key -Value (ConvertTo-PSObject $hashElem[$key] -recurse)
                }
                elseif($hashElem[$key] -is [scriptblock])
                {
                    $ret|Add-Member -MemberType "ScriptMethod" -Name $key -Value $hashElem[$key]
                }
                else
                {
                    $ret|Add-Member -MemberType "NoteProperty" -Name $key -Value $hashElem[$key]
                }
            }
            $ret
        }
    }
}

ご覧のようにコード的には割にシンプルなものが出来ました。連想配列またはその配列をパラメータにとり、またはパイプラインから渡し、連想配列要素をプロパティまたはメソッドに変換してPSObjectにAdd-Memberしてるだけです。-recurseパラメータを付けると連想配列内に連想配列がある場合に再帰的にすべてPSObjectに変換します。

それでは実際の使用例を挙げます。

# 一番単純な例。パラメータに連想配列を渡すとPSObjectに変換する。
$book = ConvertTo-PSObject @{name="Windows PowerShell ポケットリファレンス";page=300;price=2000}
Write-Host $book.name # 「Windows PowerShell ポケットリファレンス」と表示
$book.name="test" # プロパティに値をセットする
Write-Host $book.name # 「test」と表示
#$book.size="A5" # 存在しないプロパティに値を代入しようとするとエラーになる

# 連想配列をコードで組み立てていく例。
$mutaHash=@{} # 空の連想配列を作る
$mutaHash.name="mutaguchi" # キーと値を追加
$mutaHash.age=32
$mutaHash.introduce={Write-Host ("私の名前は" + $this.name + "です。")} # スクリプトブロックを追加
$mutaHash.speak={Write-Host ($args[0])} # パラメータを取るスクリプトブロックを追加
$muta = $mutaHash|ConvertTo-PSObject # 連想配列はパイプラインで渡すことができる
$muta.introduce() # 「私の名前はmutaguchiです。」と表示
$muta.speak("こんにちは。") # 「こんにちは。」と表示

# 連想配列の配列→PSObjectの配列に変換
$stationeryHashes=@()
$stationeryHashes+=@{name="鉛筆";price=100} 
$stationeryHashes+=@{name="消しゴム";price=50}
$stationeryHashes+=@{name="コピー用紙";price=500}
$stationeryHashes+=@{name="万年筆";price=30000}
$stationeries = ConvertTo-PSObject $stationeryHashes
# "200円以上の文具を列挙"
$stationeries|?{$_.price -ge 200}|%{Write-Host $_.name} # 「コピー用紙」と「万年筆」が表示

# 連想配列の配列をリテラルで一気に記述する
$getPrice={Write-Host $this.price} # 共通のメソッドを定義
$pcItems=
@(
    @{
        code=25;
        name="ハードディスク2TB";
        price=7000;
        getPrice=$getPrice
    },
    @{
        code=56;
        name="メモリ8GB";
        price=8000;
        getPrice=$getPrice
    },
    @{
        code=137;
        name="23インチ液晶ディスプレイ";
        price=35000;
        getPrice=$getPrice
    }
)|ConvertTo-PSObject
$pcItems[1].getPrice() # 「8000」と表示
$pcItems|Format-List
<#
表示:
name  : ハードディスク2TB
code  : 25
price : 7000

name  : メモリ8GB
code  : 56
price : 8000

name  : 23インチ液晶ディスプレイ
code  : 137
price : 35000
#>

# 連想配列の中に連想配列を含めたもの→PSObjectをプロパティの値に持つPSObject
$blog=
@{
    utl="http://winscript.jp/powershell/";
    title="PowerShell Scripting Weblog";
    date=[datetime]"2011/05/16 00:25:31";
    keywords=@("スクリプト","PowerShell","WSH"); # 配列を含めることもできる
    author=@{name="mutaguchi";age=32;speak={Write-Host "ようこそ私のブログへ"}} # 連想配列を含める
}|ConvertTo-PSObject -recurse # -recurseパラメータを指定すると再帰的にすべての連想配列をPSObjectに変換する
$blog.author.speak() # 「ようこそ私のブログへ」と表示
Write-Host $blog.keywords[1] # 「PowerShell」と表示
# ※配列要素に連想配列以外の値が含まれている場合は展開しない

このように簡単な関数一つで、連想配列にあった問題点をすべて解消しつつ簡単な記述で独自のオブジェクトを記述できるようになりました。おそらくかなり便利だと思いますのでぜひ使ってみてください。

余談:ScriptPropertyを使う場合

余談ですが、今回使用したNotePropertyはプロパティに代入できる型を指定したり、リードオンリーなプロパティを作ったりすることができません。そういうのを作りたい場合はScriptPropertyを使います。Add-Memberコマンドレットの-valueパラメータにゲッターを、-secondValueパラメータにセッターをそれぞれスクリプトブロックで記述します。

しかしこいつはあまりいけてないです。これらのスクリプトブロック内で参照するフィールドを別途Add-MemberでNotePropertyを使って作成する必要があるのですが、これをprivateにすることができません。よってGet-Memberでもばっちり表示されてしまいますし、フィールドを直接書き換えたりもできてしまいます。

また今回のように連想配列をPSObjectに変換する場合はprivateフィールド名も自動生成する必要があるのですが、それをScriptProperty内のゲッター、セッターから取得する方法がなく、たぶんInvoke-Expressionを使うしかありません。

これらを踏まえて元の連想配列要素の値の型を引き継ぎ、それ以外の型を代入できないようにしたScriptPropertyバージョンも一応書いてみました。ConvertTo-PSObject関数のelse句の部分を以下に置き換えます。

#$ret|Add-Member -MemberType "NoteProperty" -Name ("_" +$key) -Value $hash[$key]
"`$ret|Add-Member -MemberType ScriptProperty -Name $key -Value {[" + $hash[$key].gettype().fullname + "]`$this._" + $key + "} -SecondValue {`$this._" + $key + "=[" + $hash[$key].gettype().fullname + "]`$args[0]}"|iex

まあこれはいまいちなんで参考程度に。

2012/08/23追記
この記事を書いた時は知らなかったのですが、実は単にNotePropertyだけを持つユーザー定義オブジェクトを作成するのであれば、もっと簡単な方法があります。

# PowerShell 1.0
$o=New-Object PSObject|Add-Member noteproperty Code 137 -pass|Add-Member noteproperty Name 23インチ液晶ディスプレイ -pass

# PowerShell 2.0
$o=New-Object PSObject -Property @{Code=137;Name="23インチ液晶ディスプレイ"}

# PowerShell 3.0
$o=[pscustomobject]@{Code=137;Name="23インチ液晶ディスプレイ"}

3つのコードはほぼ等価です。PowerShell 2.0と3.0では連想配列リテラルを用いて簡単にカスタムオブジェクトを作れるようになりました。

元記事:http://blogs.wankuma.com/mutaguchi/archive/2011/05/16/199086.aspx

Copyright © 2005-2016 Daisuke Mutaguchi All rights reserved

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

Awards

Books

Twitter