2017/12/10
PowerShellで利用するテキストデータ形式の比較
この記事はPowerShell Advent Calendar 2017の10日目です。
PowerShellはオブジェクトを扱うシェルですが、別にテキストデータを扱えない訳ではありません。むしろ、PowerShellで取得したデータをテキストファイルとして保存したり、スクリプトで用いるデータをテキストファイルで保存しておくことは日常的に行われることだと思います。
ただし、PowerShellで扱うデータはオブジェクトであり、テキストファイルは文字通り文字列であることから、コマンドレットを用いる等、何らかの手段で変換が必要になります。また、テキストデータ形式にも様々な種類があり、それぞれメリット、デメリットが存在します。今回の記事では、PowerShellで用いるデータを保持しておく際のテキストデータ形式について比較をしてみます。
プレーンテキスト
プレーンテキスト、すなわち書式なしのテキストファイルです。もっともシンプルな使い方をする場合、文字列配列の各要素に含まれる文字列が、テキストファイルの1行と対応します。
書き出し
$lines | Set-Content -LiteralPath file.txt -Encoding UTF8
$linesは文字列変数です。
特に理由がなければ文字コードはUTF-8で良いと思います。
追記
$lines | Add-Content -LiteralPath file.txt -Encoding UTF8
Add-Contentは実行のたびにファイルを開いて、書き込んでから閉じるという動作をするので、1行ずつforeachで実行するのはNGです。
読み込み
$lines = @(Get-Content -LiteralPath file.txt -Encoding UTF8)
メリット
- 文字列配列をテキストファイルに書き出すのは多分これが一番楽だと思います。
- 書き出したデータは人間にも読みやすい。 編集もしやすい。
デメリット
- 文字列だけを保存しておきたいというケースがそもそも少ない。
CSV
コンマ等の特別な文字で区切り、1行あたりに複数のデータを保存できる形式です。
PowerShellのコマンドレットで扱う場合、オブジェクトが持つプロパティがヘッダ列名に対応します。各行にオブジェクト配列1要素のプロパティ値が、カンマ区切りで保持されます。
書き出し
$objects | Export-Csv -LiteralPath file.csv -NoTypeInformation -Encoding Default
$objectsは任意のオブジェクト配列です。必要であればSelect-Objectコマンドレットを併用して、プロパティを絞り込みます。
文字コードはExcelでそのまま読み込み/書き出しができるDefault(日本語環境ではShift_JIS)がお勧めです。(最近のExcel2016ならUTF8も一応読めますが)
追記
$objects | Export-Csv -LiteralPath file.csv -Append -NoTypeInformation -Encoding Default
読み込み
$objects = Import-Csv -LiteralPath file.csv -Encoding Default
メリット
- オブジェクトのプロパティ値が、すべて数値あるいは文字列で表現できる値を持つ場合に最も適合する。
- 人間にも読みやすく、ある程度は編集もできる。
- Excelで開ける。
デメリット
- オブジェクトのプロパティが、数値と文字列以外のオブジェクトである場合、すなわち、階層構造を持つデータの保存には適さない。
- 数値も文字列として読み込まれてしまうので、数値として扱いたい場合は変換が必要になる。
- Export-CsvとImport-Csvで扱うCSVファイルはヘッダが必須。つまり、ヘッダなしのCSVファイルが既にあって、それを読み書きするという用途には適さない。(できなくはないが)
- 書き出し時の列順を制御することができない。つまり、PowerShellで書き出したCSVを、列順が固定であるとの想定である他のプログラムで読み込むことは基本NG。
- 書き出し時、1つ目の要素に存在しないプロパティは、2つ目以降では存在しないものとして扱われる。同種のオブジェクトで構成される配列なら通常は問題ないのだが、要素によって動的に追加されるプロパティがあったりなかったりすると厄介。(ADでありがち)
JSON
JavaScriptのような表記でデータを保持するデータ形式です。データの受け渡しに様々な言語で利用できます。Web APIでもよく利用されます。
PowerShellではv3からJSONを扱うコマンドレットが提供されています。
書き出し
$objects | ConvertTo-Json | Set-Content -LiteralPath file.json -Encoding UTF8
読み込み
$objects = Get-Content -LiteralPath file.json -Encoding UTF8 -Raw | ConvertFrom-Json
メリット
- CSVと異なり、階層構造を持ったデータでも扱える。
- CSVと異なり、数値は数値型のまま読み書き可能。 (整数値はint、小数値はdecimal)
- 人間にもまぁまぁ読めるし、頑張れば編集できなくもない。
デメリット
- -Depthパラメータによりプロパティを展開する階層の深さを指定はできるが、プロパティに応じて深さ指定を変化させるというようなことはできない。基本的には、自分で構築したPSCustomObjectを使うか、JSON化する前に自分で元オブジェクトを整形しておく必要がある。
- 直接ファイルに書き出し、追記、ファイルから読み込みするコマンドレットはない。
- 実は細かい話をしだすと色々と罠があります…。
CLIXML
PowerShellではPSリモーティング等、プロセス間でオブジェクトのやり取りを行う際に、CLIXML形式を介してシリアライズ/デシリアライズが実行されます。シリアライズ対象によっては、完全に元のクラスのオブジェクトに復元されます。(復元されないオブジェクトにはクラス名にDeserialized.との接頭辞が付与され、プロパティ値のみ復元される)
ユーザーもコマンドレットを用いて、任意のデータをCLIXML形式でシリアライズし、XMLファイルとして保存することができます。
書き出し
$objects | Export-Clixml -LiteralPath file.xml
読み込み
$objects = Import-Clixml -LiteralPath file.xml
メリット
- 元のオブジェクトの構造、プロパティ値と型情報を含めてほぼ完全にテキストファイルに保存できる。
- 復元したオブジェクトはプロパティ値を参照できるのはもちろん、オブジェクト全体が完全にデシリアライズされ、元の型に戻った場合には、メソッドを実行することも可能。
- 例え元の型に戻らず、Deserialized.との接頭辞が付いた状態でも、コンソールに表示する場合は元の型のフォーマットが使われるので見やすい。
デメリット
- すべてのオブジェクトが元の型に戻せるわけではない。戻せるかどうかは確認が必要。
- 人間が読み書きするようなものではない。
ちなみに、ConvertTo-Xmlという似たようなコマンドレットがありますが、出力形式はCLIXMLではない上、復元の手段もなく、かといって別に読みやすいXMLというわけでもなく、正直何のために使うのかよく分かりません(適切なxsltでも用意すればいいのかな?)。まだConvertTo-Htmlの方が使えそうです。
psd1
psd1は「PowerShellデータファイル」で、モジュールマニフェストやローカライズデータに使われるファイル形式です。スクリプトファイルの1種ですが、数値や文字列リテラル、配列、連想配列、コメントなど基本的な言語要素のみ使用可能です。PowerShell 5.0以降ではImport-PowerShellDataFileコマンドレットを用いて、任意のpsd1ファイルのデータを読み込み、変数に格納することが可能です。
書き出し
書き出し用のコマンドレットはありません。
読み込み
例えば以下のような内容をbackup_setting.psd1として保存しておきます。ルート要素は必ず連想配列にします。
@{ Directories = @( @{ From = "C:\test1" # コピー元 To = "D:\backup\test1" # コピー先 Exclude = @("*.exe", "*.dll") Recurse = $true }, @{ From = "C:\test2" To = "D:\backup\test2" Exclude = @("*.exe") LimitSize = 50MB }, @{ From = "C:\test3" To = "D:\backup\test4" } ) Start = "0:00" }
なお、dataセクションで全体を括ってもいいですが、psd1で許容される言語要素はdataセクションより更に制限がきついので、敢えてしなくてもいいんじゃないかと思います。
このファイルは以下のように読み込めます。
$setting = Import-PowerShellDataFile -LiteralPath backup_setting.psd1
$settingには連想配列が格納され、以下のように値が参照できます。
$setting.Directories | foreach {Copy-Item -Path $_.From -Destination $_.To}
メリット
- PowerShellの構文でデータを記述できる。
- 通常のps1ファイルを呼び出すのとは異なり、式の評価やコマンド実行などはされない分、セキュアである。
- 配列と連想配列の組み合わせにより、JSONライクな階層構造を持てる。型情報も保持される。
- JSONとは違い、コメントが入れられる。
デメリット
- 記述できるデータはプリミティブなものだけ。
- スクリプトから書き出すためのコマンドレットがない。こういうアプローチで頑張ればできるかも?
- 利用できるのはPowerShell 5.0以降のみ。一応、下位バージョンでやる方法はあります。
まとめ
PowerShellで扱うデータをテキストファイルとして保存する際には、各テキストデータ形式の特性を理解し、メリット、デメリットを踏まえて選定する必要があります。
また、当然ながらテキストファイルに保持することが不適切なデータもありますので、そこは注意してください。(画像データを敢えてBase64とかでエンコードしてテキストファイル化する意味があるのか、とかですね)
個人的には…
ちょっとした作業ログ等を記録しておきたい→プレーンテキスト
.NETオブジェクトの一部のプロパティだけ抜き出してファイル化したい→CSV
自分で構築したPSCustomObjectをファイル化したい→JSON
.NETオブジェクト全体をファイル化したい→CLIXML
スクリプトで使う設定データを用意したい→psd1
みたいな感じでなんとなく使い分けていると思います。psd1はまだ採用例はないですが…。
今回はビルトインのコマンドレットで扱えるもののみ取り上げましたが、他にもyaml等のテキストデータ形式が存在し、有志によるモジュールを用いて扱うことが可能です。
2015/12/10
PowerShellでスクレイピング 後編 HTMLをパースする
この記事はPowerShell Advent Calendar 2015の10日目の記事です。
はじめに
前編では、Invoke-WebRequestコマンドレットやWebClientクラスを用いて、WebページからHTMLの文字列を取得するところまで説明しました。
後編の今回は、取得したHTML文字列をパースして、オブジェクトとして利用可能しやすい形に変換する話です。
IEエンジンによるHTMLパース(DOM)
前編でも触れましたが、Invoke-WebRequestコマンドレットは、レスポンス文字列を取得すると同時に、HTMLをパース(構文解析)し、結果をオブジェクトとして構造化してくれます。
実はこのHTMLパース、内部的にInternet Explorerのエンジンを呼び出すことで実現されています。(ちなみに後で説明しますが、-UseBasicParsingパラメータを付与すると、IEエンジンを使わずごく基本的なパースのみ行うようになります。)
Invoke-WebRequestコマンドレットの出力であるHtmlWebResponseObjectオブジェクトのParsedHtmlプロパティを経由することで、HTMLパースされたオブジェクトを、DOM(Document Object Model)に従ってアクセスすることができます。(-UseBasicParsing指定時は不可)
HTMLのtable要素を切り出し、table各行を1オブジェクト、各セルをプロパティとして、オブジェクト配列化する例を以下に示します。
$response = Invoke-WebRequest http://winscript.jp/powershell/301 # DOMを利用して1つ目のtable要素を取得 $table = $response.ParsedHtml.getElementsByTagName("table")| select -First 1 # tableの1行目をプロパティ名として取得 $properties = ($table.rows| select -first 1).Cells| foreach {$_.innerText} # tableの残りの行に対して、各セルのinnerTextをプロパティ値としてオブジェクト化 $objs = foreach($row in ($table.rows| select -skip 1)) { $row.Cells| foreach -Begin { $index = 0 $obj = [ordered]@{} } -Process { $obj += @{$properties[$index] = $_.innerText} $index++ } -End { [pscustomobject]$obj } } $objs| Format-List
ところで前編で軽く触れましたが、IEエンジンによるパースは、Invoke-WebRequestコマンドレットを用いずとも、以下のようにして直接IEのCOMインターフェースを呼ぶことで利用可能です。
$client = New-Object System.Net.WebClient $content = $client.DownloadString("http://winscript.jp/powershell/301") $parsedHtml = New-Object -com "HTMLFILE" $parsedHtml.IHTMLDocument2_write($content) $parsedHtml.Close() $table = $parsedHtml.getElementsByTagName("table")| select -First 1 # 以下同様…
というより実際に試すと直接IEエンジンを呼び出す方がずっと速いです。理由はよく分かりませんが…。
HTML要素コレクションの取得
Invoke-WebRequestコマンドレットを用いると、DOMとは別に、すべての要素(AllElementsプロパティ)、input要素(InputFieldsプロパティ)、img要素(Imagesプロパティ)、a要素(Linksプロパティ)、script要素(Scriptsプロパティ)を含むコレクションを、HtmlWebResponseObjectオブジェクトの対応するプロパティからそれぞれ取得することができます。
コレクションに含まれる各要素は、innerText(タグ内の文字列)、innerHTML(タグ内のHTML)、tagName(タグ名)等のプロパティが共通して利用可能です。また要素の属性(たとえばa要素ならリンク先を示すhref属性)に、プロパティとしてアクセス可能となります。
以下はBingでWeb検索した結果から、ページタイトルとURLを抜き出す例です。HtmlWebResponseObjectのLinksプロパティでa要素の配列を取ってきて、次に検索結果では無いっぽいURLを、hrefプロパティの値を見てwhereで除外し、最後にinnerTextプロパティとhrefプロパティをTitle、Urlとリネームしてから値を出力しています。泥臭い処理が混じってますが、この泥臭さがスクレイピングなのかもなぁと思います。
$searchWord = "PowerShell 配列" $notSearchResults = "/","#","javascript:","http://go.microsoft.com/" $response = Invoke-WebRequest "https://www.bing.com/search?q=$([Uri]::EscapeDataString($searchWord))" $response.Links | where { $href = $_.href !($notSearchResults|? {$href.StartsWith($_)}) }| select @{L = "Title"; E = "innerText"}, @{L = "Url"; E = "href"}| Format-List
form要素についてもほぼ同様にFormsプロパティからコレクションを取得できますが、このコレクションにはFormObjectという特別なオブジェクトが含まれます。FormObjectのFieldsプロパティは、Key=パラメータ名、Value=パラメータ値が格納された連想配列となっています。この連想配列は書き替えが可能なので、前編で説明した、ログオンを要するWebサイト等で用いると便利かと思います。
以下に、HtmlWebResponseObjectオブジェクトのプロパティをまとめます。(×印は使用不可を表す)
プロパティ名 | 説明 | -UseBasicParsing 指定時 |
AllElements | 本文に含まれるすべての要素のコレクション | × |
Forms | フォーム(form要素)のコレクション | × |
InputFields | 入力フィールド(input要素)のコレクション | |
Images | 画像(img要素)のコレクション | |
Links | リンク(a要素)のコレクション | |
Scripts | スクリプト(script要素)のコレクション | × |
このように、一部のプロパティについては-UseBasicParsing指定時でも利用可能です。サーバーOS等でIEエンジンが利用できない場合には-UseBasicParsingパラメータが必須となりますが、その場合でも最低限のパースはしてくれるわけです。
HTML要素のコレクションを利用する方法は、DOMを使う方法に比べると自由度は少ないですが、「ページから画像のリストを取得したい」等の処理は簡便に行うことができます。
その他のHTMLパース手法
最後に、Invoke-WebRequestコマンドレットとIEエンジン以外のHTMLパース手法について軽くご紹介します。
XMLとしてパース(XHTML限定)
XHTMLというのはごくかいつまんで言うと、HTMLをXMLで定義したものです。XHTMLはXMLなので、XMLとしてパースして用いることができます。
PowerShellは[xml](XmlDocument)型アクセラレータと型アダプタにより、XML要素への簡便なアクセス手段を提供しています。以下のように、[xml]型アクセラレータを用い、取得したXHTML文字列を[xml]型に変換すると、以降は型アダプタの機能により、ドット演算子で要素を辿っていくことができます。
$client = New-Object System.Net.WebClient $content = $client.DownloadString("XHTMLなページ") $xml = [xml]$content $xml.html.body.h2.'#text'
ただ世の中のWebページ上のXHTML文書が、すべてXML文書としてvalidなものであるかと言われると、現実はかなり厳しいです。そしてXML文書としてエラーがある場合は、型アクセラレータの処理は容赦なく失敗します。なのでこの手法は「使えたら強いが、大抵使えない」レベルのものと思って頂ければいいと思います。
SgmlReader
標準機能にこだわらなければ、.NET製のHTMLパーサーを使うのが楽かと思います。SgmlReaderは通常のHTML文書(当然、XHTMLに限らず)をXmlDocumentへとパースしてくれるので、PowerShellと相性が良いのではないかと思います。
以下にサンプルを載せておきます。
Add-Type -Path .\SgmlReaderDll.dll function Get-HTMLDocument { param([uri]$Uri) $sgmlReader = New-Object Sgml.SgmlReader -Property @{ Href = $Uri.AbsoluteUri CaseFolding = [Sgml.CaseFolding]::ToLower } $doc = New-Object System.Xml.XmlDocument $doc.Load($sgmlReader) $doc } $xml = Get-HTMLDocument http://winscript.jp/ $xml.html.body.div|? id -eq outer|% div|? id -eq main|% {$_.p.innerText}
ぎたぱそ氏も以前SgmlReaderを取り上げておられるので、そちらも参考にして下さい。:Html Agility Pack と SgmlReader を使って PowerShell でスクレイピングしてみる - tech.guitarrapc.cóm
正規表現等で自前パース
これまではHTMLパースを既存のコマンドやライブラリを用いて行ってきましたが、対象のHTMLが非常にシンプルである場合とか、HTMLですらなく単なるテキストの場合だとか、対象ページは分量が多いものの必要箇所はごくわずかで、かつピンポイントに取得可能な場合等々は、むしろ自前でパースするコードを書いた方が手っ取り早いこともあります。
例えばYAMAHAのルーターで、管理Webのシステム情報レポートからグローバルIPアドレスを取ってくる、みたいなことは、
$response = Invoke-WebRequest http://サーバー/detail/status.html -UseBasicParsing -Credential $credential if($response.Content -match "PP IP Address Local\: (.+)\,") { $ipAddress = $Matches[1] }
のようなコードで十分かと思います。
ConvertFrom-String
これはまだ検証してないんですが、PowerShell5.0の新機能、Auto-Generated Example-Driven Parsingの実装であるConvertFrom-Stringコマンドレットを用いて、HTMLパースができないかな、と考えています。
ConvertFrom-Stringについては過去記事参照:[v5] Auto-Generated Example-Driven Parsing について - PowerShell Scripting Weblog
まとめ
前後編に渡って、PowerShellでのWebスクレイピングの手法について解説しました。スクレイピングはWeb APIが用意されていない場合の苦肉の策ですが、背に腹は代えられない場合というのは稀によくあると思います。そういうときに今回の記事が参考になれば幸いです。
次回あたりには、Web APIがちゃんと用意されてる場合に、PowerShellから利用する話をやろうかと思います。
2012/12/01
PowerShellらしい関数の書き方 [PS Advent Calendar '12]
今日から、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/25
バックグラウンドジョブを使った並列処理 [PS Advent Calendar '11]
はじめに
PowerShell Advent Calendar 2011の25日目最終日の記事、そしてこれが私の記事では4回目となります。今回もバックグラウンドジョブについての話題です。今回はバックグラウンドジョブを使って並列処理をやってみようという試みです。
これまでの記事は以下になります。
13日目:バックグラウンドジョブとの通信
19日目:PowerShell 3.0で追加されるバックグラウンドジョブ関係の新機能
ところでつい2日前、WMF3 CTP2 Windows PowerShell Workflow.pdfというpdfファイルが公開されました。これは19日目に書いたPS workflowについての詳しい説明(英語)です。構文だけでなくPSスクリプトとの違いやWFとの関係などが詳しく書かれています。ぜひ目を通しておくことをお勧めします。23日目のAhfさんの記事と併せて読むと理解が深まると思いますよ!
並列処理スクリプト
C#をご存知の方なら、PowerShellのバックグラウンドジョブ機能はC#4.0から使えるTaskオブジェクトとちょっと似てるかなーと思われるかもしれません。ではC#4.0でコレクションに対して並列処理でループを回すParallel.For()やParallel.Invoke()みたいなことはPowerShellでできないのか、という疑問が出てくるかと思います。
前回述べたようにPowerShell 3.0ならworkflowを使えば並列処理が可能で、for -parallelステートメントやparallelブロックでParallel.For()やParallel.Invoke()みたいなことが可能になります。しかしPowerShell 3.0がリリースされるのはまだ先ですし制限事項も多いので、なんとかPowerShell 2.0で、しかもworkflowのような制限なしで、並列処理のスクリプトは書けないものかと考えてみました。
function ParallelForEach-Object { [CmdletBinding()] param( [Parameter(Mandatory=$true,Position=1)][scriptblock]$process, [scriptblock]$begin={}, [scriptblock]$end={}, [Parameter(ValueFromPipeline=$true)][psobject]$inputObject ) begin { &$begin $jobs=@() } process { $jobs|Receive-Job while(@($jobs|?{$_.State -eq "Running"}).Length -ge 5) { $jobs|Receive-Job start-sleep -Milliseconds 100 } $jobs += Start-Job $process -argumentList $inputObject } end { while(@($jobs |?{$_.State -eq "Running"}).Length -gt 0) { $jobs|Receive-Job start-sleep -Milliseconds 100 } $jobs|Receive-Job $jobs|remove-job &$end } } $watch=new-object System.Diagnostics.Stopwatch "ForEach-Object 開始" $watch.Start() 1..10|ForEach-Object { "start: " + $_ Start-Sleep -sec 5 "end: " + $_ } $watch.Stop() "ForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec" $watch.Reset() "ParallelForEach-Object 開始" $watch.Start() 1..10|ParallelForEach-Object { "start: " + $args[0] Start-Sleep -sec 5 "end: " + $args[0] } $watch.Stop() "ParallelForEach-Objectの場合:" + $watch.Elapsed.TotalSeconds + " sec"
ParallelForEach-Object関数はパイプラインから渡されたコレクションの各要素について、並列にスクリプトブロックを実行させるものです。同等の処理をForEach-Objectを使って同期的に逐次処理した場合とかかる時間を比較しています。10個の要素があり、各要素につき5秒かかる処理なので、逐次的に処理すると当然50秒以上かかりますが、ParallelForEach-Object関数を使って並列処理させると環境にもよりますが20秒以内に完了します。
この関数では渡されたコレクション1要素に対し1つのジョブを割り当て、同時に5ジョブまで(呼び出し元を含めて同時稼働が6プロセスまで)を並列実行するようにしています。
ただこれはあくまでなんちゃって並列処理なので、並列化することで本当に処理が高速になるかどうかは環境次第かと思います。一応、うちのCore2Duo (2コアCPU)な環境だと、足し算を3万回ほどする処理を10回行う場合、逐次処理とこの関数を使った並列処理では54秒が39秒に短縮され、有意な実行時間差が出ました。
またジョブを開始するのに新しくプロセスを起動させるので、1ループあたりの実行時間がプロセス起動にかかる時間より短ければ、この関数による並列化で処理時間の短縮は見込めません。
処理の対象が複数のリモートPCである場合などは割と有効なのかなと思います。たとえば複数サーバーから別々のファイルを同時にダウンロードするときなど。
ここではParallel.For()やParallel.ForEach()相当の関数を書きましたが、Parallel.Invoke()のような関数も書けるかと思います。スクリプトブロックの配列をStart-Jobで順に走らせ、Wait-Job, Receive-Jobする感じですね。
あとここではやりませんでしたが、Start-Jobの代わりにInvoke-Commandを使い複数のリモートPCに処理を振り分ければ、なんちゃって分散処理もできるのかなあと思いました。
おわりに
実はこのスクリプトを書いたのはPS Workflowの調査前のことで、Workflowで同様のことが可能になることを知って少々愕然としたのですが、それなりに面白いスクリプトかと思ったので公開することにしました。ともあれ、これからのマルチコア、メニーコアの時代、非同期処理や並列処理はますます重要になるかと思います。管理スクリプトにおいてもこれらの概念を意識しないわけにはいかなくなるでしょう。全4回にわたってPowerShellのバックグラウンド機能を解説してきましたが、これらがあなたの非同期&並列スクリプトライフ(?)の一助になれば幸いです。
さてさて、これでPSアドベントカレンダー2011もおしまいです。楽しんでいただけたでしょうか? 私自身も自分で記事を書いていて楽しかったですし、他の方の記事を読むのも色々な発見があり、とても有意義な25日間でした。記事を書いて参加していただいた方々、そして読者の方々に厚く御礼申し上げます。これからもぜひ、PowerShellを活用し、楽しんでくださいませ。
それでは皆様、良いクリスマスをお過ごしください!
2011/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.aspx2007/01/10
functionとfilterの違い
functionもfilterも関数で、どっちもパイプラインからの入力を受け付けることができます。入力オブジェクトはfunctionの場合は$input、filterの場合は$_に格納されます。じゃあどう違うのか?というと、
・functionは入力オブジェクト配列を一度に処理する
・filterは入力オブジェクト配列を個別に処理する
という点です。次のコードを実行してみると違いがわかると思います。
function func1 { $input "func1" } filter filter1 { $_ "filter1" } 1..5 | func1 "" 1..5 | filter1
実行結果:
1 2 3 4 5 func1 1 filter1 2 filter1 3 filter1 4 filter1 5 filter1
$inputには入力されたオブジェクトがそのまま格納されているのに対し、$_には各要素が分解されてそれぞれ格納されていることがわかります。
ただ、少し謎なのがfunction内でbegin,process,endキーワードを使った場合です。次の例をご覧ください。
function func2 { begin { "最初の1回呼ばれます" } process { "複数回呼ばれます $input" } end { "最後の1回呼ばれます" } } 1..5 | func2
これを実行すると次のようになります。
最初の1回呼ばれます 複数回呼ばれます 1 複数回呼ばれます 2 複数回呼ばれます 3 複数回呼ばれます 4 複数回呼ばれます 5 最後の1回呼ばれます
この場合、$inputは入力要素を分解したものが格納されていますよね。しかも、$inputを$_としても同じ結果になります。うーむ、じゃあfunction + process とfilterの違いは何でしょう?
元記事:http://blogs.wankuma.com/mutaguchi/archive/2007/01/10/55277.aspx
Copyright © 2005-2018 Daisuke Mutaguchi All rights reserved
mailto: mutaguchi at roy.hi-ho.ne.jp
プライバシーポリシー